Merge branch 'feat/m222-liteloader-install-route-and-suppor'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
77f73a307f
49 files changed
+3373
-664
No files matched your search
@@ -161,11 +161,25 @@ use super::{Action, Message};
|
|||||||
///
|
///
|
||||||
/// `lang` 是 i18n 的入参,调用点传的是 `pages::Model.lang`(M106 起 = `App` 的生效语言,
|
/// `lang` 是 i18n 的入参,调用点传的是 `pages::Model.lang`(M106 起 = `App` 的生效语言,
|
||||||
/// 见 `ui::lang::LangState`);页面自己不读全局、不猜语言。
|
/// 见 `ui::lang::LangState`);页面自己不读全局、不猜语言。
|
||||||
|
/// 四参数的 [`view_sized`]:给**用不到窗口高度**的调用方(只有单测;产品路径走
|
||||||
|
/// `pages::view` → [`view_sized`],所以这条在非 test 构建里是死代码,`#[cfg(test)]` 收口)。
|
||||||
|
#[cfg(test)]
|
||||||
pub fn view<'a>(
|
pub fn view<'a>(
|
||||||
ui: &'a Ui,
|
ui: &'a Ui,
|
||||||
metrics: FrameMetrics,
|
metrics: FrameMetrics,
|
||||||
model: &'a Download,
|
model: &'a Download,
|
||||||
lang: Lang,
|
lang: Lang,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
view_sized(ui, metrics, model, lang, 760.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同 [`view`],但显式给**窗口高度**(版本列表要按整行取高,见 [`rows::whole_rows`])。
|
||||||
|
pub fn view_sized<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
metrics: FrameMetrics,
|
||||||
|
model: &'a Download,
|
||||||
|
lang: Lang,
|
||||||
|
window_height: f32,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let catalog = lang.catalog();
|
let catalog = lang.catalog();
|
||||||
|
|
||||||
@@ -182,7 +196,15 @@ pub fn view<'a>(
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
frame::page(ui, metrics, header, body(ui, metrics, catalog, model))
|
// 正文列高度 = 窗口 − 标题栏 − 页头 − 上下页边距(唯一来源 [`frame::body_height`])。
|
||||||
|
// 版本列表要按**整行**取高,就得知道这一块有多高(D30 r2 S1b/S9)。
|
||||||
|
let body_height = frame::body_height(ui, metrics, window_height, true);
|
||||||
|
frame::page(
|
||||||
|
ui,
|
||||||
|
metrics,
|
||||||
|
header,
|
||||||
|
body(ui, metrics, catalog, model, body_height),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 正文:加载态仍是居中占位;其余状态都长在三栏骨架上。
|
/// 正文:加载态仍是居中占位;其余状态都长在三栏骨架上。
|
||||||
@@ -191,6 +213,7 @@ fn body<'a>(
|
|||||||
metrics: FrameMetrics,
|
metrics: FrameMetrics,
|
||||||
catalog: Catalog,
|
catalog: Catalog,
|
||||||
model: &'a Download,
|
model: &'a Download,
|
||||||
|
body_height: f32,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
if model.is_loading() {
|
if model.is_loading() {
|
||||||
// 还没拉到版本列表:三栏没有内容可填,给占位(安装面板照旧带着,见 M94 复审 p3)
|
// 还没拉到版本列表:三栏没有内容可填,给占位(安装面板照旧带着,见 M94 复审 p3)
|
||||||
@@ -221,7 +244,7 @@ fn body<'a>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let content: Element<'a, Message> = if rails.tier.is_three_column() {
|
let content: Element<'a, Message> = if rails.tier.is_three_column() {
|
||||||
rails::three_column(ui, catalog, model, rails)
|
rails::three_column(ui, catalog, model, rails, body_height)
|
||||||
} else {
|
} else {
|
||||||
// 三栏不成立(可用宽 < 600):类型与主版本原样退回顶部 chips,列表吃掉整条可用宽。
|
// 三栏不成立(可用宽 < 600):类型与主版本原样退回顶部 chips,列表吃掉整条可用宽。
|
||||||
// 高度分法跟着档位走:chips 档多花两行 chip,面板必须收回自己的一半(M125 缺陷 1)。
|
// 高度分法跟着档位走:chips 档多花两行 chip,面板必须收回自己的一半(M125 缺陷 1)。
|
||||||
@@ -233,6 +256,8 @@ fn body<'a>(
|
|||||||
catalog,
|
catalog,
|
||||||
model,
|
model,
|
||||||
rows::TailShare::of(rails.tier, &install::panel_state(model)),
|
rows::TailShare::of(rails.tier, &install::panel_state(model)),
|
||||||
|
// chips 档不按整行取高:实测那里已经是 4.0 整行、零残影,别动。
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
.spacing(ui.space(Space::X2))
|
.spacing(ui.space(Space::X2))
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `category-release` / `installing` /
|
//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `category-release` / `installing` /
|
||||||
//! `installing-late` / `installing-empty` / `failed` / `done` / `done-next` /
|
//! `installing-late` / `installing-empty` / `failed` / `done` / `done-next` /
|
||||||
//! **`options` / `options-fabric` / `options-incompat`**(M105 的安装选项区三态)/
|
//! **`options` / `options-fabric` / `options-incompat`**(M105 的安装选项区三态)/
|
||||||
|
//! **`options-liteloader`**(M222:LiteLoader 已可装,取样点用它的最高原版 1.12.2)/
|
||||||
//! **`category-ancient` / `search` / `search-major` / `search-empty` / `search-elsewhere`**
|
//! **`category-ancient` / `search` / `search-major` / `search-empty` / `search-elsewhere`**
|
||||||
//! (M107 的两级侧栏与搜索取样点)。
|
//! (M107 的两级侧栏与搜索取样点)。
|
||||||
//!
|
//!
|
||||||
@@ -81,6 +82,8 @@ pub fn demo(catalog: Catalog) -> Download {
|
|||||||
),
|
),
|
||||||
// NeoForge 最早只到 1.20.1,远古版(列表里的 `rd-132211`)根本没有构建 ⇒ 选中即报原因
|
// NeoForge 最早只到 1.20.1,远古版(列表里的 `rd-132211`)根本没有构建 ⇒ 选中即报原因
|
||||||
Some("options-incompat") => options(catalog, "rd-132211", Loader::NeoForge, None),
|
Some("options-incompat") => options(catalog, "rd-132211", Loader::NeoForge, None),
|
||||||
|
// M222 的取样点:选中 1.12.2 + LiteLoader(外加「这里只叠原版」那条中性说明)
|
||||||
|
Some("options-liteloader") => options_liteloader(catalog),
|
||||||
Some("selected") => Download {
|
Some("selected") => Download {
|
||||||
selected: Some("1.21.9".to_string()),
|
selected: Some("1.21.9".to_string()),
|
||||||
..ready(catalog)
|
..ready(catalog)
|
||||||
@@ -169,6 +172,37 @@ fn options(catalog: Catalog, version: &str, loader: Loader, cached: Option<Vec<&
|
|||||||
Download::from_snapshot(&state, Category::All, &draft, catalog)
|
Download::from_snapshot(&state, Category::All, &draft, catalog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// M222 的取样点:**选中 1.12.2 + LiteLoader**(安装选项区的第四个状态)。
|
||||||
|
///
|
||||||
|
/// 为什么单独一个函数而不是复用 [`options`]:LiteLoader 的官方版本表只覆盖 `1.5.2`~`1.12.2`,
|
||||||
|
/// 而 [`demo_versions`] 是别处基线图的行集(`tests/view.rs` 钉着 8 行),不能为这个取样点改它。
|
||||||
|
/// 这里只是给**本状态**的版本列表补一条 `1.12.2`,其余照旧走产品那条真转换
|
||||||
|
/// [`Download::from_snapshot`]:图上的 chip 行、中性说明、确认键状态都是产品行为。
|
||||||
|
fn options_liteloader(catalog: Catalog) -> Download {
|
||||||
|
const MC: &str = "1.12.2";
|
||||||
|
let mut versions = demo_versions();
|
||||||
|
versions.push(VersionSummary {
|
||||||
|
id: MC.to_string(),
|
||||||
|
kind: VersionKind::Release,
|
||||||
|
released: "2017-09-18".to_string(),
|
||||||
|
installed: false,
|
||||||
|
});
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions)));
|
||||||
|
// 真版本表里 LiteLoader 对 1.12.2 只有这一条(快照分支),照实写。
|
||||||
|
state.apply(AppEvent::Core(CoreEvent::LoaderVersions {
|
||||||
|
kind: Loader::LiteLoader.kind(),
|
||||||
|
mc_version: MC.to_string(),
|
||||||
|
result: Ok(vec!["1.12.2-SNAPSHOT".to_string()]),
|
||||||
|
}));
|
||||||
|
let draft = InstallDraft {
|
||||||
|
selected: Some(MC.to_string()),
|
||||||
|
loader: Loader::LiteLoader,
|
||||||
|
..InstallDraft::default()
|
||||||
|
};
|
||||||
|
Download::from_snapshot(&state, Category::All, &draft, catalog)
|
||||||
|
}
|
||||||
|
|
||||||
/// 22 个主版本桶(`1.21 … 1.0`)的清单:chips 档的横向滚动取证用。
|
/// 22 个主版本桶(`1.21 … 1.0`)的清单:chips 档的横向滚动取证用。
|
||||||
fn many(catalog: Catalog) -> Download {
|
fn many(catalog: Catalog) -> Download {
|
||||||
let versions = (0..=21)
|
let versions = (0..=21)
|
||||||
|
|||||||
@@ -14,22 +14,26 @@
|
|||||||
//! (`rows::TailShare::Shared`)——但只在**面板真的占高度**时(`Options` / `Install`);
|
//! (`rows::TailShare::Shared`)——但只在**面板真的占高度**时(`Options` / `Install`);
|
||||||
//! 只画一行提示的 `Hint` 态仍钉死,否则 8 个版本的列表会被砍到 4 行(r1 复审 P2-1)。
|
//! 只画一行提示的 `Hint` 态仍钉死,否则 8 个版本的列表会被砍到 4 行(r1 复审 P2-1)。
|
||||||
//!
|
//!
|
||||||
//! ## 已知风险:chips 档最多会同时出现**三层**滚动(r1 复审 P3-2,未验)
|
//! ## 面板的滚动分层(M222 D30 六轮打磨后的实际形态)
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! 版本行列表 scrollable ← 第 1 层(任何档位都在)
|
//! 版本行列表 scrollable ← 第 1 层(任何档位都在)
|
||||||
//! └ 面板 scrollable(chips 档才有) ← 第 2 层
|
//! └ 选项区 scrollable(Shared 档才有) ← 第 2 层(`options/view.rs::section`,只装 head + chips)
|
||||||
//! └ 加载器版本 menu 的 scrollable ← 第 3 层(`options/view.rs` 的 `VERSION_ROWS`)
|
//! └ 加载器版本 menu 的 scrollable ← 第 3 层(`options/view.rs::version_menu_height`)
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! **鼠标落在第 3 层上滚轮会不会冒泡到第 2 层,本仓没有实测通道**(静态图拍不到滚动嵌套)。
|
//! **选项区自己滚**这一层是 M222 S7/S1b 之后才有的:`rows::content` 对 `PanelState::Options`
|
||||||
//! iced 的 `scrollable` 在指针悬停于内层时由内层消费滚动,所以「滚到底之后继续滚会带动外层」
|
//! 不再整块套外层 `scrollable`(只有 `Install` 态还用外层那层)。
|
||||||
//! 属于**未验证行为**;两个方向的坏结果分别是「面板卡住滚不动」与「一滚就跳到外层」。
|
|
||||||
//!
|
//!
|
||||||
//! 缓解方案(本轮**没做**,理由:不是一处小改)——把「实例名」那一格从面板的滚动区里移出去,
|
//! **鼠标落在内层上滚轮会不会冒泡到外层,本仓仍没有实测通道**(静态图拍不到滚动嵌套)。
|
||||||
//! 做成面板的固定页脚:那要把 `options::section` 拆成「可滚的上半块 + 钉死的下半块」,
|
//! iced 在指针悬停于内层时由内层消费滚动,所以「滚到底之后继续滚会带动外层」属**未验证行为**;
|
||||||
//! 动 `options/view.rs` / `install.rs` / `rows.rs` 三处,而且改完仍然**无法**验证滚轮冒泡
|
//! 两个方向的坏结果分别是「面板卡住滚不动」与「一滚就跳到外层」。
|
||||||
//! (没有通道),只是把风险从「某一格被挡」换成「面板永不挡那一格」。所以先登记为已知风险。
|
//!
|
||||||
|
//! ## 钉死页脚**已经做了**(M222 `6f87c8b` 起)
|
||||||
|
//!
|
||||||
|
//! `options::section` 现在是「可滚的上半块 + 钉死的下半块」:钉死区内是**加载器版本菜单 +
|
||||||
|
//! 实例名输入框**(D30 四审:菜单也钉住,否则它正好落在裁切线上)。所以「实例名被滚走」
|
||||||
|
//! 这个风险已经消除,只剩上面那条滚轮冒泡未验。
|
||||||
//!
|
//!
|
||||||
//! ## 只有意图,没有乐观回显
|
//! ## 只有意图,没有乐观回显
|
||||||
//!
|
//!
|
||||||
@@ -105,18 +109,52 @@ pub(super) fn panel_state(model: &Download) -> PanelState<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 底部的安装面板。
|
/// 底部的安装面板。
|
||||||
|
///
|
||||||
|
/// `shared` = 选项区走「面板先保预算、列表按整行吃剩下的」那一档(`rows::TailShare::Shared`
|
||||||
|
/// 的带版本列表分支;分高规则见 `rows/tail.rs::whole_rows`,**不是**早期的「对半分」)。
|
||||||
|
/// 此时选项区自己分成两段(`options::section`):
|
||||||
|
///
|
||||||
|
/// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips;
|
||||||
|
/// - **钉死区**:说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框。
|
||||||
|
///
|
||||||
|
/// 菜单与说明分别是 D30 四审 / 八审搬进钉死区的(裁切位置由钉死区高度决定,与窗宽无关)。
|
||||||
|
///
|
||||||
|
/// **两个 Shared 分支的现实不同,别一概而论**(`rows/tail.rs::TailShare::of`):
|
||||||
|
///
|
||||||
|
/// - **三栏档 + 带加载器版本列表**:`has_loader_versions()` 要求 `problem.is_none()`,所以这一支
|
||||||
|
/// 滚动区里**只会有 head + chips**,错误行与它不可达;
|
||||||
|
/// - **chips 档(可用宽 < 600)**:`TailShare::of` 对 `Options`/`Install` 无条件 `Shared`,
|
||||||
|
/// `problem` **可以是** `Some`(静态不兼容 / 版本列表取不到 / 空表三种),那时错误行**会在
|
||||||
|
/// 滚动区里出现**、可能被滚走;`show_versions()` 为假时钉死区只剩实例名(没有菜单、也没有
|
||||||
|
/// 说明那一行)。这个行为域正好是 M227 要重新设计的地方,这里只如实记录现状。
|
||||||
pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> {
|
pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> {
|
||||||
|
panel_with(ui, catalog, model, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同 [`panel`],但显式给「面板先保预算」这一档的标志(只有 `rows::content` 需要)。
|
||||||
|
pub(super) fn panel_with<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
catalog: Catalog,
|
||||||
|
model: &'a Download,
|
||||||
|
shared: bool,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
let content = match panel_state(model) {
|
let content = match panel_state(model) {
|
||||||
PanelState::Install(install) => installing(ui, catalog, install),
|
PanelState::Install(install) => installing(ui, catalog, install),
|
||||||
// 选中版本之后**先给选项,再点「安装」**(M105:以前是选中就直接开工,等于没有加载器维度)
|
// 选中版本之后**先给选项,再点「安装」**(M105:以前是选中就直接开工,等于没有加载器维度)
|
||||||
PanelState::Options(options) => super::options::section(ui, catalog, options),
|
PanelState::Options(options) => super::options::section(ui, catalog, options, shared),
|
||||||
PanelState::Hint => hint(ui, catalog),
|
PanelState::Hint => hint(ui, catalog),
|
||||||
};
|
};
|
||||||
|
|
||||||
container(content)
|
let panel = container(content)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.padding(Padding::from([ui.space(Space::X3), 0.0]))
|
.padding(Padding::from([ui.space(Space::X3), 0.0]));
|
||||||
.into()
|
// 面板先保预算档:面板吃满自己那份(`rows/tail.rs::whole_rows` 之后剩下的高度),
|
||||||
|
// 选项区的 `scrollable` 才有确定高度可吃。
|
||||||
|
if shared {
|
||||||
|
panel.height(Length::Fill).into()
|
||||||
|
} else {
|
||||||
|
panel.into()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 没选中任何版本:给一句「选一个版本再安装」(不静默禁用,也不画一个点不动的按钮)。
|
/// 没选中任何版本:给一句「选一个版本再安装」(不静默禁用,也不画一个点不动的按钮)。
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
|
|
||||||
mod model;
|
mod model;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod s1_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
mod view;
|
mod view;
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@
|
|||||||
//! 规则来源(不猜:都是 core 安装管线自己的判据):
|
//! 规则来源(不猜:都是 core 安装管线自己的判据):
|
||||||
//! - Fabric/Quilt 的 loader 列表从 `1.14` 起才有(core 走 fabric meta,1.13 及以前没有);
|
//! - Fabric/Quilt 的 loader 列表从 `1.14` 起才有(core 走 fabric meta,1.13 及以前没有);
|
||||||
//! - NeoForge 的第一个版本是 `1.20.1`(`maven::NEOFORGE_FORGE_LAYOUT_MC`);
|
//! - NeoForge 的第一个版本是 `1.20.1`(`maven::NEOFORGE_FORGE_LAYOUT_MC`);
|
||||||
//! - Forge 从 `1.1` 起有正式构建;远古版(`rd-*` / `a*` / `b*`)与预览版在 maven 表里没有条目。
|
//! - Forge 从 `1.1` 起有正式构建;远古版(`rd-*` / `a*` / `b*`)与预览版在 maven 表里没有条目;
|
||||||
|
//! - LiteLoader 的官方版本表**实测只有 16 个原版版本**(`1.5.2` ~ `1.12.2`,2026-11 取
|
||||||
|
//! `dl.liteloader.com/versions/versions.json`),1.13 起它就不再发版,所以这两头都按
|
||||||
|
//! 「官方没有构建」拒掉(不去查列表)。
|
||||||
|
|
||||||
use unlucky_i18n::{Catalog, Key};
|
use unlucky_i18n::{Catalog, Key};
|
||||||
use unlucky_presenter::dto::{LoaderKind, VersionKind};
|
use unlucky_presenter::dto::{LoaderKind, VersionKind};
|
||||||
@@ -41,7 +44,7 @@ pub enum LoaderVersionsView {
|
|||||||
Failed(String),
|
Failed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 页面侧的加载器词表:只列 GUI 真能装的五家(与 `dto::LoaderKind::wizard_choices` 同集)。
|
/// 页面侧的加载器词表:只列 GUI 真能装的六家(与 `dto::LoaderKind::wizard_choices` 同集)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum Loader {
|
pub enum Loader {
|
||||||
/// 原版(不装加载器)
|
/// 原版(不装加载器)
|
||||||
@@ -55,16 +58,19 @@ pub enum Loader {
|
|||||||
Fabric,
|
Fabric,
|
||||||
/// Quilt
|
/// Quilt
|
||||||
Quilt,
|
Quilt,
|
||||||
|
/// LiteLoader
|
||||||
|
LiteLoader,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Loader {
|
impl Loader {
|
||||||
/// 展示顺序(任务书的口径:原版 / Forge / NeoForge / Fabric / Quilt)。
|
/// 展示顺序(任务书的口径:原版 / Forge / NeoForge / Fabric / Quilt,LiteLoader 追加在末尾)。
|
||||||
pub const ALL: [Loader; 5] = [
|
pub const ALL: [Loader; 6] = [
|
||||||
Loader::Vanilla,
|
Loader::Vanilla,
|
||||||
Loader::Forge,
|
Loader::Forge,
|
||||||
Loader::NeoForge,
|
Loader::NeoForge,
|
||||||
Loader::Fabric,
|
Loader::Fabric,
|
||||||
Loader::Quilt,
|
Loader::Quilt,
|
||||||
|
Loader::LiteLoader,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 对应的 presenter 词表(派 `AppCommand` 时用)。
|
/// 对应的 presenter 词表(派 `AppCommand` 时用)。
|
||||||
@@ -75,10 +81,11 @@ impl Loader {
|
|||||||
Loader::NeoForge => LoaderKind::NeoForge,
|
Loader::NeoForge => LoaderKind::NeoForge,
|
||||||
Loader::Fabric => LoaderKind::Fabric,
|
Loader::Fabric => LoaderKind::Fabric,
|
||||||
Loader::Quilt => LoaderKind::Quilt,
|
Loader::Quilt => LoaderKind::Quilt,
|
||||||
|
Loader::LiteLoader => LoaderKind::LiteLoader,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 presenter 词表回来(`OptiFine` / `LiteLoader` 不是本页选项)。
|
/// 从 presenter 词表回来(`OptiFine` 不是本页选项:它没有安装管线)。
|
||||||
pub const fn of(kind: LoaderKind) -> Option<Self> {
|
pub const fn of(kind: LoaderKind) -> Option<Self> {
|
||||||
match kind {
|
match kind {
|
||||||
LoaderKind::Vanilla => Some(Loader::Vanilla),
|
LoaderKind::Vanilla => Some(Loader::Vanilla),
|
||||||
@@ -86,7 +93,8 @@ impl Loader {
|
|||||||
LoaderKind::NeoForge => Some(Loader::NeoForge),
|
LoaderKind::NeoForge => Some(Loader::NeoForge),
|
||||||
LoaderKind::Fabric => Some(Loader::Fabric),
|
LoaderKind::Fabric => Some(Loader::Fabric),
|
||||||
LoaderKind::Quilt => Some(Loader::Quilt),
|
LoaderKind::Quilt => Some(Loader::Quilt),
|
||||||
LoaderKind::OptiFine | LoaderKind::LiteLoader => None,
|
LoaderKind::LiteLoader => Some(Loader::LiteLoader),
|
||||||
|
LoaderKind::OptiFine => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +110,7 @@ impl Loader {
|
|||||||
Loader::NeoForge => "NeoForge",
|
Loader::NeoForge => "NeoForge",
|
||||||
Loader::Fabric => "Fabric",
|
Loader::Fabric => "Fabric",
|
||||||
Loader::Quilt => "Quilt",
|
Loader::Quilt => "Quilt",
|
||||||
|
Loader::LiteLoader => "LiteLoader",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +146,12 @@ impl Loader {
|
|||||||
Some(_) => Err(Incompat::NeedsAtLeast("1.20.1")),
|
Some(_) => Err(Incompat::NeedsAtLeast("1.20.1")),
|
||||||
None => Err(Incompat::Unsupported),
|
None => Err(Incompat::Unsupported),
|
||||||
},
|
},
|
||||||
|
// LiteLoader 官方版本表只到 1.12.2(最老 1.5.2),两头都是「官方没有构建」。
|
||||||
|
// 预览版/快照同样没有(那 16 个原版版本全是正式版)。
|
||||||
|
Loader::LiteLoader => match rank {
|
||||||
|
Some(version) if ((1, 5, 2)..=(1, 12, 2)).contains(&version) => Ok(()),
|
||||||
|
_ => Err(Incompat::Unsupported),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,6 +215,12 @@ pub struct OptionsView {
|
|||||||
pub instance_name: String,
|
pub instance_name: String,
|
||||||
/// 不兼容 / 取不到的原因(`Some` ⇒ 确认键按下不去)
|
/// 不兼容 / 取不到的原因(`Some` ⇒ 确认键按下不去)
|
||||||
pub problem: Option<String>,
|
pub problem: Option<String>,
|
||||||
|
/// 加载器相关的**中性说明**(不是错误:不套错误色、不禁用确认键)。
|
||||||
|
///
|
||||||
|
/// 目前只有 LiteLoader 有:下载页只能装「原版 + 加载器」这一层,叠在 Forge 上要走命令行
|
||||||
|
/// (M222 的边界,见 [`Key::DownloadOptionsLiteLoaderVanillaOnly`])。把它塞进 `problem`
|
||||||
|
/// 会让用户以为这个组合不能装、还会按不下去确认键,所以单独一条。
|
||||||
|
pub note: Option<String>,
|
||||||
/// 本地已装(确认键给「已安装」禁用态)
|
/// 本地已装(确认键给「已安装」禁用态)
|
||||||
pub installed: bool,
|
pub installed: bool,
|
||||||
/// 现在能不能点「安装」
|
/// 现在能不能点「安装」
|
||||||
@@ -261,6 +282,13 @@ impl OptionsView {
|
|||||||
(Ok(()), _) => None,
|
(Ok(()), _) => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 中性说明:只解释「这一页能做到哪一层」,不参与确认键判定。
|
||||||
|
let note = (loader == Loader::LiteLoader).then(|| {
|
||||||
|
catalog
|
||||||
|
.text(Key::DownloadOptionsLiteLoaderVanillaOnly)
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
// 四条同时成立才按得下去:不兼容没有、列表真的就位(原版不需要列表)、没装过。
|
// 四条同时成立才按得下去:不兼容没有、列表真的就位(原版不需要列表)、没装过。
|
||||||
// 「列表没到位」既包括 `Loading` 也包括 `Failed` 与空表——它们都已经在 `problem` 里说了原因。
|
// 「列表没到位」既包括 `Loading` 也包括 `Failed` 与空表——它们都已经在 `problem` 里说了原因。
|
||||||
let ready = loader == Loader::Vanilla
|
let ready = loader == Loader::Vanilla
|
||||||
@@ -274,11 +302,25 @@ impl OptionsView {
|
|||||||
loader_version,
|
loader_version,
|
||||||
instance_name: draft.instance_name.clone(),
|
instance_name: draft.instance_name.clone(),
|
||||||
problem,
|
problem,
|
||||||
|
note,
|
||||||
installed: row.installed,
|
installed: row.installed,
|
||||||
can_confirm,
|
can_confirm,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 面板上会不会画出**加载器版本列表**(选项区最高的那一态)。
|
||||||
|
///
|
||||||
|
/// 判据与 `view::show_versions` 同源(原版没有、静态不兼容没有、取不到也没有),
|
||||||
|
/// 但这里是**纯模型查询**:`rows::TailShare::of` 要在装配前就知道面板会不会长高。
|
||||||
|
/// 用途(M222 D30 r1 S1):带版本列表的选项区在三栏档实测把版本列表从 6 行压到 2.4 行,
|
||||||
|
/// 于是这类面板改走 `TailShare::Shared`:面板先吃预算、列表按整行吃剩下的。
|
||||||
|
#[must_use]
|
||||||
|
pub fn has_loader_versions(&self) -> bool {
|
||||||
|
self.loader != Loader::Vanilla
|
||||||
|
&& self.problem.is_none()
|
||||||
|
&& matches!(&self.versions, LoaderVersionsView::Ready(items) if !items.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// 确认安装要提交的加载器版本(`None` = 让 core 用官方默认,只在原版路径出现)。
|
/// 确认安装要提交的加载器版本(`None` = 让 core 用官方默认,只在原版路径出现)。
|
||||||
pub fn loader_version_arg(&self) -> Option<&str> {
|
pub fn loader_version_arg(&self) -> Option<&str> {
|
||||||
(!self.loader_version.is_empty()).then_some(self.loader_version.as_str())
|
(!self.loader_version.is_empty()).then_some(self.loader_version.as_str())
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! M222 D30 r1(S1)的装配判据:选项区变高时怎么分高、菜单占几行。
|
||||||
|
//!
|
||||||
|
//! 单独一个文件是守 400 行硬线(`tests.rs` 已经装了 M105 那批兼容性/确认键的用例)。
|
||||||
|
//! 这里的数字来自出图实测:1180×760 下版本行高 46.33px、选项区从 204px 长到 372px 时
|
||||||
|
//! 版本列表从 6.0 行被压到 2.4 行。
|
||||||
|
|
||||||
|
use unlucky_i18n::Lang;
|
||||||
|
use unlucky_presenter::dto::{LoaderKind, VersionKind};
|
||||||
|
use unlucky_presenter::state::AppState;
|
||||||
|
|
||||||
|
use super::model::{Loader, OptionsView};
|
||||||
|
use super::tests::{draft, row, state_with};
|
||||||
|
|
||||||
|
/// S1 的前置:加载器版本菜单**只按条目数占高**(一行为 64、两行为 112),不空撑两行。
|
||||||
|
///
|
||||||
|
/// 抓得住:菜单高度改回「固定两行」(LiteLoader 只有 1 条版本时白占 48px,正好把上面版本
|
||||||
|
/// 列表挤掉一行);或者有人把留白算丢(高度不是 64/112 这两个整行值,菜单项会被截半行)。
|
||||||
|
#[test]
|
||||||
|
fn version_menu_height_counts_whole_items_only() {
|
||||||
|
use super::view::version_menu_height;
|
||||||
|
assert_eq!(version_menu_height(0), 64.0, "空表也按一行占(不塌成 0)");
|
||||||
|
assert_eq!(version_menu_height(1), 64.0, "一条版本 = 一行项 + 上下留白");
|
||||||
|
assert_eq!(version_menu_height(2), 112.0, "两条 = 两行");
|
||||||
|
assert_eq!(
|
||||||
|
version_menu_height(50),
|
||||||
|
112.0,
|
||||||
|
"再多也只露两行(余下靠菜单内滚动)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// S1(D30 r1)的装配判据:**带加载器版本列表**的选项区在任何档位都与列表对半分。
|
||||||
|
///
|
||||||
|
/// 实测背景(1180×760):选项区从 204px 长到 372px 时,版本列表被压成 2.4 行(110px),
|
||||||
|
/// 底缘切在第三行图标上;对半分之后列表回到 4.6 行、面板自己可滚。
|
||||||
|
///
|
||||||
|
/// 抓得住:有人把这条规则删掉(`TailShare::of` 只判 chips 档)——列表又会掉回 2 行;
|
||||||
|
/// 或者把它放宽成「任何选项区都对半分」(原版没有版本列表时不该对半分,那会把 6 行列表
|
||||||
|
/// 砍成 4 行)。
|
||||||
|
#[test]
|
||||||
|
fn tall_options_panel_shares_height_at_every_tier() {
|
||||||
|
use crate::pages::download::install::PanelState;
|
||||||
|
use crate::pages::download::rows::TailShare;
|
||||||
|
use crate::pages::layout::VersionRailTier;
|
||||||
|
|
||||||
|
let version_row = row("1.12.2", VersionKind::Release);
|
||||||
|
let with_versions = state_with(
|
||||||
|
LoaderKind::LiteLoader,
|
||||||
|
"1.12.2",
|
||||||
|
Ok(vec!["1.12.2-SNAPSHOT"]),
|
||||||
|
);
|
||||||
|
let tall = OptionsView::of(
|
||||||
|
&version_row,
|
||||||
|
&with_versions,
|
||||||
|
&draft(Loader::LiteLoader),
|
||||||
|
Lang::En.catalog(),
|
||||||
|
);
|
||||||
|
assert!(tall.has_loader_versions(), "LiteLoader 有版本列表");
|
||||||
|
for tier in [
|
||||||
|
VersionRailTier::Wide,
|
||||||
|
VersionRailTier::Narrow,
|
||||||
|
VersionRailTier::Compact,
|
||||||
|
VersionRailTier::Chips,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
TailShare::of(tier, &PanelState::Options(&tall)),
|
||||||
|
TailShare::Shared,
|
||||||
|
"{tier:?}:带版本列表的选项区必须与列表对半分"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原版没有版本列表 ⇒ 仍然钉死(三栏档不该无谓对半分)。
|
||||||
|
let plain_state = AppState::new();
|
||||||
|
let plain = OptionsView::of(
|
||||||
|
&version_row,
|
||||||
|
&plain_state,
|
||||||
|
&draft(Loader::Vanilla),
|
||||||
|
Lang::En.catalog(),
|
||||||
|
);
|
||||||
|
assert!(!plain.has_loader_versions());
|
||||||
|
for tier in [VersionRailTier::Wide, VersionRailTier::Compact] {
|
||||||
|
assert_eq!(
|
||||||
|
TailShare::of(tier, &PanelState::Options(&plain)),
|
||||||
|
TailShare::Pinned,
|
||||||
|
"{tier:?}:原版选项区没有版本列表,钉死"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不兼容(有 problem)时列表不画 ⇒ 也不该对半分。
|
||||||
|
let blocked_state = state_with(LoaderKind::LiteLoader, "1.13", Ok(Vec::new()));
|
||||||
|
let blocked = OptionsView::of(
|
||||||
|
&row("1.13", VersionKind::Release),
|
||||||
|
&blocked_state,
|
||||||
|
&draft(Loader::LiteLoader),
|
||||||
|
Lang::En.catalog(),
|
||||||
|
);
|
||||||
|
assert!(!blocked.has_loader_versions(), "不兼容时没有版本列表");
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ use unlucky_presenter::state::AppState;
|
|||||||
use super::model::{release_rank, Incompat, InstallDraft, Loader, LoaderVersionsView, OptionsView};
|
use super::model::{release_rank, Incompat, InstallDraft, Loader, LoaderVersionsView, OptionsView};
|
||||||
use crate::pages::download::model::VersionRow;
|
use crate::pages::download::model::VersionRow;
|
||||||
|
|
||||||
fn row(id: &str, kind: VersionKind) -> VersionRow {
|
pub(super) fn row(id: &str, kind: VersionKind) -> VersionRow {
|
||||||
VersionRow::of(
|
VersionRow::of(
|
||||||
&VersionSummary {
|
&VersionSummary {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
@@ -28,7 +28,7 @@ fn row(id: &str, kind: VersionKind) -> VersionRow {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state_with(kind: LoaderKind, mc: &str, items: Result<Vec<&str>, &str>) -> AppState {
|
pub(super) fn state_with(kind: LoaderKind, mc: &str, items: Result<Vec<&str>, &str>) -> AppState {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
state.apply(AppEvent::Core(CoreEvent::LoaderVersions {
|
state.apply(AppEvent::Core(CoreEvent::LoaderVersions {
|
||||||
kind,
|
kind,
|
||||||
@@ -44,7 +44,7 @@ fn state_with(kind: LoaderKind, mc: &str, items: Result<Vec<&str>, &str>) -> App
|
|||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draft(loader: Loader) -> InstallDraft {
|
pub(super) fn draft(loader: Loader) -> InstallDraft {
|
||||||
InstallDraft {
|
InstallDraft {
|
||||||
selected: Some("1.20.1".to_string()),
|
selected: Some("1.20.1".to_string()),
|
||||||
loader,
|
loader,
|
||||||
@@ -108,13 +108,33 @@ fn loader_support_matrix_is_explicit() {
|
|||||||
Loader::Forge.supports("24w14a", VersionKind::Snapshot),
|
Loader::Forge.supports("24w14a", VersionKind::Snapshot),
|
||||||
Err(Incompat::Unsupported)
|
Err(Incompat::Unsupported)
|
||||||
);
|
);
|
||||||
// 远古版:四家全都不支持
|
// LiteLoader 的官方版本表只有 1.5.2 ~ 1.12.2(16 个原版版本),两头都按「没有构建」拒。
|
||||||
|
for id in ["1.5.2", "1.7.10", "1.12.2"] {
|
||||||
|
assert_eq!(
|
||||||
|
Loader::LiteLoader.supports(id, VersionKind::Release),
|
||||||
|
Ok(()),
|
||||||
|
"{id} 官方的确有 LiteLoader"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for id in ["1.4.7", "1.13", "1.20.1"] {
|
||||||
|
assert_eq!(
|
||||||
|
Loader::LiteLoader.supports(id, VersionKind::Release),
|
||||||
|
Err(Incompat::Unsupported),
|
||||||
|
"{id} 上游没有 LiteLoader 构建"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
Loader::LiteLoader.supports("24w14a", VersionKind::Snapshot),
|
||||||
|
Err(Incompat::Unsupported)
|
||||||
|
);
|
||||||
|
// 远古版:五家全都不支持
|
||||||
let ancient = ("a1.2.6", VersionKind::Ancient(AncientKind::OldAlpha));
|
let ancient = ("a1.2.6", VersionKind::Ancient(AncientKind::OldAlpha));
|
||||||
for loader in [
|
for loader in [
|
||||||
Loader::Fabric,
|
Loader::Fabric,
|
||||||
Loader::Quilt,
|
Loader::Quilt,
|
||||||
Loader::Forge,
|
Loader::Forge,
|
||||||
Loader::NeoForge,
|
Loader::NeoForge,
|
||||||
|
Loader::LiteLoader,
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loader.supports(ancient.0, ancient.1),
|
loader.supports(ancient.0, ancient.1),
|
||||||
@@ -252,3 +272,60 @@ fn installed_versions_cannot_be_confirmed_again() {
|
|||||||
assert!(options.installed);
|
assert!(options.installed);
|
||||||
assert!(!options.can_confirm);
|
assert!(!options.can_confirm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 中性说明(M222):只有 LiteLoader 带「这里只叠原版」那条,且它**不影响**确认键。
|
||||||
|
///
|
||||||
|
/// 抓得住:说明被塞进 `problem`(会让用户以为不能装、确认键按不下去);或者说明跑到别家
|
||||||
|
/// 加载器下面(Forge/Fabric 没有这条边界)。
|
||||||
|
#[test]
|
||||||
|
fn liteloader_carries_a_neutral_note_without_blocking_confirm() {
|
||||||
|
let state = state_with(
|
||||||
|
LoaderKind::LiteLoader,
|
||||||
|
"1.12.2",
|
||||||
|
Ok(vec!["1.12.2-SNAPSHOT"]),
|
||||||
|
);
|
||||||
|
let options = OptionsView::of(
|
||||||
|
&row("1.12.2", VersionKind::Release),
|
||||||
|
&state,
|
||||||
|
&draft(Loader::LiteLoader),
|
||||||
|
Lang::En.catalog(),
|
||||||
|
);
|
||||||
|
let note = options.note.as_deref().expect("LiteLoader 应带中性说明");
|
||||||
|
// 文案在 D30 五审后压成**单行**(两行 28px 是滚动区最大的可压项),断言随之改为
|
||||||
|
// 「说清只支持原版父链、且确实只有一行」;完整命令行说法在 `ull help install` 里。
|
||||||
|
assert!(note.contains("Vanilla parent only"), "{note}");
|
||||||
|
assert_eq!(note.lines().count(), 1, "必须是一行:{note}");
|
||||||
|
assert!(options.problem.is_none(), "中性说明不能变成错误");
|
||||||
|
assert!(options.can_confirm, "说明不该禁用确认键");
|
||||||
|
|
||||||
|
// 中文目录同样给中文说明,且仍然不禁用确认键。
|
||||||
|
let zh = state_with(
|
||||||
|
LoaderKind::LiteLoader,
|
||||||
|
"1.12.2",
|
||||||
|
Ok(vec!["1.12.2-SNAPSHOT"]),
|
||||||
|
);
|
||||||
|
let options = OptionsView::of(
|
||||||
|
&row("1.12.2", VersionKind::Release),
|
||||||
|
&zh,
|
||||||
|
&draft(Loader::LiteLoader),
|
||||||
|
Lang::ZhCn.catalog(),
|
||||||
|
);
|
||||||
|
let note = options.note.as_deref().expect("中文也要有说明");
|
||||||
|
assert!(note.contains("只叠原版"), "{note}");
|
||||||
|
assert!(options.can_confirm);
|
||||||
|
|
||||||
|
// 别家加载器不带这条说明。
|
||||||
|
for loader in [Loader::Vanilla, Loader::Fabric, Loader::Forge] {
|
||||||
|
let state = state_with(loader.kind(), "1.20.1", Ok(Vec::new()));
|
||||||
|
let options = OptionsView::of(
|
||||||
|
&row("1.20.1", VersionKind::Release),
|
||||||
|
&state,
|
||||||
|
&draft(loader),
|
||||||
|
Lang::En.catalog(),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
options.note.is_none(),
|
||||||
|
"{loader:?} 不该带 LiteLoader 的说明"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,23 +3,28 @@
|
|||||||
//! ## 版面(424 正文列里的四段)
|
//! ## 版面(424 正文列里的四段)
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! 1.21.9 [安装]
|
//! ── 滚动区(Shared 档)────────────────────────────
|
||||||
//! 安装选项
|
//! 1.12.2 [安装]
|
||||||
//! [原版][Forge][NeoForge]
|
//! [原版][Forge][NeoForge][Fabric][Quilt][LiteLoader] ← 六枚一行
|
||||||
//! [Fabric][Quilt]
|
//! ── 钉死区 ──────────────────────────────────────
|
||||||
//! 加载器版本 ← 只在选了加载器时出现
|
//! Vanilla parent only — Forge goes through the CLI ← 说明行;没有说明时是「加载器版本」标签
|
||||||
//! ┌ 0.16.5 ┐ ← 官方那份列表(menu + 滚动,最多看两行)
|
//! ┌ 1.12.2-SNAPSHOT ┐ ← 官方那份列表(menu + 滚动,按条目数占高:1 条 64 / 2 条 112)
|
||||||
//! │ 0.16.4 │
|
|
||||||
//! └───────────────────┘
|
//! └───────────────────┘
|
||||||
//! [实例名 ]
|
//! [实例名 ]
|
||||||
//! NeoForge 需要 Minecraft 1.20.1 或更新 ← 不兼容时(Error 色)
|
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! ## 为什么加载器分成两行
|
//! - **钉死档**(原版等不带版本列表的面板)走另一条路:标签、说明、菜单、实例名都在同一列里
|
||||||
|
//! 顺序铺开(`04` 基线图就是这一档,不能改);
|
||||||
|
//! - 不兼容时的原因行(Error 色)在滚动区末尾(**chips 档**可达;三栏档 + 带版本列表时
|
||||||
|
//! `problem` 恒为 `None`,所以那支看不到它);
|
||||||
|
//! - 说明只有 LiteLoader 有(`Key::DownloadOptionsLiteLoaderVanillaOnly`)。
|
||||||
//!
|
//!
|
||||||
//! 五家一起排在 424 宽里最宽的那组(`原版 + Forge + NeoForge + Fabric + Quilt`)实测会溢出,
|
//! ## 为什么加载器是**六枚一行**(M222 D30 r1 实测订正)
|
||||||
//! 而 chip 的几何是组件自己的真源(`ChipGeometry`),页面不许压它 ⇒ 折成两行。
|
//!
|
||||||
//! 顺序仍是任务书的口径(原版 / Forge / NeoForge / Fabric / Quilt),只是换行。
|
//! 这里原先写「五家一起排会溢出 ⇒ 折成两行」,那是 M105 的推算。D30 的实测图(1180×760,
|
||||||
|
//! 正文列 424)是**六枚一行、末枚右缘约 404px**,没有溢出——所以示意与说明都改成了实际形态。
|
||||||
|
//! chip 的几何仍是组件自己的真源(`ChipGeometry`),页面不许压它;再来第七家时先看图再决定
|
||||||
|
//! 是否换行。
|
||||||
//!
|
//!
|
||||||
//! ## 为什么「不兼容」不禁用 chip、而是禁用确认键
|
//! ## 为什么「不兼容」不禁用 chip、而是禁用确认键
|
||||||
//!
|
//!
|
||||||
@@ -47,24 +52,46 @@ use super::super::frame;
|
|||||||
use super::super::Message;
|
use super::super::Message;
|
||||||
use super::model::{Loader, LoaderVersionsView, OptionsView};
|
use super::model::{Loader, LoaderVersionsView, OptionsView};
|
||||||
|
|
||||||
/// 五家加载器(一行放得下:实测五枚 chip 合计约 311 dp,正文列 424)。
|
// 加载器 chip 的顺序与集合**委派**给 `Loader::ALL`(原版 / Forge / NeoForge / Fabric /
|
||||||
const LOADERS: [Loader; 5] = [
|
// Quilt / LiteLoader)。这里原先是手写的五枚常量——同一份清单的第三处副本
|
||||||
Loader::Vanilla,
|
// (`dto::wizard_choices`、`Loader::ALL`、这里),M222 加 LiteLoader 时三处都得改,索性委派。
|
||||||
Loader::Forge,
|
// D30 r1 实测(1180×760 与 720×760 两种宽度、浅/深两套主题):六枚 chip 排在一行,
|
||||||
Loader::NeoForge,
|
// 末枚(LiteLoader)右缘约 404px,正文列 424 仍有余量;窄窗 720 下同一行也没有溢出。
|
||||||
Loader::Fabric,
|
|
||||||
Loader::Quilt,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// 菜单项高度 = 48(`MenuGeometry` 的项高)×2 行 + 上下留白 8×2 = 112。
|
/// 加载器版本菜单的**可见高度**(像素):最多两行项 + 上下留白 8×2。
|
||||||
/// 这么算是为了避免「列表被截掉半行」——滚动区高度必须覆盖整行。
|
///
|
||||||
const VERSION_ROWS: f32 = 7.0;
|
/// 项高 48 是 `MenuGeometry` 的真源;`Space::X2 = 8` 是上下留白 ⇒ 两行 = 112、一行 = 64。
|
||||||
|
/// 为什么按**条目数**取(M222 D30 r1 S1):LiteLoader 对 1.12.2 只有一条版本,原来固定撑到
|
||||||
|
/// 两行高(112px),那 48px 纯属空档,却正好是把上面版本列表挤成 2.4 行的元凶之一。
|
||||||
|
/// 单测 `options/view::tests` 直接钉这两档的数值。
|
||||||
|
pub(super) fn version_menu_height(items: usize) -> f32 {
|
||||||
|
const ITEM: f32 = 48.0;
|
||||||
|
let rows = items.clamp(1, 2) as f32;
|
||||||
|
rows * ITEM + Space::X2.dp() * 2.0
|
||||||
|
}
|
||||||
|
|
||||||
/// 选项区(面板的下半块)。
|
/// 选项区(面板的下半块):**可滚的上半块 + 钉死的下半块**。
|
||||||
|
///
|
||||||
|
/// `scroll` = 面板此刻是不是「自己可滚」的那一档(`rows::TailShare::Shared`)。是的话:
|
||||||
|
///
|
||||||
|
/// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips(**chips 档**还可能有错误行,见下);
|
||||||
|
/// - **钉死区**:说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框。
|
||||||
|
///
|
||||||
|
/// 为什么菜单也要钉(D30 四审):裁切位置由**钉死区高度**决定,与窗宽无关(1180 与 720 都切在
|
||||||
|
/// 同一个 y)。之前只钉实例名,菜单就正好落在切线上——760 下菜单容器 642–706 只有 18px 可见、
|
||||||
|
/// 条目文字一格都没画出来。把菜单一起钉住之后,它不再依赖首屏剩余高度分配,可达性由结构保证;
|
||||||
|
/// **两个 Shared 分支不一样**(`rows/tail.rs::TailShare::of`):
|
||||||
|
/// - 三栏档 + 带版本列表:`has_loader_versions()` 要求 `problem.is_none()` ⇒ 滚动区只有
|
||||||
|
/// head + chips,错误行不可达;
|
||||||
|
/// - chips 档(可用宽 < 600,`of` 对 `Options`/`Install` 无条件 Shared):`problem` 可以是
|
||||||
|
/// `Some`,错误行**会进滚动区**、可能被滚走;`show_versions()` 为假时钉死区只剩实例名。
|
||||||
|
/// D30 八审实测:滚动区可见高 ≈96–104px,内容(head 40 + chips 32 + 间距 4)≈76px ⇒
|
||||||
|
/// 1180×760 下 head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。
|
||||||
pub(crate) fn section<'a>(
|
pub(crate) fn section<'a>(
|
||||||
ui: &'a Ui,
|
ui: &'a Ui,
|
||||||
catalog: Catalog,
|
catalog: Catalog,
|
||||||
options: &'a OptionsView,
|
options: &'a OptionsView,
|
||||||
|
scroll: bool,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let head = row![
|
let head = row![
|
||||||
container(frame::text_role(
|
container(frame::text_role(
|
||||||
@@ -80,42 +107,81 @@ pub(crate) fn section<'a>(
|
|||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.align_y(Alignment::Center);
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
let mut body = column![
|
// 钉死档(原版 / 没有加载器版本列表)**保持旧结构**:标签与说明都在列内。
|
||||||
head,
|
// 为什么不一并改:`04` 是别处基线图的对照,它的面板矮、列表 6 行——把标签撤出会
|
||||||
frame::text_role(
|
// 让面板矮 26px,列表涨到 302px(6.86 行)反而切出半行。方案 B 只对 Shared 档生效。
|
||||||
|
if !scroll {
|
||||||
|
let mut plain = column![
|
||||||
|
head,
|
||||||
|
frame::text_role(
|
||||||
|
ui,
|
||||||
|
TypeRole::LabelMedium,
|
||||||
|
false,
|
||||||
|
catalog.text(Key::DownloadOptionsTitle),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
),
|
||||||
|
loader_row(ui, catalog, options),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Space::X2))
|
||||||
|
.width(Length::Fill);
|
||||||
|
if let Some(note) = &options.note {
|
||||||
|
plain = plain.push(frame::text_role(
|
||||||
|
ui,
|
||||||
|
TypeRole::BodySmall,
|
||||||
|
false,
|
||||||
|
note.as_str(),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if show_versions(options) {
|
||||||
|
plain = plain.push(frame::text_role(
|
||||||
|
ui,
|
||||||
|
TypeRole::LabelMedium,
|
||||||
|
false,
|
||||||
|
catalog.text(Key::DownloadOptionsLoaderVersion),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
));
|
||||||
|
plain = plain.push(version_list(ui, catalog, options));
|
||||||
|
}
|
||||||
|
if let Some(problem) = &options.problem {
|
||||||
|
plain = plain.push(frame::text_role(
|
||||||
|
ui,
|
||||||
|
TypeRole::BodySmall,
|
||||||
|
false,
|
||||||
|
problem.as_str(),
|
||||||
|
ui.color(Role::Error),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
plain = plain.push(text_field(
|
||||||
ui,
|
ui,
|
||||||
TypeRole::LabelMedium,
|
Field::new(options.instance_name.as_str())
|
||||||
false,
|
.variant(FieldVariant::Outlined)
|
||||||
catalog.text(Key::DownloadOptionsTitle),
|
.label(catalog.text(Key::DownloadOptionsInstanceName))
|
||||||
ui.color(Role::OnSurfaceVariant),
|
.placeholder(catalog.text(Key::DownloadOptionsInstanceNameHint)),
|
||||||
),
|
Message::SetInstanceName,
|
||||||
loader_row(ui, catalog, options),
|
|
||||||
]
|
|
||||||
.spacing(ui.space(Space::X2))
|
|
||||||
.width(Length::Fill);
|
|
||||||
|
|
||||||
// 「加载器版本」这一段只在**真该有列表**时出现:原版没有、静态不兼容没有(原因在下面那行)、
|
|
||||||
// 取不到也没有(同样在原因行)。留一个空标题只会让人以为界面缺了一块。
|
|
||||||
if show_versions(options) {
|
|
||||||
body = body.push(frame::text_role(
|
|
||||||
ui,
|
|
||||||
TypeRole::LabelMedium,
|
|
||||||
false,
|
|
||||||
catalog.text(Key::DownloadOptionsLoaderVersion),
|
|
||||||
ui.color(Role::OnSurfaceVariant),
|
|
||||||
));
|
));
|
||||||
body = body.push(version_list(ui, catalog, options));
|
return plain.into();
|
||||||
}
|
}
|
||||||
|
|
||||||
body = body.push(text_field(
|
// 滚动区只剩 head + 六枚 chips(方案 B,D30 六审裁决;chips 档还可能有错误行,见 `section` 文档)。
|
||||||
ui,
|
//
|
||||||
Field::new(options.instance_name.as_str())
|
// 为什么把「安装选项」标签与中性说明都撤出滚动区:
|
||||||
.variant(FieldVariant::Outlined)
|
// 五审 82px → 六审 103px 的视口里,head(40) + 标签(17) + chips(32) + 两处间距(16) = 105px,
|
||||||
.label(catalog.text(Key::DownloadOptionsInstanceName))
|
// 差 2px 就把 chip 的底边框裁掉;再塞说明(20px)就差 27~29px,说明整块 0 像素。
|
||||||
.placeholder(catalog.text(Key::DownloadOptionsInstanceNameHint)),
|
// 并排标签(方案 C)在这里不可行:六枚 chip 实测已占 404px,正文列只有 424px,
|
||||||
Message::SetInstanceName,
|
// 加上「安装选项」必然横向溢出、把第六枚裁掉——那是拿一个已完成项换另一个。
|
||||||
));
|
// 所以按塔授权的 B 落地:**说明**搬到钉死区(见下面的 `pinned`);「安装选项」标签在
|
||||||
|
// Shared 档**删掉**(不是搬——八审裁决,见下三行),Pinned 档保留。滚动区只装控件。
|
||||||
|
// Shared 档**没有**「安装选项」标签:这是设计决定(D30 八审裁决)——六枚 chip 自解释,
|
||||||
|
// 且正文列只有 424px、六枚 chip 已占 404px,加标签必然横向裁掉最后一枚。
|
||||||
|
// 原版等 Pinned 档仍保留该标签(见上面 `!scroll` 那条路)。
|
||||||
|
let mut body = column![head, loader_row(ui, catalog, options)]
|
||||||
|
.spacing(ui.space(Space::X1))
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
// 中性说明**只在钉死区画一份**(八审:这里原先也画了一份,两处像素级重影)。
|
||||||
|
// 「加载器版本」这一段只在**真该有列表**时出现:原版没有、静态不兼容没有(原因在下面那行)、
|
||||||
|
// 取不到也没有(同样在原因行)。留一个空标题只会让人以为界面缺了一块。
|
||||||
if let Some(problem) = &options.problem {
|
if let Some(problem) = &options.problem {
|
||||||
body = body.push(frame::text_role(
|
body = body.push(frame::text_role(
|
||||||
ui,
|
ui,
|
||||||
@@ -126,7 +192,51 @@ pub(crate) fn section<'a>(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
body.into()
|
// 钉死区:加载器版本菜单 + 实例名(都不参与滚动)。见 `section` 的文档。
|
||||||
|
let mut pinned = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
|
||||||
|
if show_versions(options) {
|
||||||
|
// 菜单上方那一行:**有中性说明就显示说明**(它讲的是「这一家的叠法」,位置就在
|
||||||
|
// 版本菜单正上方,语义对得上);没有说明时才退回通用标签。
|
||||||
|
// 注意标签不是「搬」下来的:Shared 档的「安装选项」标签是**删掉**的(八审裁决,见上)。
|
||||||
|
// 高度相同(BodySmall 与 LabelMedium 都是一行),所以钉死区不涨。
|
||||||
|
let line = match &options.note {
|
||||||
|
Some(note) => frame::text_role(
|
||||||
|
ui,
|
||||||
|
TypeRole::BodySmall,
|
||||||
|
false,
|
||||||
|
note.as_str(),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
),
|
||||||
|
None => frame::text_role(
|
||||||
|
ui,
|
||||||
|
TypeRole::LabelMedium,
|
||||||
|
false,
|
||||||
|
catalog.text(Key::DownloadOptionsLoaderVersion),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
pinned = pinned.push(line);
|
||||||
|
pinned = pinned.push(version_list(ui, catalog, options));
|
||||||
|
}
|
||||||
|
pinned = pinned.push(text_field(
|
||||||
|
ui,
|
||||||
|
Field::new(options.instance_name.as_str())
|
||||||
|
.variant(FieldVariant::Outlined)
|
||||||
|
.label(catalog.text(Key::DownloadOptionsInstanceName))
|
||||||
|
.placeholder(catalog.text(Key::DownloadOptionsInstanceNameHint)),
|
||||||
|
Message::SetInstanceName,
|
||||||
|
));
|
||||||
|
|
||||||
|
column![
|
||||||
|
scrollable(body)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(frame::scroll_style(ui)),
|
||||||
|
pinned,
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Space::X2))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 加载器版本这一段要不要画(见 `section` 的注释)。
|
/// 加载器版本这一段要不要画(见 `section` 的注释)。
|
||||||
@@ -146,7 +256,7 @@ fn loader_row<'a>(ui: &'a Ui, catalog: Catalog, options: &'a OptionsView) -> Ele
|
|||||||
let mut line = row![]
|
let mut line = row![]
|
||||||
.spacing(ui.space(Space::X1))
|
.spacing(ui.space(Space::X1))
|
||||||
.align_y(Alignment::Center);
|
.align_y(Alignment::Center);
|
||||||
for loader in LOADERS.iter() {
|
for loader in Loader::ALL.iter() {
|
||||||
line = line.push(chip(
|
line = line.push(chip(
|
||||||
ui,
|
ui,
|
||||||
Chip::filter(
|
Chip::filter(
|
||||||
@@ -187,7 +297,7 @@ fn version_list<'a>(
|
|||||||
list = list.item(entry);
|
list = list.item(entry);
|
||||||
}
|
}
|
||||||
scrollable(menu(ui, list))
|
scrollable(menu(ui, list))
|
||||||
.height(Length::Fixed(ui.space(Space::X4) * VERSION_ROWS))
|
.height(Length::Fixed(version_menu_height(items.len())))
|
||||||
.style(frame::scroll_style(ui))
|
.style(frame::scroll_style(ui))
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ pub(super) fn three_column<'a>(
|
|||||||
catalog: Catalog,
|
catalog: Catalog,
|
||||||
model: &'a Download,
|
model: &'a Download,
|
||||||
rails: VersionRails,
|
rails: VersionRails,
|
||||||
|
body_height: f32,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
row![
|
row![
|
||||||
panel(
|
panel(
|
||||||
@@ -92,6 +93,7 @@ pub(super) fn three_column<'a>(
|
|||||||
catalog,
|
catalog,
|
||||||
model,
|
model,
|
||||||
rows::TailShare::of(rails.tier, &super::install::panel_state(model)),
|
rows::TailShare::of(rails.tier, &super::install::panel_state(model)),
|
||||||
|
Some(rows::whole_rows(body_height)),
|
||||||
))
|
))
|
||||||
.width(Length::Fixed(rails.list))
|
.width(Length::Fixed(rails.list))
|
||||||
.height(Length::Fill),
|
.height(Length::Fill),
|
||||||
|
|||||||
@@ -25,12 +25,15 @@
|
|||||||
//! 可视高(行高 44),版本号一个字都读不出来,用户想换版本点不到。
|
//! 可视高(行高 44),版本号一个字都读不出来,用户想换版本点不到。
|
||||||
//!
|
//!
|
||||||
//! 病根是 chips 档比三栏档多花两行 chip(tabs 48 + chips 48 + 两处间距),而选项区那一块
|
//! 病根是 chips 档比三栏档多花两行 chip(tabs 48 + chips 48 + 两处间距),而选项区那一块
|
||||||
//! 固定 ≈316px(版本行 40 + 标题 16 + 五个 loader chip 34 + 标题 16 + 菜单 112 + 实例名 56)。
|
//! 固定 ≈316px(版本行 40 + **六枚** loader chip 34 + 菜单 + 实例名 56;M222 之后菜单与实例名
|
||||||
|
//! 都在面板钉死区,见 `options/view.rs::section`)。
|
||||||
//! 同样的 760 高窗口里:三栏档列表还剩 158px(3 整行),chips 档只剩 18px。
|
//! 同样的 760 高窗口里:三栏档列表还剩 158px(3 整行),chips 档只剩 18px。
|
||||||
//!
|
//!
|
||||||
//! 修法是 [`TailShare`] 两档:三栏档照旧钉死(面板整块在屏内),chips 档**且面板真的占高度**
|
//! 修法是 [`TailShare`] 两档:**不带加载器版本列表**的选项区在三栏档照旧钉死(面板整块在屏内),
|
||||||
//! (选项区展开 / 安装中)时改成两半各 `Fill`——列表拿到 ≈182px(**4 整行**),面板拿到同样一半、
|
//! chips 档**且面板真的占高度**
|
||||||
//! **自己可滚**(顶端那个实心「安装」按钮与五个 loader chip 仍在屏内,实例名那一格滚一下就到)。
|
//! (选项区展开 / 安装中)时改成「**面板先保预算、剩下按整行给列表**」
|
||||||
|
//! (`tail::whole_rows` + `PANEL_BUDGET = 300`):1180×760 下列表 **176px = 4 整行**,
|
||||||
|
//! 面板拿走其余(≈307px)。实例名那格**已经钉死**在面板底部、不需要滚(M222 `6f87c8b`)。
|
||||||
//! 「刚进页面还没选版本」(`Hint`,面板只有一行提示)**不共享**:那一态 main 本来就有
|
//! 「刚进页面还没选版本」(`Hint`,面板只有一行提示)**不共享**:那一态 main 本来就有
|
||||||
//! **7 整行**在屏(不是 8:第 8 条墨迹其实是 `Pick a version, then install` 那句 hint,
|
//! **7 整行**在屏(不是 8:第 8 条墨迹其实是 `Pick a version, then install` 那句 hint,
|
||||||
//! 横向范围对不上版本号;700×760 上放 8 行要 352px,实测只剩 309~322px),五五分会砍成
|
//! 横向范围对不上版本号;700×760 上放 8 行要 352px,实测只剩 309~322px),五五分会砍成
|
||||||
@@ -72,21 +75,26 @@ use crate::theme::Ui;
|
|||||||
|
|
||||||
use super::super::frame;
|
use super::super::frame;
|
||||||
use super::super::layout::rails::{LIST_DATE_WIDTH, LIST_KIND_WIDTH, ROW_GAP, ROW_ICON_BUTTON};
|
use super::super::layout::rails::{LIST_DATE_WIDTH, LIST_KIND_WIDTH, ROW_GAP, ROW_ICON_BUTTON};
|
||||||
use super::super::layout::VersionRailTier;
|
|
||||||
use super::super::Message;
|
use super::super::Message;
|
||||||
use super::install::{self, PanelState};
|
use super::install::{self, panel_state, PanelState};
|
||||||
use super::model::{Download, VersionRow};
|
use super::model::{Download, VersionRow};
|
||||||
use super::states;
|
use super::states;
|
||||||
|
|
||||||
|
mod tail;
|
||||||
|
|
||||||
|
pub(crate) use tail::{whole_rows, TailShare};
|
||||||
|
|
||||||
/// 列表列(三栏档与 chips 档共用一份)。
|
/// 列表列(三栏档与 chips 档共用一份)。
|
||||||
///
|
///
|
||||||
/// `share` 决定「列表」与「尾部面板」怎么分这块高度:三栏档钉死面板、chips 档各吃一半
|
/// `share` 决定「列表」与「尾部面板」怎么分这块高度(真实分支见 `tail::TailShare::of`):
|
||||||
/// (见 [`TailShare`] 与模块文档「尾巴不能把列表压成缝」)。
|
/// `Pinned` = 面板按内容高钉在底部、列表吃剩下的;`Shared` = 面板先吃 `tail::PANEL_BUDGET`、
|
||||||
|
/// 列表按**整行**吃剩下的(`tail::whole_rows`)。哪些组合走哪一支由那一处决定,这里不重复判。
|
||||||
pub(super) fn content<'a>(
|
pub(super) fn content<'a>(
|
||||||
ui: &'a Ui,
|
ui: &'a Ui,
|
||||||
catalog: Catalog,
|
catalog: Catalog,
|
||||||
model: &'a Download,
|
model: &'a Download,
|
||||||
share: TailShare,
|
share: TailShare,
|
||||||
|
list_height: Option<f32>,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let visible = visible(model);
|
let visible = visible(model);
|
||||||
|
|
||||||
@@ -97,23 +105,40 @@ pub(super) fn content<'a>(
|
|||||||
} else if visible.is_empty() {
|
} else if visible.is_empty() {
|
||||||
states::empty(ui, catalog)
|
states::empty(ui, catalog)
|
||||||
} else {
|
} else {
|
||||||
list(ui, catalog, model, &visible)
|
// 只有 `Shared`(面板先保预算)那一档才按整行取高:`Pinned` 档(面板矮)实测已经
|
||||||
|
// 是整行(`04` 图),取整反而会把它从 6 行缩到 5 行。
|
||||||
|
let height = if matches!(share, TailShare::Shared) {
|
||||||
|
list_height
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
list(ui, catalog, model, &visible, height)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 列表与尾部面板之间**总是**有一条分隔线(三态一致):M123 的取证图里「选好了版本」
|
// 列表与尾部面板之间**总是**有一条分隔线(三态一致):M123 的取证图里「选好了版本」
|
||||||
// 那一态原先没有它,版本行与选项区第一行(版本号 + 安装按钮)读起来像同一块内容。
|
// 那一态原先没有它,版本行与选项区第一行(版本号 + 安装按钮)读起来像同一块内容。
|
||||||
let tail: Element<'a, Message> = column![divider::full(ui), install::panel(ui, catalog, model)]
|
let shared = matches!(share, TailShare::Shared);
|
||||||
.spacing(ui.space(Space::X3))
|
let tail: Element<'a, Message> = column![
|
||||||
.width(Length::Fill)
|
divider::full(ui),
|
||||||
.into();
|
install::panel_with(ui, catalog, model, shared)
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Space::X3))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into();
|
||||||
|
|
||||||
// chips 档:面板收回自己的一半、内部可滚(它原先按内容高钉死,把列表压成 18px 的缝)。
|
// chips 档 / 带版本列表的选项区:面板收回自己的一半。
|
||||||
let tail: Element<'a, Message> = match share {
|
//
|
||||||
TailShare::Pinned => tail,
|
// `PanelState::Options` 走**面板内部**的滚动(上半块可滚 + 实例名钉页脚,见 `install::panel`),
|
||||||
TailShare::Shared => scrollable(tail)
|
// 所以这里不再整块套一层 scrollable——那一层曾让窄窗出现两条并列滚动条,也因为把实例名
|
||||||
|
// 卷进滚动区而让它掉到首屏外(D30 r2 S7)。其它面板(安装中/失败)内容短,保留外层滚动。
|
||||||
|
let tail: Element<'a, Message> = match (share, panel_state(model)) {
|
||||||
|
(TailShare::Shared, PanelState::Options(_)) => tail,
|
||||||
|
(TailShare::Shared, _) => scrollable(tail)
|
||||||
|
.id(iced::widget::Id::new(PANEL_SCROLL_ID))
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.style(frame::scroll_style(ui))
|
.style(frame::scroll_style(ui))
|
||||||
.into(),
|
.into(),
|
||||||
|
(TailShare::Pinned, _) => tail,
|
||||||
};
|
};
|
||||||
|
|
||||||
column![
|
column![
|
||||||
@@ -129,45 +154,11 @@ pub(super) fn content<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 列表与尾部面板怎么分这块高度(M125 缺陷 1 的修法)。
|
/// 面板那一层 `scrollable` 的 Id(M222 D30 r1 S2 的出图取证用,见 `pages::harness`)。
|
||||||
///
|
///
|
||||||
/// | 档 | 分法 | 为什么 |
|
/// 用途只有一个:窄窗(chips 档 / 带版本列表的选项区)里面板自己可滚,出图要证明
|
||||||
/// |---|---|---|
|
/// 「实例名那格滚得到」——harness 拿到这个 Id 就能派一次 `scroll_to` 再拍。
|
||||||
/// | `Wide` / `Narrow` / `Compact` | [`TailShare::Pinned`]:面板按内容高钉在底部,列表吃剩下的 | 竖栏档在 760 高的窗口里列表还剩 158px(3 整行),面板整块在屏内——两样都对 |
|
pub(crate) const PANEL_SCROLL_ID: &str = "download-panel";
|
||||||
/// | `Chips` + 面板**占高度**(选项区展开 / 安装中) | [`TailShare::Shared`]:两半各 `Fill`,面板自己可滚 | chips 档比三栏档多花两行 chip(tabs 48 + chips 48 + 间距),列表只剩 **18px**(M123 实测 0.4 行),面板 316px 全在屏内也没用——版本号读不出来就换不了版本 |
|
|
||||||
/// | 任何档 + 只画 `Hint`(没选版本,面板只有一行) | [`TailShare::Pinned`] | 一行提示不值得对半分:700×760 下 main 是 **7 整行**在屏(不是 8——第 8 条墨迹其实是 `Pick a version, then install` 那句 hint,横向范围对不上版本号;要放 8 行得 352px,实测只剩 309~322px),对半分只剩 **4 整行**、下面留 160px 空白(r1 复审 P2-1,r2 修回 7) |
|
|
||||||
///
|
|
||||||
/// 判据是**档位**不是高度:页面的模型里只有 `window_width`(`pages/model.rs`),窗口高度
|
|
||||||
/// 没有接进页面(要接得改 `pages/mod.rs` 的入口签名,超出本页的边界)。所以这里按「哪一档
|
|
||||||
/// 的竖排 chrome 会把列表压没」来分,并在报告里登记这条判据的边界。
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub(super) enum TailShare {
|
|
||||||
/// 面板钉在底部(三栏档)。
|
|
||||||
Pinned,
|
|
||||||
/// 面板与列表各吃一半(chips 档)。
|
|
||||||
Shared,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TailShare {
|
|
||||||
/// 档位 + **面板形态** → 分高方式(纯函数,单测直接钉它)。
|
|
||||||
///
|
|
||||||
/// 两个条件必须同时成立才共享:
|
|
||||||
/// 1. 档位是 `Chips`(多花两行 chip,竖排预算最紧的那一档);
|
|
||||||
/// 2. 面板真的**占高度**——`Options`(选中了版本、选项区展开)或 `Install`(进度 / 失败)。
|
|
||||||
///
|
|
||||||
/// `Hint`(刚进页面还没选版本)只是**一行**提示(≈30px),对半分会让列表从 **7 整行**
|
|
||||||
/// 掉到 **4 整行**、下半块留 ≈160px 空画布(r1 复审 P2-1 实测:main 在 700×760 的
|
|
||||||
/// `Hint` 态是 7 整行——不是 8,第 8 条墨迹其实是 `Pick a version, then install` 那句
|
|
||||||
/// hint,横向范围对不上版本号;r2 修回 7 整行)。所以 `Hint` **在任何档位都钉死**。
|
|
||||||
pub(super) const fn of(tier: VersionRailTier, state: &PanelState<'_>) -> Self {
|
|
||||||
match (tier, state) {
|
|
||||||
(VersionRailTier::Chips, PanelState::Options(_) | PanelState::Install(_)) => {
|
|
||||||
Self::Shared
|
|
||||||
}
|
|
||||||
_ => Self::Pinned,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 搜索条 + 清空按钮 + 命中数(三者一行;命中数只在有查询词时出现)。
|
/// 搜索条 + 清空按钮 + 命中数(三者一行;命中数只在有查询词时出现)。
|
||||||
pub(super) fn search_row<'a>(
|
pub(super) fn search_row<'a>(
|
||||||
@@ -263,6 +254,7 @@ fn list<'a>(
|
|||||||
catalog: Catalog,
|
catalog: Catalog,
|
||||||
model: &'a Download,
|
model: &'a Download,
|
||||||
visible: &[&'a VersionRow],
|
visible: &[&'a VersionRow],
|
||||||
|
height: Option<f32>,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let entries = column(
|
let entries = column(
|
||||||
visible
|
visible
|
||||||
@@ -281,7 +273,8 @@ fn list<'a>(
|
|||||||
.scroller_width(frame::SCROLLBAR_WIDTH)
|
.scroller_width(frame::SCROLLBAR_WIDTH)
|
||||||
.margin(frame::SCROLLBAR_MARGIN),
|
.margin(frame::SCROLLBAR_MARGIN),
|
||||||
))
|
))
|
||||||
.height(Length::Fill)
|
// `Some` = 三栏档的 `Shared` 分支按**整行**取高(见 `tail::whole_rows`);`None` = 其它(含 chips 档)用 Fill。
|
||||||
|
.height(height.map_or(Length::Fill, Length::Fixed))
|
||||||
.style(frame::scroll_style(ui))
|
.style(frame::scroll_style(ui))
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! 「列表与尾巴面板怎么分高」+ 版本行的整行取整(M125 / M222 D30)。
|
||||||
|
//!
|
||||||
|
//! 从 `rows.rs` 拆出来的理由只有一条:那边撞 400 行硬线(项目自带
|
||||||
|
//! `components::gate::every_source_file_stays_under_the_line_limit` 会判红)。本文件是
|
||||||
|
//! [`TailShare`] 的**纯搬移**,加上 M222 新增的三个只读量([`ROW_PITCH`] / [`LIST_CHROME`] /
|
||||||
|
//! [`whole_rows`],见 `rows.rs::content` 的用法)。
|
||||||
|
|
||||||
|
use crate::pages::layout::VersionRailTier;
|
||||||
|
|
||||||
|
use crate::pages::download::install::PanelState;
|
||||||
|
|
||||||
|
/// 列表与尾部面板怎么分这块高度(M125 缺陷 1 的修法)。
|
||||||
|
///
|
||||||
|
/// | 档 | 分法 | 为什么 |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | `Wide` / `Narrow` / `Compact` + 面板**不带**加载器版本列表 | [`TailShare::Pinned`]:面板按内容高钉在底部,列表吃剩下的 | 原版面板矮(`04` 实测列表 6 行),整块在屏内 |
|
||||||
|
/// | 任何档 + 选项区**带加载器版本列表** | [`TailShare::Shared`]:面板先吃 [`PANEL_BUDGET`],剩下按整行给列表([`whole_rows`]) | 带版本列表的选项区实高 ≈307px,对半分(旧规则)把列表压到 110px;现在 1180×760 是 **176px = 4 整行** |
|
||||||
|
/// | `Chips` + 面板**占高度**(选项区展开 / 安装中) | [`TailShare::Shared`] | chips 档比三栏档多花两行 chip,列表只剩 **18px**(M123 实测 0.4 行)——版本号读不出来就换不了版本 |
|
||||||
|
/// | 任何档 + 只画 `Hint`(没选版本,面板只有一行) | [`TailShare::Pinned`] | 一行提示不值得对半分:700×760 下 main 是 **7 整行**在屏(不是 8——第 8 条墨迹其实是 `Pick a version, then install` 那句 hint,横向范围对不上版本号;要放 8 行得 352px,实测只剩 309~322px),对半分只剩 **4 整行**、下面留 160px 空白(r1 复审 P2-1,r2 修回 7) |
|
||||||
|
///
|
||||||
|
/// 分高方式按「档位 + 面板形态」判;**具体像素**由窗口高度算(`pages/download.rs::view_sized`
|
||||||
|
/// 把 `model.window_height` 经 `frame::body_height` 传下来,见 [`whole_rows`])。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum TailShare {
|
||||||
|
/// 面板钉在底部(三栏档)。
|
||||||
|
Pinned,
|
||||||
|
/// 面板先吃预算、列表按整行吃剩下的(带版本列表的选项区 / chips 档占高度时)。
|
||||||
|
Shared,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TailShare {
|
||||||
|
/// 档位 + **面板形态** → 分高方式(纯函数,单测直接钉它)。
|
||||||
|
///
|
||||||
|
/// 两个条件必须同时成立才共享:
|
||||||
|
/// 1. 档位是 `Chips`(多花两行 chip,竖排预算最紧的那一档);
|
||||||
|
/// 2. 面板真的**占高度**——`Options`(选中了版本、选项区展开)或 `Install`(进度 / 失败)。
|
||||||
|
///
|
||||||
|
/// `Hint`(刚进页面还没选版本)只是**一行**提示(≈30px),对半分会让列表从 **7 整行**
|
||||||
|
/// 掉到 **4 整行**、下半块留 ≈160px 空画布(r1 复审 P2-1 实测:main 在 700×760 的
|
||||||
|
/// `Hint` 态是 7 整行——不是 8,第 8 条墨迹其实是 `Pick a version, then install` 那句
|
||||||
|
/// hint,横向范围对不上版本号;r2 修回 7 整行)。所以 `Hint` **在任何档位都钉死**。
|
||||||
|
pub(crate) fn of(tier: VersionRailTier, state: &PanelState<'_>) -> Self {
|
||||||
|
match (tier, state) {
|
||||||
|
(VersionRailTier::Chips, PanelState::Options(_) | PanelState::Install(_)) => {
|
||||||
|
Self::Shared
|
||||||
|
}
|
||||||
|
// M222 D30 r1/r2:**带加载器版本列表**的选项区在任何档位都太高。三栏档修前
|
||||||
|
// 面板 204→372px,版本列表被压到 2.4 行(110px)且底缘切在第三行图标上。
|
||||||
|
// 现在走 Shared:面板先吃 `PANEL_BUDGET`,列表按**整行**取(`whole_rows`,
|
||||||
|
// 标定 44/97)⇒ 1180×760 列表 **176px = 4 整行**、面板 ≈307px;
|
||||||
|
// 900 档 308px = 7 整行。实例名与版本菜单都在面板钉死区(`options::section`)。
|
||||||
|
(_, PanelState::Options(options)) if options.has_loader_versions() => Self::Shared,
|
||||||
|
_ => Self::Pinned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 版本行的**实测节距**(D30 三审:`04` 图行内下载图标的行首 265/309/353/397/441/485,
|
||||||
|
/// 节距恰 **44.0**;首行选中高亮矩形 253–296 也是 44px)。
|
||||||
|
///
|
||||||
|
/// 列表是 `Fill + scrollable` 时,视口高度不是节距整数倍就会切在某一行中间——D30 r2 实测
|
||||||
|
/// `01` 是 245px(5.3 行),英文态露出 6px 竖干、中文态露出「预览版」上半截。三栏档的
|
||||||
|
/// `Shared`(面板先保预算)那一支现在按整行取高([`whole_rows`]),底缘落在行边界上。
|
||||||
|
pub(crate) const ROW_PITCH: f32 = 44.0;
|
||||||
|
|
||||||
|
/// 列表以外、列内要扣掉的固定占位(**列顶 156 → 列表顶 253**,D30 三审实测 = 97)。
|
||||||
|
///
|
||||||
|
/// 1180×760:正文列 580 ⇒ floor((580−97−300) / 44) × 44 = **176px = 4 整行**,分隔线落在 437。
|
||||||
|
/// 1180×900:floor((720−97−300) / 44) × 44 = **308px = 7 整行**(八审实测分隔线 569)。
|
||||||
|
/// **改搜索条 / 表头高度时要同步这个数。**
|
||||||
|
pub(crate) const LIST_CHROME: f32 = 97.0;
|
||||||
|
|
||||||
|
/// 面板(滚动区 + 钉死区)的**最小预算**。
|
||||||
|
///
|
||||||
|
/// 滚动区(八审后只剩 head 40 + 六枚 chips 32 + 间距 4 = **76**);
|
||||||
|
/// 钉死区 = 说明/标签行 17 + 间距 8 + 菜单 64 + 间距 8 + 实例名 56 ≈ **153**。
|
||||||
|
/// 取 300 覆盖两者(76 + 153 = 229)再留余量给 chips 底边框与滚动条。
|
||||||
|
/// D30 五审实测:上一版「对半分」把滚动区压到 82px——宽档切「安装选项」字身、六枚 chip
|
||||||
|
/// 0 像素;窄档 24px 全被留白吃掉。
|
||||||
|
pub(crate) const PANEL_BUDGET: f32 = 300.0;
|
||||||
|
|
||||||
|
/// 正文列高度 → 列表视口的**整行高度**(只给三栏档的 Shared 分支用)。
|
||||||
|
///
|
||||||
|
/// 分高规则从「对半分」改成「**面板先保预算,剩下的按整行给列表**」:760 档
|
||||||
|
/// (正文列 580)⇒ 580 − 97 − 300 = 183 ⇒ floor(183/44)×44 = **176px = 4 整行**;
|
||||||
|
/// 900 档 ⇒ 720 − 97 − 300 = 323 ⇒ **308px = 7 整行**(五审在 900 已验 7 行)。
|
||||||
|
/// 公式不变的部分是「列表只吃整行」,变的是分子——这正是塔要的「一次性预算重分配」。
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn whole_rows(column_height: f32) -> f32 {
|
||||||
|
let room = (column_height - LIST_CHROME - PANEL_BUDGET).max(ROW_PITCH);
|
||||||
|
(room / ROW_PITCH).floor() * ROW_PITCH
|
||||||
|
}
|
||||||
@@ -232,7 +232,13 @@ fn render<'a>(
|
|||||||
let page = match model.screen {
|
let page = match model.screen {
|
||||||
Screen::Instances => instances::view(ui, metrics, model, launch_form),
|
Screen::Instances => instances::view(ui, metrics, model, launch_form),
|
||||||
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
|
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
|
||||||
Screen::Download => download::view(ui, metrics, &model.download, model.lang),
|
Screen::Download => download::view_sized(
|
||||||
|
ui,
|
||||||
|
metrics,
|
||||||
|
&model.download,
|
||||||
|
model.lang,
|
||||||
|
model.window_height,
|
||||||
|
),
|
||||||
// M109:内容库页同样跟 `model.lang`
|
// M109:内容库页同样跟 `model.lang`
|
||||||
// M161:内容库要按**窗口高度**选版面(矮窗口不能把列表压扁),
|
// M161:内容库要按**窗口高度**选版面(矮窗口不能把列表压扁),
|
||||||
// 正文可用高由 `frame::body_height` 从窗口高减掉外壳算出(唯一来源)。
|
// 正文可用高由 `frame::body_height` 从窗口高减掉外壳算出(唯一来源)。
|
||||||
@@ -323,9 +329,20 @@ pub fn app_with_appearance<'a>(
|
|||||||
/// 真的传进来,否则「矮窗口那几张图」画的是高窗口的版面。
|
/// 真的传进来,否则「矮窗口那几张图」画的是高窗口的版面。
|
||||||
pub fn demo_model(screen: Screen, window_width: f32, window_height: f32) -> Model {
|
pub fn demo_model(screen: Screen, window_width: f32, window_height: f32) -> Model {
|
||||||
let mut model = Model::new(screen, window_width, window_height, Some(screen.nav()));
|
let mut model = Model::new(screen, window_width, window_height, Some(screen.nav()));
|
||||||
|
// 出图的中英取证(M222):`ULL_DL_LANG=zh` 时整页走中文目录。
|
||||||
|
//
|
||||||
|
// 为什么需要这一行:页面文案来自 `model.lang`(`download::view(.., lang)` → `lang.catalog()`),
|
||||||
|
// 而 `Model::new` 的 `lang` 写死 `En`、`demo_model` 之前也不碰它——所以 `LC_ALL=zh_CN.UTF-8`
|
||||||
|
// 对出图**完全无效**(`i18n::detect_env` 不在这条链上,Windows 上平台源还会压过 env)。
|
||||||
|
// 只有 harness / 单测走 `demo_model`,产品路径由 `app/view.rs` 从 `AppState` 建模型,零影响。
|
||||||
|
let lang = match std::env::var("ULL_DL_LANG").ok().as_deref().map(str::trim) {
|
||||||
|
Some("zh" | "zh_cn" | "zh-CN" | "zh_CN") => unlucky_i18n::Lang::ZhCn,
|
||||||
|
_ => unlucky_i18n::Lang::En,
|
||||||
|
};
|
||||||
|
model.lang = lang;
|
||||||
match screen {
|
match screen {
|
||||||
Screen::Instances => model.instances = instances::demo(),
|
Screen::Instances => model.instances = instances::demo(),
|
||||||
Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()),
|
Screen::Download => model.download = download::demo(lang.catalog()),
|
||||||
Screen::Content => model.content = content::demo(content::ContentKind::Mod, model.lang),
|
Screen::Content => model.content = content::demo(content::ContentKind::Mod, model.lang),
|
||||||
Screen::Logs => model.logs = logs::demo(model.lang),
|
Screen::Logs => model.logs = logs::demo(model.lang),
|
||||||
// M159:设置页「更新」组的出图夹具(一个可装的新版本 + 一次成功检查)。
|
// M159:设置页「更新」组的出图夹具(一个可装的新版本 + 一次成功检查)。
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ pub const fn general() -> &'static str {
|
|||||||
[--dry-run] 把已铺好的版本登记成实例(只读外部、不搬文件;缺省运行目录
|
[--dry-run] 把已铺好的版本登记成实例(只读外部、不搬文件;缺省运行目录
|
||||||
落 ULL 数据区的独立目录;--dry-run 只检查不写盘)
|
落 ULL 数据区的独立目录;--dry-run 只检查不写盘)
|
||||||
install <version> [--loader X] [--loader-version V] [--name N] [--dry-run]
|
install <version> [--loader X] [--loader-version V] [--name N] [--dry-run]
|
||||||
下载并安装版本本体(--loader fabric|quilt|neoforge|forge
|
下载并安装版本本体(--loader fabric|quilt|neoforge|forge|
|
||||||
时再叠一层加载器)→ 登记实例;--dry-run 只出计划
|
liteloader 时再叠一层加载器)→ 登记实例;--dry-run 只出计划
|
||||||
launch <instance> [--dry-run] [--account <name>] [--java <path>] [--username <name>]
|
launch <instance> [--dry-run] [--account <name>] [--java <path>] [--username <name>]
|
||||||
[--max-memory <MB>] 启动实例;--dry-run 只打印组装后的命令行
|
[--max-memory <MB>] 启动实例;--dry-run 只打印组装后的命令行
|
||||||
pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]
|
pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]
|
||||||
@@ -76,9 +76,10 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
|||||||
kind vanilla | fabric | quilt | forge | neoforge | optifine | liteloader;
|
kind vanilla | fabric | quilt | forge | neoforge | optifine | liteloader;
|
||||||
原版恒为 {\"kind\":\"vanilla\",\"version\":\"\"}(键恒存在、不为 null,脚本不必判空)
|
原版恒为 {\"kind\":\"vanilla\",\"version\":\"\"}(键恒存在、不为 null,脚本不必判空)
|
||||||
version id 里的加载器版本段;原版、以及 id 里没写版本的(1.12.2-LiteLoader)为空串
|
version id 里的加载器版本段;原版、以及 id 里没写版本的(1.12.2-LiteLoader)为空串
|
||||||
注意:kind 是「认得出」的口径,不是「装得了」的口径 —— optifine / liteloader 只能由别的
|
注意:kind 是「认得出」的口径,不是「装得了」的口径 —— optifine 只能由别的启动器装好后
|
||||||
启动器装好后继承进来,install --loader 不接受这两个值;kind=vanilla 只表示 id 里没有加载器
|
继承进来,install --loader 不接受它;liteloader 自 M222 起**可以**装
|
||||||
痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。
|
(install --loader liteloader)。kind=vanilla 只表示 id 里没有加载器痕迹(整包导入被
|
||||||
|
重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。
|
||||||
|
|
||||||
%prog% instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P] \
|
%prog% instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P] \
|
||||||
[--backend default|opengl|vulkan] [--clear]
|
[--backend default|opengl|vulkan] [--clear]
|
||||||
@@ -184,10 +185,18 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
|||||||
(profiles.json + config/instances/<id>.json)。已存在且校验通过的文件会跳过,
|
(profiles.json + config/instances/<id>.json)。已存在且校验通过的文件会跳过,
|
||||||
重复装同一版本不重复下载、不重复建实例。
|
重复装同一版本不重复下载、不重复建实例。
|
||||||
--dry-run:只输出计划(文件清单 + 总大小 + 每类资源的源选择),不下载、不登记。
|
--dry-run:只输出计划(文件清单 + 总大小 + 每类资源的源选择),不下载、不登记。
|
||||||
--loader:vanilla(缺省)/ fabric / quilt / neoforge / forge。装加载器时先生成
|
--loader:vanilla(缺省)/ fabric / quilt / neoforge / forge / liteloader。装加载器时先生成
|
||||||
versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 原版 + 主类 + loader 库),
|
versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 父版本 + 主类 + loader 库),
|
||||||
原版没装会先装原版,实例的 versionId 指向 loader 版本。
|
原版没装会先装原版,实例的 versionId 指向 loader 版本。
|
||||||
--loader-version:加载器版本;缺省取官方默认(Fabric 系首个稳定版,maven 系最新)。
|
optifine 认得出但**没接入安装管线**(要跑外部 Patcher 并原地改 zip),报 loader_unsupported;
|
||||||
|
其余认不出的拼法报 loader_unknown —— 两者不是一回事。
|
||||||
|
liteloader 的特殊之处:清单是从官方版本表**从零合成**的(mainClass = launchwrapper,
|
||||||
|
--tweakClass 追加在父链参数之后),不跑任何外部进程;不叠加载器时只占一个 loader 库。
|
||||||
|
<version> 位还可以写**已装的加载器版本 id**(`1.12.2-forge-14.23.5.2860 --loader liteloader`)
|
||||||
|
表示「叠在它上面」:原版版本从盘上的继承链读出来,父版本没装会具名报错(不会猜一个原版版本号)。
|
||||||
|
只有 liteloader 接受这种叠法,别家给 usage 错误说清「只能叠在原版上」。
|
||||||
|
--loader-version:加载器版本;缺省取官方默认(Fabric 系首个稳定版,maven 系最新,
|
||||||
|
LiteLoader 优先 release 分支、没有则最新快照)。
|
||||||
Forge 可写 47.2.0 或 1.20.1-47.2.0 两种形态。
|
Forge 可写 47.2.0 或 1.20.1-47.2.0 两种形态。
|
||||||
NeoForge/Forge 里需要跑官方 installer processors 的版本会真跑一遍(M35 已实装):
|
NeoForge/Forge 里需要跑官方 installer processors 的版本会真跑一遍(M35 已实装):
|
||||||
缺 Java 时按 D23 的回退链自动装一个托管 JDK;--dry-run 只列出要跑的 processor 与依赖库。
|
缺 Java 时按 D23 的回退链自动装一个托管 JDK;--dry-run 只列出要跑的 processor 与依赖库。
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! `ull install <version> --loader <kind> [--loader-version <v>]`:加载器层的编排。
|
//! `ull install <version> --loader <kind> [--loader-version <v>]`:加载器层的编排。
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! LoaderSource::resolve(官方 meta:版本列表 → profile JSON / installer profile)
|
//! 目标解析:<version> 位是原版版本 → 叠原版;是已装的加载器版本 id → 叠在它上面(仅 LiteLoader)
|
||||||
|
//! → LoaderSource::resolve(官方 meta:版本列表 → profile JSON / installer profile / LiteLoader 版本表)
|
||||||
//! → LoaderInstallPlan::build(纯计算:库清单 + loader 版本 JSON 落点)
|
//! → LoaderInstallPlan::build(纯计算:库清单 + loader 版本 JSON 落点)
|
||||||
//! → [--dry-run 到此为止,但多带一段 processors 计划]
|
//! → [--dry-run 到此为止,但多带一段 processors 计划]
|
||||||
//! → 原版已装?否 → vanilla::plan_and_install(先装原版)
|
//! → 原版已装?否 → vanilla::plan_and_install(先装原版)
|
||||||
@@ -13,16 +14,22 @@
|
|||||||
//! 「先装原版」是**硬前置**:loader 版本 JSON 只声明 `inheritsFrom`,jar 与资源都在原版那层,
|
//! 「先装原版」是**硬前置**:loader 版本 JSON 只声明 `inheritsFrom`,jar 与资源都在原版那层,
|
||||||
//! 原版缺失时启动链必然断裂。已装则跳过(`is_version_installed` 判据 = 版本 JSON 在盘上)。
|
//! 原版缺失时启动链必然断裂。已装则跳过(`is_version_installed` 判据 = 版本 JSON 在盘上)。
|
||||||
//!
|
//!
|
||||||
|
//! **父版本**:`<version>` 位写成加载器版本 id(`1.12.2-forge-14.23.5.2860`)时,它就是父版本,
|
||||||
|
//! 原版版本从盘上的继承链读出来(`addon::loader::parent`)。只有 LiteLoader 接受这种叠法
|
||||||
|
//! (它靠 `--tweakClass` 追加生效);别家**具名拒绝**,不会默默改成叠原版。
|
||||||
|
//!
|
||||||
//! processors 段的顺序也是硬约束:**先跑 processors,再写版本 JSON**。版本清单存在是
|
//! processors 段的顺序也是硬约束:**先跑 processors,再写版本 JSON**。版本清单存在是
|
||||||
//! "这版装完了"的判据,抢在 processors 前写会留下一个启动必崩却被报成已安装的实例。
|
//! "这版装完了"的判据,抢在 processors 前写会留下一个启动必崩却被报成已安装的实例。
|
||||||
|
|
||||||
use unlucky_core::addon::loader::{
|
use unlucky_core::addon::loader::{
|
||||||
LoaderError, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderSource,
|
parent, LoaderError, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderSource,
|
||||||
};
|
};
|
||||||
use unlucky_core::download::blocking::block_on;
|
use unlucky_core::download::blocking::block_on;
|
||||||
use unlucky_core::download::cache::now_ms;
|
use unlucky_core::download::cache::now_ms;
|
||||||
use unlucky_core::game::arguments::Environment;
|
use unlucky_core::game::arguments::Environment;
|
||||||
use unlucky_core::game::install::execute;
|
use unlucky_core::game::install::execute;
|
||||||
|
use unlucky_core::game::loader::{InstanceLoader, LoaderKind as WideLoaderKind};
|
||||||
|
use unlucky_core::game::GameLayout;
|
||||||
|
|
||||||
use super::report_loader::ProcessorSection;
|
use super::report_loader::ProcessorSection;
|
||||||
use super::{
|
use super::{
|
||||||
@@ -38,11 +45,11 @@ use crate::output::Report;
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// 用法错误、加载器未知/不支持、元数据读取失败、原版装不上、库下载失败、
|
/// 用法错误、加载器未知/不支持、父版本缺失或组合不支持、元数据读取失败、原版装不上、
|
||||||
/// installer processors 失败、实例登记失败。
|
/// 库下载失败、installer processors 失败、实例登记失败。
|
||||||
pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Report, CliError> {
|
pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Report, CliError> {
|
||||||
let version = args.version.trim().to_string();
|
let requested = args.version.trim().to_string();
|
||||||
if version.is_empty() {
|
if requested.is_empty() {
|
||||||
return Err(CliError::usage(
|
return Err(CliError::usage(
|
||||||
"install 需要版本 id:%prog% install <version> --loader fabric",
|
"install 需要版本 id:%prog% install <version> --loader fabric",
|
||||||
));
|
));
|
||||||
@@ -54,10 +61,17 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
|
|||||||
let meta_cache = layout.meta_cache();
|
let meta_cache = layout.meta_cache();
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
|
|
||||||
|
let (version, parent) = resolve_target(ctx, &game, &requested, kind)?;
|
||||||
|
|
||||||
let release = block_on(async {
|
let release = block_on(async {
|
||||||
let source = LoaderSource::new(&engine, &meta_cache);
|
let source = LoaderSource::new(&engine, &meta_cache);
|
||||||
source
|
source
|
||||||
.resolve(kind, &version, args.loader_version.as_deref())
|
.resolve_on(
|
||||||
|
kind,
|
||||||
|
&version,
|
||||||
|
args.loader_version.as_deref(),
|
||||||
|
parent.as_deref(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
.map_err(|error: LoaderError| map_loader_error(&error, ctx))?;
|
.map_err(|error: LoaderError| map_loader_error(&error, ctx))?;
|
||||||
@@ -73,8 +87,9 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
|
|||||||
layout: &game,
|
layout: &game,
|
||||||
release: &release,
|
release: &release,
|
||||||
environment: &Environment::host(),
|
environment: &Environment::host(),
|
||||||
vanilla_installed: game.is_version_installed(&version),
|
parent_installed: game.is_version_installed(&release.parent_version_id),
|
||||||
});
|
})
|
||||||
|
.map_err(|error| map_loader_error(&error, ctx))?;
|
||||||
let processor_plan = loader_processors::plan(ctx, &release)?;
|
let processor_plan = loader_processors::plan(ctx, &release)?;
|
||||||
|
|
||||||
if args.dry_run {
|
if args.dry_run {
|
||||||
@@ -162,3 +177,32 @@ fn map_loader_error(error: &LoaderError, ctx: &Ctx) -> CliError {
|
|||||||
};
|
};
|
||||||
CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint)
|
CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `<version>` 位 → (原版版本, 父版本 id)。
|
||||||
|
///
|
||||||
|
/// 两种写法(`InstallArgs.version` 的文档口径允许版本 id):
|
||||||
|
/// - 原版版本(`1.12.2`)→ 叠原版,父版本 = 原版;
|
||||||
|
/// - **已装的加载器版本 id**(`1.12.2-forge-14.23.5.2860`)→ 叠在它上面,原版版本从盘上的
|
||||||
|
/// 继承链读出来。只有 LiteLoader 接受这种叠法;别家给具名错误,不静默改成叠原版。
|
||||||
|
fn resolve_target(
|
||||||
|
ctx: &Ctx,
|
||||||
|
game: &GameLayout,
|
||||||
|
requested: &str,
|
||||||
|
kind: LoaderKind,
|
||||||
|
) -> Result<(String, Option<String>), CliError> {
|
||||||
|
if InstanceLoader::parse(requested).kind == WideLoaderKind::Vanilla {
|
||||||
|
return Ok((requested.to_string(), None));
|
||||||
|
}
|
||||||
|
if kind != LoaderKind::LiteLoader {
|
||||||
|
return Err(CliError::usage(format!(
|
||||||
|
"`{requested}` 是一个已装加载器版本 id:只有 LiteLoader 支持叠在别的加载器层上,{} 只能叠在原版上",
|
||||||
|
kind.label()
|
||||||
|
))
|
||||||
|
.with_hint(
|
||||||
|
"换成原版版本号:`%prog% install 1.20.1 --loader forge`;或先 `%prog% instances list` 看已装版本",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mc_version =
|
||||||
|
parent::root_version_id(game, requested).map_err(|error| map_loader_error(&error, ctx))?;
|
||||||
|
Ok((mc_version, Some(requested.to_string())))
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
//! --loader 缺省 → vanilla:M27 原版安装(resolve → plan → execute → register)
|
//! --loader 缺省 → vanilla:M27 原版安装(resolve → plan → execute → register)
|
||||||
//! --loader fabric|quilt → addon::loader:原版(已装则跳)→ loader 版本 JSON → 补 loader 库 → register
|
//! --loader fabric|quilt → addon::loader:原版(已装则跳)→ loader 版本 JSON → 补 loader 库 → register
|
||||||
//! --loader neoforge|forge → 同上;需要 installer processors 的版本再跑 M35 执行器(依赖库 → java → 版本 JSON)
|
//! --loader neoforge|forge → 同上;需要 installer processors 的版本再跑 M35 执行器(依赖库 → java → 版本 JSON)
|
||||||
|
//! --loader liteloader → 官方版本表 → 从零合成 launchwrapper 清单(不跑任何外部进程)
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! CLI 只做四件事:建下载引擎、把计划/结果排版成人读与 `--json` 两种形态
|
//! CLI 只做四件事:建下载引擎、把计划/结果排版成人读与 `--json` 两种形态
|
||||||
@@ -22,6 +23,7 @@ use std::sync::Arc;
|
|||||||
use unlucky_core::addon::loader::LoaderKind;
|
use unlucky_core::addon::loader::LoaderKind;
|
||||||
use unlucky_core::download::{DownloadEngine, DownloadErrorKind};
|
use unlucky_core::download::{DownloadEngine, DownloadErrorKind};
|
||||||
use unlucky_core::game::install::{InstallError, InstallReport, Registration};
|
use unlucky_core::game::install::{InstallError, InstallReport, Registration};
|
||||||
|
use unlucky_core::game::LoaderKind as WideLoaderKind;
|
||||||
|
|
||||||
use crate::cli::InstallArgs;
|
use crate::cli::InstallArgs;
|
||||||
use crate::context::Ctx;
|
use crate::context::Ctx;
|
||||||
@@ -33,9 +35,14 @@ pub(super) const CONCURRENCY: usize = 16;
|
|||||||
|
|
||||||
/// 执行 `install`:按 `--loader` 分发。
|
/// 执行 `install`:按 `--loader` 分发。
|
||||||
///
|
///
|
||||||
|
/// "认不出" 与 "认得但装不了" 是两件事,错误码也分开(`loader_unknown` / `loader_unsupported`):
|
||||||
|
/// `optifine` 属于后者——core 的识别词表里有它(`ull help instances` 也这么写),只是安装管线
|
||||||
|
/// 没接(要跑外部 `optifine.Patcher` + 原地改 zip)。两者混成一句"未知加载器"会让用户以为
|
||||||
|
/// 是自己的版本 id 写错了。
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// 用法错误、加载器未知、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。
|
/// 用法错误、加载器未知/认得但装不了、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。
|
||||||
pub fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
|
pub fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
|
||||||
match args
|
match args
|
||||||
.loader
|
.loader
|
||||||
@@ -46,10 +53,18 @@ pub fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
|
|||||||
None | Some("vanilla") | Some("none") => vanilla::run(ctx, args),
|
None | Some("vanilla") | Some("none") => vanilla::run(ctx, args),
|
||||||
Some(raw) => match LoaderKind::parse(raw) {
|
Some(raw) => match LoaderKind::parse(raw) {
|
||||||
Some(kind) => loader::run(ctx, args, kind),
|
Some(kind) => loader::run(ctx, args, kind),
|
||||||
None => Err(
|
None => Err(match WideLoaderKind::parse(raw) {
|
||||||
CliError::user("loader_unknown", format!("未知加载器 `{raw}`"))
|
Some(WideLoaderKind::OptiFine) => CliError::user(
|
||||||
.with_hint("--loader 取值:vanilla / fabric / quilt / neoforge / forge"),
|
"loader_unsupported",
|
||||||
),
|
"optifine 认得出但还没接入安装管线(要跑外部 Patcher 并原地改 zip)",
|
||||||
|
)
|
||||||
|
.with_hint(
|
||||||
|
"先用官方安装器装好,再 `%prog% instances list` 确认它已在;或用 fabric / quilt / neoforge / forge / liteloader",
|
||||||
|
),
|
||||||
|
_ => CliError::user("loader_unknown", format!("未知加载器 `{raw}`")).with_hint(
|
||||||
|
"--loader 取值:vanilla / fabric / quilt / neoforge / forge / liteloader(optifine 认得出但装不了)",
|
||||||
|
),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ mod tests {
|
|||||||
let release = LoaderRelease {
|
let release = LoaderRelease {
|
||||||
kind: LoaderKind::Fabric,
|
kind: LoaderKind::Fabric,
|
||||||
mc_version: "1.20.1".to_string(),
|
mc_version: "1.20.1".to_string(),
|
||||||
|
parent_version_id: "1.20.1".to_string(),
|
||||||
loader_version: "0.15.11".to_string(),
|
loader_version: "0.15.11".to_string(),
|
||||||
version_id: "1.20.1-fabric-0.15.11".to_string(),
|
version_id: "1.20.1-fabric-0.15.11".to_string(),
|
||||||
version_json: VersionJson::from_json_str(
|
version_json: VersionJson::from_json_str(
|
||||||
@@ -349,8 +350,9 @@ mod tests {
|
|||||||
layout: &layout,
|
layout: &layout,
|
||||||
release: &release,
|
release: &release,
|
||||||
environment: &Environment::host(),
|
environment: &Environment::host(),
|
||||||
vanilla_installed: true,
|
parent_installed: true,
|
||||||
})
|
})
|
||||||
|
.expect("计划应可组")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **M216 r2 P2-2**:装加载器时「先装原版」那一步替换了共享 `.minecraft` 里的原版清单,
|
/// **M216 r2 P2-2**:装加载器时「先装原版」那一步替换了共享 `.minecraft` 里的原版清单,
|
||||||
@@ -372,7 +374,6 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
warnings: vec!["已替换版本清单 /g/.minecraft/versions/1.20.1/1.20.1.json".to_string()],
|
warnings: vec!["已替换版本清单 /g/.minecraft/versions/1.20.1/1.20.1.json".to_string()],
|
||||||
};
|
};
|
||||||
|
|
||||||
let text = human_lines(false, &plan, None, Some(&vanilla), None, &[], "n", None).join("\n");
|
let text = human_lines(false, &plan, None, Some(&vanilla), None, &[], "n", None).join("\n");
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("已替换版本清单"),
|
text.contains("已替换版本清单"),
|
||||||
@@ -385,7 +386,6 @@ mod tests {
|
|||||||
// 没先装原版时不许凭空多说话。
|
// 没先装原版时不许凭空多说话。
|
||||||
let plain = human_lines(false, &plan, None, None, None, &[], "n", None).join("\n");
|
let plain = human_lines(false, &plan, None, None, None, &[], "n", None).join("\n");
|
||||||
assert!(!plain.contains("原版版本清单已替换"), "{plain}");
|
assert!(!plain.contains("原版版本清单已替换"), "{plain}");
|
||||||
|
|
||||||
// `--json` 的 `vanilla` 段(结构化,脚本可读)。
|
// `--json` 的 `vanilla` 段(结构化,脚本可读)。
|
||||||
let block = vanilla_json(Some(&vanilla)).expect("应当有 vanilla 段");
|
let block = vanilla_json(Some(&vanilla)).expect("应当有 vanilla 段");
|
||||||
assert_eq!(block["manifest"]["kind"], Value::String("replaced".into()));
|
assert_eq!(block["manifest"]["kind"], Value::String("replaced".into()));
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! `ull install --loader liteloader` 的**离线**可验面:词表分流、父版本缺失、help 文案。
|
||||||
|
//!
|
||||||
|
//! 这里刻意不联网:LiteLoader 的版本表在 `dl.liteloader.com`,把「拉到表之后」的链路放在
|
||||||
|
//! `crates/core/tests/liteloader/**`(真实响应夹具 + 合成 + 计划 + 落盘 + 启动参数)。
|
||||||
|
//! 本文件守的是「进门那一步」:`optifine` 与随机拼法的错误码必须分开、父版本没装必须具名报错、
|
||||||
|
//! 非 LiteLoader 不许叠在别的加载器层上。
|
||||||
|
|
||||||
|
#[path = "support/mod.rs"]
|
||||||
|
mod support;
|
||||||
|
|
||||||
|
use support::{parse_json, run_cli, TempDir};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn optifine_is_recognized_but_not_supported() {
|
||||||
|
let dir = TempDir::new("liteloader-optifine");
|
||||||
|
let (code, stdout, _) = run_cli(&[
|
||||||
|
"install",
|
||||||
|
"1.20.1",
|
||||||
|
"--loader",
|
||||||
|
"optifine",
|
||||||
|
"--json",
|
||||||
|
"--data-dir",
|
||||||
|
&dir.arg(),
|
||||||
|
]);
|
||||||
|
assert_eq!(code, 1);
|
||||||
|
let value = parse_json(&stdout);
|
||||||
|
assert_eq!(
|
||||||
|
value["error"]["code"], "loader_unsupported",
|
||||||
|
"optifine 是「认得但装不了」,不是未知加载器:{stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
value["error"]["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("optifine"),
|
||||||
|
"{stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `optifine` 之外,真正不认识的拼法仍是 `loader_unknown`(样本用**明显无效**的名字,
|
||||||
|
/// 不拿任何真实加载器名当「未知」前提)。
|
||||||
|
#[test]
|
||||||
|
fn genuinely_unknown_loader_stays_unknown() {
|
||||||
|
let dir = TempDir::new("liteloader-unknown");
|
||||||
|
let (code, stdout, _) = run_cli(&[
|
||||||
|
"install",
|
||||||
|
"1.20.1",
|
||||||
|
"--loader",
|
||||||
|
"ull-invalid-loader-test",
|
||||||
|
"--json",
|
||||||
|
"--data-dir",
|
||||||
|
&dir.arg(),
|
||||||
|
]);
|
||||||
|
assert_eq!(code, 1);
|
||||||
|
let value = parse_json(&stdout);
|
||||||
|
assert_eq!(value["error"]["code"], "loader_unknown", "{stdout}");
|
||||||
|
let hint = value["error"]["hint"].as_str().unwrap_or_default();
|
||||||
|
assert!(hint.contains("liteloader"), "提示要列全六家:{hint}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 父版本(`1.12.2-forge-…`)没装时:具名报缺哪个父版本,且**不发网络请求**。
|
||||||
|
#[test]
|
||||||
|
fn liteloader_on_a_missing_forge_parent_names_the_parent() {
|
||||||
|
let dir = TempDir::new("liteloader-missing-parent");
|
||||||
|
let (code, stdout, _) = run_cli(&[
|
||||||
|
"install",
|
||||||
|
"1.12.2-forge-14.23.5.2860",
|
||||||
|
"--loader",
|
||||||
|
"liteloader",
|
||||||
|
"--json",
|
||||||
|
"--data-dir",
|
||||||
|
&dir.arg(),
|
||||||
|
]);
|
||||||
|
assert_eq!(code, 1);
|
||||||
|
let value = parse_json(&stdout);
|
||||||
|
assert_eq!(value["error"]["code"], "loader_not_found", "{stdout}");
|
||||||
|
let message = value["error"]["message"].as_str().unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
message.contains("1.12.2-forge-14.23.5.2860"),
|
||||||
|
"要点名缺哪个父版本:{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只有 LiteLoader 接受「叠在别的加载器层上」;别家在进门处就说清(不静默改成叠原版)。
|
||||||
|
#[test]
|
||||||
|
fn only_liteloader_accepts_stacking_on_another_loader_layer() {
|
||||||
|
let dir = TempDir::new("liteloader-stack-guard");
|
||||||
|
let (code, stdout, _) = run_cli(&[
|
||||||
|
"install",
|
||||||
|
"1.12.2-forge-14.23.5.2860",
|
||||||
|
"--loader",
|
||||||
|
"fabric",
|
||||||
|
"--json",
|
||||||
|
"--data-dir",
|
||||||
|
&dir.arg(),
|
||||||
|
]);
|
||||||
|
assert_eq!(code, 1);
|
||||||
|
let value = parse_json(&stdout);
|
||||||
|
assert_eq!(value["error"]["code"], "usage", "{stdout}");
|
||||||
|
let message = value["error"]["message"].as_str().unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
message.contains("LiteLoader") && message.contains("Fabric"),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_help_names_the_liteloader_route() {
|
||||||
|
let (code, stdout, _) = run_cli(&["help", "install"]);
|
||||||
|
assert_eq!(code, 0);
|
||||||
|
for needle in [
|
||||||
|
"liteloader",
|
||||||
|
"optifine",
|
||||||
|
"launchwrapper",
|
||||||
|
"tweakClass",
|
||||||
|
"loader_unsupported",
|
||||||
|
"loader_unknown",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
stdout.contains(needle),
|
||||||
|
"`ull help install` 少了 `{needle}`:\n{stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn general_help_lists_liteloader_under_install() {
|
||||||
|
let (code, stdout, _) = run_cli(&["--help"]);
|
||||||
|
assert_eq!(code, 0);
|
||||||
|
let install_block = stdout
|
||||||
|
.lines()
|
||||||
|
.skip_while(|line| !line.trim_start().starts_with("install <version>"))
|
||||||
|
.take(3)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(
|
||||||
|
install_block.contains("liteloader"),
|
||||||
|
"总帮助的 install 段要提到 liteloader:\n{install_block}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -69,11 +69,13 @@ fn instances_list_json_shape() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn install_rejects_unknown_loader_before_touching_network() {
|
fn install_rejects_unknown_loader_before_touching_network() {
|
||||||
let dir = TempDir::new("install-loader");
|
let dir = TempDir::new("install-loader");
|
||||||
|
// 样本必须是一个**任何词表里都没有**的拼法:`liteloader` 自 M222 起是受支持加载器,
|
||||||
|
// 拿它当「未知」会让这条用例变成真去拉 dl.liteloader.com 的联网测试。
|
||||||
let (code, stdout, _) = run_cli(&[
|
let (code, stdout, _) = run_cli(&[
|
||||||
"install",
|
"install",
|
||||||
"1.20.1",
|
"1.20.1",
|
||||||
"--loader",
|
"--loader",
|
||||||
"liteloader",
|
"ull-invalid-loader-test",
|
||||||
"--json",
|
"--json",
|
||||||
"--data-dir",
|
"--data-dir",
|
||||||
&dir.arg(),
|
&dir.arg(),
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ pub const FABRIC_META: &str = "https://meta.fabricmc.net/v2";
|
|||||||
/// Quilt 官方 meta 根(同上,`bmclapi-quilt-meta`)。
|
/// Quilt 官方 meta 根(同上,`bmclapi-quilt-meta`)。
|
||||||
pub const QUILT_META: &str = "https://meta.quiltmc.org/v3";
|
pub const QUILT_META: &str = "https://meta.quiltmc.org/v3";
|
||||||
|
|
||||||
/// meta 根地址(按加载器)。
|
/// meta 根地址(按加载器;非 fabric 系给空串,调用方已按 [`LoaderKind::is_fabric_family`] 分流)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn meta_base(kind: LoaderKind) -> &'static str {
|
pub fn meta_base(kind: LoaderKind) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
|
LoaderKind::Fabric => FABRIC_META,
|
||||||
LoaderKind::Quilt => QUILT_META,
|
LoaderKind::Quilt => QUILT_META,
|
||||||
_ => FABRIC_META,
|
// Forge / NeoForge 走 maven,LiteLoader 走官方版本表——都不能回落到 fabric meta。
|
||||||
|
LoaderKind::NeoForge | LoaderKind::Forge | LoaderKind::LiteLoader => "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +161,7 @@ pub fn build_release(
|
|||||||
Ok(LoaderRelease {
|
Ok(LoaderRelease {
|
||||||
kind,
|
kind,
|
||||||
mc_version: mc_version.to_string(),
|
mc_version: mc_version.to_string(),
|
||||||
|
parent_version_id: mc_version.to_string(),
|
||||||
loader_version: loader_version.to_string(),
|
loader_version: loader_version.to_string(),
|
||||||
version_id,
|
version_id,
|
||||||
version_json,
|
version_json,
|
||||||
@@ -213,15 +216,28 @@ fn lift_flat_digest(library: &mut Library) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// loader 本体库必须在列表里:缺了就补一条(并用告警说明),别装出个"没有 loader"的实例。
|
/// loader 本体库必须在列表里:缺了就补一条(并用告警说明),别装出个"没有 loader"的实例。
|
||||||
|
///
|
||||||
|
/// 只有 fabric 系会走到这里(调用方 [`build_release`] 的入口已按
|
||||||
|
/// [`LoaderKind::is_fabric_family`] 分流);这里仍然**穷尽 match**,因为过去那对 `_ =>`
|
||||||
|
/// 会在新加一支加载器时把它静默当成 `fabric-loader` 补进库表。
|
||||||
fn ensure_loader_library(
|
fn ensure_loader_library(
|
||||||
kind: LoaderKind,
|
kind: LoaderKind,
|
||||||
loader_version: &str,
|
loader_version: &str,
|
||||||
libraries: &mut Vec<Library>,
|
libraries: &mut Vec<Library>,
|
||||||
warnings: &mut Vec<String>,
|
warnings: &mut Vec<String>,
|
||||||
) {
|
) {
|
||||||
let (group, artifact) = match kind {
|
let (group, artifact, maven_base) = match kind {
|
||||||
LoaderKind::Quilt => ("org.quiltmc", "quilt-loader"),
|
LoaderKind::Fabric => (
|
||||||
_ => ("net.fabricmc", "fabric-loader"),
|
"net.fabricmc",
|
||||||
|
"fabric-loader",
|
||||||
|
"https://maven.fabricmc.net/",
|
||||||
|
),
|
||||||
|
LoaderKind::Quilt => (
|
||||||
|
"org.quiltmc",
|
||||||
|
"quilt-loader",
|
||||||
|
"https://maven.quiltmc.org/repository/release/",
|
||||||
|
),
|
||||||
|
LoaderKind::NeoForge | LoaderKind::Forge | LoaderKind::LiteLoader => return,
|
||||||
};
|
};
|
||||||
let present = libraries.iter().any(|library| {
|
let present = libraries.iter().any(|library| {
|
||||||
library
|
library
|
||||||
@@ -231,10 +247,6 @@ fn ensure_loader_library(
|
|||||||
if present {
|
if present {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let maven_base = match kind {
|
|
||||||
LoaderKind::Quilt => "https://maven.quiltmc.org/repository/release/",
|
|
||||||
_ => "https://maven.fabricmc.net/",
|
|
||||||
};
|
|
||||||
libraries.push(Library {
|
libraries.push(Library {
|
||||||
name: format!("{group}:{artifact}:{loader_version}"),
|
name: format!("{group}:{artifact}:{loader_version}"),
|
||||||
url: Some(maven_base.to_string()),
|
url: Some(maven_base.to_string()),
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ pub fn build_release(
|
|||||||
Ok(LoaderRelease {
|
Ok(LoaderRelease {
|
||||||
kind,
|
kind,
|
||||||
mc_version: mc_version.to_string(),
|
mc_version: mc_version.to_string(),
|
||||||
|
parent_version_id: mc_version.to_string(),
|
||||||
loader_version,
|
loader_version,
|
||||||
version_id,
|
version_id,
|
||||||
version_json,
|
version_json,
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//! LiteLoader:官方 `versions.json` → **从零合成**一份 launchwrapper 版本 JSON。
|
||||||
|
//!
|
||||||
|
//! 依据(A 级:官方数据 + 官方构建脚本,不查二手转述):
|
||||||
|
//! - 数据源 `https://dl.liteloader.com/versions/versions.json`(2026-11 实测 200 / 49 KB,
|
||||||
|
//! 16 个原版版本);
|
||||||
|
//! - HMCL 的同类实现只做一件事(`HMCLCore/.../liteloader/LiteLoaderInstallTask.java`):
|
||||||
|
//! `mainClass = launchwrapper` + `--tweakClass <record.tweakClass>` + 「记录自带库 + 本体 jar」,
|
||||||
|
//! **不跑任何外部进程**;本模块按同样的协议独立实现,没有抄它的代码。
|
||||||
|
//!
|
||||||
|
//! # 四条与上游形状有关的硬事实(都实测过,别按直觉改)
|
||||||
|
//!
|
||||||
|
//! 1. **分支键是 md5,不是版本号**:`artefacts."com.mumfrey:liteloader"` 下的键是 md5,
|
||||||
|
//! 真正的版本号在记录的 `version` 字段(`1.7.10_04`)。按 `version` 去重(同一版本会挂
|
||||||
|
//! 两个 md5 键),`latest` 分支要跳过。
|
||||||
|
//! 2. **目录名是原版版本,不是 loader 版本**:release 直链形如
|
||||||
|
//! `<repo>/com/mumfrey/liteloader/1.7.10/liteloader-1.7.10_04.jar`(`1.7.10` 是原版)。
|
||||||
|
//! 3. **1.12.2 这类版本只有 `snapshots` 分支**(上游 16 个版本里 8 个如此,含最常用的 1.12.2)。
|
||||||
|
//! 记录的 `file`(`liteloader-1.12.2-SNAPSHOT.jar`)**下不动**,仓库里只有带时间戳的
|
||||||
|
//! `liteloader-1.12.2-<timestamp>-<build>-release.jar`;要拼出直链必须再读一次
|
||||||
|
//! `maven-metadata.xml`(`<timestamp>` + `<buildNumber>`)。见 [`snapshot_artifact_url`]。
|
||||||
|
//! 4. **分支级 `libraries` 不是要装的库**:那是 ForgeGradle 开发环境用的(1.12.2 的
|
||||||
|
//! `snapshots.libraries` 里挂着 `org.spongepowered:mixin`)。要进游戏 classpath 的是
|
||||||
|
//! **记录级** `libraries`(`launchwrapper` + `asm-all`),与 HMCL 的取法一致。
|
||||||
|
//!
|
||||||
|
//! # 库基址为什么按 group 分流
|
||||||
|
//!
|
||||||
|
//! 记录里的库大多不写 `url`,上游默认(Mojang 库仓)**实测 404**:`org.ow2.asm:asm-all:5.2`
|
||||||
|
//! 在 `libraries.minecraft.net` 上不存在(5.0.3 存在),而 `net.minecraft:launchwrapper:*`
|
||||||
|
//! 只在 Mojang 库仓有(Central / Forge maven 都 404)。所以 `net.minecraft` 走 Mojang 默认基址、
|
||||||
|
//! 其余走 Maven Central(实测 206)。这条分流有单测钉住,别把两侧弄反。
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use super::plan::{LoaderRelease, LoaderSupport};
|
||||||
|
use super::{LoaderError, LoaderKind};
|
||||||
|
use crate::game::arguments::{Argument, Arguments};
|
||||||
|
use crate::game::library::{Library, LibraryDownloads};
|
||||||
|
use crate::game::manifest::{DownloadInfo, VersionJson};
|
||||||
|
use crate::game::version::ReleaseType;
|
||||||
|
|
||||||
|
/// 官方版本表。
|
||||||
|
pub const VERSION_LIST_URL: &str = "https://dl.liteloader.com/versions/versions.json";
|
||||||
|
|
||||||
|
/// 本体 jar 与 release 分支的仓库根(BMCLAPI 有 `bmclapi-liteloader` 改写规则)。
|
||||||
|
pub const DEFAULT_REPO: &str = "https://dl.liteloader.com/versions/";
|
||||||
|
|
||||||
|
/// 非 Mojang 库的基址:Mojang 库仓里没有 `asm-all:5.2`(实测 404),Central 有(实测 206)。
|
||||||
|
pub const CENTRAL_REPO: &str = "https://repo1.maven.org/maven2/";
|
||||||
|
|
||||||
|
/// 快照分支的仓库根——**固定这一个**,不跟着 `repo.url` 走。
|
||||||
|
///
|
||||||
|
/// 上游每个原版版本的 `repo.url` 有两种形态(`…/versions/` 与 `…/repo/`),而**快照只发布在
|
||||||
|
/// snapshots 仓**:1.10.2 的 `repo.url` 是 `http://dl.liteloader.com/repo/`,按它拼
|
||||||
|
/// `…/repo/com/mumfrey/liteloader/1.10.2-SNAPSHOT/maven-metadata.xml` 实测会 302 到
|
||||||
|
/// `…/repositories/liteloader/…` 再 404;而
|
||||||
|
/// `https://repo.mumfrey.com/content/repositories/snapshots/com/mumfrey/liteloader/1.10.2-SNAPSHOT/maven-metadata.xml`
|
||||||
|
/// 是 200。HMCL 的构建脚本同样把这个快照仓**写死**(`UpdateLiteLoaderVersionList.SNAPSHOT_FILE`,
|
||||||
|
/// 注释还引了它自己的 issue #3147)。1.12.2 的 `repo.url` 恰好就是这条路径,所以只测 1.12.2
|
||||||
|
/// 看不出这个问题——单测专门钉了 1.10.2。
|
||||||
|
pub const SNAPSHOT_REPO: &str = "https://repo.mumfrey.com/content/repositories/snapshots/";
|
||||||
|
|
||||||
|
/// launchwrapper 主类(LiteLoader 靠它做 tweak 注入)。
|
||||||
|
pub const LAUNCHWRAPPER_MAIN: &str = "net.minecraft.launchwrapper.Launch";
|
||||||
|
|
||||||
|
/// 记录没带 launchwrapper 时的兜底版本(1.12.2 的记录带的是 1.12)。
|
||||||
|
const LAUNCHWRAPPER_FALLBACK: &str = "1.12";
|
||||||
|
|
||||||
|
/// 本体库坐标的 group / artifact。
|
||||||
|
const LOADER_GROUP: &str = "com.mumfrey";
|
||||||
|
/// 本体库坐标的 artifact 名。
|
||||||
|
const LOADER_ARTIFACT: &str = "liteloader";
|
||||||
|
|
||||||
|
mod versions;
|
||||||
|
|
||||||
|
pub use versions::{parse_versions, snapshot_artifact_url};
|
||||||
|
|
||||||
|
/// 一条可安装的 LiteLoader 版本(已归一化,不含 `latest` 别名分支)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LiteLoaderEntry {
|
||||||
|
/// 版本号(`1.7.10_04` / `1.12.2-SNAPSHOT`)
|
||||||
|
pub version: String,
|
||||||
|
/// 记录所属的原版版本(拼目录与文件名用)
|
||||||
|
pub mc_version: String,
|
||||||
|
/// 是否快照分支(只有快照时要再读 maven-metadata 才拼得出直链)
|
||||||
|
pub snapshot: bool,
|
||||||
|
/// tweak 类(`com.mumfrey.liteloader.launch.LiteLoaderTweaker`)
|
||||||
|
pub tweak_class: String,
|
||||||
|
/// 记录级库(**不含**本体 jar,本体由 [`build_release`] 补)
|
||||||
|
pub libraries: Vec<Library>,
|
||||||
|
/// release 分支的直链(快照分支为 `None`)
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// 快照分支的 maven 目录(`…/com/mumfrey/liteloader/1.12.2-SNAPSHOT/`)
|
||||||
|
pub snapshot_dir: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 默认版本:优先非快照(稳定),没有则取最新的那条。
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_entry(entries: &[LiteLoaderEntry]) -> Option<&LiteLoaderEntry> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| !entry.snapshot)
|
||||||
|
.or_else(|| entries.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按版本号找一条(大小写不敏感)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn find_entry<'a>(
|
||||||
|
entries: &'a [LiteLoaderEntry],
|
||||||
|
version: &str,
|
||||||
|
) -> Option<&'a LiteLoaderEntry> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.version.eq_ignore_ascii_case(version.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合成 loader 版本 JSON(**纯函数**:直链由调用方给,快照的直链要先去查 maven-metadata)。
|
||||||
|
///
|
||||||
|
/// 拼出来的清单只有四件事:主类 = launchwrapper、`--tweakClass` 进 `arguments.game`、
|
||||||
|
/// 库 = 记录库 + 本体 jar、`inheritsFrom` = 父版本(缺省原版)。
|
||||||
|
///
|
||||||
|
/// **`arguments.game` 而不是 `minecraftArguments`**:继承合并里 `minecraftArguments` 是
|
||||||
|
/// **子覆盖父**([`crate::game::inherit::merge`]),写它会把父链(Forge 1.12.2 的
|
||||||
|
/// `--tweakClass FMLTweaker`)整串顶掉,Forge 当场不加载;`arguments.game` 才是父在前、子在后
|
||||||
|
/// **追加**,且启动时排在 legacy 参数之后([`crate::launch::game_args`] 第 24/25 项)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 记录缺 `tweakClass`、或没有本体直链时返回 metadata 错误(没有它这份清单装上也起不来)。
|
||||||
|
pub fn build_release(
|
||||||
|
mc_version: &str,
|
||||||
|
parent_version_id: &str,
|
||||||
|
entry: &LiteLoaderEntry,
|
||||||
|
artifact_url: &str,
|
||||||
|
) -> Result<LoaderRelease, LoaderError> {
|
||||||
|
if entry.tweak_class.trim().is_empty() {
|
||||||
|
return Err(LoaderError::metadata(format!(
|
||||||
|
"LiteLoader {} 的记录没有 tweakClass:这份清单装上也起不来",
|
||||||
|
entry.version
|
||||||
|
))
|
||||||
|
.with_hint("换一个 LiteLoader 版本,或核对 dl.liteloader.com 的版本表"));
|
||||||
|
}
|
||||||
|
if artifact_url.trim().is_empty() {
|
||||||
|
return Err(LoaderError::metadata(format!(
|
||||||
|
"LiteLoader {} 没有可下载的本体 jar 地址",
|
||||||
|
entry.version
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let version_id = LoaderKind::LiteLoader.version_id(mc_version, &entry.version);
|
||||||
|
let mut libraries = entry.libraries.clone();
|
||||||
|
libraries.push(loader_library(entry, artifact_url));
|
||||||
|
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
if parent_version_id != mc_version {
|
||||||
|
warnings.push(format!(
|
||||||
|
"LiteLoader {} 叠在 {parent_version_id} 上(父链不是原版 {mc_version}):tweakClass 追加在父链参数之后,父链自己的 tweakClass 不动",
|
||||||
|
entry.version
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !libraries
|
||||||
|
.iter()
|
||||||
|
.any(|library| library.name.starts_with("net.minecraft:launchwrapper:"))
|
||||||
|
{
|
||||||
|
libraries.push(Library {
|
||||||
|
name: format!("net.minecraft:launchwrapper:{LAUNCHWRAPPER_FALLBACK}"),
|
||||||
|
..Library::default()
|
||||||
|
});
|
||||||
|
warnings.push(
|
||||||
|
"LiteLoader 的记录没带 launchwrapper:已补 1.12(Launch 主类就靠它)".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let version_json = VersionJson {
|
||||||
|
id: Some(version_id.clone()),
|
||||||
|
name: Some(format!("{mc_version} · LiteLoader {}", entry.version)),
|
||||||
|
inherits_from: Some(parent_version_id.to_string()),
|
||||||
|
// 客户端 jar 恒在原版那层(父链与 loader 层都不带 jar)。
|
||||||
|
jar: Some(mc_version.to_string()),
|
||||||
|
main_class: Some(LAUNCHWRAPPER_MAIN.to_string()),
|
||||||
|
minecraft_arguments: None,
|
||||||
|
arguments: Some(Arguments {
|
||||||
|
game: vec![
|
||||||
|
Argument::Plain("--tweakClass".to_string()),
|
||||||
|
Argument::Plain(entry.tweak_class.clone()),
|
||||||
|
],
|
||||||
|
jvm: Vec::new(),
|
||||||
|
}),
|
||||||
|
version_type: Some(if entry.snapshot {
|
||||||
|
ReleaseType::Snapshot
|
||||||
|
} else {
|
||||||
|
ReleaseType::Release
|
||||||
|
}),
|
||||||
|
libraries: Some(libraries),
|
||||||
|
..VersionJson::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(LoaderRelease {
|
||||||
|
kind: LoaderKind::LiteLoader,
|
||||||
|
mc_version: mc_version.to_string(),
|
||||||
|
parent_version_id: parent_version_id.to_string(),
|
||||||
|
loader_version: entry.version.clone(),
|
||||||
|
version_id,
|
||||||
|
version_json,
|
||||||
|
support: LoaderSupport::JsonRoute,
|
||||||
|
installer: None,
|
||||||
|
install_profile: None,
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本体 jar 的那一条库:直链绝对地址(快照的路径与 maven 坐标不一致,不能靠坐标推导)。
|
||||||
|
fn loader_library(entry: &LiteLoaderEntry, artifact_url: &str) -> Library {
|
||||||
|
Library {
|
||||||
|
name: format!("{LOADER_GROUP}:{LOADER_ARTIFACT}:{}", entry.version),
|
||||||
|
url: Some(DEFAULT_REPO.to_string()),
|
||||||
|
downloads: Some(LibraryDownloads {
|
||||||
|
artifact: Some(DownloadInfo {
|
||||||
|
id: None,
|
||||||
|
sha1: None,
|
||||||
|
size: None,
|
||||||
|
url: Some(artifact_url.to_string()),
|
||||||
|
path: None,
|
||||||
|
}),
|
||||||
|
classifiers: BTreeMap::new(),
|
||||||
|
}),
|
||||||
|
..Library::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! 官方版本表(`dl.liteloader.com/versions/versions.json`)的解析:原版版本 → 条目列表。
|
||||||
|
//!
|
||||||
|
//! 上游形状的四条硬事实写在父模块的文档里([`super`]);这里只放解析与 URL 拼装。
|
||||||
|
//! 单独一个文件的理由是行数:父模块要装「解析」与「合成清单」两件事,一起写会破 400 行硬线。
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::{LiteLoaderEntry, LoaderError, CENTRAL_REPO, DEFAULT_REPO, SNAPSHOT_REPO};
|
||||||
|
use crate::game::library::Library;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Root {
|
||||||
|
#[serde(default)]
|
||||||
|
versions: BTreeMap<String, GameVersions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GameVersions {
|
||||||
|
#[serde(default)]
|
||||||
|
repo: Option<Repo>,
|
||||||
|
#[serde(default)]
|
||||||
|
artefacts: Option<Branch>,
|
||||||
|
#[serde(default)]
|
||||||
|
snapshots: Option<Branch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Repo {
|
||||||
|
#[serde(default)]
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Branch {
|
||||||
|
#[serde(rename = "com.mumfrey:liteloader", default)]
|
||||||
|
liteloader: BTreeMap<String, Record>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Record {
|
||||||
|
#[serde(default, rename = "tweakClass")]
|
||||||
|
tweak_class: String,
|
||||||
|
#[serde(default)]
|
||||||
|
libraries: Vec<Library>,
|
||||||
|
#[serde(default)]
|
||||||
|
file: String,
|
||||||
|
#[serde(default)]
|
||||||
|
version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
stream: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析官方版本表:原版版本 → 该原版下的 LiteLoader 版本(**新在前**)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 顶层不是 `{"versions": {…}}`、或一个版本都认不出时返回 metadata 错误。
|
||||||
|
pub fn parse_versions(json: &str) -> Result<BTreeMap<String, Vec<LiteLoaderEntry>>, LoaderError> {
|
||||||
|
let root: Root = serde_json::from_str(json)
|
||||||
|
.map_err(|error| LoaderError::metadata(format!("LiteLoader 版本表解析失败:{error}")))?;
|
||||||
|
let mut out: BTreeMap<String, Vec<LiteLoaderEntry>> = BTreeMap::new();
|
||||||
|
for (mc_version, game) in &root.versions {
|
||||||
|
let repo = game
|
||||||
|
.repo
|
||||||
|
.as_ref()
|
||||||
|
.map(|repo| https_repo(&repo.url))
|
||||||
|
.filter(|url| !url.is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_REPO.to_string());
|
||||||
|
let mut entries: Vec<LiteLoaderEntry> = Vec::new();
|
||||||
|
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||||
|
if let Some(branch) = &game.artefacts {
|
||||||
|
collect(branch, mc_version, &repo, false, &mut entries, &mut seen);
|
||||||
|
}
|
||||||
|
if let Some(branch) = &game.snapshots {
|
||||||
|
collect(branch, mc_version, &repo, true, &mut entries, &mut seen);
|
||||||
|
}
|
||||||
|
if entries.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.sort_by(|left, right| cmp_versions(&right.version, &left.version));
|
||||||
|
out.insert(mc_version.clone(), entries);
|
||||||
|
}
|
||||||
|
if out.is_empty() {
|
||||||
|
return Err(LoaderError::not_found(
|
||||||
|
"LiteLoader 版本表里没有任何可用版本(数据源形状变了?)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收一个分支的记录(`latest` 别名与重复版本跳过)。
|
||||||
|
fn collect(
|
||||||
|
branch: &Branch,
|
||||||
|
mc_version: &str,
|
||||||
|
repo: &str,
|
||||||
|
snapshot_branch: bool,
|
||||||
|
out: &mut Vec<LiteLoaderEntry>,
|
||||||
|
seen: &mut BTreeSet<String>,
|
||||||
|
) {
|
||||||
|
for (key, record) in &branch.liteloader {
|
||||||
|
if key.eq_ignore_ascii_case("latest") || record.version.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !seen.insert(record.version.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 记录自己标 SNAPSHOT 的也走快照路线(release 分支里出现过混编)。
|
||||||
|
let snapshot = snapshot_branch || record.stream.eq_ignore_ascii_case("SNAPSHOT");
|
||||||
|
let dir = format!("{repo}com/mumfrey/liteloader/{mc_version}/");
|
||||||
|
let (url, snapshot_dir) = if snapshot {
|
||||||
|
// 快照仓是**固定**的(见 `SNAPSHOT_REPO` 的注释),不跟 `repo.url` 走。
|
||||||
|
let snap_dir = format!("{SNAPSHOT_REPO}com/mumfrey/liteloader/{mc_version}-SNAPSHOT/");
|
||||||
|
(None, Some(snap_dir))
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
(!record.file.is_empty()).then(|| format!("{dir}{}", record.file)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
out.push(LiteLoaderEntry {
|
||||||
|
version: record.version.clone(),
|
||||||
|
mc_version: mc_version.to_string(),
|
||||||
|
snapshot,
|
||||||
|
tweak_class: record.tweak_class.clone(),
|
||||||
|
libraries: record.libraries.iter().map(rebased).collect(),
|
||||||
|
url,
|
||||||
|
snapshot_dir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `http://…` → `https://…`(上游表里混着 http;HMCL 同样改写)。
|
||||||
|
fn https_repo(url: &str) -> String {
|
||||||
|
if let Some(rest) = url.strip_prefix("http://") {
|
||||||
|
return format!("https://{rest}");
|
||||||
|
}
|
||||||
|
url.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录级库:保留上游字段(`http` → `https`),只给「没写 url」的库补基址(见模块文档的 group 分流)。
|
||||||
|
fn rebased(library: &Library) -> Library {
|
||||||
|
let mut out = library.clone();
|
||||||
|
match out
|
||||||
|
.url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|url| !url.is_empty())
|
||||||
|
{
|
||||||
|
Some(url) => out.url = Some(https_repo(url)),
|
||||||
|
None => {
|
||||||
|
let group = out.name.split(':').next().unwrap_or_default();
|
||||||
|
if group != "net.minecraft" {
|
||||||
|
out.url = Some(CENTRAL_REPO.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 版本号比较:按 `.`/`-`/`_` 切段,纯数字段按数值比,其余按字面量比。
|
||||||
|
fn cmp_versions(left: &str, right: &str) -> std::cmp::Ordering {
|
||||||
|
let lhs: Vec<&str> = left.split(['.', '-', '_']).collect();
|
||||||
|
let rhs: Vec<&str> = right.split(['.', '-', '_']).collect();
|
||||||
|
for (a, b) in lhs.iter().zip(rhs.iter()) {
|
||||||
|
let order = match (a.parse::<u64>(), b.parse::<u64>()) {
|
||||||
|
(Ok(a), Ok(b)) => a.cmp(&b),
|
||||||
|
_ => a.cmp(b),
|
||||||
|
};
|
||||||
|
if order != std::cmp::Ordering::Equal {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lhs.len().cmp(&rhs.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 `maven-metadata.xml` 拼出快照产物的直链(`<dir>liteloader-<mc>-<ts>-<build>-release.jar`)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 缺 `<timestamp>`/`<buildNumber>` 时返回 metadata 错误(宁可报错,也不猜一个 404 直链)。
|
||||||
|
pub fn snapshot_artifact_url(
|
||||||
|
entry: &LiteLoaderEntry,
|
||||||
|
metadata_xml: &str,
|
||||||
|
) -> Result<String, LoaderError> {
|
||||||
|
let dir = entry.snapshot_dir.as_deref().ok_or_else(|| {
|
||||||
|
LoaderError::metadata(format!(
|
||||||
|
"LiteLoader {} 不是快照版本,没有 maven 目录",
|
||||||
|
entry.version
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let timestamp = tag_value(metadata_xml, "timestamp").ok_or_else(|| {
|
||||||
|
LoaderError::metadata(format!(
|
||||||
|
"{} 的 maven-metadata.xml 里没有 <timestamp>:拿不到快照直链",
|
||||||
|
entry.version
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let build = tag_value(metadata_xml, "buildNumber").ok_or_else(|| {
|
||||||
|
LoaderError::metadata(format!(
|
||||||
|
"{} 的 maven-metadata.xml 里没有 <buildNumber>",
|
||||||
|
entry.version
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(format!(
|
||||||
|
"{dir}liteloader-{}-{timestamp}-{build}-release.jar",
|
||||||
|
entry.mc_version
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取 `<tag>…</tag>` 里的文本(maven 元数据只有一层,不引 XML 库,与 [`super::maven`] 同口径)。
|
||||||
|
fn tag_value(xml: &str, tag: &str) -> Option<String> {
|
||||||
|
let open = format!("<{tag}>");
|
||||||
|
let close = format!("</{tag}>");
|
||||||
|
let start = xml.find(&open)? + open.len();
|
||||||
|
let end = xml[start..].find(&close)? + start;
|
||||||
|
let value = xml[start..end].trim();
|
||||||
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
|
}
|
||||||
@@ -6,6 +6,18 @@
|
|||||||
//! 镜像:两家 maven 都在 D7 镜像表里有 BMCLAPI 规则(`bmclapi-neoforge` /
|
//! 镜像:两家 maven 都在 D7 镜像表里有 BMCLAPI 规则(`bmclapi-neoforge` /
|
||||||
//! `bmclapi-forge-maven-net`),所以这里一律**写官方地址**,重写交给 `core::download`
|
//! `bmclapi-forge-maven-net`),所以这里一律**写官方地址**,重写交给 `core::download`
|
||||||
//! 的镜像展开(与 M27 的"不拼镜像 URL"纪律一致)。
|
//! 的镜像展开(与 M27 的"不拼镜像 URL"纪律一致)。
|
||||||
|
//!
|
||||||
|
//! # 非 maven 族一律「没有答案」,不再回落 Forge
|
||||||
|
//!
|
||||||
|
//! 这里每个函数过去都用 `_ =>` 把「不是 NeoForge」的全当 Forge。加了第三个数据源
|
||||||
|
//! (LiteLoader:官方版本表 + 从零合成,不是 maven)之后,那个通配会**静默拼出 Forge 的
|
||||||
|
//! URL**:请求 404 或被当成 Forge 装成一个"像模像样"的错实例。现在:
|
||||||
|
//!
|
||||||
|
//! - 地址类函数返回 `Option`,非 maven 族给 `None`;
|
||||||
|
//! - 版本判定类函数对非 maven 族给「不匹配 / 放行原样」,语义上本就如此。
|
||||||
|
//!
|
||||||
|
//! 真正的入口分流在 [`super::meta::LoaderSource`](按
|
||||||
|
//! [`LoaderKind::is_fabric_family`] / [`LoaderKind::is_maven_family`] 三路派发)。
|
||||||
|
|
||||||
use super::LoaderKind;
|
use super::LoaderKind;
|
||||||
|
|
||||||
@@ -16,49 +28,50 @@ pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net";
|
|||||||
/// NeoForge 唯一走 Forge 布局的原版版本(1.20.1 是"NeoForge 还叫 forge 的时候")。
|
/// NeoForge 唯一走 Forge 布局的原版版本(1.20.1 是"NeoForge 还叫 forge 的时候")。
|
||||||
pub const NEOFORGE_FORGE_LAYOUT_MC: &str = "1.20.1";
|
pub const NEOFORGE_FORGE_LAYOUT_MC: &str = "1.20.1";
|
||||||
|
|
||||||
/// maven 根地址。
|
/// maven 根地址(非 maven 族给空串;调用方已按 [`LoaderKind::is_maven_family`] 分流)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn base(kind: LoaderKind) -> &'static str {
|
pub fn base(kind: LoaderKind) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
LoaderKind::NeoForge => NEOFORGE_MAVEN,
|
LoaderKind::NeoForge => NEOFORGE_MAVEN,
|
||||||
_ => FORGE_MAVEN,
|
LoaderKind::Forge => FORGE_MAVEN,
|
||||||
|
// fabric 系与 LiteLoader 都不是 maven 族——绝不能回落到 Forge 的仓库。
|
||||||
|
LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 该原版版本对应的 `maven-metadata.xml` 地址。
|
/// maven 路径段(`group/artifact`)与该原版版本的匹配规则。
|
||||||
#[must_use]
|
fn layout(kind: LoaderKind, mc_version: &str) -> Option<(&'static str, &'static str)> {
|
||||||
pub fn metadata_url(kind: LoaderKind, mc_version: &str) -> String {
|
match kind {
|
||||||
let (root, path) = match kind {
|
|
||||||
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => {
|
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => {
|
||||||
(NEOFORGE_MAVEN, "net/neoforged/forge")
|
Some((NEOFORGE_MAVEN, "net/neoforged/forge"))
|
||||||
}
|
}
|
||||||
LoaderKind::NeoForge => (NEOFORGE_MAVEN, "net/neoforged/neoforge"),
|
LoaderKind::NeoForge => Some((NEOFORGE_MAVEN, "net/neoforged/neoforge")),
|
||||||
_ => (FORGE_MAVEN, "net/minecraftforge/forge"),
|
LoaderKind::Forge => Some((FORGE_MAVEN, "net/minecraftforge/forge")),
|
||||||
};
|
LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => None,
|
||||||
format!("{root}/{path}/maven-metadata.xml")
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// installer jar 地址。
|
/// 该原版版本对应的 `maven-metadata.xml` 地址;非 maven 族给 `None`。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn installer_url(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String {
|
pub fn metadata_url(kind: LoaderKind, mc_version: &str) -> Option<String> {
|
||||||
let (root, path, file) = match kind {
|
let (root, path) = layout(kind, mc_version)?;
|
||||||
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => (
|
Some(format!("{root}/{path}/maven-metadata.xml"))
|
||||||
NEOFORGE_MAVEN,
|
}
|
||||||
"net/neoforged/forge",
|
|
||||||
format!("forge-{maven_version}-installer.jar"),
|
/// installer jar 地址;非 maven 族给 `None`。
|
||||||
),
|
#[must_use]
|
||||||
LoaderKind::NeoForge => (
|
pub fn installer_url(kind: LoaderKind, mc_version: &str, maven_version: &str) -> Option<String> {
|
||||||
NEOFORGE_MAVEN,
|
let (root, path) = layout(kind, mc_version)?;
|
||||||
"net/neoforged/neoforge",
|
let file = match kind {
|
||||||
format!("neoforge-{maven_version}-installer.jar"),
|
LoaderKind::NeoForge if mc_version != NEOFORGE_FORGE_LAYOUT_MC => {
|
||||||
),
|
format!("neoforge-{maven_version}-installer.jar")
|
||||||
_ => (
|
}
|
||||||
FORGE_MAVEN,
|
LoaderKind::NeoForge | LoaderKind::Forge => {
|
||||||
"net/minecraftforge/forge",
|
format!("forge-{maven_version}-installer.jar")
|
||||||
format!("forge-{maven_version}-installer.jar"),
|
}
|
||||||
),
|
LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => return None,
|
||||||
};
|
};
|
||||||
format!("{root}/{path}/{maven_version}/{file}")
|
Some(format!("{root}/{path}/{maven_version}/{file}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 扫 maven-metadata.xml 里的 `<version>` 值。
|
/// 扫 maven-metadata.xml 里的 `<version>` 值。
|
||||||
@@ -106,7 +119,7 @@ pub fn neoforge_mc_version(version: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// maven 版本是否属于该原版版本。
|
/// maven 版本是否属于该原版版本(非 maven 族恒 `false`:它们的版本号不是 maven 口径)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn matches_mc(kind: LoaderKind, mc_version: &str, version: &str) -> bool {
|
pub fn matches_mc(kind: LoaderKind, mc_version: &str, version: &str) -> bool {
|
||||||
match kind {
|
match kind {
|
||||||
@@ -114,7 +127,8 @@ pub fn matches_mc(kind: LoaderKind, mc_version: &str, version: &str) -> bool {
|
|||||||
version.starts_with(&format!("{mc_version}-"))
|
version.starts_with(&format!("{mc_version}-"))
|
||||||
}
|
}
|
||||||
LoaderKind::NeoForge => neoforge_mc_version(version).as_deref() == Some(mc_version),
|
LoaderKind::NeoForge => neoforge_mc_version(version).as_deref() == Some(mc_version),
|
||||||
_ => version.starts_with(&format!("{mc_version}-")),
|
LoaderKind::Forge => version.starts_with(&format!("{mc_version}-")),
|
||||||
|
LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,13 +151,15 @@ pub fn select_versions(kind: LoaderKind, mc_version: &str, versions: &[String])
|
|||||||
/// 用户给的版本号归一成 maven 版本号(Forge 的 maven 版本带原版前缀)。
|
/// 用户给的版本号归一成 maven 版本号(Forge 的 maven 版本带原版前缀)。
|
||||||
///
|
///
|
||||||
/// `--loader-version 47.2.0` 与 `--loader-version 1.20.1-47.2.0` 都接受;
|
/// `--loader-version 47.2.0` 与 `--loader-version 1.20.1-47.2.0` 都接受;
|
||||||
/// NeoForge(非 1.20.1 布局)本身就是完整 maven 版本,原样返回。
|
/// NeoForge(非 1.20.1 布局)本身就是完整 maven 版本,原样返回;
|
||||||
|
/// 非 maven 族(fabric 系 / LiteLoader)的版本号原样返回——它们的版本号本就自带原版口径。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn normalize_maven_version(kind: LoaderKind, mc_version: &str, raw: &str) -> String {
|
pub fn normalize_maven_version(kind: LoaderKind, mc_version: &str, raw: &str) -> String {
|
||||||
let raw = raw.trim();
|
let raw = raw.trim();
|
||||||
let needs_prefix = match kind {
|
let needs_prefix = match kind {
|
||||||
|
LoaderKind::Forge => true,
|
||||||
LoaderKind::NeoForge => mc_version == NEOFORGE_FORGE_LAYOUT_MC,
|
LoaderKind::NeoForge => mc_version == NEOFORGE_FORGE_LAYOUT_MC,
|
||||||
_ => true,
|
LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => false,
|
||||||
};
|
};
|
||||||
if needs_prefix && !raw.starts_with(&format!("{mc_version}-")) {
|
if needs_prefix && !raw.starts_with(&format!("{mc_version}-")) {
|
||||||
format!("{mc_version}-{raw}")
|
format!("{mc_version}-{raw}")
|
||||||
@@ -152,15 +168,25 @@ pub fn normalize_maven_version(kind: LoaderKind, mc_version: &str, raw: &str) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用户视角的加载器版本号(Forge 去掉原版前缀,NeoForge 原样)。
|
/// 用户视角的加载器版本号(带原版前缀的 maven 版本要去掉前缀)。
|
||||||
|
///
|
||||||
|
/// 谁带前缀是**按加载器 + 布局**定的:Forge 恒带;NeoForge 只有「1.20.1 走 forge 布局」
|
||||||
|
/// 那一段带(`1.20.1-47.1.106` → `47.1.106`),其余 NeoForge 版本本身就是完整 maven 版本;
|
||||||
|
/// fabric 系与 LiteLoader 的版本号本来就是用户视角的。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn display_loader_version(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String {
|
pub fn display_loader_version(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String {
|
||||||
match kind {
|
let strip = match kind {
|
||||||
LoaderKind::NeoForge if mc_version != NEOFORGE_FORGE_LAYOUT_MC => maven_version.to_string(),
|
LoaderKind::Forge => true,
|
||||||
_ => maven_version
|
LoaderKind::NeoForge => mc_version == NEOFORGE_FORGE_LAYOUT_MC,
|
||||||
|
LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => false,
|
||||||
|
};
|
||||||
|
if strip {
|
||||||
|
maven_version
|
||||||
.strip_prefix(&format!("{mc_version}-"))
|
.strip_prefix(&format!("{mc_version}-"))
|
||||||
.unwrap_or(maven_version)
|
.unwrap_or(maven_version)
|
||||||
.to_string(),
|
.to_string()
|
||||||
|
} else {
|
||||||
|
maven_version.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
//! 不写游戏目录(installer jar 落 `.ULL/cache/meta/` 作离线复用),镜像/退避/health 全在
|
//! 不写游戏目录(installer jar 落 `.ULL/cache/meta/` 作离线复用),镜像/退避/health 全在
|
||||||
//! `core::download` 的引擎里(我们一律递**官方地址**,重写由镜像表负责)。
|
//! `core::download` 的引擎里(我们一律递**官方地址**,重写由镜像表负责)。
|
||||||
//!
|
//!
|
||||||
//! 两族的取数路径完全不同:
|
//! 三个数据源的取数路径完全不同(**三路显式派发**,不许再有「不是 fabric 就是 maven」):
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! Fabric / Quilt : meta API → profile/json(官方直出可用版本 JSON)
|
//! Fabric / Quilt : meta API → profile/json(官方直出可用版本 JSON)
|
||||||
//! NeoForge/Forge : maven-metadata.xml → installer jar → 读 install_profile.json + version.json
|
//! NeoForge/Forge : maven-metadata.xml → installer jar → 读 install_profile.json + version.json
|
||||||
|
//! LiteLoader : dl.liteloader.com/versions.json →(快照再读 maven-metadata.xml)→ 从零合成
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
@@ -18,6 +19,7 @@ use zip::ZipArchive;
|
|||||||
|
|
||||||
use super::fabric;
|
use super::fabric;
|
||||||
use super::forge::{self, InstallProfile};
|
use super::forge::{self, InstallProfile};
|
||||||
|
use super::liteloader;
|
||||||
use super::maven;
|
use super::maven;
|
||||||
use super::plan::LoaderRelease;
|
use super::plan::LoaderRelease;
|
||||||
use super::{LoaderError, LoaderKind};
|
use super::{LoaderError, LoaderKind};
|
||||||
@@ -27,6 +29,9 @@ use crate::game::manifest::VersionJson;
|
|||||||
/// installer jar 的缓存文件名前缀(超长/含 `+` 的版本串会被规整成安全文件名)。
|
/// installer jar 的缓存文件名前缀(超长/含 `+` 的版本串会被规整成安全文件名)。
|
||||||
const INSTALLER_TAG: &str = "installer";
|
const INSTALLER_TAG: &str = "installer";
|
||||||
|
|
||||||
|
/// LiteLoader 官方版本表的缓存文件名(整表 49 KB,一次取全量再本地筛)。
|
||||||
|
const LITELOADER_LIST_TAG: &str = "liteloader-versions.json";
|
||||||
|
|
||||||
/// 远端加载器元数据来源。
|
/// 远端加载器元数据来源。
|
||||||
pub struct LoaderSource<'a> {
|
pub struct LoaderSource<'a> {
|
||||||
engine: &'a DownloadEngine,
|
engine: &'a DownloadEngine,
|
||||||
@@ -56,7 +61,17 @@ impl<'a> LoaderSource<'a> {
|
|||||||
let entries = fabric::parse_loader_list(kind, &body)?;
|
let entries = fabric::parse_loader_list(kind, &body)?;
|
||||||
return Ok(entries.into_iter().map(|entry| entry.version).collect());
|
return Ok(entries.into_iter().map(|entry| entry.version).collect());
|
||||||
}
|
}
|
||||||
let url = maven::metadata_url(kind, mc_version);
|
if kind == LoaderKind::LiteLoader {
|
||||||
|
let table = self.liteloader_table().await?;
|
||||||
|
let entries = table.get(mc_version.trim()).ok_or_else(|| {
|
||||||
|
LoaderError::not_found(format!(
|
||||||
|
"LiteLoader 没有适配原版 {mc_version} 的版本(官方版本表 {} 条原版版本)",
|
||||||
|
table.len()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
return Ok(entries.iter().map(|entry| entry.version.clone()).collect());
|
||||||
|
}
|
||||||
|
let url = maven::metadata_url(kind, mc_version).ok_or_else(|| non_maven(kind))?;
|
||||||
let body = self.fetch_text(&url).await?;
|
let body = self.fetch_text(&url).await?;
|
||||||
let all = maven::parse_metadata_versions(&body);
|
let all = maven::parse_metadata_versions(&body);
|
||||||
// maven 表是升序的,统一成"最新在前"。
|
// maven 表是升序的,统一成"最新在前"。
|
||||||
@@ -74,7 +89,9 @@ impl<'a> LoaderSource<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 解析一个加载器版本(`loader_version` 为 `None` 时取官方默认:Fabric 系取首个稳定版,
|
/// 解析一个加载器版本(`loader_version` 为 `None` 时取官方默认:Fabric 系取首个稳定版,
|
||||||
/// maven 系取该原版版本下最新的一版)。
|
/// maven 系取该原版版本下最新的一版,LiteLoader 取首个 release / 否则最新)。
|
||||||
|
///
|
||||||
|
/// 等价于 [`Self::resolve_on`],父版本 = 原版本身。
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
@@ -84,10 +101,41 @@ impl<'a> LoaderSource<'a> {
|
|||||||
kind: LoaderKind,
|
kind: LoaderKind,
|
||||||
mc_version: &str,
|
mc_version: &str,
|
||||||
loader_version: Option<&str>,
|
loader_version: Option<&str>,
|
||||||
|
) -> Result<LoaderRelease, LoaderError> {
|
||||||
|
self.resolve_on(kind, mc_version, loader_version, None)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析一个加载器版本,并**指定父版本**(`parent` 为 `None` 时等于 `mc_version`)。
|
||||||
|
///
|
||||||
|
/// 目前只有 LiteLoader 接受「父版本不是原版」:它靠 `--tweakClass` 追加生效,叠在
|
||||||
|
/// Forge 1.12.2 上是官方主推组合。其它加载器声明自己的主类/启动目标,叠在别人的 loader
|
||||||
|
/// 层上没有装配路径,因此**具名拒绝**,而不是默默装成「叠在原版上」。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 同上;另外「非 LiteLoader 却指定了非原版父版本」返回 `unsupported`。
|
||||||
|
pub async fn resolve_on(
|
||||||
|
&self,
|
||||||
|
kind: LoaderKind,
|
||||||
|
mc_version: &str,
|
||||||
|
loader_version: Option<&str>,
|
||||||
|
parent: Option<&str>,
|
||||||
) -> Result<LoaderRelease, LoaderError> {
|
) -> Result<LoaderRelease, LoaderError> {
|
||||||
let requested = loader_version
|
let requested = loader_version
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty());
|
||||||
|
let parent_version_id = parent
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(mc_version);
|
||||||
|
if parent_version_id != mc_version && kind != LoaderKind::LiteLoader {
|
||||||
|
return Err(LoaderError::unsupported(format!(
|
||||||
|
"{} 没有「叠在 {parent_version_id} 上」的装配路线:它自己声明主类与启动目标,只能叠在原版 {mc_version} 上",
|
||||||
|
kind.label()
|
||||||
|
))
|
||||||
|
.with_hint("先装原版,再在其上安装这一家加载器"));
|
||||||
|
}
|
||||||
if kind.is_fabric_family() {
|
if kind.is_fabric_family() {
|
||||||
let version = match requested {
|
let version = match requested {
|
||||||
Some(version) => version.to_string(),
|
Some(version) => version.to_string(),
|
||||||
@@ -106,6 +154,11 @@ impl<'a> LoaderSource<'a> {
|
|||||||
let body = self.fetch_text(&url).await?;
|
let body = self.fetch_text(&url).await?;
|
||||||
return fabric::build_release(kind, mc_version, &version, &body);
|
return fabric::build_release(kind, mc_version, &version, &body);
|
||||||
}
|
}
|
||||||
|
if kind == LoaderKind::LiteLoader {
|
||||||
|
return self
|
||||||
|
.resolve_liteloader(mc_version, requested, parent_version_id)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
let maven_version = match requested {
|
let maven_version = match requested {
|
||||||
Some(raw) => maven::normalize_maven_version(kind, mc_version, raw),
|
Some(raw) => maven::normalize_maven_version(kind, mc_version, raw),
|
||||||
@@ -118,7 +171,8 @@ impl<'a> LoaderSource<'a> {
|
|||||||
LoaderError::not_found(format!("{} 没有可用的加载器版本", kind.label()))
|
LoaderError::not_found(format!("{} 没有可用的加载器版本", kind.label()))
|
||||||
})?,
|
})?,
|
||||||
};
|
};
|
||||||
let url = maven::installer_url(kind, mc_version, &maven_version);
|
let url = maven::installer_url(kind, mc_version, &maven_version)
|
||||||
|
.ok_or_else(|| non_maven(kind))?;
|
||||||
let path = self.installer_path(kind, &maven_version);
|
let path = self.installer_path(kind, &maven_version);
|
||||||
let jar = self.fetch_installer(&url, &path).await?;
|
let jar = self.fetch_installer(&url, &path).await?;
|
||||||
let (profile, embedded) = read_installer(&jar)?;
|
let (profile, embedded) = read_installer(&jar)?;
|
||||||
@@ -131,9 +185,68 @@ impl<'a> LoaderSource<'a> {
|
|||||||
)?;
|
)?;
|
||||||
// M35 执行器要的两样东西:installer 本体(跑 processor 与解包)与它声明的 profile。
|
// M35 执行器要的两样东西:installer 本体(跑 processor 与解包)与它声明的 profile。
|
||||||
release.installer = Some(jar);
|
release.installer = Some(jar);
|
||||||
|
release.parent_version_id = parent_version_id.to_string();
|
||||||
Ok(release)
|
Ok(release)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LiteLoader:整表 → 选条 →(快照再查 maven-metadata)→ 合成清单。
|
||||||
|
async fn resolve_liteloader(
|
||||||
|
&self,
|
||||||
|
mc_version: &str,
|
||||||
|
requested: Option<&str>,
|
||||||
|
parent_version_id: &str,
|
||||||
|
) -> Result<LoaderRelease, LoaderError> {
|
||||||
|
let table = self.liteloader_table().await?;
|
||||||
|
let entries = table.get(mc_version.trim()).ok_or_else(|| {
|
||||||
|
LoaderError::not_found(format!(
|
||||||
|
"LiteLoader 没有适配原版 {mc_version} 的版本(官方版本表 {} 条原版版本)",
|
||||||
|
table.len()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let entry = match requested {
|
||||||
|
Some(raw) => liteloader::find_entry(entries, raw).ok_or_else(|| {
|
||||||
|
LoaderError::not_found(format!(
|
||||||
|
"LiteLoader {} 没有版本 `{raw}`(该原版下有:{})",
|
||||||
|
mc_version,
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.version.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" / ")
|
||||||
|
))
|
||||||
|
})?,
|
||||||
|
None => liteloader::default_entry(entries).ok_or_else(|| {
|
||||||
|
LoaderError::not_found(format!("LiteLoader 没有适配原版 {mc_version} 的版本"))
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
// 直链:release 分支记录里就有;快照分支必须再读一次 maven-metadata.xml。
|
||||||
|
let artifact_url = match (&entry.url, &entry.snapshot_dir) {
|
||||||
|
(Some(url), _) => url.clone(),
|
||||||
|
(None, Some(dir)) => {
|
||||||
|
let metadata = self.fetch_text(&format!("{dir}maven-metadata.xml")).await?;
|
||||||
|
liteloader::snapshot_artifact_url(entry, &metadata)?
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
return Err(LoaderError::metadata(format!(
|
||||||
|
"LiteLoader {} 的记录既没有直链也没有快照目录",
|
||||||
|
entry.version
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
liteloader::build_release(mc_version, parent_version_id, entry, &artifact_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取 LiteLoader 整张官方版本表(按原版版本分组)。
|
||||||
|
async fn liteloader_table(
|
||||||
|
&self,
|
||||||
|
) -> Result<std::collections::BTreeMap<String, Vec<liteloader::LiteLoaderEntry>>, LoaderError>
|
||||||
|
{
|
||||||
|
let body = self
|
||||||
|
.fetch_text_named(liteloader::VERSION_LIST_URL, LITELOADER_LIST_TAG)
|
||||||
|
.await?;
|
||||||
|
liteloader::parse_versions(&body)
|
||||||
|
}
|
||||||
|
|
||||||
/// installer jar 的本地缓存路径(`+`/非法字符规整成 `_`)。
|
/// installer jar 的本地缓存路径(`+`/非法字符规整成 `_`)。
|
||||||
fn installer_path(&self, kind: LoaderKind, maven_version: &str) -> PathBuf {
|
fn installer_path(&self, kind: LoaderKind, maven_version: &str) -> PathBuf {
|
||||||
let safe: String = maven_version
|
let safe: String = maven_version
|
||||||
@@ -162,7 +275,12 @@ impl<'a> LoaderSource<'a> {
|
|||||||
|
|
||||||
/// 取一小段文本元数据(版本列表 / profile JSON / maven metadata)。
|
/// 取一小段文本元数据(版本列表 / profile JSON / maven metadata)。
|
||||||
async fn fetch_text(&self, url: &str) -> Result<String, LoaderError> {
|
async fn fetch_text(&self, url: &str) -> Result<String, LoaderError> {
|
||||||
let dest = self.meta_cache.join("loader-meta.bin");
|
self.fetch_text_named(url, "loader-meta.bin").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同上,缓存文件名按调用方给(LiteLoader 整表单独一档,别把小响应和 49 KB 的表混存)。
|
||||||
|
async fn fetch_text_named(&self, url: &str, tag: &str) -> Result<String, LoaderError> {
|
||||||
|
let dest = self.meta_cache.join(tag);
|
||||||
let bytes = self
|
let bytes = self
|
||||||
.engine
|
.engine
|
||||||
.download_bytes(DownloadRequest::new(
|
.download_bytes(DownloadRequest::new(
|
||||||
@@ -176,6 +294,14 @@ impl<'a> LoaderSource<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 非 maven 族被送进 maven 分支时的统一报错(入口已分流,走到这里说明调用链有 bug)。
|
||||||
|
fn non_maven(kind: LoaderKind) -> LoaderError {
|
||||||
|
LoaderError::unsupported(format!(
|
||||||
|
"{} 不是 maven 族加载器,没有 maven 元数据/installer 地址",
|
||||||
|
kind.label()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// 读 installer jar 里的 `install_profile.json` 与内嵌的版本 JSON。
|
/// 读 installer jar 里的 `install_profile.json` 与内嵌的版本 JSON。
|
||||||
///
|
///
|
||||||
/// 内嵌版本 JSON 的路径来自 profile 的 `json` 字段(通常 `/version.json`);
|
/// 内嵌版本 JSON 的路径来自 profile 的 `json` 字段(通常 `/version.json`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 加载器安装(Fabric / Quilt / NeoForge / Forge):官方元数据 → loader 版本 JSON → 补库清单。
|
//! 加载器安装(Fabric / Quilt / NeoForge / Forge / LiteLoader):官方元数据 → loader 版本 JSON → 补库清单。
|
||||||
//!
|
//!
|
||||||
//! 设计输入:`DevDocs/PlanDoc/08-mod-and-recommend.md` §1(D10 加载器安装)、
|
//! 设计输入:`DevDocs/PlanDoc/08-mod-and-recommend.md` §1(D10 加载器安装)、
|
||||||
//! `DevDocs/analyse/05-modloader-modpack.md` §1(五加载器安装协议、`patch + priority` 主线)。
|
//! `DevDocs/analyse/05-modloader-modpack.md` §1(五加载器安装协议、`patch + priority` 主线)。
|
||||||
@@ -22,17 +22,23 @@
|
|||||||
//! | 路线 | 加载器 | 做法 |
|
//! | 路线 | 加载器 | 做法 |
|
||||||
//! |------|--------|------|
|
//! |------|--------|------|
|
||||||
//! | [`LoaderSupport::JsonRoute`] | Fabric / Quilt | 官方 meta 直接给 launcher 用的 profile JSON |
|
//! | [`LoaderSupport::JsonRoute`] | Fabric / Quilt | 官方 meta 直接给 launcher 用的 profile JSON |
|
||||||
|
//! | [`LoaderSupport::JsonRoute`] | LiteLoader | 官方版本表 → **从零合成**清单(见 [`liteloader`]) |
|
||||||
//! | [`LoaderSupport::JsonRoute`] | Forge spec-0(如 1.12.2 重建版) | installer 里的 `version.json` + 库清单,universal jar 走 maven 直取 |
|
//! | [`LoaderSupport::JsonRoute`] | Forge spec-0(如 1.12.2 重建版) | installer 里的 `version.json` + 库清单,universal jar 走 maven 直取 |
|
||||||
//! | [`LoaderSupport::RequiresInstaller`] | Forge spec≥1 / NeoForge | installer 声明了 client 侧 processors(拆包/映射),**必须外调 java** |
|
//! | [`LoaderSupport::RequiresInstaller`] | Forge spec≥1 / NeoForge | installer 声明了 client 侧 processors(拆包/映射),**必须外调 java** |
|
||||||
//!
|
//!
|
||||||
//! 第三类本期只出**明确 unsupported**:版本 JSON 与库清单仍照常生成(`--dry-run` 可看),
|
//! 第四类本期只出**明确 unsupported**:版本 JSON 与库清单仍照常生成(`--dry-run` 可看),
|
||||||
//! 但真装会被拒——半成品实例比明确报错更糟(沿 M27 §7「如实告知」的纪律)。
|
//! 但真装会被拒——半成品实例比明确报错更糟(沿 M27 §7「如实告知」的纪律)。
|
||||||
//! 老协议 installer(内联 `versionInfo` + universal jar 解包,≈1.12.2 之前的重建版)同样归 unsupported。
|
//! 老协议 installer(内联 `versionInfo` + universal jar 解包,≈1.12.2 之前的重建版)同样归 unsupported。
|
||||||
|
//!
|
||||||
|
//! OptiFine 仍**认得出但装不了**([`LoaderKind::from_core`] 返回 `None`):它要跑外部
|
||||||
|
//! `optifine.Patcher` 并原地改 zip,不在本期(见 `DevDocs/artifacts/m218/SURVEY-p1.md` §3)。
|
||||||
|
|
||||||
pub mod fabric;
|
pub mod fabric;
|
||||||
pub mod forge;
|
pub mod forge;
|
||||||
|
pub mod liteloader;
|
||||||
pub mod maven;
|
pub mod maven;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
|
pub mod parent;
|
||||||
pub mod plan;
|
pub mod plan;
|
||||||
pub mod processors;
|
pub mod processors;
|
||||||
|
|
||||||
@@ -49,6 +55,7 @@ use crate::download::{DownloadError, DownloadErrorKind};
|
|||||||
/// 支持的加载器种类。
|
/// 支持的加载器种类。
|
||||||
///
|
///
|
||||||
/// `vanilla` / `none` **不在这里**:那是"不装加载器",由 CLI 走 M27 的原版路径。
|
/// `vanilla` / `none` **不在这里**:那是"不装加载器",由 CLI 走 M27 的原版路径。
|
||||||
|
/// OptiFine 也**不在**这里:它要跑外部 `optifine.Patcher` + 原地改 zip,本启动器仍只识别不安装。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub enum LoaderKind {
|
pub enum LoaderKind {
|
||||||
/// Fabric(`meta.fabricmc.net`,纯 meta,无 installer)。
|
/// Fabric(`meta.fabricmc.net`,纯 meta,无 installer)。
|
||||||
@@ -59,6 +66,8 @@ pub enum LoaderKind {
|
|||||||
NeoForge,
|
NeoForge,
|
||||||
/// Forge(`maven.minecraftforge.net`,两代 installer 协议)。
|
/// Forge(`maven.minecraftforge.net`,两代 installer 协议)。
|
||||||
Forge,
|
Forge,
|
||||||
|
/// LiteLoader(`dl.liteloader.com`,官方版本表 + 从零合成 launchwrapper 清单,无 installer)。
|
||||||
|
LiteLoader,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoaderKind {
|
impl LoaderKind {
|
||||||
@@ -67,7 +76,7 @@ impl LoaderKind {
|
|||||||
/// 七个 id 的匹配**委派**给 [`crate::game::LoaderKind::parse`](词表的源头,M126),
|
/// 七个 id 的匹配**委派**给 [`crate::game::LoaderKind::parse`](词表的源头,M126),
|
||||||
/// 本方法只多收一个**输入别名** `neo-forge`:那是 Modrinth 的拼法(mrpack
|
/// 本方法只多收一个**输入别名** `neo-forge`:那是 Modrinth 的拼法(mrpack
|
||||||
/// `dependencies` 的键、CF 包里也有这种写法),**只进不出**——[`Self::as_str`] 绝不产出
|
/// `dependencies` 的键、CF 包里也有这种写法),**只进不出**——[`Self::as_str`] 绝不产出
|
||||||
/// 它。识别得出但本启动器装不了的几家(原版 / OptiFine / LiteLoader)返回 `None`。
|
/// 它。识别得出但本启动器装不了的(原版 / OptiFine)返回 `None`。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn parse(raw: &str) -> Option<Self> {
|
pub fn parse(raw: &str) -> Option<Self> {
|
||||||
let wanted = raw.trim();
|
let wanted = raw.trim();
|
||||||
@@ -77,7 +86,7 @@ impl LoaderKind {
|
|||||||
Self::from_core(crate::game::LoaderKind::parse(wanted)?)
|
Self::from_core(crate::game::LoaderKind::parse(wanted)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// core 的宽种类 → 本模块的四家(`None` = 识别得出但本启动器装不了)。
|
/// core 的宽种类 → 本模块能装的五家(`None` = 识别得出但本启动器装不了)。
|
||||||
///
|
///
|
||||||
/// 穷尽 match:core 加一支就编不过,逼作者决定「它能不能装」。
|
/// 穷尽 match:core 加一支就编不过,逼作者决定「它能不能装」。
|
||||||
const fn from_core(kind: crate::game::LoaderKind) -> Option<Self> {
|
const fn from_core(kind: crate::game::LoaderKind) -> Option<Self> {
|
||||||
@@ -86,9 +95,8 @@ impl LoaderKind {
|
|||||||
crate::game::LoaderKind::Quilt => Some(Self::Quilt),
|
crate::game::LoaderKind::Quilt => Some(Self::Quilt),
|
||||||
crate::game::LoaderKind::NeoForge => Some(Self::NeoForge),
|
crate::game::LoaderKind::NeoForge => Some(Self::NeoForge),
|
||||||
crate::game::LoaderKind::Forge => Some(Self::Forge),
|
crate::game::LoaderKind::Forge => Some(Self::Forge),
|
||||||
crate::game::LoaderKind::Vanilla
|
crate::game::LoaderKind::LiteLoader => Some(Self::LiteLoader),
|
||||||
| crate::game::LoaderKind::OptiFine
|
crate::game::LoaderKind::Vanilla | crate::game::LoaderKind::OptiFine => None,
|
||||||
| crate::game::LoaderKind::LiteLoader => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +113,7 @@ impl LoaderKind {
|
|||||||
Self::Quilt => crate::game::LoaderKind::Quilt.as_str(),
|
Self::Quilt => crate::game::LoaderKind::Quilt.as_str(),
|
||||||
Self::NeoForge => crate::game::LoaderKind::NeoForge.as_str(),
|
Self::NeoForge => crate::game::LoaderKind::NeoForge.as_str(),
|
||||||
Self::Forge => crate::game::LoaderKind::Forge.as_str(),
|
Self::Forge => crate::game::LoaderKind::Forge.as_str(),
|
||||||
|
Self::LiteLoader => crate::game::LoaderKind::LiteLoader.as_str(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +125,7 @@ impl LoaderKind {
|
|||||||
Self::Quilt => "Quilt",
|
Self::Quilt => "Quilt",
|
||||||
Self::NeoForge => "NeoForge",
|
Self::NeoForge => "NeoForge",
|
||||||
Self::Forge => "Forge",
|
Self::Forge => "Forge",
|
||||||
|
Self::LiteLoader => "LiteLoader",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +135,25 @@ impl LoaderKind {
|
|||||||
matches!(self, Self::Fabric | Self::Quilt)
|
matches!(self, Self::Fabric | Self::Quilt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 是否走 maven 族(installer jar + install profile)。
|
||||||
|
///
|
||||||
|
/// 第三个数据源(LiteLoader)既不是 fabric 系也不是 maven 族,所以这条路**必须显式问**,
|
||||||
|
/// 不能再用「不是 fabric 就是 maven」的二分——那会把 LiteLoader 静默送进 Forge 的 URL 拼装。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_maven_family(self) -> bool {
|
||||||
|
matches!(self, Self::NeoForge | Self::Forge)
|
||||||
|
}
|
||||||
|
|
||||||
/// 全部加载器(CLI 的 `--loader` 枚举提示用)。
|
/// 全部加载器(CLI 的 `--loader` 枚举提示用)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn all() -> &'static [Self] {
|
pub const fn all() -> &'static [Self] {
|
||||||
&[Self::Fabric, Self::Quilt, Self::NeoForge, Self::Forge]
|
&[
|
||||||
|
Self::Fabric,
|
||||||
|
Self::Quilt,
|
||||||
|
Self::NeoForge,
|
||||||
|
Self::Forge,
|
||||||
|
Self::LiteLoader,
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// loader 版本 id 的词根:`<mc>-<kind>-<loader>`(`versions/<id>/` 与实例 `versionId` 都用它)。
|
/// loader 版本 id 的词根:`<mc>-<kind>-<loader>`(`versions/<id>/` 与实例 `versionId` 都用它)。
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
//! 父版本链:把「叠在哪一层上」翻译成原版版本 id(LiteLoader 叠在 Forge 上时要用)。
|
||||||
|
//!
|
||||||
|
//! 加载器层只声明 `inheritsFrom`,客户端 jar、资源索引都在**根**那一层(原版)。所以当用户
|
||||||
|
//! 指定的父版本不是原版(`1.12.2-forge-14.23.5.2860`)时,我们既要记住父版本 id(写进
|
||||||
|
//! 合成清单的 `inheritsFrom`),又要知道根的原版 id(拼 loader 版本 id、定位 jar)。
|
||||||
|
//!
|
||||||
|
//! 判据只有一条:**读盘**。父版本在盘上就必须读得出它的 `inheritsFrom`;读不出来说明父版本
|
||||||
|
//! 压根没装好,这时报错比猜一个原版版本号强(猜错会装出 `inheritsFrom` 指向不存在版本、
|
||||||
|
//! 启动必崩的实例)。沿链走最多 [`MAX_DEPTH`] 跳,成环即报错(与
|
||||||
|
//! [`crate::game::inherit::resolve`] 同口径)。
|
||||||
|
|
||||||
|
use crate::game::layout::GameLayout;
|
||||||
|
use crate::game::manifest::VersionJson;
|
||||||
|
|
||||||
|
use super::LoaderError;
|
||||||
|
|
||||||
|
/// 继承链深度上限(现实里 loader + patch 不超过 5 层)。
|
||||||
|
pub const MAX_DEPTH: usize = 32;
|
||||||
|
|
||||||
|
/// 从盘上的版本 JSON 沿 `inheritsFrom` 走到根,返回根版本 id(原版)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 版本 JSON 不存在 → `not_found`(父版本还没装);读不出来 / 成环 → `metadata`。
|
||||||
|
pub fn root_version_id(layout: &GameLayout, version_id: &str) -> Result<String, LoaderError> {
|
||||||
|
let mut chain = vec![version_id.to_string()];
|
||||||
|
let mut current = version_id.to_string();
|
||||||
|
for _ in 0..MAX_DEPTH {
|
||||||
|
let manifest = read_manifest(layout, ¤t)?;
|
||||||
|
match manifest.inherits_from.as_deref().map(str::trim) {
|
||||||
|
Some(parent) if !parent.is_empty() => {
|
||||||
|
if chain.iter().any(|id| id == parent) {
|
||||||
|
chain.push(parent.to_string());
|
||||||
|
return Err(LoaderError::metadata(format!(
|
||||||
|
"父版本链成环:{}",
|
||||||
|
chain.join(" → ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
chain.push(parent.to_string());
|
||||||
|
current = parent.to_string();
|
||||||
|
}
|
||||||
|
_ => return Ok(current),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(LoaderError::metadata(format!(
|
||||||
|
"父版本链超过 {MAX_DEPTH} 层,拒绝继续解析:{}",
|
||||||
|
chain.join(" → ")
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读一份版本 JSON;文件不存在时给出「先装父版本」的可执行提示。
|
||||||
|
fn read_manifest(layout: &GameLayout, version_id: &str) -> Result<VersionJson, LoaderError> {
|
||||||
|
let path = layout.version_json(version_id);
|
||||||
|
let text = std::fs::read_to_string(&path).map_err(|error| {
|
||||||
|
LoaderError::not_found(format!(
|
||||||
|
"父版本 {version_id} 还没装好(读不到 {}:{error})",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
.with_hint("先用同一个父版本装一次它自己的加载器(例如 `install <原版> --loader forge`),再在它上面叠")
|
||||||
|
})?;
|
||||||
|
VersionJson::from_json_str(&text).map_err(|error| {
|
||||||
|
LoaderError::metadata(format!("父版本 {version_id} 的清单解析失败:{error}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ use std::path::PathBuf;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use super::forge::InstallProfile;
|
use super::forge::InstallProfile;
|
||||||
use super::LoaderKind;
|
use super::{LoaderError, LoaderKind};
|
||||||
use crate::game::arguments::Environment;
|
use crate::game::arguments::Environment;
|
||||||
use crate::game::install::libraries;
|
use crate::game::install::libraries;
|
||||||
use crate::game::install::{InstallPlan, PlanFile};
|
use crate::game::install::{InstallPlan, PlanFile};
|
||||||
@@ -71,17 +71,20 @@ impl LoaderSupport {
|
|||||||
pub struct LoaderRelease {
|
pub struct LoaderRelease {
|
||||||
/// 加载器种类。
|
/// 加载器种类。
|
||||||
pub kind: LoaderKind,
|
pub kind: LoaderKind,
|
||||||
/// 目标原版版本(`inheritsFrom` 的父版本 id)。
|
/// 目标原版版本(客户端 jar 与资源都在这一层)。
|
||||||
pub mc_version: String,
|
pub mc_version: String,
|
||||||
/// 加载器自身版本号(`0.15.11` / `47.2.0` / `21.1.80`)。
|
/// **父版本 id**:合成清单的 `inheritsFrom`。默认等于 [`Self::mc_version`](叠在原版上),
|
||||||
|
/// 叠在别的加载器层上时是那一层的版本 id(`1.12.2-forge-14.23.5.2860`)。
|
||||||
|
pub parent_version_id: String,
|
||||||
|
/// 加载器自身版本号(`0.15.11` / `47.2.0` / `21.1.80` / `1.12.2-SNAPSHOT`)。
|
||||||
pub loader_version: String,
|
pub loader_version: String,
|
||||||
/// 生成的 loader 版本 id(`1.20.1-fabric-0.15.11`,即 `versions/<id>/` 的目录名)。
|
/// 生成的 loader 版本 id(`1.20.1-fabric-0.15.11`,即 `versions/<id>/` 的目录名)。
|
||||||
pub version_id: String,
|
pub version_id: String,
|
||||||
/// loader 版本 JSON(`inheritsFrom` 指向原版)。
|
/// loader 版本 JSON(`inheritsFrom` 指向父版本)。
|
||||||
pub version_json: VersionJson,
|
pub version_json: VersionJson,
|
||||||
/// 可走哪条路线。
|
/// 可走哪条路线。
|
||||||
pub support: LoaderSupport,
|
pub support: LoaderSupport,
|
||||||
/// installer jar 的磁盘路径(M35 执行器的输入;Fabric 系为 `None`)。
|
/// installer jar 的磁盘路径(M35 执行器的输入;Fabric 系与 LiteLoader 为 `None`)。
|
||||||
pub installer: Option<PathBuf>,
|
pub installer: Option<PathBuf>,
|
||||||
/// installer 里读出来的 install profile(执行器要 `processors` / `data` / `libraries`)。
|
/// installer 里读出来的 install profile(执行器要 `processors` / `data` / `libraries`)。
|
||||||
pub install_profile: Option<InstallProfile>,
|
pub install_profile: Option<InstallProfile>,
|
||||||
@@ -96,6 +99,12 @@ impl LoaderRelease {
|
|||||||
self.support.is_installable()
|
self.support.is_installable()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 父版本是不是原版本身(`false` = 叠在别的加载器层上)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn parent_is_vanilla(&self) -> bool {
|
||||||
|
self.parent_version_id == self.mc_version
|
||||||
|
}
|
||||||
|
|
||||||
/// 是否要外调 java 跑 installer processors。
|
/// 是否要外调 java 跑 installer processors。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn requires_installer(&self) -> bool {
|
pub const fn requires_installer(&self) -> bool {
|
||||||
@@ -118,6 +127,7 @@ impl LoaderRelease {
|
|||||||
"kind": self.kind.as_str(),
|
"kind": self.kind.as_str(),
|
||||||
"label": self.kind.label(),
|
"label": self.kind.label(),
|
||||||
"mc_version": self.mc_version,
|
"mc_version": self.mc_version,
|
||||||
|
"parent_version_id": self.parent_version_id,
|
||||||
"loader_version": self.loader_version,
|
"loader_version": self.loader_version,
|
||||||
"version_id": self.version_id,
|
"version_id": self.version_id,
|
||||||
"inherits_from": self.version_json.inherits_from,
|
"inherits_from": self.version_json.inherits_from,
|
||||||
@@ -138,8 +148,11 @@ pub struct LoaderPlanInput<'a> {
|
|||||||
pub release: &'a LoaderRelease,
|
pub release: &'a LoaderRelease,
|
||||||
/// 规则求值环境(OS / 架构 / 特性开关)。
|
/// 规则求值环境(OS / 架构 / 特性开关)。
|
||||||
pub environment: &'a Environment,
|
pub environment: &'a Environment,
|
||||||
/// 原版的 `versions/<mc>/<mc>.json` 是否已在盘上。
|
/// **父版本**([`LoaderRelease::parent_version_id`])的清单是否已在盘上。
|
||||||
pub vanilla_installed: bool,
|
///
|
||||||
|
/// 父版本是原版时缺了能自动补装([`LoaderInstallPlan::vanilla_install_needed`]);
|
||||||
|
/// 父版本是别的加载器层(Forge)时缺了**直接报错**——我们不能替用户决定装哪一版 Forge。
|
||||||
|
pub parent_installed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 加载器安装计划:M27 的 [`InstallPlan`] + 加载器自己的上下文。
|
/// 加载器安装计划:M27 的 [`InstallPlan`] + 加载器自己的上下文。
|
||||||
@@ -162,9 +175,40 @@ impl LoaderInstallPlan {
|
|||||||
///
|
///
|
||||||
/// 缺下载地址的库降级成告警(见 [`Self::summary_json`] 的 `warnings`),不硬失败——
|
/// 缺下载地址的库降级成告警(见 [`Self::summary_json`] 的 `warnings`),不硬失败——
|
||||||
/// 与 M27「宁可给出可修复的骨架,也不静默假装成功」一致。
|
/// 与 M27「宁可给出可修复的骨架,也不静默假装成功」一致。
|
||||||
#[must_use]
|
///
|
||||||
pub fn build(input: LoaderPlanInput<'_>) -> Self {
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 父版本不是原版、而它又没装在盘上时返回 [`LoaderError::not_found`](具名说清是哪个
|
||||||
|
/// 父版本缺了)。**不能默默回落成叠原版**:那会装出一份和用户意图不同的实例。
|
||||||
|
pub fn build(input: LoaderPlanInput<'_>) -> Result<Self, LoaderError> {
|
||||||
let release = input.release;
|
let release = input.release;
|
||||||
|
// 同一个 loader 版本 id 不能同时表示两条父链(`<原版>-liteloader-<ver>` 与
|
||||||
|
// 「叠在 Forge 上」撞同一个目录):盘上那份的 `inheritsFrom` 与本次请求不符就具名报错,
|
||||||
|
// 而不是把旧清单覆盖掉——旧实例会静默换父链,启动行为跟着变。
|
||||||
|
if let Some(existing) = existing_parent(input.layout, &release.version_id) {
|
||||||
|
if existing != release.parent_version_id {
|
||||||
|
return Err(LoaderError::unsupported(format!(
|
||||||
|
"{} 已装在盘上,父版本是 {existing},与本次请求的 {} 不同",
|
||||||
|
release.version_id, release.parent_version_id
|
||||||
|
))
|
||||||
|
.with_hint(format!(
|
||||||
|
"loader 版本 id 由「原版版本 + 加载器 + 加载器版本」决定,叠在别的层上会撞同一个目录:先删掉 versions/{}(旧实例也会失去这一层),或换一个加载器版本",
|
||||||
|
release.version_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !input.parent_installed && !release.parent_is_vanilla() {
|
||||||
|
return Err(LoaderError::not_found(format!(
|
||||||
|
"父版本 {} 还没装好:{} {} 要叠在它上面",
|
||||||
|
release.parent_version_id,
|
||||||
|
release.kind.label(),
|
||||||
|
release.loader_version
|
||||||
|
))
|
||||||
|
.with_hint(format!(
|
||||||
|
"先把父版本装上(同一个原版版本先装一次它自己的加载器),再重跑;本次不会替你猜一个 {} 的版本",
|
||||||
|
release.parent_version_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
let (files, library_warnings) = libraries::plan(
|
let (files, library_warnings) = libraries::plan(
|
||||||
input.layout,
|
input.layout,
|
||||||
&release.version_id,
|
&release.version_id,
|
||||||
@@ -181,7 +225,7 @@ impl LoaderInstallPlan {
|
|||||||
if let Some(reason) = release.installer_reason() {
|
if let Some(reason) = release.installer_reason() {
|
||||||
warnings.push(format!("需要外调 java 跑 installer processors:{reason}"));
|
warnings.push(format!("需要外调 java 跑 installer processors:{reason}"));
|
||||||
}
|
}
|
||||||
if !input.vanilla_installed {
|
if !input.parent_installed {
|
||||||
warnings.push(format!(
|
warnings.push(format!(
|
||||||
"原版 {} 尚未安装:执行时会先装原版再叠加载器",
|
"原版 {} 尚未安装:执行时会先装原版再叠加载器",
|
||||||
release.mc_version
|
release.mc_version
|
||||||
@@ -200,11 +244,11 @@ impl LoaderInstallPlan {
|
|||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Ok(Self {
|
||||||
release: release.clone(),
|
release: release.clone(),
|
||||||
install,
|
install,
|
||||||
vanilla_install_needed: !input.vanilla_installed,
|
vanilla_install_needed: !input.parent_installed && release.parent_is_vanilla(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 计划里的待下载文件。
|
/// 计划里的待下载文件。
|
||||||
@@ -233,3 +277,17 @@ impl LoaderInstallPlan {
|
|||||||
summary
|
summary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 盘上那份 `versions/<id>/<id>.json` 的 `inheritsFrom`(不存在 / 读不出 / 没写 → `None`)。
|
||||||
|
///
|
||||||
|
/// 只用来挡「同一版本 id 撞两条父链」,不参与其它判定:读失败不该让安装失败,
|
||||||
|
/// 所以错误一律吞成 `None`(真正的父子链校验由 [`crate::game::inherit::resolve`] 与
|
||||||
|
/// [`LoaderPlanInput::parent_installed`] 负责)。
|
||||||
|
fn existing_parent(layout: &GameLayout, version_id: &str) -> Option<String> {
|
||||||
|
let text = std::fs::read_to_string(layout.version_json(version_id)).ok()?;
|
||||||
|
let manifest = VersionJson::from_json_str(&text).ok()?;
|
||||||
|
manifest
|
||||||
|
.inherits_from
|
||||||
|
.map(|parent| parent.trim().to_string())
|
||||||
|
.filter(|parent| !parent.is_empty())
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
//! LiteLoader 数据源与合成:官方版本表(**真实响应的裁剪版**)→ 版本条目 → 合成清单。
|
||||||
|
//!
|
||||||
|
//! 夹具 `LITELOADER_VERSIONS` 是 2026-11 从 `https://dl.liteloader.com/versions/versions.json`
|
||||||
|
//! 真取的一份响应(200 / 49129 字节 / 16 个原版版本)里挑三个原版版本原样留下:
|
||||||
|
//! `1.7.10`(只有 release 分支)、`1.10.2`(release + snapshots,且 `repo.url` 是 `…/repo/`
|
||||||
|
//! 形态——专门用来钉「快照仓固定」这条)、`1.12.2`(只有 snapshots 分支)。删掉的是另外 13 个原版版本与
|
||||||
|
//! 每个版本下的多余分支;**字段名与结构一个没改**(键仍是 md5、`latest` 别名也留着,用来
|
||||||
|
//! 钉「跳过别名 + 按 version 去重」)。`LITELOADER_METADATA` 是同一次探测里取的
|
||||||
|
//! `repo.mumfrey.com/…/liteloader/1.12.2-SNAPSHOT/maven-metadata.xml`(200 / 358 字节)。
|
||||||
|
|
||||||
|
use crate::addon::loader::liteloader::{
|
||||||
|
build_release, default_entry, find_entry, parse_versions, snapshot_artifact_url, CENTRAL_REPO,
|
||||||
|
};
|
||||||
|
use crate::addon::loader::{LoaderErrorKind, LoaderKind};
|
||||||
|
use crate::game::loader::{InstanceLoader, LoaderKind as Wide};
|
||||||
|
use crate::game::version::ReleaseType;
|
||||||
|
|
||||||
|
/// 真实响应的裁剪版(见文件头)。
|
||||||
|
const LITELOADER_VERSIONS: &str = r#"{
|
||||||
|
"meta": {
|
||||||
|
"description": "LiteLoader is a lightweight mod bootstrap designed to provide basic loader functionality for mods which don't need to modify game mechanics.",
|
||||||
|
"authors": "Mumfrey",
|
||||||
|
"url": "http://dl.liteloader.com",
|
||||||
|
"updated": "2017-11-29T16:31:41+00:00",
|
||||||
|
"updatedTime": 1511973101
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"1.7.10": {
|
||||||
|
"repo": {"stream": "RELEASE", "type": "ivy", "url": "http://dl.liteloader.com/versions/", "classifier": "mcpnames"},
|
||||||
|
"artefacts": {"com.mumfrey:liteloader": {
|
||||||
|
"63ada46e033d0cb6782bada09ad5ca4e": {
|
||||||
|
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
|
||||||
|
"libraries": [{"name": "net.minecraft:launchwrapper:1.11"}, {"name": "org.ow2.asm:asm-all:5.0.3"}],
|
||||||
|
"srcJar": "liteloader-1.7.10_04-mcpnames-sources.jar",
|
||||||
|
"mcpJar": "liteloader-1.7.10_04-mcpnames.jar",
|
||||||
|
"stream": "RELEASE",
|
||||||
|
"file": "liteloader-1.7.10_04.jar",
|
||||||
|
"version": "1.7.10_04",
|
||||||
|
"md5": "63ada46e033d0cb6782bada09ad5ca4e",
|
||||||
|
"timestamp": "1414368553"
|
||||||
|
},
|
||||||
|
"db7235aefd407ac1fde09a7baba50839": {
|
||||||
|
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
|
||||||
|
"libraries": [
|
||||||
|
{"name": "net.minecraft:launchwrapper:1.9"},
|
||||||
|
{"name": "org.ow2.asm:asm-all:5.0.3", "url": "http://repo.maven.apache.org/maven2/"}
|
||||||
|
],
|
||||||
|
"stream": "RELEASE",
|
||||||
|
"file": "liteloader-1.7.10_00.jar",
|
||||||
|
"version": "1.7.10_00",
|
||||||
|
"md5": "db7235aefd407ac1fde09a7baba50839",
|
||||||
|
"timestamp": "1404330030"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
},
|
||||||
|
"1.10.2": {
|
||||||
|
"repo": {"stream": "RELEASE", "type": "m2", "url": "http://dl.liteloader.com/repo/", "classifier": ""},
|
||||||
|
"artefacts": {"com.mumfrey:liteloader": {
|
||||||
|
"8a7c21f32d77ee08b393dd3921ced8eb": {
|
||||||
|
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
|
||||||
|
"libraries": [{"name": "net.minecraft:launchwrapper:1.12"}, {"name": "org.ow2.asm:asm-all:5.0.3"}],
|
||||||
|
"stream": "RELEASE",
|
||||||
|
"file": "liteloader-1.10.2.jar",
|
||||||
|
"version": "1.10.2",
|
||||||
|
"md5": "8a7c21f32d77ee08b393dd3921ced8eb",
|
||||||
|
"timestamp": "1479473570"
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
"snapshots": {
|
||||||
|
"libraries": [
|
||||||
|
{"name": "net.minecraft:launchwrapper:1.12"},
|
||||||
|
{"name": "org.spongepowered:mixin:0.5.17-SNAPSHOT", "url": "https://repo.spongepowered.org/maven/"}
|
||||||
|
],
|
||||||
|
"com.mumfrey:liteloader": {
|
||||||
|
"1cdd72f7232e45551f16cc8ffd27ccf3": {
|
||||||
|
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
|
||||||
|
"libraries": [{"name": "net.minecraft:launchwrapper:1.12"}, {"name": "org.ow2.asm:asm-all:5.0.3"}],
|
||||||
|
"stream": "SNAPSHOT",
|
||||||
|
"file": "liteloader-1.10.2-SNAPSHOT.jar",
|
||||||
|
"version": "1.10.2-SNAPSHOT",
|
||||||
|
"build": "1.10.2-SNAPSHOT-rE85C8AE-b11-11",
|
||||||
|
"md5": "1cdd72f7232e45551f16cc8ffd27ccf3",
|
||||||
|
"timestamp": "1485987183",
|
||||||
|
"lastSuccessfulBuild": 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"1.12.2": {
|
||||||
|
"repo": {"stream": "SNAPSHOT", "type": "m2", "url": "http://repo.mumfrey.com/content/repositories/snapshots/", "classifier": ""},
|
||||||
|
"snapshots": {
|
||||||
|
"libraries": [
|
||||||
|
{"name": "net.minecraft:launchwrapper:1.12"},
|
||||||
|
{"name": "org.spongepowered:mixin:0.7.4-SNAPSHOT", "url": "https://repo.spongepowered.org/maven/"}
|
||||||
|
],
|
||||||
|
"com.mumfrey:liteloader": {
|
||||||
|
"1420785ecbfed5aff4a586c5c9dd97eb": {
|
||||||
|
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
|
||||||
|
"libraries": [{"name": "net.minecraft:launchwrapper:1.12"}, {"name": "org.ow2.asm:asm-all:5.2"}],
|
||||||
|
"stream": "SNAPSHOT",
|
||||||
|
"file": "liteloader-1.12.2-SNAPSHOT.jar",
|
||||||
|
"version": "1.12.2-SNAPSHOT",
|
||||||
|
"build": "1.12.2-SNAPSHOT-r4CC2BB0-b4-4",
|
||||||
|
"md5": "1420785ecbfed5aff4a586c5c9dd97eb",
|
||||||
|
"timestamp": "1511880271",
|
||||||
|
"lastSuccessfulBuild": 4
|
||||||
|
},
|
||||||
|
"latest": {
|
||||||
|
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
|
||||||
|
"libraries": [{"name": "net.minecraft:launchwrapper:1.12"}, {"name": "org.ow2.asm:asm-all:5.2"}],
|
||||||
|
"stream": "SNAPSHOT",
|
||||||
|
"file": "liteloader-1.12.2-SNAPSHOT.jar",
|
||||||
|
"version": "1.12.2-SNAPSHOT",
|
||||||
|
"md5": "1420785ecbfed5aff4a586c5c9dd97eb",
|
||||||
|
"timestamp": "1511880271",
|
||||||
|
"lastSuccessfulBuild": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
/// 同一次探测取的 maven-metadata(真实 358 字节响应的原样)。
|
||||||
|
const LITELOADER_METADATA: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<metadata>
|
||||||
|
<groupId>com.mumfrey</groupId>
|
||||||
|
<artifactId>liteloader</artifactId>
|
||||||
|
<version>1.12.2-SNAPSHOT</version>
|
||||||
|
<versioning>
|
||||||
|
<snapshot>
|
||||||
|
<timestamp>20171128.144431</timestamp>
|
||||||
|
<buildNumber>4</buildNumber>
|
||||||
|
</snapshot>
|
||||||
|
<lastUpdated>20171128144431</lastUpdated>
|
||||||
|
</versioning>
|
||||||
|
</metadata>"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_the_real_version_table() {
|
||||||
|
let table = parse_versions(LITELOADER_VERSIONS).expect("真实响应必须能解析");
|
||||||
|
assert_eq!(table.len(), 3, "只留了三个原版版本");
|
||||||
|
|
||||||
|
// release 分支:按 version 去重、新的排前面。
|
||||||
|
let releases = &table["1.7.10"];
|
||||||
|
assert_eq!(
|
||||||
|
releases
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.version.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["1.7.10_04", "1.7.10_00"]
|
||||||
|
);
|
||||||
|
// 目录名是**原版**版本(1.7.10),不是 loader 版本(1.7.10_04)。
|
||||||
|
assert_eq!(
|
||||||
|
releases[0].url.as_deref(),
|
||||||
|
Some("https://dl.liteloader.com/versions/com/mumfrey/liteloader/1.7.10/liteloader-1.7.10_04.jar")
|
||||||
|
);
|
||||||
|
assert!(!releases[0].snapshot);
|
||||||
|
|
||||||
|
// 记录级库:没 url 的非 Mojang 库补 Central,Mojang 的库保持默认基址。
|
||||||
|
let names: Vec<&str> = releases[0]
|
||||||
|
.libraries
|
||||||
|
.iter()
|
||||||
|
.map(|library| library.name.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
names,
|
||||||
|
[
|
||||||
|
"net.minecraft:launchwrapper:1.11",
|
||||||
|
"org.ow2.asm:asm-all:5.0.3"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
releases[0].libraries[0].url, None,
|
||||||
|
"Mojang 库仓才有 launchwrapper"
|
||||||
|
);
|
||||||
|
assert_eq!(releases[0].libraries[1].url.as_deref(), Some(CENTRAL_REPO));
|
||||||
|
|
||||||
|
// 记录自己写了 url 的,保留但 http → https。
|
||||||
|
assert_eq!(
|
||||||
|
releases[1].libraries[1].url.as_deref(),
|
||||||
|
Some("https://repo.maven.apache.org/maven2/")
|
||||||
|
);
|
||||||
|
|
||||||
|
// snapshot 分支:`latest` 别名跳过,直链留给 maven-metadata 现拼。
|
||||||
|
let snapshots = &table["1.12.2"];
|
||||||
|
assert_eq!(snapshots.len(), 1, "latest 别名不算一个版本");
|
||||||
|
assert!(snapshots[0].snapshot);
|
||||||
|
assert!(snapshots[0].url.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
snapshots[0].snapshot_dir.as_deref(),
|
||||||
|
Some("https://repo.mumfrey.com/content/repositories/snapshots/com/mumfrey/liteloader/1.12.2-SNAPSHOT/")
|
||||||
|
);
|
||||||
|
// 分支级 libraries 里那条 mixin 不该混进记录(那是 ForgeGradle 开发环境用的)。
|
||||||
|
assert!(!snapshots[0]
|
||||||
|
.libraries
|
||||||
|
.iter()
|
||||||
|
.any(|library| library.name.contains("mixin")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_url_comes_from_maven_metadata() {
|
||||||
|
let table = parse_versions(LITELOADER_VERSIONS).expect("解析");
|
||||||
|
let entry = &table["1.12.2"][0];
|
||||||
|
assert_eq!(
|
||||||
|
snapshot_artifact_url(entry, LITELOADER_METADATA).expect("应拼得出直链"),
|
||||||
|
"https://repo.mumfrey.com/content/repositories/snapshots/com/mumfrey/liteloader/1.12.2-SNAPSHOT/liteloader-1.12.2-20171128.144431-4-release.jar"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 缺 <buildNumber>:报错,不猜一个指向 404 的直链。
|
||||||
|
let broken = LITELOADER_METADATA.replace("<buildNumber>4</buildNumber>", "");
|
||||||
|
let error = snapshot_artifact_url(entry, &broken).expect_err("缺字段必须报错");
|
||||||
|
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_release_synthesizes_launchwrapper_manifest() {
|
||||||
|
let table = parse_versions(LITELOADER_VERSIONS).expect("解析");
|
||||||
|
let entry = find_entry(&table["1.12.2"], "1.12.2-snapshot").expect("版本号大小写不敏感");
|
||||||
|
let url = snapshot_artifact_url(entry, LITELOADER_METADATA).expect("直链");
|
||||||
|
let release = build_release("1.12.2", "1.12.2", entry, &url).expect("合成");
|
||||||
|
|
||||||
|
assert_eq!(release.version_id, "1.12.2-liteloader-1.12.2-SNAPSHOT");
|
||||||
|
let json = &release.version_json;
|
||||||
|
assert_eq!(
|
||||||
|
json.main_class.as_deref(),
|
||||||
|
Some("net.minecraft.launchwrapper.Launch")
|
||||||
|
);
|
||||||
|
// 父子链靠 arguments.game 追加,不写 minecraftArguments(写了会顶掉 Forge 的 tweakClass)。
|
||||||
|
assert!(json.minecraft_arguments.is_none());
|
||||||
|
let game: Vec<String> = json
|
||||||
|
.arguments
|
||||||
|
.as_ref()
|
||||||
|
.expect("arguments 必须有")
|
||||||
|
.expand(&Default::default())
|
||||||
|
.game;
|
||||||
|
assert_eq!(
|
||||||
|
game,
|
||||||
|
[
|
||||||
|
"--tweakClass",
|
||||||
|
"com.mumfrey.liteloader.launch.LiteLoaderTweaker"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(json.inherits_from.as_deref(), Some("1.12.2"));
|
||||||
|
assert_eq!(json.jar.as_deref(), Some("1.12.2"), "jar 恒在原版那层");
|
||||||
|
assert_eq!(json.version_type, Some(ReleaseType::Snapshot));
|
||||||
|
|
||||||
|
// 本体 jar 那一条:绝对直链 + maven 坐标(快照路径与坐标不一致,所以必须给 url)。
|
||||||
|
let loader = json
|
||||||
|
.libraries
|
||||||
|
.as_ref()
|
||||||
|
.expect("libraries")
|
||||||
|
.iter()
|
||||||
|
.find(|library| library.name == "com.mumfrey:liteloader:1.12.2-SNAPSHOT")
|
||||||
|
.expect("缺本体库");
|
||||||
|
assert_eq!(
|
||||||
|
loader
|
||||||
|
.downloads
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|downloads| downloads.artifact.as_ref())
|
||||||
|
.and_then(|artifact| artifact.url.as_deref()),
|
||||||
|
Some(url.as_str())
|
||||||
|
);
|
||||||
|
assert!(!release.requires_installer(), "LiteLoader 不跑任何外部进程");
|
||||||
|
assert!(release.warnings.is_empty(), "{:?}", release.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synthesized_version_id_parses_back_to_liteloader() {
|
||||||
|
for (mc, loader_version) in [("1.12.2", "1.12.2-SNAPSHOT"), ("1.7.10", "1.7.10_04")] {
|
||||||
|
let id = LoaderKind::LiteLoader.version_id(mc, loader_version);
|
||||||
|
let parsed = InstanceLoader::parse(&id);
|
||||||
|
assert_eq!(parsed.kind, Wide::LiteLoader, "`{id}` 认错了家");
|
||||||
|
assert_eq!(parsed.version, loader_version, "`{id}` 的版本段");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_tweak_class_is_a_metadata_error() {
|
||||||
|
let table = parse_versions(LITELOADER_VERSIONS).expect("解析");
|
||||||
|
let mut entry = table["1.7.10"][0].clone();
|
||||||
|
entry.tweak_class = " ".to_string();
|
||||||
|
let error = build_release("1.7.10", "1.7.10", &entry, "https://example.invalid/x.jar")
|
||||||
|
.expect_err("缺 tweakClass 必须报错");
|
||||||
|
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_entry_prefers_release_builds() {
|
||||||
|
let table = parse_versions(LITELOADER_VERSIONS).expect("解析");
|
||||||
|
// 1.7.10 只有 release:取最新那条。
|
||||||
|
assert_eq!(
|
||||||
|
default_entry(&table["1.7.10"]).map(|entry| entry.version.as_str()),
|
||||||
|
Some("1.7.10_04")
|
||||||
|
);
|
||||||
|
// 1.12.2 只有快照:没有 release 可选时退回最新。
|
||||||
|
assert_eq!(
|
||||||
|
default_entry(&table["1.12.2"]).map(|entry| entry.version.as_str()),
|
||||||
|
Some("1.12.2-SNAPSHOT")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_version_does_not_fall_back() {
|
||||||
|
let table = parse_versions(LITELOADER_VERSIONS).expect("解析");
|
||||||
|
assert!(find_entry(&table["1.7.10"], "1.7.10_99").is_none());
|
||||||
|
// 形状不符的表(顶层不是 versions)也要报 metadata,而不是当空表。
|
||||||
|
let error = parse_versions(r#"{"versions": []}"#).expect_err("形状不符必须报错");
|
||||||
|
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 快照仓**固定**在 `repo.mumfrey.com/content/repositories/snapshots/`,不跟 `repo.url` 走。
|
||||||
|
///
|
||||||
|
/// 抓得住:1.10.2 的 `repo.url` 是 `…/repo/`,照它拼快照路径会 302 到
|
||||||
|
/// `…/repositories/liteloader/…` 再 404(实测)。1.12.2 的 `repo.url` 恰好就是快照仓,
|
||||||
|
/// 所以只测 1.12.2 会漏掉这个 bug。
|
||||||
|
#[test]
|
||||||
|
fn snapshot_repo_is_fixed_not_taken_from_repo_url() {
|
||||||
|
let table = parse_versions(LITELOADER_VERSIONS).expect("解析");
|
||||||
|
let entries = &table["1.10.2"];
|
||||||
|
let snapshot = entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.snapshot)
|
||||||
|
.expect("1.10.2 应有快照条目");
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.snapshot_dir.as_deref(),
|
||||||
|
Some("https://repo.mumfrey.com/content/repositories/snapshots/com/mumfrey/liteloader/1.10.2-SNAPSHOT/")
|
||||||
|
);
|
||||||
|
// release 条目仍走该版本自己的 `repo.url`(上游 `/repo/` 会 302 到真地址,引擎跟重定向)。
|
||||||
|
let release = entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| !entry.snapshot)
|
||||||
|
.expect("1.10.2 应有 release 条目");
|
||||||
|
assert_eq!(
|
||||||
|
release.url.as_deref(),
|
||||||
|
Some("https://dl.liteloader.com/repo/com/mumfrey/liteloader/1.10.2/liteloader-1.10.2.jar")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -75,10 +75,13 @@ fn forge_versions_carry_the_minecraft_prefix() {
|
|||||||
fn neoforge_1201_uses_forge_layout() {
|
fn neoforge_1201_uses_forge_layout() {
|
||||||
// 1.20.1 的 NeoForge 早产版挂在 net/neoforged/forge 下,版本串带原版前缀。
|
// 1.20.1 的 NeoForge 早产版挂在 net/neoforged/forge 下,版本串带原版前缀。
|
||||||
assert!(metadata_url(LoaderKind::NeoForge, "1.20.1")
|
assert!(metadata_url(LoaderKind::NeoForge, "1.20.1")
|
||||||
|
.expect("maven 族应有地址")
|
||||||
.ends_with("net/neoforged/forge/maven-metadata.xml"));
|
.ends_with("net/neoforged/forge/maven-metadata.xml"));
|
||||||
assert!(metadata_url(LoaderKind::NeoForge, "1.21.1")
|
assert!(metadata_url(LoaderKind::NeoForge, "1.21.1")
|
||||||
|
.expect("maven 族应有地址")
|
||||||
.ends_with("net/neoforged/neoforge/maven-metadata.xml"));
|
.ends_with("net/neoforged/neoforge/maven-metadata.xml"));
|
||||||
assert!(metadata_url(LoaderKind::Forge, "1.20.1")
|
assert!(metadata_url(LoaderKind::Forge, "1.20.1")
|
||||||
|
.expect("maven 族应有地址")
|
||||||
.ends_with("net/minecraftforge/forge/maven-metadata.xml"));
|
.ends_with("net/minecraftforge/forge/maven-metadata.xml"));
|
||||||
|
|
||||||
let versions = vec!["1.20.1-47.1.106".to_string(), "1.20.1-47.1.90".to_string()];
|
let versions = vec!["1.20.1-47.1.106".to_string(), "1.20.1-47.1.90".to_string()];
|
||||||
@@ -119,16 +122,16 @@ fn versions_are_normalized_and_displayed() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn installer_urls_point_at_official_maven() {
|
fn installer_urls_point_at_official_maven() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
installer_url(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"),
|
installer_url(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0").expect("maven 族应有地址"),
|
||||||
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar"
|
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80"),
|
installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80").expect("maven 族应有地址"),
|
||||||
"https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar"
|
"https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar"
|
||||||
);
|
);
|
||||||
// 1.20.1 的 NeoForge 走 forge 布局(文件名也是 forge-)。
|
// 1.20.1 的 NeoForge 走 forge 布局(文件名也是 forge-)。
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
installer_url(LoaderKind::NeoForge, "1.20.1", "1.20.1-47.1.106"),
|
installer_url(LoaderKind::NeoForge, "1.20.1", "1.20.1-47.1.106").expect("maven 族应有地址"),
|
||||||
"https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"
|
"https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
mod fabric;
|
mod fabric;
|
||||||
mod forge;
|
mod forge;
|
||||||
|
mod liteloader;
|
||||||
mod maven;
|
mod maven;
|
||||||
mod plan;
|
mod plan;
|
||||||
mod vocabulary;
|
mod vocabulary;
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ fn plan_contains_only_loader_libraries() {
|
|||||||
layout: &layout,
|
layout: &layout,
|
||||||
release: &release,
|
release: &release,
|
||||||
environment: &environment(),
|
environment: &environment(),
|
||||||
vanilla_installed: false,
|
parent_installed: false,
|
||||||
});
|
})
|
||||||
|
.expect("计划应可组");
|
||||||
|
|
||||||
assert_eq!(plan.install.version_id, "1.20.1-fabric-0.15.11");
|
assert_eq!(plan.install.version_id, "1.20.1-fabric-0.15.11");
|
||||||
// jar 与资源归原版层,loader 计划里只有库。
|
// jar 与资源归原版层,loader 计划里只有库。
|
||||||
@@ -71,8 +72,9 @@ fn plan_marks_missing_vanilla_and_unsupported() {
|
|||||||
layout: &layout,
|
layout: &layout,
|
||||||
release: &release,
|
release: &release,
|
||||||
environment: &environment(),
|
environment: &environment(),
|
||||||
vanilla_installed: false,
|
parent_installed: false,
|
||||||
});
|
})
|
||||||
|
.expect("计划应可组");
|
||||||
assert!(plan.vanilla_install_needed);
|
assert!(plan.vanilla_install_needed);
|
||||||
assert!(plan
|
assert!(plan
|
||||||
.warnings()
|
.warnings()
|
||||||
@@ -83,8 +85,9 @@ fn plan_marks_missing_vanilla_and_unsupported() {
|
|||||||
layout: &layout,
|
layout: &layout,
|
||||||
release: &release,
|
release: &release,
|
||||||
environment: &environment(),
|
environment: &environment(),
|
||||||
vanilla_installed: true,
|
parent_installed: true,
|
||||||
});
|
})
|
||||||
|
.expect("计划应可组");
|
||||||
assert!(!installed.vanilla_install_needed);
|
assert!(!installed.vanilla_install_needed);
|
||||||
|
|
||||||
// 把 support 改成"需要 installer",计划里要出现"要外调 java"的告警(M35 起可真装)。
|
// 把 support 改成"需要 installer",计划里要出现"要外调 java"的告警(M35 起可真装)。
|
||||||
@@ -96,8 +99,9 @@ fn plan_marks_missing_vanilla_and_unsupported() {
|
|||||||
layout: &layout,
|
layout: &layout,
|
||||||
release: &blocked,
|
release: &blocked,
|
||||||
environment: &environment(),
|
environment: &environment(),
|
||||||
vanilla_installed: true,
|
parent_installed: true,
|
||||||
});
|
})
|
||||||
|
.expect("计划应可组");
|
||||||
assert!(plan
|
assert!(plan
|
||||||
.warnings()
|
.warnings()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -112,8 +116,9 @@ fn summary_json_carries_loader_block() {
|
|||||||
layout: &layout,
|
layout: &layout,
|
||||||
release: &release,
|
release: &release,
|
||||||
environment: &environment(),
|
environment: &environment(),
|
||||||
vanilla_installed: true,
|
parent_installed: true,
|
||||||
});
|
})
|
||||||
|
.expect("计划应可组");
|
||||||
let summary = plan.summary_json();
|
let summary = plan.summary_json();
|
||||||
assert_eq!(summary["loader"]["kind"], "fabric");
|
assert_eq!(summary["loader"]["kind"], "fabric");
|
||||||
assert_eq!(summary["loader"]["version_id"], "1.20.1-fabric-0.15.11");
|
assert_eq!(summary["loader"]["version_id"], "1.20.1-fabric-0.15.11");
|
||||||
@@ -170,14 +175,15 @@ fn loader_meta_and_maven_urls_hit_bmclapi_mirrors() {
|
|||||||
assert_eq!(candidates[0].source, "bmclapi");
|
assert_eq!(candidates[0].source, "bmclapi");
|
||||||
assert!(candidates[0].url.contains("/quilt-meta/"));
|
assert!(candidates[0].url.contains("/quilt-meta/"));
|
||||||
|
|
||||||
let neoforge_installer = maven::installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80");
|
let neoforge_installer =
|
||||||
|
maven::installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80").expect("maven 族应有地址");
|
||||||
let candidates = table.expand(&neoforge_installer, ResourceKind::Library, &mirror_first);
|
let candidates = table.expand(&neoforge_installer, ResourceKind::Library, &mirror_first);
|
||||||
assert_eq!(candidates[0].source, "bmclapi");
|
assert_eq!(candidates[0].source, "bmclapi");
|
||||||
assert!(candidates[0]
|
assert!(candidates[0]
|
||||||
.url
|
.url
|
||||||
.starts_with("https://bmclapi2.bangbang93.com/maven/net/neoforged/neoforge/"));
|
.starts_with("https://bmclapi2.bangbang93.com/maven/net/neoforged/neoforge/"));
|
||||||
|
|
||||||
let forge_meta = maven::metadata_url(LoaderKind::Forge, "1.20.1");
|
let forge_meta = maven::metadata_url(LoaderKind::Forge, "1.20.1").expect("maven 族应有地址");
|
||||||
let candidates = table.expand(&forge_meta, ResourceKind::VersionManifest, &mirror_first);
|
let candidates = table.expand(&forge_meta, ResourceKind::VersionManifest, &mirror_first);
|
||||||
assert_eq!(candidates[0].source, "bmclapi");
|
assert_eq!(candidates[0].source, "bmclapi");
|
||||||
assert!(candidates[0]
|
assert!(candidates[0]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
use crate::addon::loader::LoaderKind;
|
use crate::addon::loader::LoaderKind;
|
||||||
use crate::game::{InstanceLoader, LoaderKind as Wide};
|
use crate::game::{InstanceLoader, LoaderKind as Wide};
|
||||||
|
|
||||||
/// `as_str` 是 core 词表的委派:四家装得上的一一对应,且绝不产出别名。
|
/// `as_str` 是 core 词表的委派:五家装得上的一一对应,且绝不产出别名。
|
||||||
///
|
///
|
||||||
/// 抓得住:委派接错支(`Fabric` 映到 `Quilt`);有人把 `as_str` 改回手写字面量并写错;
|
/// 抓得住:委派接错支(`Fabric` 映到 `Quilt`);有人把 `as_str` 改回手写字面量并写错;
|
||||||
/// 有人让 `as_str` 产出 `neo-forge`(别名只进不出);生成端 `<mc>-<kind>-<loader>` 与解析端
|
/// 有人让 `as_str` 产出 `neo-forge`(别名只进不出);生成端 `<mc>-<kind>-<loader>` 与解析端
|
||||||
@@ -21,6 +21,7 @@ fn install_side_ids_are_delegated_to_the_core_vocabulary() {
|
|||||||
(LoaderKind::Quilt, Wide::Quilt),
|
(LoaderKind::Quilt, Wide::Quilt),
|
||||||
(LoaderKind::NeoForge, Wide::NeoForge),
|
(LoaderKind::NeoForge, Wide::NeoForge),
|
||||||
(LoaderKind::Forge, Wide::Forge),
|
(LoaderKind::Forge, Wide::Forge),
|
||||||
|
(LoaderKind::LiteLoader, Wide::LiteLoader),
|
||||||
];
|
];
|
||||||
for (install, wide) in cases {
|
for (install, wide) in cases {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -41,7 +42,7 @@ fn install_side_ids_are_delegated_to_the_core_vocabulary() {
|
|||||||
/// 别名策略:`neo-forge` 只作为**输入**被接受(Modrinth 的拼法),产出永远是 `neoforge`。
|
/// 别名策略:`neo-forge` 只作为**输入**被接受(Modrinth 的拼法),产出永远是 `neoforge`。
|
||||||
///
|
///
|
||||||
/// 抓得住:别名被搬进词表(`as_str` 开始产出 `neo-forge`);别名只认小写;安装侧顺手接受了
|
/// 抓得住:别名被搬进词表(`as_str` 开始产出 `neo-forge`);别名只认小写;安装侧顺手接受了
|
||||||
/// 本启动器装不了的几家(原版 / OptiFine / LiteLoader)。
|
/// 本启动器装不了的几家(原版 / OptiFine)。
|
||||||
/// 抓不住:`neo-forge` 之外 Modrinth 以后可能再发明的拼法——没有样本可写。
|
/// 抓不住:`neo-forge` 之外 Modrinth 以后可能再发明的拼法——没有样本可写。
|
||||||
#[test]
|
#[test]
|
||||||
fn modrinth_alias_is_input_only() {
|
fn modrinth_alias_is_input_only() {
|
||||||
@@ -57,7 +58,7 @@ fn modrinth_alias_is_input_only() {
|
|||||||
"neoforge",
|
"neoforge",
|
||||||
"产出不许带连字符"
|
"产出不许带连字符"
|
||||||
);
|
);
|
||||||
for raw in ["vanilla", "optifine", "liteloader", "", "rift"] {
|
for raw in ["vanilla", "optifine", "", "rift"] {
|
||||||
assert_eq!(LoaderKind::parse(raw), None, "`{raw}` 不该被安装侧接受");
|
assert_eq!(LoaderKind::parse(raw), None, "`{raw}` 不该被安装侧接受");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ pub fn apply_patches(base: VersionJson, patches: &[VersionJson]) -> VersionJson
|
|||||||
/// 这两点**与 analyse/03 §8.3 第 15 条建议相左**(该条主张逐字段/逐 key 合并)。
|
/// 这两点**与 analyse/03 §8.3 第 15 条建议相左**(该条主张逐字段/逐 key 合并)。
|
||||||
/// 保留现状的理由是生态兼容:现实 modpack/loader 的版本 JSON 按 HMCL/原版语义编写,
|
/// 保留现状的理由是生态兼容:现实 modpack/loader 的版本 JSON 按 HMCL/原版语义编写,
|
||||||
/// 逐 key 合并会让"子层想整份换掉 downloads"的场景语义漂移。经 tower 裁决 2026-10-29 维持。
|
/// 逐 key 合并会让"子层想整份换掉 downloads"的场景语义漂移。经 tower 裁决 2026-10-29 维持。
|
||||||
|
///
|
||||||
|
/// # 两种 args 表达的语义**不一样**(M222 核实,别混)
|
||||||
|
///
|
||||||
|
/// - `arguments.game` / `arguments.jvm`:父在前、子在后**追加**([`Arguments::merge`]);
|
||||||
|
/// - `minecraftArguments`(pre-1.13 的单串):**子覆盖父**,子不写才继承。
|
||||||
|
///
|
||||||
|
/// 所以「叠一层加载器」只能往 `arguments.game` 里加参数;往 `minecraftArguments` 里写会把父链
|
||||||
|
/// (例如 Forge 1.12.2 的 `--tweakClass FMLTweaker`)整串顶掉。回归:
|
||||||
|
/// `crates/core/tests/liteloader/install.rs` 的 `merge_laws_liteloader_depends_on` 与
|
||||||
|
/// `stacks_on_forge_and_keeps_the_forge_tweak_class`。
|
||||||
pub fn merge(child: &VersionJson, parent: &VersionJson) -> VersionJson {
|
pub fn merge(child: &VersionJson, parent: &VersionJson) -> VersionJson {
|
||||||
let mut extra = parent.extra.clone();
|
let mut extra = parent.extra.clone();
|
||||||
extra.extend(child.extra.iter().map(|(k, v)| (k.clone(), v.clone())));
|
extra.extend(child.extra.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
//! 器」的地方一律调 [`InstanceLoader::parse`],**不许**再写一份标记匹配。生成端
|
//! 器」的地方一律调 [`InstanceLoader::parse`],**不许**再写一份标记匹配。生成端
|
||||||
//! ([`crate::addon::loader::LoaderKind::version_id`] 拼 `<mc>-<kind>-<loader>`)与解析端的一致
|
//! ([`crate::addon::loader::LoaderKind::version_id`] 拼 `<mc>-<kind>-<loader>`)与解析端的一致
|
||||||
//! 性由 [`tests::own_version_ids_parse_back_to_their_loader`] 钉住(**从生成端取 id**,不手抄);
|
//! 性由 [`tests::own_version_ids_parse_back_to_their_loader`] 钉住(**从生成端取 id**,不手抄);
|
||||||
//! OptiFine / LiteLoader 本启动器不能装(见 `presenter::host::install`),只能继承进来。
|
//! OptiFine 本启动器不能装(见 `presenter::host::install`),只能继承进来;LiteLoader 自 M222 起有安装路线。
|
||||||
//!
|
//!
|
||||||
//! # 命名词表只有一张表(M126)
|
//! # 命名词表只有一张表(M126)
|
||||||
//!
|
//!
|
||||||
@@ -125,7 +125,7 @@ loader_kind_vocab! {
|
|||||||
Forge => "forge", marker;
|
Forge => "forge", marker;
|
||||||
/// OptiFine(第三方,本启动器不装,只继承)
|
/// OptiFine(第三方,本启动器不装,只继承)
|
||||||
OptiFine => "optifine", marker;
|
OptiFine => "optifine", marker;
|
||||||
/// LiteLoader(第三方,本启动器不装,只继承)
|
/// LiteLoader(自 M222 起可装:官方版本表 + 从零合成 launchwrapper 清单)
|
||||||
LiteLoader => "liteloader", marker;
|
LiteLoader => "liteloader", marker;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"description": "LiteLoader is a lightweight mod bootstrap designed to provide basic loader functionality for mods which don't need to modify game mechanics.",
|
||||||
|
"authors": "Mumfrey",
|
||||||
|
"url": "http://dl.liteloader.com",
|
||||||
|
"updated": "2017-11-29T16:31:41+00:00",
|
||||||
|
"updatedTime": 1511973101
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"1.7.10": {
|
||||||
|
"repo": {
|
||||||
|
"stream": "RELEASE",
|
||||||
|
"type": "ivy",
|
||||||
|
"url": "http://dl.liteloader.com/versions/",
|
||||||
|
"classifier": "mcpnames"
|
||||||
|
},
|
||||||
|
"artefacts": {
|
||||||
|
"com.mumfrey:liteloader": {
|
||||||
|
"63ada46e033d0cb6782bada09ad5ca4e": {
|
||||||
|
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
|
||||||
|
"libraries": [
|
||||||
|
{ "name": "net.minecraft:launchwrapper:1.11" },
|
||||||
|
{ "name": "org.ow2.asm:asm-all:5.0.3" }
|
||||||
|
],
|
||||||
|
"srcJar": "liteloader-1.7.10_04-mcpnames-sources.jar",
|
||||||
|
"mcpJar": "liteloader-1.7.10_04-mcpnames.jar",
|
||||||
|
"stream": "RELEASE",
|
||||||
|
"file": "liteloader-1.7.10_04.jar",
|
||||||
|
"version": "1.7.10_04",
|
||||||
|
"md5": "63ada46e033d0cb6782bada09ad5ca4e",
|
||||||
|
"timestamp": "1414368553"
|
||||||
|
},
|
||||||
|
"db7235aefd407ac1fde09a7baba50839": {
|
||||||
|
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
|
||||||
|
"libraries": [
|
||||||
|
{ "name": "net.minecraft:launchwrapper:1.9" },
|
||||||
|
{
|
||||||
|
"name": "org.ow2.asm:asm-all:5.0.3",
|
||||||
|
"url": "http://repo.maven.apache.org/maven2/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stream": "RELEASE",
|
||||||
|
"file": "liteloader-1.7.10_00.jar",
|
||||||
|
"version": "1.7.10_00",
|
||||||
|
"md5": "db7235aefd407ac1fde09a7baba50839",
|
||||||
|
"timestamp": "1404330030"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"1.12.2": {
|
||||||
|
"repo": {
|
||||||
|
"stream": "SNAPSHOT",
|
||||||
|
"type": "m2",
|
||||||
|
"url": "http://repo.mumfrey.com/content/repositories/snapshots/",
|
||||||
|
"classifier": ""
|
||||||
|
},
|
||||||
|
"snapshots": {
|
||||||
|
"libraries": [
|
||||||
|
{ "name": "net.minecraft:launchwrapper:1.12" },
|
||||||
|
{
|
||||||
|
"name": "org.spongepowered:mixin:0.7.4-SNAPSHOT",
|
||||||
|
"url": "https://repo.spongepowered.org/maven/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"com.mumfrey:liteloader": {
|
||||||
|
"1420785ecbfed5aff4a586c5c9dd97eb": {
|
||||||
|
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
|
||||||
|
"libraries": [
|
||||||
|
{ "name": "net.minecraft:launchwrapper:1.12" },
|
||||||
|
{ "name": "org.ow2.asm:asm-all:5.2" }
|
||||||
|
],
|
||||||
|
"stream": "SNAPSHOT",
|
||||||
|
"file": "liteloader-1.12.2-SNAPSHOT.jar",
|
||||||
|
"version": "1.12.2-SNAPSHOT",
|
||||||
|
"build": "1.12.2-SNAPSHOT-r4CC2BB0-b4-4",
|
||||||
|
"md5": "1420785ecbfed5aff4a586c5c9dd97eb",
|
||||||
|
"timestamp": "1511880271",
|
||||||
|
"lastSuccessfulBuild": 4
|
||||||
|
},
|
||||||
|
"latest": {
|
||||||
|
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
|
||||||
|
"libraries": [
|
||||||
|
{ "name": "net.minecraft:launchwrapper:1.12" },
|
||||||
|
{ "name": "org.ow2.asm:asm-all:5.2" }
|
||||||
|
],
|
||||||
|
"stream": "SNAPSHOT",
|
||||||
|
"file": "liteloader-1.12.2-SNAPSHOT.jar",
|
||||||
|
"version": "1.12.2-SNAPSHOT",
|
||||||
|
"md5": "1420785ecbfed5aff4a586c5c9dd97eb",
|
||||||
|
"timestamp": "1511880271",
|
||||||
|
"lastSuccessfulBuild": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
//! 边界与失败面:父版本缺失、组合不支持、数据源形状坏、取消。
|
||||||
|
//!
|
||||||
|
//! 网络层的失败映射(`DownloadError` → `loader_network`)由 `core::download` 的引擎测试
|
||||||
|
//! 与既有加载器测试覆盖,这里**不联网**:外部源的真实可用性在 mission 报告里单独列
|
||||||
|
//! (含探测结果),不混进单元测试冒充。
|
||||||
|
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
|
||||||
|
use unlucky_core::addon::loader::liteloader::parse_versions;
|
||||||
|
use unlucky_core::addon::loader::{
|
||||||
|
LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderSource,
|
||||||
|
};
|
||||||
|
use unlucky_core::download::blocking::block_on;
|
||||||
|
use unlucky_core::game::arguments::Environment;
|
||||||
|
use unlucky_core::game::install::{execute_with_cancel, InstallErrorKind};
|
||||||
|
use unlucky_core::game::layout::GameLayout;
|
||||||
|
|
||||||
|
use crate::support::{engine, liteloader_release, prefill, put_version, TempDir, VANILLA_1122};
|
||||||
|
|
||||||
|
/// 父版本不是原版、又没装在盘上 → **具名报错**,绝不默默改成叠原版。
|
||||||
|
#[test]
|
||||||
|
fn missing_loader_parent_is_a_named_error() {
|
||||||
|
let temp = TempDir::new("missing-parent");
|
||||||
|
let layout = GameLayout::new(temp.path());
|
||||||
|
put_version(&layout, "1.12.2", VANILLA_1122);
|
||||||
|
let release = liteloader_release("1.12.2-forge-14.23.5.2860");
|
||||||
|
|
||||||
|
let error = LoaderInstallPlan::build(LoaderPlanInput {
|
||||||
|
layout: &layout,
|
||||||
|
release: &release,
|
||||||
|
environment: &Environment::host(),
|
||||||
|
parent_installed: false,
|
||||||
|
})
|
||||||
|
.expect_err("父版本缺失必须报错");
|
||||||
|
assert_eq!(error.kind(), LoaderErrorKind::NotFound);
|
||||||
|
assert!(
|
||||||
|
error.message().contains("1.12.2-forge-14.23.5.2860"),
|
||||||
|
"错误里要点名缺的是哪个父版本:{}",
|
||||||
|
error.message()
|
||||||
|
);
|
||||||
|
assert!(error.hint().is_some(), "要给出下一步怎么办");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 父版本链:读盘定位原版版本 id(合成 loader id / 定位 jar 都用它)。
|
||||||
|
#[test]
|
||||||
|
fn parent_chain_walks_to_the_vanilla_root() {
|
||||||
|
let temp = TempDir::new("parent-chain");
|
||||||
|
let layout = GameLayout::new(temp.path());
|
||||||
|
put_version(&layout, "1.12.2", VANILLA_1122);
|
||||||
|
put_version(
|
||||||
|
&layout,
|
||||||
|
"1.12.2-forge-14.23.5.2860",
|
||||||
|
crate::support::FORGE_1122,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
unlucky_core::addon::loader::parent::root_version_id(&layout, "1.12.2-forge-14.23.5.2860")
|
||||||
|
.expect("两层链应走得到根"),
|
||||||
|
"1.12.2"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unlucky_core::addon::loader::parent::root_version_id(&layout, "1.12.2").expect("根是自己"),
|
||||||
|
"1.12.2"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 没装的父版本 → not_found(而不是猜一个原版版本号)。
|
||||||
|
let error = unlucky_core::addon::loader::parent::root_version_id(&layout, "9.9.9-forge-1.0.0")
|
||||||
|
.expect_err("缺父版本必须报错");
|
||||||
|
assert_eq!(error.kind(), LoaderErrorKind::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只有 LiteLoader 接受「父版本不是原版」;别家**具名拒绝**,不去发请求。
|
||||||
|
#[test]
|
||||||
|
fn only_liteloader_accepts_a_non_vanilla_parent() {
|
||||||
|
let temp = TempDir::new("combo-guard");
|
||||||
|
let engine = engine(temp.path());
|
||||||
|
let source = LoaderSource::new(&engine, temp.path());
|
||||||
|
let error = block_on(async {
|
||||||
|
source
|
||||||
|
.resolve_on(
|
||||||
|
LoaderKind::Forge,
|
||||||
|
"1.12.2",
|
||||||
|
Some("14.23.5.2860"),
|
||||||
|
Some("1.12.2-liteloader-1.12.2-SNAPSHOT"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.expect_err("Forge 叠在 LiteLoader 上必须被拒");
|
||||||
|
assert_eq!(error.kind(), LoaderErrorKind::Unsupported);
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.message()
|
||||||
|
.contains("1.12.2-liteloader-1.12.2-SNAPSHOT"),
|
||||||
|
"要说清是哪个组合:{}",
|
||||||
|
error.message()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 版本表形状坏 / 空表:各自给具名大类,不当成「没有版本」糊过去。
|
||||||
|
#[test]
|
||||||
|
fn broken_version_table_is_reported_distinctly() {
|
||||||
|
let shape = parse_versions(r#"{"versions": []}"#).expect_err("形状不符");
|
||||||
|
assert_eq!(shape.kind(), LoaderErrorKind::Metadata);
|
||||||
|
let empty = parse_versions(r#"{"meta": {}, "versions": {}}"#).expect_err("空表");
|
||||||
|
assert_eq!(empty.kind(), LoaderErrorKind::NotFound);
|
||||||
|
|
||||||
|
// 版本段缺失(记录没有 version):只剩 latest 别名 → 仍当空表处理。
|
||||||
|
let only_latest = r#"{"versions": {"1.7.10": {"artefacts": {"com.mumfrey:liteloader":
|
||||||
|
{"latest": {"tweakClass": "x", "version": "", "file": "liteloader.jar"}}}}}}"#;
|
||||||
|
let error = parse_versions(only_latest).expect_err("只有 http 别名时没有可用版本");
|
||||||
|
assert_eq!(error.kind(), LoaderErrorKind::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消:执行段看标志就返回 `Cancelled`,**版本 JSON 不落盘**(不留半成品)。
|
||||||
|
#[test]
|
||||||
|
fn cancel_leaves_no_manifest() {
|
||||||
|
let temp = TempDir::new("cancel");
|
||||||
|
let layout = GameLayout::new(temp.path());
|
||||||
|
put_version(&layout, "1.12.2", VANILLA_1122);
|
||||||
|
let release = liteloader_release("1.12.2");
|
||||||
|
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||||
|
layout: &layout,
|
||||||
|
release: &release,
|
||||||
|
environment: &Environment::host(),
|
||||||
|
parent_installed: true,
|
||||||
|
})
|
||||||
|
.expect("计划应可组");
|
||||||
|
|
||||||
|
let engine = engine(temp.path());
|
||||||
|
prefill(&plan.install);
|
||||||
|
let cancel = AtomicBool::new(true);
|
||||||
|
let error = block_on(execute_with_cancel(
|
||||||
|
&engine,
|
||||||
|
&plan.install,
|
||||||
|
4,
|
||||||
|
None,
|
||||||
|
Some(&cancel),
|
||||||
|
))
|
||||||
|
.expect_err("取消必须返回错误");
|
||||||
|
assert_eq!(error.kind(), InstallErrorKind::Cancelled);
|
||||||
|
assert!(
|
||||||
|
!layout.version_json(&release.version_id).exists(),
|
||||||
|
"取消之后不许留下版本清单"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同一个 loader 版本 id 被两条父链请求:**具名报错**,且盘上那份清单一个字节都不动。
|
||||||
|
///
|
||||||
|
/// 为什么必须有这道门:`<原版>-liteloader-<ver>` 对「叠原版」与「叠 Forge」是同一个目录名,
|
||||||
|
/// 直接覆盖会让已经登记、指向这个版本 id 的旧实例**静默换父链**(启动行为跟着变)。
|
||||||
|
#[test]
|
||||||
|
fn parent_collision_is_refused_instead_of_overwriting() {
|
||||||
|
let temp = TempDir::new("parent-collision");
|
||||||
|
let layout = GameLayout::new(temp.path());
|
||||||
|
put_version(&layout, "1.12.2", VANILLA_1122);
|
||||||
|
put_version(
|
||||||
|
&layout,
|
||||||
|
"1.12.2-forge-14.23.5.2860",
|
||||||
|
crate::support::FORGE_1122,
|
||||||
|
);
|
||||||
|
|
||||||
|
let vanilla_release = liteloader_release("1.12.2");
|
||||||
|
let existing = serde_json::json!({
|
||||||
|
"id": vanilla_release.version_id,
|
||||||
|
"inheritsFrom": "1.12.2",
|
||||||
|
"mainClass": "net.minecraft.launchwrapper.Launch",
|
||||||
|
"arguments": {"game": ["--tweakClass", "com.mumfrey.liteloader.launch.LiteLoaderTweaker"]},
|
||||||
|
"libraries": []
|
||||||
|
});
|
||||||
|
put_version(
|
||||||
|
&layout,
|
||||||
|
&vanilla_release.version_id,
|
||||||
|
&serde_json::to_string_pretty(&existing).expect("序列化"),
|
||||||
|
);
|
||||||
|
let before = std::fs::read_to_string(layout.version_json(&vanilla_release.version_id))
|
||||||
|
.expect("读旧清单");
|
||||||
|
|
||||||
|
let forge_release = liteloader_release("1.12.2-forge-14.23.5.2860");
|
||||||
|
assert_eq!(
|
||||||
|
forge_release.version_id, vanilla_release.version_id,
|
||||||
|
"两条父链撞同一个 id"
|
||||||
|
);
|
||||||
|
let error = LoaderInstallPlan::build(LoaderPlanInput {
|
||||||
|
layout: &layout,
|
||||||
|
release: &forge_release,
|
||||||
|
environment: &Environment::host(),
|
||||||
|
parent_installed: true,
|
||||||
|
})
|
||||||
|
.expect_err("撞父链必须报错");
|
||||||
|
assert_eq!(error.kind(), LoaderErrorKind::Unsupported);
|
||||||
|
assert!(
|
||||||
|
error.message().contains("1.12.2-forge-14.23.5.2860") && error.message().contains("1.12.2"),
|
||||||
|
"要说清两条父链各是什么:{}",
|
||||||
|
error.message()
|
||||||
|
);
|
||||||
|
|
||||||
|
let after = std::fs::read_to_string(layout.version_json(&vanilla_release.version_id))
|
||||||
|
.expect("读旧清单");
|
||||||
|
assert_eq!(before, after, "报错之后盘上那份清单不许被改动");
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
//! 解析 → 计划 → 落盘 → 继承解析 → 启动参数顺序(两条父链:原版 / Forge)。
|
||||||
|
|
||||||
|
use unlucky_core::addon::loader::{LoaderInstallPlan, LoaderPlanInput};
|
||||||
|
use unlucky_core::download::blocking::block_on;
|
||||||
|
use unlucky_core::game::arguments::Environment;
|
||||||
|
use unlucky_core::game::inherit::resolve;
|
||||||
|
use unlucky_core::game::install::execute;
|
||||||
|
use unlucky_core::game::layout::GameLayout;
|
||||||
|
use unlucky_core::launch::command_builder::tokenize;
|
||||||
|
|
||||||
|
use crate::support::{
|
||||||
|
engine, liteloader_release, prefill, put_version, read_version, TempDir, FORGE_1122,
|
||||||
|
VANILLA_1122,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 原版父链:装完能落盘,清单指向原版,`arguments.game` 带 tweakClass。
|
||||||
|
#[test]
|
||||||
|
fn installs_over_vanilla_and_writes_the_manifest() {
|
||||||
|
let temp = TempDir::new("vanilla");
|
||||||
|
let layout = GameLayout::new(temp.path());
|
||||||
|
put_version(&layout, "1.12.2", VANILLA_1122);
|
||||||
|
|
||||||
|
let release = liteloader_release("1.12.2");
|
||||||
|
assert_eq!(release.version_id, "1.12.2-liteloader-1.12.2-SNAPSHOT");
|
||||||
|
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||||
|
layout: &layout,
|
||||||
|
release: &release,
|
||||||
|
environment: &Environment::host(),
|
||||||
|
parent_installed: true,
|
||||||
|
})
|
||||||
|
.expect("父版本已装,计划应可组");
|
||||||
|
assert!(!plan.vanilla_install_needed);
|
||||||
|
assert!(plan.install.client_jar().is_none(), "jar 归原版层");
|
||||||
|
|
||||||
|
// 三个库:launchwrapper(Mojang 库仓)+ asm-all(Central)+ 本体 jar(官方直链)。
|
||||||
|
let names: Vec<&str> = plan.files().iter().map(|file| file.name.as_str()).collect();
|
||||||
|
assert_eq!(names.len(), 3, "{names:?}");
|
||||||
|
let loader_jar = plan
|
||||||
|
.files()
|
||||||
|
.iter()
|
||||||
|
.find(|file| file.name == "com.mumfrey:liteloader:1.12.2-SNAPSHOT")
|
||||||
|
.expect("缺本体 jar");
|
||||||
|
assert!(
|
||||||
|
loader_jar
|
||||||
|
.url
|
||||||
|
.ends_with("/1.12.2-SNAPSHOT/liteloader-1.12.2-20171128.144431-4-release.jar"),
|
||||||
|
"本体直链:{}",
|
||||||
|
loader_jar.url
|
||||||
|
);
|
||||||
|
let asm = plan
|
||||||
|
.files()
|
||||||
|
.iter()
|
||||||
|
.find(|file| file.name.contains("asm-all"))
|
||||||
|
.expect("缺 asm-all");
|
||||||
|
assert!(
|
||||||
|
asm.url.starts_with("https://repo1.maven.org/maven2/"),
|
||||||
|
"asm-all 只能从 Central 取:{}",
|
||||||
|
asm.url
|
||||||
|
);
|
||||||
|
|
||||||
|
// 全部落点先铺好占位文件(库无 sha1/size → 存在即信),执行段只写版本 JSON。
|
||||||
|
let engine = engine(temp.path());
|
||||||
|
let prefilled = prefill(&plan.install);
|
||||||
|
assert_eq!(prefilled, 3);
|
||||||
|
let report = block_on(execute(&engine, &plan.install, 4, None)).expect("执行应成功");
|
||||||
|
assert!(report.is_complete(), "{:?}", report.failures(3));
|
||||||
|
|
||||||
|
let written = read_version(&layout, &release.version_id);
|
||||||
|
assert_eq!(written.inherits_from.as_deref(), Some("1.12.2"));
|
||||||
|
assert_eq!(
|
||||||
|
written.main_class.as_deref(),
|
||||||
|
Some("net.minecraft.launchwrapper.Launch")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
written.minecraft_arguments.is_none(),
|
||||||
|
"loader 层不许写 minecraftArguments:那会顶掉父链"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forge 父链:**父链的 FMLTweaker 必须还在**,LiteLoader 的 tweakClass 追加在它后面。
|
||||||
|
///
|
||||||
|
/// 这条是 M218 SURVEY-p1 的「准入条件」:靠 `--tweakClass` 生效,就要求继承时
|
||||||
|
/// `arguments.game` 是追加、`minecraftArguments` 不被子层覆盖。启动参数顺序照
|
||||||
|
/// `core::launch::game_args` 的第 24 项(legacy 子串)→ 第 25 项(`arguments.game`)复现,
|
||||||
|
/// 真实 argv 由 `tests/launch/cmdline_snapshot.rs` 钉住。
|
||||||
|
#[test]
|
||||||
|
fn stacks_on_forge_and_keeps_the_forge_tweak_class() {
|
||||||
|
let temp = TempDir::new("forge");
|
||||||
|
let layout = GameLayout::new(temp.path());
|
||||||
|
put_version(&layout, "1.12.2", VANILLA_1122);
|
||||||
|
put_version(&layout, "1.12.2-forge-14.23.5.2860", FORGE_1122);
|
||||||
|
|
||||||
|
let release = liteloader_release("1.12.2-forge-14.23.5.2860");
|
||||||
|
assert!(!release.parent_is_vanilla());
|
||||||
|
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||||
|
layout: &layout,
|
||||||
|
release: &release,
|
||||||
|
environment: &Environment::host(),
|
||||||
|
parent_installed: true,
|
||||||
|
})
|
||||||
|
.expect("父版本已装,计划应可组");
|
||||||
|
assert_eq!(plan.install.jar_id, "1.12.2", "jar 仍取原版那层");
|
||||||
|
|
||||||
|
let engine = engine(temp.path());
|
||||||
|
prefill(&plan.install);
|
||||||
|
let report = block_on(execute(&engine, &plan.install, 4, None)).expect("执行应成功");
|
||||||
|
assert!(report.is_complete(), "{:?}", report.failures(3));
|
||||||
|
|
||||||
|
let installed = read_version(&layout, &release.version_id);
|
||||||
|
let mut source = std::collections::BTreeMap::new();
|
||||||
|
source.insert("1.12.2".to_string(), read_version(&layout, "1.12.2"));
|
||||||
|
source.insert(
|
||||||
|
"1.12.2-forge-14.23.5.2860".to_string(),
|
||||||
|
read_version(&layout, "1.12.2-forge-14.23.5.2860"),
|
||||||
|
);
|
||||||
|
source.insert(release.version_id.clone(), installed);
|
||||||
|
let resolved = resolve(&source, &source[&release.version_id]).expect("三层继承应可解析");
|
||||||
|
|
||||||
|
// ① 父链的 legacy 参数整串还在(子层没写 minecraftArguments)。
|
||||||
|
let legacy = resolved
|
||||||
|
.minecraft_arguments
|
||||||
|
.as_deref()
|
||||||
|
.expect("Forge 的 minecraftArguments 不许被子层顶掉");
|
||||||
|
assert!(
|
||||||
|
legacy.contains("net.minecraftforge.fml.common.launcher.FMLTweaker"),
|
||||||
|
"Forge 的 tweakClass 丢了:{legacy}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ② 启动顺序:legacy(第 24 项)在前,arguments.game(第 25 项)在后。
|
||||||
|
let mut game: Vec<String> = tokenize(legacy);
|
||||||
|
game.extend(
|
||||||
|
resolved
|
||||||
|
.arguments
|
||||||
|
.as_ref()
|
||||||
|
.expect("arguments 应保留")
|
||||||
|
.expand(&Environment::host())
|
||||||
|
.game,
|
||||||
|
);
|
||||||
|
let forge_at = game
|
||||||
|
.iter()
|
||||||
|
.position(|token| token.contains("FMLTweaker"))
|
||||||
|
.expect("Forge tweakClass 不在游戏参数里");
|
||||||
|
let liteloader_at = game
|
||||||
|
.iter()
|
||||||
|
.position(|token| token == "com.mumfrey.liteloader.launch.LiteLoaderTweaker")
|
||||||
|
.expect("LiteLoader tweakClass 不在游戏参数里");
|
||||||
|
assert!(
|
||||||
|
forge_at < liteloader_at,
|
||||||
|
"launchwrapper 按出现顺序应用 tweak:Forge 必须在前,实际 {game:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(game[liteloader_at - 1], "--tweakClass");
|
||||||
|
|
||||||
|
// ③ 主类与库顺序:Forge 的库在前(classpath 先到先得),LiteLoader 的在后。
|
||||||
|
assert_eq!(
|
||||||
|
resolved.main_class.as_deref(),
|
||||||
|
Some("net.minecraft.launchwrapper.Launch")
|
||||||
|
);
|
||||||
|
let libraries: Vec<&str> = resolved
|
||||||
|
.libraries
|
||||||
|
.as_deref()
|
||||||
|
.expect("libraries")
|
||||||
|
.iter()
|
||||||
|
.map(|library| library.name.as_str())
|
||||||
|
.collect();
|
||||||
|
let forge_lib = libraries
|
||||||
|
.iter()
|
||||||
|
.position(|name| name.starts_with("net.minecraftforge:forge:"))
|
||||||
|
.expect("缺 Forge 本体库");
|
||||||
|
let liteloader_lib = libraries
|
||||||
|
.iter()
|
||||||
|
.position(|name| name.starts_with("com.mumfrey:liteloader:"))
|
||||||
|
.expect("缺 LiteLoader 本体库");
|
||||||
|
assert!(forge_lib < liteloader_lib, "{libraries:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **新式 args 的父链**:父版本用 `arguments.game`(没有 `minecraftArguments`)。
|
||||||
|
///
|
||||||
|
/// 现实里 LiteLoader 只到 1.12.2,碰不到这种父版本;但「追加语义」是这套路线的准入条件,
|
||||||
|
/// 两种父版本形状都得钉住——否则哪天上游把老版本清单改成新式,我们会静默丢参数。
|
||||||
|
/// 这里用一份**手工夹具**(`--launchTarget forgeclient` 是 Forge 1.13+ 的真实形态)说明:
|
||||||
|
/// 父层参数在前、LiteLoader 的 tweakClass 在后,中间不互相覆盖。
|
||||||
|
#[test]
|
||||||
|
fn new_style_parent_args_are_appended_not_replaced() {
|
||||||
|
const MODERN_PARENT: &str = r#"{
|
||||||
|
"id": "1.12.2-modern-1.0",
|
||||||
|
"inheritsFrom": "1.12.2",
|
||||||
|
"mainClass": "net.minecraft.launchwrapper.Launch",
|
||||||
|
"arguments": {"game": ["--launchTarget", "forgeclient"], "jvm": ["-Dmodern=1"]},
|
||||||
|
"libraries": [{"name": "example:modern:1.0", "url": "https://example.invalid/"}]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let temp = TempDir::new("modern-args");
|
||||||
|
let layout = GameLayout::new(temp.path());
|
||||||
|
put_version(&layout, "1.12.2", VANILLA_1122);
|
||||||
|
put_version(&layout, "1.12.2-modern-1.0", MODERN_PARENT);
|
||||||
|
|
||||||
|
let release = liteloader_release("1.12.2-modern-1.0");
|
||||||
|
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||||
|
layout: &layout,
|
||||||
|
release: &release,
|
||||||
|
environment: &Environment::host(),
|
||||||
|
parent_installed: true,
|
||||||
|
})
|
||||||
|
.expect("计划应可组");
|
||||||
|
let engine = engine(temp.path());
|
||||||
|
prefill(&plan.install);
|
||||||
|
let report = block_on(execute(&engine, &plan.install, 4, None)).expect("执行应成功");
|
||||||
|
assert!(report.is_complete(), "{:?}", report.failures(3));
|
||||||
|
|
||||||
|
let mut source = std::collections::BTreeMap::new();
|
||||||
|
source.insert("1.12.2".to_string(), read_version(&layout, "1.12.2"));
|
||||||
|
source.insert(
|
||||||
|
"1.12.2-modern-1.0".to_string(),
|
||||||
|
read_version(&layout, "1.12.2-modern-1.0"),
|
||||||
|
);
|
||||||
|
source.insert(
|
||||||
|
release.version_id.clone(),
|
||||||
|
read_version(&layout, &release.version_id),
|
||||||
|
);
|
||||||
|
let resolved = resolve(&source, &source[&release.version_id]).expect("三层继承应可解析");
|
||||||
|
|
||||||
|
// 子层没写 legacy 串:继承结果里的 `minecraftArguments` 只能是祖父层(原版)那一份,
|
||||||
|
// 里面绝不能出现 LiteLoader 的 tweakClass(写了就说明我们用了覆盖语义)。
|
||||||
|
let legacy = resolved
|
||||||
|
.minecraft_arguments
|
||||||
|
.as_deref()
|
||||||
|
.expect("原版那份 legacy 串应沿链继承下来");
|
||||||
|
assert!(
|
||||||
|
!legacy.contains("LiteLoaderTweaker"),
|
||||||
|
"loader 层不许往 minecraftArguments 里写:{legacy}"
|
||||||
|
);
|
||||||
|
let game = resolved
|
||||||
|
.arguments
|
||||||
|
.as_ref()
|
||||||
|
.expect("arguments")
|
||||||
|
.expand(&Environment::host())
|
||||||
|
.game;
|
||||||
|
let assert_order = |needle: &str| {
|
||||||
|
game.iter()
|
||||||
|
.position(|token| token == needle)
|
||||||
|
.unwrap_or_else(|| panic!("缺 `{needle}`:{game:?}"))
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
assert_order("forgeclient")
|
||||||
|
< assert_order("com.mumfrey.liteloader.launch.LiteLoaderTweaker"),
|
||||||
|
"父层参数必须在前:{game:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LiteLoader 生效所依赖的**两条继承合并律**(M222 第一步核实,直接钉在合并函数上)。
|
||||||
|
///
|
||||||
|
/// 1. `arguments.game`:父在前、子在后**追加**(所以 tweakClass 能叠在父链参数之后);
|
||||||
|
/// 2. `minecraftArguments`:**子覆盖父**(所以 loader 层绝不能写它,否则父链的 tweakClass
|
||||||
|
/// 整串消失)。
|
||||||
|
///
|
||||||
|
/// 抓得住:有人把 `minecraftArguments` 改成追加(HMCL/原版语义不是这样),或把
|
||||||
|
/// `arguments.game` 改成整体替换(LiteLoader/其它叠层加载器当场失效)。
|
||||||
|
/// 抓不住:真实启动 argv 的顺序(那是 `launch::game_args` 的第 24/25 项,
|
||||||
|
/// 由 `tests/launch/cmdline_snapshot.rs` 钉住;本 mission 没有改那一层)。
|
||||||
|
#[test]
|
||||||
|
fn merge_laws_liteloader_depends_on() {
|
||||||
|
use unlucky_core::game::inherit::merge;
|
||||||
|
use unlucky_core::game::manifest::VersionJson;
|
||||||
|
|
||||||
|
let parse = |json: &str| VersionJson::from_json_str(json).expect("夹具应可解析");
|
||||||
|
|
||||||
|
let parent = parse(
|
||||||
|
r#"{"id":"p","minecraftArguments":"--tweakClass FMLTweaker","arguments":{"game":["--parent"]}}"#,
|
||||||
|
);
|
||||||
|
let child = parse(r#"{"id":"c","arguments":{"game":["--child"]}}"#);
|
||||||
|
let merged = merge(&child, &parent);
|
||||||
|
assert_eq!(
|
||||||
|
merged.minecraft_arguments.as_deref(),
|
||||||
|
Some("--tweakClass FMLTweaker"),
|
||||||
|
"子没写就继承父(这条是「叠在 Forge 上」的前提)"
|
||||||
|
);
|
||||||
|
let game: Vec<String> = merged
|
||||||
|
.arguments
|
||||||
|
.as_ref()
|
||||||
|
.expect("arguments")
|
||||||
|
.expand(&Environment::host())
|
||||||
|
.game;
|
||||||
|
assert_eq!(
|
||||||
|
game,
|
||||||
|
["--parent", "--child"],
|
||||||
|
"arguments.game 是父在前、子在后追加"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 子写了 minecraftArguments:整串替换(不是追加)。
|
||||||
|
let loud_child = parse(r#"{"id":"c2","minecraftArguments":"--tweakClass LiteLoaderTweaker"}"#);
|
||||||
|
let merged = merge(&loud_child, &parent);
|
||||||
|
assert_eq!(
|
||||||
|
merged.minecraft_arguments.as_deref(),
|
||||||
|
Some("--tweakClass LiteLoaderTweaker"),
|
||||||
|
"子覆盖父:这正是 loader 层不许写 minecraftArguments 的原因"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//! LiteLoader 安装链路的集成测试(`tests/liteloader*` 这个 test target 的入口)。
|
||||||
|
//!
|
||||||
|
//! 判据链:**真实元数据夹具 → 解析 → 合成清单 → 计划 → 落盘 → 继承解析 → 启动参数顺序**。
|
||||||
|
//! 全程不联网:LiteLoader 的库没有 sha1/size,`execute` 的跳过判据是「存在即信」,
|
||||||
|
//! 所以先把每个待下文件按计划里的落点写一份占位字节,执行期就只做「跳过 + 写版本 JSON」。
|
||||||
|
//!
|
||||||
|
//! 夹具来源(可追溯):
|
||||||
|
//! - `fixtures/liteloader-versions.json` = 2026-11 从 `https://dl.liteloader.com/versions/versions.json`
|
||||||
|
//! 真取的响应(200 / 49129 字节 / 16 个原版版本)裁剪到两个原版版本,**字段名与嵌套结构原样**;
|
||||||
|
//! - `metadata.xml` = 同一次探测里 `repo.mumfrey.com/…/liteloader/1.12.2-SNAPSHOT/maven-metadata.xml`
|
||||||
|
//! 的原样响应(200 / 358 字节);
|
||||||
|
//! - 原版 / Forge 1.12.2 的版本 JSON 是按官方清单形状写的最小夹具(Forge 那侧的
|
||||||
|
//! `minecraftArguments` 与 `crates/core/src/addon/loader/tests/forge.rs` 的实测夹具一致)。
|
||||||
|
//!
|
||||||
|
//! 未覆盖(如实留档):真实下载(走 `core::download` 引擎的镜像/退避,已有 M13 的单测覆盖)、
|
||||||
|
//! 真实 MC 启动、`dl.liteloader.com` / `repo.mumfrey.com` 的持续可用性。
|
||||||
|
|
||||||
|
mod guards;
|
||||||
|
mod install;
|
||||||
|
mod support;
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
//! 集成测试公用件:临时游戏目录、离线下载引擎、元数据夹具、父版本清单夹具。
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use unlucky_core::addon::loader::liteloader::{self, LiteLoaderEntry};
|
||||||
|
use unlucky_core::addon::loader::LoaderRelease;
|
||||||
|
use unlucky_core::download::backoff::BackoffPolicy;
|
||||||
|
use unlucky_core::download::cache::ContentCache;
|
||||||
|
use unlucky_core::download::chunked::ChunkedConfig;
|
||||||
|
use unlucky_core::download::config::EngineConfig;
|
||||||
|
use unlucky_core::download::engine::DownloadEngine;
|
||||||
|
use unlucky_core::download::health::HealthRegistry;
|
||||||
|
use unlucky_core::download::mirrors::MirrorTable;
|
||||||
|
use unlucky_core::download::region::RegionProfile;
|
||||||
|
use unlucky_core::game::layout::GameLayout;
|
||||||
|
use unlucky_core::game::manifest::VersionJson;
|
||||||
|
|
||||||
|
/// 真实响应的裁剪版(见 `tests/liteloader.rs` 文件头的来源说明)。
|
||||||
|
pub const VERSION_TABLE: &str = include_str!("fixtures/liteloader-versions.json");
|
||||||
|
|
||||||
|
/// 1.12.2-SNAPSHOT 的 maven-metadata(真实响应原样)。
|
||||||
|
pub const METADATA_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<metadata>
|
||||||
|
<groupId>com.mumfrey</groupId>
|
||||||
|
<artifactId>liteloader</artifactId>
|
||||||
|
<version>1.12.2-SNAPSHOT</version>
|
||||||
|
<versioning>
|
||||||
|
<snapshot>
|
||||||
|
<timestamp>20171128.144431</timestamp>
|
||||||
|
<buildNumber>4</buildNumber>
|
||||||
|
</snapshot>
|
||||||
|
<lastUpdated>20171128144431</lastUpdated>
|
||||||
|
</versioning>
|
||||||
|
</metadata>"#;
|
||||||
|
|
||||||
|
/// 原版 1.12.2(老式 `minecraftArguments`,与官方清单同形)。
|
||||||
|
pub const VANILLA_1122: &str = r#"{
|
||||||
|
"id": "1.12.2",
|
||||||
|
"type": "release",
|
||||||
|
"mainClass": "net.minecraft.client.main.Main",
|
||||||
|
"assets": "1.12",
|
||||||
|
"assetIndex": {"id": "1.12"},
|
||||||
|
"minecraftArguments": "--username ${auth_player_name} --version ${version_name}",
|
||||||
|
"libraries": [
|
||||||
|
{"name": "com.mojang:patchy:1.1", "url": "https://libraries.minecraft.net/"}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
/// Forge 1.12.2-14.23.5.2860(spec-0 的 version.json:主类 launchwrapper + FMLTweaker)。
|
||||||
|
pub const FORGE_1122: &str = r#"{
|
||||||
|
"id": "1.12.2-forge-14.23.5.2860",
|
||||||
|
"inheritsFrom": "1.12.2",
|
||||||
|
"mainClass": "net.minecraft.launchwrapper.Launch",
|
||||||
|
"minecraftArguments": "--tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker",
|
||||||
|
"libraries": [
|
||||||
|
{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860", "url": "https://maven.minecraftforge.net/"}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
/// 唯一临时目录守卫(`drop` 时递归删除)。
|
||||||
|
pub struct TempDir {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempDir {
|
||||||
|
pub fn new(tag: &str) -> Self {
|
||||||
|
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let stamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |since| since.as_nanos());
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"ull-liteloader-{tag}-{}-{stamp}-{unique}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&path);
|
||||||
|
std::fs::create_dir_all(&path).expect("临时目录应可创建");
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 离线引擎:单次尝试、短超时、分块关闭(测试里只用来跑「全部跳过」的执行段)。
|
||||||
|
pub fn engine(cache_root: &Path) -> DownloadEngine {
|
||||||
|
let config = EngineConfig {
|
||||||
|
max_attempts_per_source: 1,
|
||||||
|
backoff: BackoffPolicy {
|
||||||
|
base_ms: 1,
|
||||||
|
max_ms: 2,
|
||||||
|
jitter_ratio: 0.0,
|
||||||
|
max_attempts: 1,
|
||||||
|
},
|
||||||
|
connect_timeout: Duration::from_secs(3),
|
||||||
|
read_timeout: Duration::from_secs(3),
|
||||||
|
chunked: ChunkedConfig::disabled(),
|
||||||
|
..EngineConfig::default()
|
||||||
|
};
|
||||||
|
DownloadEngine::new(
|
||||||
|
config,
|
||||||
|
Arc::new(MirrorTable::builtin()),
|
||||||
|
Arc::new(RegionProfile::direct(0)),
|
||||||
|
Arc::new(HealthRegistry::in_memory()),
|
||||||
|
Arc::new(ContentCache::open(cache_root.join("cache"))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析夹具表里某个原版版本的条目。
|
||||||
|
pub fn entries(mc_version: &str) -> Vec<LiteLoaderEntry> {
|
||||||
|
let table = liteloader::parse_versions(VERSION_TABLE).expect("夹具版本表应可解析");
|
||||||
|
table.get(mc_version).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取 1.12.2 的 SNAPSHOT 条目并合成 release(父版本由调用方给)。
|
||||||
|
pub fn liteloader_release(parent_version_id: &str) -> LoaderRelease {
|
||||||
|
let entries = entries("1.12.2");
|
||||||
|
let entry = liteloader::default_entry(&entries).expect("1.12.2 应有一条版本");
|
||||||
|
let url = liteloader::snapshot_artifact_url(entry, METADATA_XML).expect("快照直链");
|
||||||
|
liteloader::build_release("1.12.2", parent_version_id, entry, &url).expect("合成清单")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把一份版本 JSON 写进 `versions/<id>/<id>.json`(模拟「父版本已装好」)。
|
||||||
|
pub fn put_version(layout: &GameLayout, id: &str, json: &str) {
|
||||||
|
let path = layout.version_json(id);
|
||||||
|
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("建版本目录");
|
||||||
|
std::fs::write(&path, json).expect("写版本 JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读盘上的版本 JSON。
|
||||||
|
pub fn read_version(layout: &GameLayout, id: &str) -> VersionJson {
|
||||||
|
let path = layout.version_json(id);
|
||||||
|
let text = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("读不到 {}:{error}", path.display()));
|
||||||
|
VersionJson::from_json_str(&text).expect("版本 JSON 应可解析")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按计划把每个待下文件写成占位字节(`execute` 的跳过判据 = 无 sha1 且存在即信)。
|
||||||
|
pub fn prefill(plan: &unlucky_core::game::install::InstallPlan) -> usize {
|
||||||
|
for file in &plan.files {
|
||||||
|
std::fs::create_dir_all(file.dest.parent().expect("文件父目录")).expect("建库目录");
|
||||||
|
std::fs::write(&file.dest, b"placeholder").expect("写占位文件");
|
||||||
|
}
|
||||||
|
plan.files.len()
|
||||||
|
}
|
||||||
@@ -99,6 +99,12 @@ macro_rules! download_entries {
|
|||||||
"{loader} needs Minecraft {min} or newer",
|
"{loader} needs Minecraft {min} or newer",
|
||||||
"{loader} 需要 Minecraft {min} 或更新"
|
"{loader} 需要 Minecraft {min} 或更新"
|
||||||
),
|
),
|
||||||
|
// M222:下载页只做「原版 + 加载器」一层,Forge 父链只有命令行能走。中性说明,
|
||||||
|
// 不是错误(不禁用确认键、不套错误色)——所以它不走 `problem` 那条通道。
|
||||||
|
DownloadOptionsLiteLoaderVanillaOnly => (
|
||||||
|
"Vanilla parent only — Forge goes through the CLI",
|
||||||
|
"只叠原版;叠 Forge 请走命令行"
|
||||||
|
),
|
||||||
DownloadCancelled => (
|
DownloadCancelled => (
|
||||||
"Install cancelled — finished files are kept, the instance was not created",
|
"Install cancelled — finished files are kept, the instance was not created",
|
||||||
"安装已取消 —— 已下好的文件保留,实例未登记"
|
"安装已取消 —— 已下好的文件保留,实例未登记"
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
|||||||
("DownloadOptionsInstanceNameHint", "Empty = use the version id", "留空就用版本号"),
|
("DownloadOptionsInstanceNameHint", "Empty = use the version id", "留空就用版本号"),
|
||||||
("DownloadOptionsIncompatible", "{loader} has no builds for {version}", "{loader} 没有适配 {version} 的构建"),
|
("DownloadOptionsIncompatible", "{loader} has no builds for {version}", "{loader} 没有适配 {version} 的构建"),
|
||||||
("DownloadOptionsNeedsAtLeast", "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新"),
|
("DownloadOptionsNeedsAtLeast", "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新"),
|
||||||
|
("DownloadOptionsLiteLoaderVanillaOnly", "Vanilla parent only — Forge goes through the CLI", "只叠原版;叠 Forge 请走命令行"),
|
||||||
("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"),
|
("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"),
|
||||||
("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"),
|
("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"),
|
||||||
("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
|
("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
|
||||||
|
|||||||
@@ -1,395 +1,28 @@
|
|||||||
//! 实例相关 DTO:磁贴与列表视图的唯一数据源。
|
//! 实例相关 DTO:磁贴与列表视图的唯一数据源。
|
||||||
|
//!
|
||||||
|
//! 按关注点拆成四个子模块(M222 为了给 LiteLoader 加入口而拆,纯搬移 + 新增一支):
|
||||||
|
//!
|
||||||
|
//! | 子模块 | 内容 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | [`loader`] | 加载器词表(磁贴徽标 + 向导可选)、[`LoaderInfo`] |
|
||||||
|
//! | [`runtime`] | [`Progress`] / [`LaunchStage`] / [`InstanceRuntime`] / [`InstanceSummary`] |
|
||||||
|
//! | [`create`] | 新建实例向导的提交请求 [`CreateRequest`] |
|
||||||
|
//!
|
||||||
|
//! 路径对外**一个字没变**(`unlucky_presenter::dto::InstanceSummary` 等仍从这里取),
|
||||||
|
//! 拆的是文件不是 API。
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
mod create;
|
||||||
|
mod loader;
|
||||||
|
mod runtime;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
pub use create::CreateRequest;
|
||||||
|
pub use loader::{LoaderInfo, LoaderKind};
|
||||||
|
pub use runtime::{InstanceRuntime, InstanceSummary, LaunchStage, Progress};
|
||||||
|
|
||||||
pub use super::launch_failure::{LaunchFailureCause, LaunchFailureStage, LaunchFailureView};
|
pub use super::launch_failure::{LaunchFailureCause, LaunchFailureStage, LaunchFailureView};
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use unlucky_core::game::LoaderKind as CoreLoaderKind;
|
|
||||||
|
|
||||||
use super::LaunchArgsView;
|
|
||||||
|
|
||||||
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
|
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
|
||||||
pub type InstanceId = String;
|
pub type InstanceId = String;
|
||||||
|
|
||||||
/// 加载器种类(磁贴徽标 + 详情页版本链)。
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum LoaderKind {
|
|
||||||
/// 原版(无加载器)
|
|
||||||
Vanilla,
|
|
||||||
/// Fabric
|
|
||||||
Fabric,
|
|
||||||
/// Quilt
|
|
||||||
Quilt,
|
|
||||||
/// Forge
|
|
||||||
Forge,
|
|
||||||
/// NeoForge
|
|
||||||
NeoForge,
|
|
||||||
/// OptiFine
|
|
||||||
OptiFine,
|
|
||||||
/// LiteLoader
|
|
||||||
LiteLoader,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LoaderKind {
|
|
||||||
/// 徽标显示文字。
|
|
||||||
pub fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Vanilla => "原版",
|
|
||||||
Self::Fabric => "Fabric",
|
|
||||||
Self::Quilt => "Quilt",
|
|
||||||
Self::Forge => "Forge",
|
|
||||||
Self::NeoForge => "NeoForge",
|
|
||||||
Self::OptiFine => "OptiFine",
|
|
||||||
Self::LiteLoader => "LiteLoader",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 本 DTO 种类 → core 的宽种类。
|
|
||||||
///
|
|
||||||
/// **结构映射,一个 id 字面量都没有**:词表只有一个真相源
|
|
||||||
/// ([`CoreLoaderKind::as_str`]),本 crate 写不进第二份。加一支 DTO 种类这里编不过。
|
|
||||||
const fn to_core(self) -> CoreLoaderKind {
|
|
||||||
match self {
|
|
||||||
Self::Vanilla => CoreLoaderKind::Vanilla,
|
|
||||||
Self::Fabric => CoreLoaderKind::Fabric,
|
|
||||||
Self::Quilt => CoreLoaderKind::Quilt,
|
|
||||||
Self::Forge => CoreLoaderKind::Forge,
|
|
||||||
Self::NeoForge => CoreLoaderKind::NeoForge,
|
|
||||||
Self::OptiFine => CoreLoaderKind::OptiFine,
|
|
||||||
Self::LiteLoader => CoreLoaderKind::LiteLoader,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// core 的宽种类 → 本 DTO 种类([`Self::to_core`] 的反向,同样不含字面量)。
|
|
||||||
///
|
|
||||||
/// core 给 `game::LoaderKind` 加一支、而 DTO 还没跟上时,这里编不过——这正是要的:
|
|
||||||
/// UI 不许「新加载器默默显示成原版」。
|
|
||||||
const fn from_core(kind: CoreLoaderKind) -> Self {
|
|
||||||
match kind {
|
|
||||||
CoreLoaderKind::Vanilla => Self::Vanilla,
|
|
||||||
CoreLoaderKind::Fabric => Self::Fabric,
|
|
||||||
CoreLoaderKind::Quilt => Self::Quilt,
|
|
||||||
CoreLoaderKind::Forge => Self::Forge,
|
|
||||||
CoreLoaderKind::NeoForge => Self::NeoForge,
|
|
||||||
CoreLoaderKind::OptiFine => Self::OptiFine,
|
|
||||||
CoreLoaderKind::LiteLoader => Self::LiteLoader,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 稳定 id(`vanilla` / `fabric` / …;GUI 与 `--json` 同词表)。
|
|
||||||
///
|
|
||||||
/// **委派**给 [`CoreLoaderKind::as_str`](M126):词表的源头表在 core(0.2.0 已发布契约;
|
|
||||||
/// 另一处逐字副本是 `ull help instances` 的词表行),这里只剩一次结构映射。
|
|
||||||
#[must_use]
|
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
self.to_core().as_str()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从稳定 id 解析(未知给 `None`,由调用方决定报错文案)。
|
|
||||||
///
|
|
||||||
/// 七个 id 的匹配委派给 [`CoreLoaderKind::parse`];本方法只额外收 `none` / 空串这两个
|
|
||||||
/// **GUI 输入别名**(表单里「不装加载器」的写法)——它们不是对外输出的 id,也绝不参与
|
|
||||||
/// [`Self::as_str`]。
|
|
||||||
pub fn parse(raw: &str) -> Option<Self> {
|
|
||||||
let wanted = raw.trim();
|
|
||||||
if wanted.is_empty() || wanted.eq_ignore_ascii_case("none") {
|
|
||||||
return Some(Self::Vanilla);
|
|
||||||
}
|
|
||||||
Some(Self::from_core(CoreLoaderKind::parse(wanted)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 向导里的可选加载器(顺序即展示顺序)。
|
|
||||||
pub fn wizard_choices() -> [Self; 5] {
|
|
||||||
[
|
|
||||||
Self::Vanilla,
|
|
||||||
Self::Fabric,
|
|
||||||
Self::Quilt,
|
|
||||||
Self::Forge,
|
|
||||||
Self::NeoForge,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 加载器 + 版本(例如 Fabric 0.16.5)。
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct LoaderInfo {
|
|
||||||
/// 加载器种类
|
|
||||||
pub kind: LoaderKind,
|
|
||||||
/// 加载器版本号;原版为空串
|
|
||||||
pub version: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LoaderInfo {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
kind: LoaderKind::Vanilla,
|
|
||||||
version: String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LoaderInfo {
|
|
||||||
/// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。
|
|
||||||
pub fn badge(&self) -> String {
|
|
||||||
if self.kind == LoaderKind::Vanilla || self.version.is_empty() {
|
|
||||||
self.kind.label().to_string()
|
|
||||||
} else {
|
|
||||||
format!("{} {}", self.kind.label(), self.version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Progress {
|
|
||||||
/// 已完成量
|
|
||||||
pub done: u64,
|
|
||||||
/// 总量;0 = 未知
|
|
||||||
pub total: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Progress {
|
|
||||||
/// 0.0~1.0;未知总量返回 0.0。
|
|
||||||
pub fn ratio(self) -> f32 {
|
|
||||||
if self.total == 0 {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 总量是否已知。
|
|
||||||
pub fn is_known(self) -> bool {
|
|
||||||
self.total > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum LaunchStage {
|
|
||||||
/// 下载资源
|
|
||||||
Download,
|
|
||||||
/// 校验文件
|
|
||||||
Verify,
|
|
||||||
/// 组装命令行并拉起进程
|
|
||||||
Launch,
|
|
||||||
/// 进程已起、等待游戏窗口(M28 的窗口检测阶段)
|
|
||||||
Window,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LaunchStage {
|
|
||||||
/// 磁贴上的阶段文案。
|
|
||||||
pub fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Download => "下载资源",
|
|
||||||
Self::Verify => "校验文件",
|
|
||||||
Self::Launch => "组装命令行",
|
|
||||||
Self::Window => "等待窗口",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 实例运行时状态(磁贴的四种外观)。
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
||||||
pub enum InstanceRuntime {
|
|
||||||
/// 空闲
|
|
||||||
#[default]
|
|
||||||
Idle,
|
|
||||||
/// 启动管线进行中
|
|
||||||
Busy {
|
|
||||||
/// 当前阶段
|
|
||||||
stage: LaunchStage,
|
|
||||||
/// 该阶段进度
|
|
||||||
progress: Progress,
|
|
||||||
},
|
|
||||||
/// 游戏进程存活
|
|
||||||
Running {
|
|
||||||
/// 进程号(停止/诊断用)
|
|
||||||
pid: u32,
|
|
||||||
},
|
|
||||||
/// 上次启动失败(磁贴显示「重试」)
|
|
||||||
///
|
|
||||||
/// M161 之前这里只有一个 `reason: String`,而且里面已经写着「启动失败:…」——
|
|
||||||
/// 界面再包一层就成了「英文前缀 + 中文句子」,中文界面则是「启动失败 · 启动失败:…」。
|
|
||||||
/// 现在给的是结构化原因(见 [`LaunchFailureCause`]):**没起来**与**起来又崩**是两种形状,
|
|
||||||
/// 界面各说各的话(复审 P1-1:早先崩溃被说成「启动游戏进程失败」)。
|
|
||||||
Failed {
|
|
||||||
/// 这一场为什么是失败态
|
|
||||||
cause: LaunchFailureCause,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 实例摘要:磁贴/列表视图唯一数据源。
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct InstanceSummary {
|
|
||||||
/// 稳定 id
|
|
||||||
pub id: InstanceId,
|
|
||||||
/// 显示名
|
|
||||||
pub name: String,
|
|
||||||
/// 游戏版本(如 1.20.4)
|
|
||||||
pub mc_version: String,
|
|
||||||
/// 加载器信息
|
|
||||||
pub loader: LoaderInfo,
|
|
||||||
/// ISO-8601 UTC 最后游玩时间;None = 从未启动
|
|
||||||
pub last_played: Option<String>,
|
|
||||||
/// 运行时状态
|
|
||||||
pub runtime: InstanceRuntime,
|
|
||||||
/// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄)
|
|
||||||
pub icon: Option<PathBuf>,
|
|
||||||
/// 隔离档位(`isolated` / `shared` / `custom`;未知为空)
|
|
||||||
pub isolation: String,
|
|
||||||
/// 该实例绑定的版本 id(详情页/启动用)
|
|
||||||
pub version_id: String,
|
|
||||||
/// 版本本体是否在盘上(缺载荷时磁贴亮黄条)
|
|
||||||
pub version_present: bool,
|
|
||||||
/// 启动参数(M157:实例配置页「启动参数」区的数据;含三级回落后的生效值与版本门槛)
|
|
||||||
pub launch: LaunchArgsView,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 新建实例向导提交给 host 的请求(已通过向导校验)。
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct CreateRequest {
|
|
||||||
/// 目标原版版本 id(`1.20.4`)
|
|
||||||
pub version: String,
|
|
||||||
/// 加载器
|
|
||||||
pub loader: LoaderKind,
|
|
||||||
/// 加载器版本(空 = 让 core 取最新)
|
|
||||||
pub loader_version: String,
|
|
||||||
/// 实例名(空 = 用版本 id / 加载器版本 id)
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CreateRequest {
|
|
||||||
/// 实际使用的实例名。
|
|
||||||
pub fn instance_name(&self) -> String {
|
|
||||||
if self.name.trim().is_empty() {
|
|
||||||
self.version.clone()
|
|
||||||
} else {
|
|
||||||
self.name.trim().to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loader_badges_and_ids() {
|
|
||||||
let vanilla = LoaderInfo::default();
|
|
||||||
assert_eq!(vanilla.badge(), "原版");
|
|
||||||
let fabric = LoaderInfo {
|
|
||||||
kind: LoaderKind::Fabric,
|
|
||||||
version: "0.16.5".to_string(),
|
|
||||||
};
|
|
||||||
assert_eq!(fabric.badge(), "Fabric 0.16.5");
|
|
||||||
for kind in LoaderKind::wizard_choices() {
|
|
||||||
assert_eq!(LoaderKind::parse(kind.as_str()), Some(kind));
|
|
||||||
}
|
|
||||||
assert_eq!(LoaderKind::parse("fabric"), Some(LoaderKind::Fabric));
|
|
||||||
assert_eq!(LoaderKind::parse("unknown"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 对外契约:七个 id 在测试里**独立抄一遍**(0.2.0 已发布形状),并与 core 的 `ALL` 做
|
|
||||||
/// **集合比较**——这是对外词表的守卫(与 CLI 的 help 守卫互补)。显示路径另有守卫:
|
|
||||||
/// `presenter::host::game::every_core_loader_kind_reaches_the_tile_as_itself`(按 `ALL` 遍历)。
|
|
||||||
///
|
|
||||||
/// 为什么不是编译器管:编译期只拦得到「漏 match」,而新种类在还有 match 的消费方用最省事的
|
|
||||||
/// `=> Vanilla` 糊过去**编得过**,那时新加载器会静默显示成原版(`game::loader` 模块文档
|
|
||||||
/// 第 ③ 步)。对外词表由下面第 ② 段的集合断言守,显示路径由上面那条测试守。
|
|
||||||
///
|
|
||||||
/// 抓得住:改词表(`neoforge` → `neo_forge`);**新增一家加载器而没主动更新这份契约**
|
|
||||||
/// (core 的 `ALL` 多一支 ⇒ 集合不等);DTO 委派接错支(`OptiFine` 映到 `LiteLoader`);
|
|
||||||
/// `parse` 收进不该有的别名。
|
|
||||||
/// 抓不住:core 的解析规则(那是 `game::loader` 的
|
|
||||||
/// `every_stable_id_parses_back_to_its_kind` 等测试的职责),以及真二进制输出面的 JSON
|
|
||||||
/// (CLI 的 `json_rows_expose_loader_kind_and_version`)。
|
|
||||||
#[test]
|
|
||||||
fn loader_kind_ids_are_the_released_contract() {
|
|
||||||
const CONTRACT: [(LoaderKind, &str); 7] = [
|
|
||||||
(LoaderKind::Vanilla, "vanilla"),
|
|
||||||
(LoaderKind::Fabric, "fabric"),
|
|
||||||
(LoaderKind::Quilt, "quilt"),
|
|
||||||
(LoaderKind::Forge, "forge"),
|
|
||||||
(LoaderKind::NeoForge, "neoforge"),
|
|
||||||
(LoaderKind::OptiFine, "optifine"),
|
|
||||||
(LoaderKind::LiteLoader, "liteloader"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// ① DTO 这一层:每支的 id、反查、大小写。
|
|
||||||
for (kind, id) in CONTRACT {
|
|
||||||
assert_eq!(kind.as_str(), id, "{kind:?} 的稳定 id 变了");
|
|
||||||
assert_eq!(LoaderKind::parse(id), Some(kind), "`{id}` 认不回来");
|
|
||||||
let upper = id.to_ascii_uppercase();
|
|
||||||
assert_eq!(LoaderKind::parse(&upper), Some(kind), "大写 `{upper}`");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ② core 的词表 ↔ 这份契约:**集合相等**才过(多一支、少一支、改一个字都红)。
|
|
||||||
let mut actual: Vec<&str> = CoreLoaderKind::ALL
|
|
||||||
.iter()
|
|
||||||
.map(|kind| kind.as_str())
|
|
||||||
.collect();
|
|
||||||
actual.sort_unstable();
|
|
||||||
let mut expected: Vec<&str> = CONTRACT.iter().map(|(_, id)| *id).collect();
|
|
||||||
expected.sort_unstable();
|
|
||||||
assert_eq!(
|
|
||||||
actual, expected,
|
|
||||||
"core 的 `LoaderKind::ALL` 必须逐字等于对外契约:加一家加载器时先改这里 + help"
|
|
||||||
);
|
|
||||||
|
|
||||||
// GUI 输入别名(不是对外 id):不装加载器有三种写法;别名与拼法都不许混进词表。
|
|
||||||
for alias in ["none", "", " "] {
|
|
||||||
assert_eq!(
|
|
||||||
LoaderKind::parse(alias),
|
|
||||||
Some(LoaderKind::Vanilla),
|
|
||||||
"`{alias}`"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(LoaderKind::parse("neo-forge"), None, "别名只归安装侧");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn progress_ratio_handles_unknown_total() {
|
|
||||||
assert_eq!(Progress { done: 1, total: 0 }.ratio(), 0.0);
|
|
||||||
assert!((Progress { done: 1, total: 4 }.ratio() - 0.25).abs() < f32::EPSILON);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn create_request_defaults_name_to_version() {
|
|
||||||
let request = CreateRequest {
|
|
||||||
version: "1.20.4".to_string(),
|
|
||||||
loader: LoaderKind::Vanilla,
|
|
||||||
loader_version: String::new(),
|
|
||||||
name: " ".to_string(),
|
|
||||||
};
|
|
||||||
assert_eq!(request.instance_name(), "1.20.4");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn instance_summary_is_serde_round_trip_safe() {
|
|
||||||
let instance = InstanceSummary {
|
|
||||||
id: "inst-1".to_string(),
|
|
||||||
name: "生存".to_string(),
|
|
||||||
mc_version: "1.20.4".to_string(),
|
|
||||||
loader: LoaderInfo {
|
|
||||||
kind: LoaderKind::Fabric,
|
|
||||||
version: "0.16.5".to_string(),
|
|
||||||
},
|
|
||||||
last_played: Some("2026-10-31T12:00:00.000Z".to_string()),
|
|
||||||
runtime: InstanceRuntime::Busy {
|
|
||||||
stage: LaunchStage::Window,
|
|
||||||
progress: Progress { done: 1, total: 2 },
|
|
||||||
},
|
|
||||||
icon: None,
|
|
||||||
isolation: "isolated".to_string(),
|
|
||||||
version_id: "1.20.4".to_string(),
|
|
||||||
version_present: true,
|
|
||||||
launch: LaunchArgsView::default(),
|
|
||||||
};
|
|
||||||
let text = serde_json::to_string(&instance).expect("序列化");
|
|
||||||
let back: InstanceSummary = serde_json::from_str(&text).expect("反序列化");
|
|
||||||
assert_eq!(back, instance);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! 新建实例向导提交给 host 的请求。
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::LoaderKind;
|
||||||
|
|
||||||
|
/// 新建实例向导提交给 host 的请求(已通过向导校验)。
|
||||||
|
///
|
||||||
|
/// ⚠️ **没有「父版本」槽位**:向导/下载页只装「叠在原版上」的那一层。要把 LiteLoader 叠在
|
||||||
|
/// Forge 上,core 侧已有真实装配路径(`LoaderSource::resolve_on` 的 `parent` 参数 +
|
||||||
|
/// `LoaderPlanInput::parent_installed`),但把父版本从 UI 传下来需要同时扩 `CreateRequest`、
|
||||||
|
/// 下载页草稿与向导校验三处,本轮只做原版父链(见 M222 报告「未做/待排」)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CreateRequest {
|
||||||
|
/// 目标原版版本 id(`1.20.4`)
|
||||||
|
pub version: String,
|
||||||
|
/// 加载器
|
||||||
|
pub loader: LoaderKind,
|
||||||
|
/// 加载器版本(空 = 让 core 取最新)
|
||||||
|
pub loader_version: String,
|
||||||
|
/// 实例名(空 = 用版本 id / 加载器版本 id)
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateRequest {
|
||||||
|
/// 实际使用的实例名。
|
||||||
|
pub fn instance_name(&self) -> String {
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
self.version.clone()
|
||||||
|
} else {
|
||||||
|
self.name.trim().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! 加载器词表(磁贴徽标 + 向导可选)与 [`LoaderInfo`]。
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use unlucky_core::game::LoaderKind as CoreLoaderKind;
|
||||||
|
|
||||||
|
/// 加载器种类(磁贴徽标 + 详情页版本链)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum LoaderKind {
|
||||||
|
/// 原版(无加载器)
|
||||||
|
Vanilla,
|
||||||
|
/// Fabric
|
||||||
|
Fabric,
|
||||||
|
/// Quilt
|
||||||
|
Quilt,
|
||||||
|
/// Forge
|
||||||
|
Forge,
|
||||||
|
/// NeoForge
|
||||||
|
NeoForge,
|
||||||
|
/// OptiFine
|
||||||
|
OptiFine,
|
||||||
|
/// LiteLoader
|
||||||
|
LiteLoader,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoaderKind {
|
||||||
|
/// 徽标显示文字。
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Vanilla => "原版",
|
||||||
|
Self::Fabric => "Fabric",
|
||||||
|
Self::Quilt => "Quilt",
|
||||||
|
Self::Forge => "Forge",
|
||||||
|
Self::NeoForge => "NeoForge",
|
||||||
|
Self::OptiFine => "OptiFine",
|
||||||
|
Self::LiteLoader => "LiteLoader",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本 DTO 种类 → core 的宽种类。
|
||||||
|
///
|
||||||
|
/// **结构映射,一个 id 字面量都没有**:词表只有一个真相源
|
||||||
|
/// ([`CoreLoaderKind::as_str`]),本 crate 写不进第二份。加一支 DTO 种类这里编不过。
|
||||||
|
const fn to_core(self) -> CoreLoaderKind {
|
||||||
|
match self {
|
||||||
|
Self::Vanilla => CoreLoaderKind::Vanilla,
|
||||||
|
Self::Fabric => CoreLoaderKind::Fabric,
|
||||||
|
Self::Quilt => CoreLoaderKind::Quilt,
|
||||||
|
Self::Forge => CoreLoaderKind::Forge,
|
||||||
|
Self::NeoForge => CoreLoaderKind::NeoForge,
|
||||||
|
Self::OptiFine => CoreLoaderKind::OptiFine,
|
||||||
|
Self::LiteLoader => CoreLoaderKind::LiteLoader,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// core 的宽种类 → 本 DTO 种类([`Self::to_core`] 的反向,同样不含字面量)。
|
||||||
|
///
|
||||||
|
/// core 给 `game::LoaderKind` 加一支、而 DTO 还没跟上时,这里编不过——这正是要的:
|
||||||
|
/// UI 不许「新加载器默默显示成原版」。
|
||||||
|
const fn from_core(kind: CoreLoaderKind) -> Self {
|
||||||
|
match kind {
|
||||||
|
CoreLoaderKind::Vanilla => Self::Vanilla,
|
||||||
|
CoreLoaderKind::Fabric => Self::Fabric,
|
||||||
|
CoreLoaderKind::Quilt => Self::Quilt,
|
||||||
|
CoreLoaderKind::Forge => Self::Forge,
|
||||||
|
CoreLoaderKind::NeoForge => Self::NeoForge,
|
||||||
|
CoreLoaderKind::OptiFine => Self::OptiFine,
|
||||||
|
CoreLoaderKind::LiteLoader => Self::LiteLoader,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 稳定 id(`vanilla` / `fabric` / …;GUI 与 `--json` 同词表)。
|
||||||
|
///
|
||||||
|
/// **委派**给 [`CoreLoaderKind::as_str`](M126):词表的源头表在 core(0.2.0 已发布契约;
|
||||||
|
/// 另一处逐字副本是 `ull help instances` 的词表行),这里只剩一次结构映射。
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
self.to_core().as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从稳定 id 解析(未知给 `None`,由调用方决定报错文案)。
|
||||||
|
///
|
||||||
|
/// 七个 id 的匹配委派给 [`CoreLoaderKind::parse`];本方法只额外收 `none` / 空串这两个
|
||||||
|
/// **GUI 输入别名**(表单里「不装加载器」的写法)——它们不是对外输出的 id,也绝不参与
|
||||||
|
/// [`Self::as_str`]。
|
||||||
|
pub fn parse(raw: &str) -> Option<Self> {
|
||||||
|
let wanted = raw.trim();
|
||||||
|
if wanted.is_empty() || wanted.eq_ignore_ascii_case("none") {
|
||||||
|
return Some(Self::Vanilla);
|
||||||
|
}
|
||||||
|
Some(Self::from_core(CoreLoaderKind::parse(wanted)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 向导里的可选加载器(顺序即展示顺序)。
|
||||||
|
///
|
||||||
|
/// 六选 = 原版 + 五家能装的。OptiFine 仍**不在这里**:它要跑外部 `optifine.Patcher` 并原地
|
||||||
|
/// 改 zip,core 侧至今没有安装路线(见 `DevDocs/artifacts/m218/SURVEY-p1.md` §3),
|
||||||
|
/// 放进向导只会让用户选完才失败。
|
||||||
|
pub fn wizard_choices() -> [Self; 6] {
|
||||||
|
[
|
||||||
|
Self::Vanilla,
|
||||||
|
Self::Fabric,
|
||||||
|
Self::Quilt,
|
||||||
|
Self::Forge,
|
||||||
|
Self::NeoForge,
|
||||||
|
Self::LiteLoader,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载器 + 版本(例如 Fabric 0.16.5)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct LoaderInfo {
|
||||||
|
/// 加载器种类
|
||||||
|
pub kind: LoaderKind,
|
||||||
|
/// 加载器版本号;原版为空串
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LoaderInfo {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: LoaderKind::Vanilla,
|
||||||
|
version: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoaderInfo {
|
||||||
|
/// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。
|
||||||
|
pub fn badge(&self) -> String {
|
||||||
|
if self.kind == LoaderKind::Vanilla || self.version.is_empty() {
|
||||||
|
self.kind.label().to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} {}", self.kind.label(), self.version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
//! 进度 / 阶段 / 运行时状态 / 实例摘要(磁贴与列表的唯一数据源)。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::super::LaunchArgsView;
|
||||||
|
use super::{InstanceId, LoaderInfo};
|
||||||
|
|
||||||
|
/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Progress {
|
||||||
|
/// 已完成量
|
||||||
|
pub done: u64,
|
||||||
|
/// 总量;0 = 未知
|
||||||
|
pub total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Progress {
|
||||||
|
/// 0.0~1.0;未知总量返回 0.0。
|
||||||
|
pub fn ratio(self) -> f32 {
|
||||||
|
if self.total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 总量是否已知。
|
||||||
|
pub fn is_known(self) -> bool {
|
||||||
|
self.total > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum LaunchStage {
|
||||||
|
/// 下载资源
|
||||||
|
Download,
|
||||||
|
/// 校验文件
|
||||||
|
Verify,
|
||||||
|
/// 组装命令行并拉起进程
|
||||||
|
Launch,
|
||||||
|
/// 进程已起、等待游戏窗口(M28 的窗口检测阶段)
|
||||||
|
Window,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LaunchStage {
|
||||||
|
/// 磁贴上的阶段文案。
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Download => "下载资源",
|
||||||
|
Self::Verify => "校验文件",
|
||||||
|
Self::Launch => "组装命令行",
|
||||||
|
Self::Window => "等待窗口",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实例运行时状态(磁贴的四种外观)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum InstanceRuntime {
|
||||||
|
/// 空闲
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
/// 启动管线进行中
|
||||||
|
Busy {
|
||||||
|
/// 当前阶段
|
||||||
|
stage: LaunchStage,
|
||||||
|
/// 该阶段进度
|
||||||
|
progress: Progress,
|
||||||
|
},
|
||||||
|
/// 游戏进程存活
|
||||||
|
Running {
|
||||||
|
/// 进程号(停止/诊断用)
|
||||||
|
pid: u32,
|
||||||
|
},
|
||||||
|
/// 上次启动失败(磁贴显示「重试」)
|
||||||
|
///
|
||||||
|
/// M161 之前这里只有一个 `reason: String`,而且里面已经写着「启动失败:…」——
|
||||||
|
/// 界面再包一层就成了「英文前缀 + 中文句子」,中文界面则是「启动失败 · 启动失败:…」。
|
||||||
|
/// 现在给的是结构化原因(见 [`crate::dto::LaunchFailureCause`]):**没起来**与**起来又崩**是两种形状,
|
||||||
|
/// 界面各说各的话(复审 P1-1:早先崩溃被说成「启动游戏进程失败」)。
|
||||||
|
Failed {
|
||||||
|
/// 这一场为什么是失败态
|
||||||
|
cause: crate::dto::LaunchFailureCause,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实例摘要:磁贴/列表视图唯一数据源。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct InstanceSummary {
|
||||||
|
/// 稳定 id
|
||||||
|
pub id: InstanceId,
|
||||||
|
/// 显示名
|
||||||
|
pub name: String,
|
||||||
|
/// 游戏版本(如 1.20.4)
|
||||||
|
pub mc_version: String,
|
||||||
|
/// 加载器信息
|
||||||
|
pub loader: LoaderInfo,
|
||||||
|
/// ISO-8601 UTC 最后游玩时间;None = 从未启动
|
||||||
|
pub last_played: Option<String>,
|
||||||
|
/// 运行时状态
|
||||||
|
pub runtime: InstanceRuntime,
|
||||||
|
/// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄)
|
||||||
|
pub icon: Option<PathBuf>,
|
||||||
|
/// 隔离档位(`isolated` / `shared` / `custom`;未知为空)
|
||||||
|
pub isolation: String,
|
||||||
|
/// 该实例绑定的版本 id(详情页/启动用)
|
||||||
|
pub version_id: String,
|
||||||
|
/// 版本本体是否在盘上(缺载荷时磁贴亮黄条)
|
||||||
|
pub version_present: bool,
|
||||||
|
/// 启动参数(M157:实例配置页「启动参数」区的数据;含三级回落后的生效值与版本门槛)
|
||||||
|
pub launch: LaunchArgsView,
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
//! 实例 DTO 的单测(词表契约 / 徽标 / 序列化往返)。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use unlucky_core::game::LoaderKind as CoreLoaderKind;
|
||||||
|
|
||||||
|
use crate::dto::LaunchArgsView;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loader_badges_and_ids() {
|
||||||
|
let vanilla = LoaderInfo::default();
|
||||||
|
assert_eq!(vanilla.badge(), "原版");
|
||||||
|
let fabric = LoaderInfo {
|
||||||
|
kind: LoaderKind::Fabric,
|
||||||
|
version: "0.16.5".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(fabric.badge(), "Fabric 0.16.5");
|
||||||
|
let liteloader = LoaderInfo {
|
||||||
|
kind: LoaderKind::LiteLoader,
|
||||||
|
version: "1.12.2-SNAPSHOT".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(liteloader.badge(), "LiteLoader 1.12.2-SNAPSHOT");
|
||||||
|
for kind in LoaderKind::wizard_choices() {
|
||||||
|
assert_eq!(LoaderKind::parse(kind.as_str()), Some(kind));
|
||||||
|
}
|
||||||
|
assert_eq!(LoaderKind::parse("fabric"), Some(LoaderKind::Fabric));
|
||||||
|
assert_eq!(LoaderKind::parse("unknown"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 向导可选集:**每一支都能被 core 的安装侧接受**(OptiFine 不在其中,因为它装不了)。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 OptiFine 塞进向导(选完才失败)、漏掉 LiteLoader(GUI 走不到真实路线)。
|
||||||
|
#[test]
|
||||||
|
fn wizard_choices_are_all_installable() {
|
||||||
|
let installable: Vec<&str> = LoaderKind::wizard_choices()
|
||||||
|
.iter()
|
||||||
|
.filter(|kind| **kind != LoaderKind::Vanilla)
|
||||||
|
.map(|kind| kind.as_str())
|
||||||
|
.collect();
|
||||||
|
for id in &installable {
|
||||||
|
assert!(
|
||||||
|
unlucky_core::addon::loader::LoaderKind::parse(id).is_some(),
|
||||||
|
"`{id}` 在向导里但安装侧不认"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(installable.len(), 5, "原版 + 五家能装的:{installable:?}");
|
||||||
|
assert!(!installable.contains(&"optifine"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对外契约:七个 id 在测试里**独立抄一遍**(0.2.0 已发布形状),并与 core 的 `ALL` 做
|
||||||
|
/// **集合比较**——这是对外词表的守卫(与 CLI 的 help 守卫互补)。显示路径另有守卫:
|
||||||
|
/// `presenter::host::game::every_core_loader_kind_reaches_the_tile_as_itself`(按 `ALL` 遍历)。
|
||||||
|
///
|
||||||
|
/// 为什么不是编译器管:编译期只拦得到「漏 match」,而新种类在还有 match 的消费方用最省事的
|
||||||
|
/// `=> Vanilla` 糊过去**编得过**,那时新加载器会静默显示成原版(`game::loader` 模块文档
|
||||||
|
/// 第 ③ 步)。对外词表由下面第 ② 段的集合断言守,显示路径由上面那条测试守。
|
||||||
|
///
|
||||||
|
/// 抓得住:改词表(`neoforge` → `neo_forge`);**新增一家加载器而没主动更新这份契约**
|
||||||
|
/// (core 的 `ALL` 多一支 ⇒ 集合不等);DTO 委派接错支(`OptiFine` 映到 `LiteLoader`);
|
||||||
|
/// `parse` 收进不该有的别名。
|
||||||
|
/// 抓不住:core 的解析规则(那是 `game::loader` 的
|
||||||
|
/// `every_stable_id_parses_back_to_its_kind` 等测试的职责),以及真二进制输出面的 JSON
|
||||||
|
/// (CLI 的 `json_rows_expose_loader_kind_and_version`)。
|
||||||
|
#[test]
|
||||||
|
fn loader_kind_ids_are_the_released_contract() {
|
||||||
|
const CONTRACT: [(LoaderKind, &str); 7] = [
|
||||||
|
(LoaderKind::Vanilla, "vanilla"),
|
||||||
|
(LoaderKind::Fabric, "fabric"),
|
||||||
|
(LoaderKind::Quilt, "quilt"),
|
||||||
|
(LoaderKind::Forge, "forge"),
|
||||||
|
(LoaderKind::NeoForge, "neoforge"),
|
||||||
|
(LoaderKind::OptiFine, "optifine"),
|
||||||
|
(LoaderKind::LiteLoader, "liteloader"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ① DTO 这一层:每支的 id、反查、大小写。
|
||||||
|
for (kind, id) in CONTRACT {
|
||||||
|
assert_eq!(kind.as_str(), id, "{kind:?} 的稳定 id 变了");
|
||||||
|
assert_eq!(LoaderKind::parse(id), Some(kind), "`{id}` 认不回来");
|
||||||
|
let upper = id.to_ascii_uppercase();
|
||||||
|
assert_eq!(LoaderKind::parse(&upper), Some(kind), "大写 `{upper}`");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② core 的词表 ↔ 这份契约:**集合相等**才过(多一支、少一支、改一个字都红)。
|
||||||
|
let mut actual: Vec<&str> = CoreLoaderKind::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|kind| kind.as_str())
|
||||||
|
.collect();
|
||||||
|
actual.sort_unstable();
|
||||||
|
let mut expected: Vec<&str> = CONTRACT.iter().map(|(_, id)| *id).collect();
|
||||||
|
expected.sort_unstable();
|
||||||
|
assert_eq!(
|
||||||
|
actual, expected,
|
||||||
|
"core 的 `LoaderKind::ALL` 必须逐字等于对外契约:加一家加载器时先改这里 + help"
|
||||||
|
);
|
||||||
|
|
||||||
|
// GUI 输入别名(不是对外 id):不装加载器有三种写法;别名与拼法都不许混进词表。
|
||||||
|
for alias in ["none", "", " "] {
|
||||||
|
assert_eq!(
|
||||||
|
LoaderKind::parse(alias),
|
||||||
|
Some(LoaderKind::Vanilla),
|
||||||
|
"`{alias}`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(LoaderKind::parse("neo-forge"), None, "别名只归安装侧");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_ratio_handles_unknown_total() {
|
||||||
|
assert_eq!(Progress { done: 1, total: 0 }.ratio(), 0.0);
|
||||||
|
assert!((Progress { done: 1, total: 4 }.ratio() - 0.25).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_request_defaults_name_to_version() {
|
||||||
|
let request = CreateRequest {
|
||||||
|
version: "1.20.4".to_string(),
|
||||||
|
loader: LoaderKind::Vanilla,
|
||||||
|
loader_version: String::new(),
|
||||||
|
name: " ".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(request.instance_name(), "1.20.4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instance_summary_is_serde_round_trip_safe() {
|
||||||
|
let instance = InstanceSummary {
|
||||||
|
id: "inst-1".to_string(),
|
||||||
|
name: "生存".to_string(),
|
||||||
|
mc_version: "1.20.4".to_string(),
|
||||||
|
loader: LoaderInfo {
|
||||||
|
kind: LoaderKind::Fabric,
|
||||||
|
version: "0.16.5".to_string(),
|
||||||
|
},
|
||||||
|
last_played: Some("2026-10-31T12:00:00.000Z".to_string()),
|
||||||
|
runtime: InstanceRuntime::Busy {
|
||||||
|
stage: LaunchStage::Window,
|
||||||
|
progress: Progress { done: 1, total: 2 },
|
||||||
|
},
|
||||||
|
icon: None,
|
||||||
|
isolation: "isolated".to_string(),
|
||||||
|
version_id: "1.20.4".to_string(),
|
||||||
|
version_present: true,
|
||||||
|
launch: LaunchArgsView::default(),
|
||||||
|
};
|
||||||
|
let text = serde_json::to_string(&instance).expect("序列化");
|
||||||
|
let back: InstanceSummary = serde_json::from_str(&text).expect("反序列化");
|
||||||
|
assert_eq!(back, instance);
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
//! 差异只在线程模型:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA),
|
//! 差异只在线程模型:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA),
|
||||||
//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。
|
//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。
|
||||||
//!
|
//!
|
||||||
//! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge(四家,与 CLI 对齐)。OptiFine/LiteLoader
|
//! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge/LiteLoader(五家,与 CLI 对齐)。OptiFine
|
||||||
//! 两家 core 侧都还没有安装管线,仍在 GUI 里显式拒装并说清去哪装。
|
//! 仍没有安装管线(要跑外部 `optifine.Patcher` + 原地改 zip),在 GUI 里显式拒装并说清去哪装。
|
||||||
//! 失败一律落 `.ULL/logs/install.log`([`super::install_journal`]),文案里带日志路径。
|
//! 失败一律落 `.ULL/logs/install.log`([`super::install_journal`]),文案里带日志路径。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -87,7 +87,7 @@ pub fn loader_label(kind: LoaderKind, version: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GUI 侧支持的加载器(五选:原版 + 四家加载器,与 CLI 的 `--loader` 词表一致)。
|
/// GUI 侧支持的加载器(六选:原版 + 五家加载器,与 CLI 的 `--loader` 词表一致)。
|
||||||
///
|
///
|
||||||
/// 写成穷举 `matches!` 而不是 `true`:将来 core 加了第六种加载器,这里默认拒绝,
|
/// 写成穷举 `matches!` 而不是 `true`:将来 core 加了第六种加载器,这里默认拒绝,
|
||||||
/// 由 [`unsupported_reason`] 给出明确说法,而不是让它走进不属于它的安装分支。
|
/// 由 [`unsupported_reason`] 给出明确说法,而不是让它走进不属于它的安装分支。
|
||||||
@@ -99,13 +99,14 @@ pub fn supported(kind: LoaderKind) -> bool {
|
|||||||
| LoaderKind::Quilt
|
| LoaderKind::Quilt
|
||||||
| LoaderKind::Forge
|
| LoaderKind::Forge
|
||||||
| LoaderKind::NeoForge
|
| LoaderKind::NeoForge
|
||||||
|
| LoaderKind::LiteLoader
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 不支持的加载器 → 给用户的可执行建议。
|
/// 不支持的加载器 → 给用户的可执行建议。
|
||||||
pub fn unsupported_reason(kind: LoaderKind) -> String {
|
pub fn unsupported_reason(kind: LoaderKind) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge);请先在实例页导入已有版本,或换成受支持的加载器。",
|
"{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge/LiteLoader);请先在实例页导入已有版本,或换成受支持的加载器。",
|
||||||
kind.label()
|
kind.label()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,8 +122,11 @@ pub(super) fn install_loader(
|
|||||||
layout: &env.game,
|
layout: &env.game,
|
||||||
release: &release,
|
release: &release,
|
||||||
environment: &Environment::host(),
|
environment: &Environment::host(),
|
||||||
vanilla_installed: env.game.is_version_installed(version),
|
// 向导/下载页只能装「叠在原版上」的层(CreateRequest 没有父版本槽位),
|
||||||
});
|
// 所以父版本恒等于原版版本;真装缺原版时由下面的 `vanilla_install_needed` 补装。
|
||||||
|
parent_installed: env.game.is_version_installed(&release.parent_version_id),
|
||||||
|
})
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
if plan.vanilla_install_needed {
|
if plan.vanilla_install_needed {
|
||||||
let mut vanilla = request.clone();
|
let mut vanilla = request.clone();
|
||||||
vanilla.loader = LoaderKind::Vanilla;
|
vanilla.loader = LoaderKind::Vanilla;
|
||||||
|
|||||||
@@ -143,16 +143,17 @@ fn loader_support_matrix_is_explicit() {
|
|||||||
LoaderKind::Quilt,
|
LoaderKind::Quilt,
|
||||||
LoaderKind::Forge,
|
LoaderKind::Forge,
|
||||||
LoaderKind::NeoForge,
|
LoaderKind::NeoForge,
|
||||||
|
// M222:LiteLoader 走「官方版本表 + 从零合成 launchwrapper 清单」,不跑外部进程。
|
||||||
|
LoaderKind::LiteLoader,
|
||||||
] {
|
] {
|
||||||
assert!(supported(kind), "{} 应当受支持", kind.label());
|
assert!(supported(kind), "{} 应当受支持", kind.label());
|
||||||
}
|
}
|
||||||
// 这两家 core 侧还没有安装管线,仍然显式拒装并说清去哪装。
|
// OptiFine 仍没有安装管线(要跑外部 Patcher + 原地改 zip),显式拒装并说清可替代做法。
|
||||||
for kind in [LoaderKind::OptiFine, LoaderKind::LiteLoader] {
|
let optifine = LoaderKind::OptiFine;
|
||||||
assert!(!supported(kind), "{} 不该被当成可装", kind.label());
|
assert!(!supported(optifine), "{} 不该被当成可装", optifine.label());
|
||||||
let reason = unsupported_reason(kind);
|
let reason = unsupported_reason(optifine);
|
||||||
assert!(reason.contains(kind.label()), "{reason}");
|
assert!(reason.contains(optifine.label()), "{reason}");
|
||||||
assert!(reason.contains("还没有接入"), "{reason}");
|
assert!(reason.contains("还没有接入"), "{reason}");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user