From 944b1610c8a0605b5cbe5fb62d12ad5746d734aa Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 22:09:54 +0800 Subject: [PATCH 01/22] =?UTF-8?q?feat(core):=20LiteLoader=20=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E8=B7=AF=E7=BA=BF=EF=BC=88=E5=AE=98=E6=96=B9=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=A1=A8=20+=20=E4=BB=8E=E9=9B=B6=E5=90=88=E6=88=90?= =?UTF-8?q?=20launchwrapper=20=E6=B8=85=E5=8D=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 M218 SURVEY-p1 §4.1 的清单接上 LiteLoader 的安装路线,不动 OptiFine。 - core: 新增 addon::loader::liteloader(官方 versions.json 解析 + 合成清单)与 addon::loader::parent(父版本链读盘定位原版版本);LoaderKind 加 LiteLoader; LoaderRelease 加 parent_version_id、LoaderPlanInput::vanilla_installed → parent_installed; LoaderInstallPlan::build 改返回 Result(父版本缺失具名报错);LoaderSource::resolve_on 支持指定父版本(仅 LiteLoader,其它组合具名 unsupported)。 - 继承边界(本 mission 第一步核实):arguments.game 父在前子在后追加,minecraftArguments 子覆盖父,启动顺序 legacy(24) → arguments.game(25)。所以 tweakClass 只写 arguments.game, 绝不写 minecraftArguments(会顶掉 Forge 1.12.2 的 FMLTweaker)。 - maven/fabric 的 `_ =>` 通配改成穷尽分支:非 maven 族不再静默回落 Forge 的仓库; maven::{metadata_url,installer_url} 改返回 Option。 - presenter: supported() 收 LiteLoader、wizard_choices 六选、dto::instances 按关注点拆成 instances/{loader,runtime,create,tests}.rs(原文件 395 行,动手前先拆)。 - cli: --loader 区分「认不出」(loader_unknown) 与「认得但装不了」(loader_unsupported); 位写加载器版本 id 时按父版本处理(只有 liteloader 接受);help 只改 install 段。 - app-iced: 下载页加载器 chip 加 LiteLoader(1.5.2~1.12.2,两头按官方没有构建拒), chip 清单改为委派 Loader::ALL。 - 测试: 真实响应的裁剪夹具(dl.liteloader.com 与 repo.mumfrey.com 的 maven-metadata) 驱动「解析→计划→落盘→继承→启动参数顺序」全链;Forge 父链有回归(FMLTweaker 必须还在 且排在 LiteLoader 之前);父版本缺失/组合不支持/取消/坏表各自具名错误。 --- .../src/pages/download/options/model.rs | 27 +- .../src/pages/download/options/tests.rs | 22 +- .../src/pages/download/options/view.rs | 17 +- crates/cli/src/cli/help.rs | 18 +- crates/cli/src/commands/install/loader.rs | 62 ++- crates/cli/src/commands/install/mod.rs | 25 +- .../cli/src/commands/install/report_loader.rs | 5 +- crates/cli/tests/liteloader_test.rs | 140 ++++++ crates/cli/tests/output_test.rs | 4 +- crates/core/src/addon/loader/fabric.rs | 7 +- crates/core/src/addon/loader/forge.rs | 1 + crates/core/src/addon/loader/liteloader.rs | 214 +++++++++ .../src/addon/loader/liteloader/versions.rs | 218 ++++++++++ crates/core/src/addon/loader/maven.rs | 92 ++-- crates/core/src/addon/loader/meta.rs | 136 +++++- crates/core/src/addon/loader/mod.rs | 41 +- crates/core/src/addon/loader/parent.rs | 64 +++ crates/core/src/addon/loader/plan.rs | 55 ++- .../core/src/addon/loader/tests/liteloader.rs | 276 ++++++++++++ crates/core/src/addon/loader/tests/maven.rs | 9 +- crates/core/src/addon/loader/tests/mod.rs | 1 + crates/core/src/addon/loader/tests/plan.rs | 30 +- .../core/src/addon/loader/tests/vocabulary.rs | 7 +- crates/core/src/game/loader.rs | 4 +- crates/core/tests/liteloader.rs | 20 + .../fixtures/liteloader-versions.json | 98 +++++ crates/core/tests/liteloader/guards.rs | 157 +++++++ crates/core/tests/liteloader/install.rs | 174 ++++++++ crates/core/tests/liteloader/support.rs | 157 +++++++ crates/presenter/src/dto/instances.rs | 409 +----------------- crates/presenter/src/dto/instances/create.rs | 34 ++ crates/presenter/src/dto/instances/loader.rs | 138 ++++++ crates/presenter/src/dto/instances/runtime.rs | 115 +++++ crates/presenter/src/dto/instances/tests.rs | 146 +++++++ crates/presenter/src/host/install.rs | 9 +- crates/presenter/src/host/install/routes.rs | 7 +- crates/presenter/src/host/install_tests.rs | 6 +- 37 files changed, 2427 insertions(+), 518 deletions(-) create mode 100644 crates/cli/tests/liteloader_test.rs create mode 100644 crates/core/src/addon/loader/liteloader.rs create mode 100644 crates/core/src/addon/loader/liteloader/versions.rs create mode 100644 crates/core/src/addon/loader/parent.rs create mode 100644 crates/core/src/addon/loader/tests/liteloader.rs create mode 100644 crates/core/tests/liteloader.rs create mode 100644 crates/core/tests/liteloader/fixtures/liteloader-versions.json create mode 100644 crates/core/tests/liteloader/guards.rs create mode 100644 crates/core/tests/liteloader/install.rs create mode 100644 crates/core/tests/liteloader/support.rs create mode 100644 crates/presenter/src/dto/instances/create.rs create mode 100644 crates/presenter/src/dto/instances/loader.rs create mode 100644 crates/presenter/src/dto/instances/runtime.rs create mode 100644 crates/presenter/src/dto/instances/tests.rs diff --git a/crates/app-iced/src/pages/download/options/model.rs b/crates/app-iced/src/pages/download/options/model.rs index 3db89f3..0a50dd6 100644 --- a/crates/app-iced/src/pages/download/options/model.rs +++ b/crates/app-iced/src/pages/download/options/model.rs @@ -22,7 +22,10 @@ //! 规则来源(不猜:都是 core 安装管线自己的判据): //! - Fabric/Quilt 的 loader 列表从 `1.14` 起才有(core 走 fabric meta,1.13 及以前没有); //! - 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_presenter::dto::{LoaderKind, VersionKind}; @@ -41,7 +44,7 @@ pub enum LoaderVersionsView { Failed(String), } -/// 页面侧的加载器词表:只列 GUI 真能装的五家(与 `dto::LoaderKind::wizard_choices` 同集)。 +/// 页面侧的加载器词表:只列 GUI 真能装的六家(与 `dto::LoaderKind::wizard_choices` 同集)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Loader { /// 原版(不装加载器) @@ -55,16 +58,19 @@ pub enum Loader { Fabric, /// Quilt Quilt, + /// LiteLoader + LiteLoader, } impl Loader { - /// 展示顺序(任务书的口径:原版 / Forge / NeoForge / Fabric / Quilt)。 - pub const ALL: [Loader; 5] = [ + /// 展示顺序(任务书的口径:原版 / Forge / NeoForge / Fabric / Quilt,LiteLoader 追加在末尾)。 + pub const ALL: [Loader; 6] = [ Loader::Vanilla, Loader::Forge, Loader::NeoForge, Loader::Fabric, Loader::Quilt, + Loader::LiteLoader, ]; /// 对应的 presenter 词表(派 `AppCommand` 时用)。 @@ -75,10 +81,11 @@ impl Loader { Loader::NeoForge => LoaderKind::NeoForge, Loader::Fabric => LoaderKind::Fabric, Loader::Quilt => LoaderKind::Quilt, + Loader::LiteLoader => LoaderKind::LiteLoader, } } - /// 从 presenter 词表回来(`OptiFine` / `LiteLoader` 不是本页选项)。 + /// 从 presenter 词表回来(`OptiFine` 不是本页选项:它没有安装管线)。 pub const fn of(kind: LoaderKind) -> Option { match kind { LoaderKind::Vanilla => Some(Loader::Vanilla), @@ -86,7 +93,8 @@ impl Loader { LoaderKind::NeoForge => Some(Loader::NeoForge), LoaderKind::Fabric => Some(Loader::Fabric), 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::Fabric => "Fabric", Loader::Quilt => "Quilt", + Loader::LiteLoader => "LiteLoader", } } @@ -137,6 +146,12 @@ impl Loader { Some(_) => Err(Incompat::NeedsAtLeast("1.20.1")), None => Err(Incompat::Unsupported), }, + // LiteLoader 官方版本表只到 1.12.2(最老 1.5.2),两头都是「官方没有构建」。 + // 预览版/快照同样没有(那 16 个原版版本全是正式版)。 + Loader::LiteLoader => match rank { + Some(version) if version >= (1, 5, 2) && version <= (1, 12, 2) => Ok(()), + _ => Err(Incompat::Unsupported), + }, } } } diff --git a/crates/app-iced/src/pages/download/options/tests.rs b/crates/app-iced/src/pages/download/options/tests.rs index aa81251..016886e 100644 --- a/crates/app-iced/src/pages/download/options/tests.rs +++ b/crates/app-iced/src/pages/download/options/tests.rs @@ -108,13 +108,33 @@ fn loader_support_matrix_is_explicit() { Loader::Forge.supports("24w14a", VersionKind::Snapshot), 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)); for loader in [ Loader::Fabric, Loader::Quilt, Loader::Forge, Loader::NeoForge, + Loader::LiteLoader, ] { assert_eq!( loader.supports(ancient.0, ancient.1), diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index 54fb428..c153ebf 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -47,14 +47,13 @@ use super::super::frame; use super::super::Message; use super::model::{Loader, LoaderVersionsView, OptionsView}; -/// 五家加载器(一行放得下:实测五枚 chip 合计约 311 dp,正文列 424)。 -const LOADERS: [Loader; 5] = [ - Loader::Vanilla, - Loader::Forge, - Loader::NeoForge, - Loader::Fabric, - Loader::Quilt, -]; +/// 加载器 chip 的顺序与集合**委派**给 [`Loader::ALL`](原版 / Forge / NeoForge / Fabric / +/// Quilt / LiteLoader)。 +/// +/// 这里原先是手写的五枚常量——那是同一份清单的第三处副本(`dto::wizard_choices`、 +/// `Loader::ALL`、这里),M222 加 LiteLoader 时三处都要改,索性改成委派。 +/// ⚠️ **宽度需 D30 视觉复验**:五枚 chip 实测合计约 311 dp(正文列 424),第六枚(LiteLoader) +/// 估算约 +50 dp,理论仍在一行内,但这是估算不是实测——本轮无构建槽位,未出浅/深色截图。 /// 菜单项高度 = 48(`MenuGeometry` 的项高)×2 行 + 上下留白 8×2 = 112。 /// 这么算是为了避免「列表被截掉半行」——滚动区高度必须覆盖整行。 @@ -146,7 +145,7 @@ fn loader_row<'a>(ui: &'a Ui, catalog: Catalog, options: &'a OptionsView) -> Ele let mut line = row![] .spacing(ui.space(Space::X1)) .align_y(Alignment::Center); - for loader in LOADERS.iter() { + for loader in Loader::ALL.iter() { line = line.push(chip( ui, Chip::filter( diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index 339de64..673238f 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -22,8 +22,8 @@ pub const fn general() -> &'static str { [--dry-run] 把已铺好的版本登记成实例(只读外部、不搬文件;缺省运行目录 落 ULL 数据区的独立目录;--dry-run 只检查不写盘) install [--loader X] [--loader-version V] [--name N] [--dry-run] - 下载并安装版本本体(--loader fabric|quilt|neoforge|forge - 时再叠一层加载器)→ 登记实例;--dry-run 只出计划 + 下载并安装版本本体(--loader fabric|quilt|neoforge|forge| + liteloader 时再叠一层加载器)→ 登记实例;--dry-run 只出计划 launch [--dry-run] [--account ] [--java ] [--username ] [--max-memory ] 启动实例;--dry-run 只打印组装后的命令行 pack import [--target ] [--name N] [--overwrite] [--no-register] @@ -184,10 +184,18 @@ pub fn topic(name: &str) -> Option<&'static str> { (profiles.json + config/instances/.json)。已存在且校验通过的文件会跳过, 重复装同一版本不重复下载、不重复建实例。 --dry-run:只输出计划(文件清单 + 总大小 + 每类资源的源选择),不下载、不登记。 ---loader:vanilla(缺省)/ fabric / quilt / neoforge / forge。装加载器时先生成 -versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 原版 + 主类 + loader 库), +--loader:vanilla(缺省)/ fabric / quilt / neoforge / forge / liteloader。装加载器时先生成 +versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 父版本 + 主类 + loader 库), 原版没装会先装原版,实例的 versionId 指向 loader 版本。 ---loader-version:加载器版本;缺省取官方默认(Fabric 系首个稳定版,maven 系最新)。 + optifine 认得出但**没接入安装管线**(要跑外部 Patcher 并原地改 zip),报 loader_unsupported; + 其余认不出的拼法报 loader_unknown —— 两者不是一回事。 + liteloader 的特殊之处:清单是从官方版本表**从零合成**的(mainClass = launchwrapper, + --tweakClass 追加在父链参数之后),不跑任何外部进程;不叠加载器时只占一个 loader 库。 + 位还可以写**已装的加载器版本 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 两种形态。 NeoForge/Forge 里需要跑官方 installer processors 的版本会真跑一遍(M35 已实装): 缺 Java 时按 D23 的回退链自动装一个托管 JDK;--dry-run 只列出要跑的 processor 与依赖库。 diff --git a/crates/cli/src/commands/install/loader.rs b/crates/cli/src/commands/install/loader.rs index 66f9975..25264eb 100644 --- a/crates/cli/src/commands/install/loader.rs +++ b/crates/cli/src/commands/install/loader.rs @@ -1,7 +1,8 @@ //! `ull install --loader [--loader-version ]`:加载器层的编排。 //! //! ```text -//! LoaderSource::resolve(官方 meta:版本列表 → profile JSON / installer profile) +//! 目标解析: 位是原版版本 → 叠原版;是已装的加载器版本 id → 叠在它上面(仅 LiteLoader) +//! → LoaderSource::resolve(官方 meta:版本列表 → profile JSON / installer profile / LiteLoader 版本表) //! → LoaderInstallPlan::build(纯计算:库清单 + loader 版本 JSON 落点) //! → [--dry-run 到此为止,但多带一段 processors 计划] //! → 原版已装?否 → vanilla::plan_and_install(先装原版) @@ -13,16 +14,22 @@ //! 「先装原版」是**硬前置**:loader 版本 JSON 只声明 `inheritsFrom`,jar 与资源都在原版那层, //! 原版缺失时启动链必然断裂。已装则跳过(`is_version_installed` 判据 = 版本 JSON 在盘上)。 //! +//! **父版本**:`` 位写成加载器版本 id(`1.12.2-forge-14.23.5.2860`)时,它就是父版本, +//! 原版版本从盘上的继承链读出来(`addon::loader::parent`)。只有 LiteLoader 接受这种叠法 +//! (它靠 `--tweakClass` 追加生效);别家**具名拒绝**,不会默默改成叠原版。 +//! //! processors 段的顺序也是硬约束:**先跑 processors,再写版本 JSON**。版本清单存在是 //! "这版装完了"的判据,抢在 processors 前写会留下一个启动必崩却被报成已安装的实例。 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::cache::now_ms; use unlucky_core::game::arguments::Environment; 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::{ @@ -38,11 +45,11 @@ use crate::output::Report; /// /// # Errors /// -/// 用法错误、加载器未知/不支持、元数据读取失败、原版装不上、库下载失败、 -/// installer processors 失败、实例登记失败。 +/// 用法错误、加载器未知/不支持、父版本缺失或组合不支持、元数据读取失败、原版装不上、 +/// 库下载失败、installer processors 失败、实例登记失败。 pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result { - let version = args.version.trim().to_string(); - if version.is_empty() { + let requested = args.version.trim().to_string(); + if requested.is_empty() { return Err(CliError::usage( "install 需要版本 id:%prog% install --loader fabric", )); @@ -54,10 +61,17 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result Result CliError { }; CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint) } + +/// `` 位 → (原版版本, 父版本 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), 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()))) +} diff --git a/crates/cli/src/commands/install/mod.rs b/crates/cli/src/commands/install/mod.rs index fcd31f2..6f08dc6 100644 --- a/crates/cli/src/commands/install/mod.rs +++ b/crates/cli/src/commands/install/mod.rs @@ -4,6 +4,7 @@ //! --loader 缺省 → vanilla:M27 原版安装(resolve → plan → execute → register) //! --loader fabric|quilt → addon::loader:原版(已装则跳)→ loader 版本 JSON → 补 loader 库 → register //! --loader neoforge|forge → 同上;需要 installer processors 的版本再跑 M35 执行器(依赖库 → java → 版本 JSON) +//! --loader liteloader → 官方版本表 → 从零合成 launchwrapper 清单(不跑任何外部进程) //! ``` //! //! CLI 只做四件事:建下载引擎、把计划/结果排版成人读与 `--json` 两种形态 @@ -22,6 +23,7 @@ use std::sync::Arc; use unlucky_core::addon::loader::LoaderKind; use unlucky_core::download::{DownloadEngine, DownloadErrorKind}; use unlucky_core::game::install::{InstallError, InstallReport, Registration}; +use unlucky_core::game::LoaderKind as WideLoaderKind; use crate::cli::InstallArgs; use crate::context::Ctx; @@ -33,9 +35,14 @@ pub(super) const CONCURRENCY: usize = 16; /// 执行 `install`:按 `--loader` 分发。 /// +/// "认不出" 与 "认得但装不了" 是两件事,错误码也分开(`loader_unknown` / `loader_unsupported`): +/// `optifine` 属于后者——core 的识别词表里有它(`ull help instances` 也这么写),只是安装管线 +/// 没接(要跑外部 `optifine.Patcher` + 原地改 zip)。两者混成一句"未知加载器"会让用户以为 +/// 是自己的版本 id 写错了。 +/// /// # Errors /// -/// 用法错误、加载器未知、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。 +/// 用法错误、加载器未知/认得但装不了、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。 pub fn run(ctx: &Ctx, args: &InstallArgs) -> Result { match args .loader @@ -46,10 +53,18 @@ pub fn run(ctx: &Ctx, args: &InstallArgs) -> Result { None | Some("vanilla") | Some("none") => vanilla::run(ctx, args), Some(raw) => match LoaderKind::parse(raw) { Some(kind) => loader::run(ctx, args, kind), - None => Err( - CliError::user("loader_unknown", format!("未知加载器 `{raw}`")) - .with_hint("--loader 取值:vanilla / fabric / quilt / neoforge / forge"), - ), + None => Err(match WideLoaderKind::parse(raw) { + Some(WideLoaderKind::OptiFine) => CliError::user( + "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 认得出但装不了)", + ), + }), }, } } diff --git a/crates/cli/src/commands/install/report_loader.rs b/crates/cli/src/commands/install/report_loader.rs index 7133d9d..a400efa 100644 --- a/crates/cli/src/commands/install/report_loader.rs +++ b/crates/cli/src/commands/install/report_loader.rs @@ -334,6 +334,7 @@ mod tests { let release = LoaderRelease { kind: LoaderKind::Fabric, mc_version: "1.20.1".to_string(), + parent_version_id: "1.20.1".to_string(), loader_version: "0.15.11".to_string(), version_id: "1.20.1-fabric-0.15.11".to_string(), version_json: VersionJson::from_json_str( @@ -349,8 +350,9 @@ mod tests { layout: &layout, release: &release, environment: &Environment::host(), - vanilla_installed: true, + parent_installed: true, }) + .expect("计划应可组") } /// **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()], }; - let text = human_lines(false, &plan, None, Some(&vanilla), None, &[], "n", None).join("\n"); assert!( text.contains("已替换版本清单"), diff --git a/crates/cli/tests/liteloader_test.rs b/crates/cli/tests/liteloader_test.rs new file mode 100644 index 0000000..2e8080d --- /dev/null +++ b/crates/cli/tests/liteloader_test.rs @@ -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 ")) + .take(3) + .collect::>() + .join("\n"); + assert!( + install_block.contains("liteloader"), + "总帮助的 install 段要提到 liteloader:\n{install_block}" + ); +} diff --git a/crates/cli/tests/output_test.rs b/crates/cli/tests/output_test.rs index 862842c..ee8de44 100644 --- a/crates/cli/tests/output_test.rs +++ b/crates/cli/tests/output_test.rs @@ -69,11 +69,13 @@ fn instances_list_json_shape() { #[test] fn install_rejects_unknown_loader_before_touching_network() { let dir = TempDir::new("install-loader"); + // 样本必须是一个**任何词表里都没有**的拼法:`liteloader` 自 M222 起是受支持加载器, + // 拿它当「未知」会让这条用例变成真去拉 dl.liteloader.com 的联网测试。 let (code, stdout, _) = run_cli(&[ "install", "1.20.1", "--loader", - "liteloader", + "ull-invalid-loader-test", "--json", "--data-dir", &dir.arg(), diff --git a/crates/core/src/addon/loader/fabric.rs b/crates/core/src/addon/loader/fabric.rs index ec28c57..4820159 100644 --- a/crates/core/src/addon/loader/fabric.rs +++ b/crates/core/src/addon/loader/fabric.rs @@ -31,12 +31,14 @@ pub const FABRIC_META: &str = "https://meta.fabricmc.net/v2"; /// Quilt 官方 meta 根(同上,`bmclapi-quilt-meta`)。 pub const QUILT_META: &str = "https://meta.quiltmc.org/v3"; -/// meta 根地址(按加载器)。 +/// meta 根地址(按加载器;非 fabric 系给空串,调用方已按 [`LoaderKind::is_fabric_family`] 分流)。 #[must_use] pub fn meta_base(kind: LoaderKind) -> &'static str { match kind { + LoaderKind::Fabric => FABRIC_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 { kind, mc_version: mc_version.to_string(), + parent_version_id: mc_version.to_string(), loader_version: loader_version.to_string(), version_id, version_json, diff --git a/crates/core/src/addon/loader/forge.rs b/crates/core/src/addon/loader/forge.rs index 76c0ee5..76b7ea1 100644 --- a/crates/core/src/addon/loader/forge.rs +++ b/crates/core/src/addon/loader/forge.rs @@ -195,6 +195,7 @@ pub fn build_release( Ok(LoaderRelease { kind, mc_version: mc_version.to_string(), + parent_version_id: mc_version.to_string(), loader_version, version_id, version_json, diff --git a/crates/core/src/addon/loader/liteloader.rs b/crates/core/src/addon/loader/liteloader.rs new file mode 100644 index 0000000..efe3299 --- /dev/null +++ b/crates/core/src/addon/loader/liteloader.rs @@ -0,0 +1,214 @@ +//! 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 ` + 「记录自带库 + 本体 jar」, +//! **不跑任何外部进程**;本模块按同样的协议独立实现,没有抄它的代码。 +//! +//! # 四条与上游形状有关的硬事实(都实测过,别按直觉改) +//! +//! 1. **分支键是 md5,不是版本号**:`artefacts."com.mumfrey:liteloader"` 下的键是 md5, +//! 真正的版本号在记录的 `version` 字段(`1.7.10_04`)。按 `version` 去重(同一版本会挂 +//! 两个 md5 键),`latest` 分支要跳过。 +//! 2. **目录名是原版版本,不是 loader 版本**:release 直链形如 +//! `/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---release.jar`;要拼出直链必须再读一次 +//! `maven-metadata.xml`(`` + ``)。见 [`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 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/"; + +/// 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, + /// release 分支的直链(快照分支为 `None`) + pub url: Option, + /// 快照分支的 maven 目录(`…/com/mumfrey/liteloader/1.12.2-SNAPSHOT/`) + pub snapshot_dir: Option, +} + +/// 默认版本:优先非快照(稳定),没有则取最新的那条。 +#[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 { + 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() + } +} diff --git a/crates/core/src/addon/loader/liteloader/versions.rs b/crates/core/src/addon/loader/liteloader/versions.rs new file mode 100644 index 0000000..4edc30e --- /dev/null +++ b/crates/core/src/addon/loader/liteloader/versions.rs @@ -0,0 +1,218 @@ +//! 官方版本表(`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, VERSION_LIST_URL}; +use crate::game::library::Library; + +#[derive(Debug, Deserialize)] +struct Root { + #[serde(default)] + versions: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct GameVersions { + #[serde(default)] + repo: Option, + #[serde(default)] + artefacts: Option, + #[serde(default)] + snapshots: Option, +} + +#[derive(Debug, Deserialize)] +struct Repo { + #[serde(default)] + url: String, +} + +#[derive(Debug, Deserialize)] +struct Branch { + #[serde(rename = "com.mumfrey:liteloader", default)] + liteloader: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct Record { + #[serde(default, rename = "tweakClass")] + tweak_class: String, + #[serde(default)] + libraries: Vec, + #[serde(default)] + file: String, + #[serde(default)] + version: String, + #[serde(default)] + stream: String, +} + +/// 解析官方版本表:原版版本 → 该原版下的 LiteLoader 版本(**新在前**)。 +/// +/// # Errors +/// +/// 顶层不是 `{"versions": {…}}`、或一个版本都认不出时返回 metadata 错误。 +pub fn parse_versions(json: &str) -> Result>, LoaderError> { + let root: Root = serde_json::from_str(json) + .map_err(|error| LoaderError::metadata(format!("LiteLoader 版本表解析失败:{error}")))?; + let mut out: BTreeMap> = 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 = Vec::new(); + let mut seen: BTreeSet = 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, + seen: &mut BTreeSet, +) { + 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 { + let snap_dir = format!("{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::(), b.parse::()) { + (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` 拼出快照产物的直链(`liteloader----release.jar`)。 +/// +/// # Errors +/// +/// 缺 ``/`` 时返回 metadata 错误(宁可报错,也不猜一个 404 直链)。 +pub fn snapshot_artifact_url( + entry: &LiteLoaderEntry, + metadata_xml: &str, +) -> Result { + 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 里没有 :拿不到快照直链", + entry.version + )) + })?; + let build = tag_value(metadata_xml, "buildNumber").ok_or_else(|| { + LoaderError::metadata(format!( + "{} 的 maven-metadata.xml 里没有 ", + entry.version + )) + })?; + Ok(format!( + "{dir}liteloader-{}-{timestamp}-{build}-release.jar", + entry.mc_version + )) +} + +/// 取 `…` 里的文本(maven 元数据只有一层,不引 XML 库,与 [`super::maven`] 同口径)。 +fn tag_value(xml: &str, tag: &str) -> Option { + let open = format!("<{tag}>"); + let close = format!(""); + 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()) +} diff --git a/crates/core/src/addon/loader/maven.rs b/crates/core/src/addon/loader/maven.rs index 898e2f4..fa0c29f 100644 --- a/crates/core/src/addon/loader/maven.rs +++ b/crates/core/src/addon/loader/maven.rs @@ -6,6 +6,18 @@ //! 镜像:两家 maven 都在 D7 镜像表里有 BMCLAPI 规则(`bmclapi-neoforge` / //! `bmclapi-forge-maven-net`),所以这里一律**写官方地址**,重写交给 `core::download` //! 的镜像展开(与 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; @@ -16,49 +28,50 @@ pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net"; /// NeoForge 唯一走 Forge 布局的原版版本(1.20.1 是"NeoForge 还叫 forge 的时候")。 pub const NEOFORGE_FORGE_LAYOUT_MC: &str = "1.20.1"; -/// maven 根地址。 +/// maven 根地址(非 maven 族给空串;调用方已按 [`LoaderKind::is_maven_family`] 分流)。 #[must_use] pub fn base(kind: LoaderKind) -> &'static str { match kind { LoaderKind::NeoForge => NEOFORGE_MAVEN, - _ => FORGE_MAVEN, + LoaderKind::Forge => FORGE_MAVEN, + // fabric 系与 LiteLoader 都不是 maven 族——绝不能回落到 Forge 的仓库。 + LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => "", } } -/// 该原版版本对应的 `maven-metadata.xml` 地址。 -#[must_use] -pub fn metadata_url(kind: LoaderKind, mc_version: &str) -> String { - let (root, path) = match kind { +/// maven 路径段(`group/artifact`)与该原版版本的匹配规则。 +fn layout(kind: LoaderKind, mc_version: &str) -> Option<(&'static str, &'static str)> { + match kind { 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"), - _ => (FORGE_MAVEN, "net/minecraftforge/forge"), - }; - format!("{root}/{path}/maven-metadata.xml") + LoaderKind::NeoForge => Some((NEOFORGE_MAVEN, "net/neoforged/neoforge")), + LoaderKind::Forge => Some((FORGE_MAVEN, "net/minecraftforge/forge")), + LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => None, + } } -/// installer jar 地址。 +/// 该原版版本对应的 `maven-metadata.xml` 地址;非 maven 族给 `None`。 #[must_use] -pub fn installer_url(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String { - let (root, path, file) = match kind { - LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => ( - NEOFORGE_MAVEN, - "net/neoforged/forge", - format!("forge-{maven_version}-installer.jar"), - ), - LoaderKind::NeoForge => ( - NEOFORGE_MAVEN, - "net/neoforged/neoforge", - format!("neoforge-{maven_version}-installer.jar"), - ), - _ => ( - FORGE_MAVEN, - "net/minecraftforge/forge", - format!("forge-{maven_version}-installer.jar"), - ), +pub fn metadata_url(kind: LoaderKind, mc_version: &str) -> Option { + let (root, path) = layout(kind, mc_version)?; + Some(format!("{root}/{path}/maven-metadata.xml")) +} + +/// installer jar 地址;非 maven 族给 `None`。 +#[must_use] +pub fn installer_url(kind: LoaderKind, mc_version: &str, maven_version: &str) -> Option { + let (root, path) = layout(kind, mc_version)?; + let file = match kind { + LoaderKind::NeoForge if mc_version != NEOFORGE_FORGE_LAYOUT_MC => { + format!("neoforge-{maven_version}-installer.jar") + } + LoaderKind::NeoForge | LoaderKind::Forge => { + 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 里的 `` 值。 @@ -106,7 +119,7 @@ pub fn neoforge_mc_version(version: &str) -> Option { } } -/// maven 版本是否属于该原版版本。 +/// maven 版本是否属于该原版版本(非 maven 族恒 `false`:它们的版本号不是 maven 口径)。 #[must_use] pub fn matches_mc(kind: LoaderKind, mc_version: &str, version: &str) -> bool { match kind { @@ -114,7 +127,8 @@ pub fn matches_mc(kind: LoaderKind, mc_version: &str, version: &str) -> bool { version.starts_with(&format!("{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 版本带原版前缀)。 /// /// `--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] pub fn normalize_maven_version(kind: LoaderKind, mc_version: &str, raw: &str) -> String { let raw = raw.trim(); let needs_prefix = match kind { + LoaderKind::Forge => true, 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}-")) { format!("{mc_version}-{raw}") @@ -152,15 +168,17 @@ pub fn normalize_maven_version(kind: LoaderKind, mc_version: &str, raw: &str) -> } } -/// 用户视角的加载器版本号(Forge 去掉原版前缀,NeoForge 原样)。 +/// 用户视角的加载器版本号(Forge 去掉原版前缀,NeoForge 与非 maven 族原样)。 #[must_use] pub fn display_loader_version(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String { match kind { - LoaderKind::NeoForge if mc_version != NEOFORGE_FORGE_LAYOUT_MC => maven_version.to_string(), - _ => maven_version + LoaderKind::Forge => maven_version .strip_prefix(&format!("{mc_version}-")) .unwrap_or(maven_version) .to_string(), + LoaderKind::NeoForge | LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => { + maven_version.to_string() + } } } diff --git a/crates/core/src/addon/loader/meta.rs b/crates/core/src/addon/loader/meta.rs index 29d0367..b91e9d4 100644 --- a/crates/core/src/addon/loader/meta.rs +++ b/crates/core/src/addon/loader/meta.rs @@ -4,11 +4,12 @@ //! 不写游戏目录(installer jar 落 `.ULL/cache/meta/` 作离线复用),镜像/退避/health 全在 //! `core::download` 的引擎里(我们一律递**官方地址**,重写由镜像表负责)。 //! -//! 两族的取数路径完全不同: +//! 三个数据源的取数路径完全不同(**三路显式派发**,不许再有「不是 fabric 就是 maven」): //! //! ```text //! Fabric / Quilt : meta API → profile/json(官方直出可用版本 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; @@ -18,6 +19,7 @@ use zip::ZipArchive; use super::fabric; use super::forge::{self, InstallProfile}; +use super::liteloader; use super::maven; use super::plan::LoaderRelease; use super::{LoaderError, LoaderKind}; @@ -27,6 +29,9 @@ use crate::game::manifest::VersionJson; /// installer jar 的缓存文件名前缀(超长/含 `+` 的版本串会被规整成安全文件名)。 const INSTALLER_TAG: &str = "installer"; +/// LiteLoader 官方版本表的缓存文件名(整表 49 KB,一次取全量再本地筛)。 +const LITELOADER_LIST_TAG: &str = "liteloader-versions.json"; + /// 远端加载器元数据来源。 pub struct LoaderSource<'a> { engine: &'a DownloadEngine, @@ -56,7 +61,17 @@ impl<'a> LoaderSource<'a> { let entries = fabric::parse_loader_list(kind, &body)?; 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 all = maven::parse_metadata_versions(&body); // maven 表是升序的,统一成"最新在前"。 @@ -74,7 +89,9 @@ impl<'a> LoaderSource<'a> { } /// 解析一个加载器版本(`loader_version` 为 `None` 时取官方默认:Fabric 系取首个稳定版, - /// maven 系取该原版版本下最新的一版)。 + /// maven 系取该原版版本下最新的一版,LiteLoader 取首个 release / 否则最新)。 + /// + /// 等价于 [`Self::resolve_on`],父版本 = 原版本身。 /// /// # Errors /// @@ -84,10 +101,41 @@ impl<'a> LoaderSource<'a> { kind: LoaderKind, mc_version: &str, loader_version: Option<&str>, + ) -> Result { + 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 { let requested = loader_version .map(str::trim) .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() { let version = match requested { Some(version) => version.to_string(), @@ -106,6 +154,11 @@ impl<'a> LoaderSource<'a> { let body = self.fetch_text(&url).await?; 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 { Some(raw) => maven::normalize_maven_version(kind, mc_version, raw), @@ -118,7 +171,8 @@ impl<'a> LoaderSource<'a> { 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 jar = self.fetch_installer(&url, &path).await?; let (profile, embedded) = read_installer(&jar)?; @@ -131,9 +185,68 @@ impl<'a> LoaderSource<'a> { )?; // M35 执行器要的两样东西:installer 本体(跑 processor 与解包)与它声明的 profile。 release.installer = Some(jar); + release.parent_version_id = parent_version_id.to_string(); Ok(release) } + /// LiteLoader:整表 → 选条 →(快照再查 maven-metadata)→ 合成清单。 + async fn resolve_liteloader( + &self, + mc_version: &str, + requested: Option<&str>, + parent_version_id: &str, + ) -> Result { + 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::>() + .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>, LoaderError> + { + let body = self + .fetch_text_named(liteloader::VERSION_LIST_URL, LITELOADER_LIST_TAG) + .await?; + liteloader::parse_versions(&body) + } + /// installer jar 的本地缓存路径(`+`/非法字符规整成 `_`)。 fn installer_path(&self, kind: LoaderKind, maven_version: &str) -> PathBuf { let safe: String = maven_version @@ -162,7 +275,12 @@ impl<'a> LoaderSource<'a> { /// 取一小段文本元数据(版本列表 / profile JSON / maven metadata)。 async fn fetch_text(&self, url: &str) -> Result { - 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 { + let dest = self.meta_cache.join(tag); let bytes = self .engine .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。 /// /// 内嵌版本 JSON 的路径来自 profile 的 `json` 字段(通常 `/version.json`); diff --git a/crates/core/src/addon/loader/mod.rs b/crates/core/src/addon/loader/mod.rs index a915f2d..53346e7 100644 --- a/crates/core/src/addon/loader/mod.rs +++ b/crates/core/src/addon/loader/mod.rs @@ -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/analyse/05-modloader-modpack.md` §1(五加载器安装协议、`patch + priority` 主线)。 @@ -22,17 +22,23 @@ //! | 路线 | 加载器 | 做法 | //! |------|--------|------| //! | [`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::RequiresInstaller`] | Forge spec≥1 / NeoForge | installer 声明了 client 侧 processors(拆包/映射),**必须外调 java** | //! -//! 第三类本期只出**明确 unsupported**:版本 JSON 与库清单仍照常生成(`--dry-run` 可看), +//! 第四类本期只出**明确 unsupported**:版本 JSON 与库清单仍照常生成(`--dry-run` 可看), //! 但真装会被拒——半成品实例比明确报错更糟(沿 M27 §7「如实告知」的纪律)。 //! 老协议 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 forge; +pub mod liteloader; pub mod maven; pub mod meta; +pub mod parent; pub mod plan; pub mod processors; @@ -49,6 +55,7 @@ use crate::download::{DownloadError, DownloadErrorKind}; /// 支持的加载器种类。 /// /// `vanilla` / `none` **不在这里**:那是"不装加载器",由 CLI 走 M27 的原版路径。 +/// OptiFine 也**不在**这里:它要跑外部 `optifine.Patcher` + 原地改 zip,本启动器仍只识别不安装。 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum LoaderKind { /// Fabric(`meta.fabricmc.net`,纯 meta,无 installer)。 @@ -59,6 +66,8 @@ pub enum LoaderKind { NeoForge, /// Forge(`maven.minecraftforge.net`,两代 installer 协议)。 Forge, + /// LiteLoader(`dl.liteloader.com`,官方版本表 + 从零合成 launchwrapper 清单,无 installer)。 + LiteLoader, } impl LoaderKind { @@ -67,7 +76,7 @@ impl LoaderKind { /// 七个 id 的匹配**委派**给 [`crate::game::LoaderKind::parse`](词表的源头,M126), /// 本方法只多收一个**输入别名** `neo-forge`:那是 Modrinth 的拼法(mrpack /// `dependencies` 的键、CF 包里也有这种写法),**只进不出**——[`Self::as_str`] 绝不产出 - /// 它。识别得出但本启动器装不了的几家(原版 / OptiFine / LiteLoader)返回 `None`。 + /// 它。识别得出但本启动器装不了的(原版 / OptiFine)返回 `None`。 #[must_use] pub fn parse(raw: &str) -> Option { let wanted = raw.trim(); @@ -77,7 +86,7 @@ impl LoaderKind { Self::from_core(crate::game::LoaderKind::parse(wanted)?) } - /// core 的宽种类 → 本模块的四家(`None` = 识别得出但本启动器装不了)。 + /// core 的宽种类 → 本模块能装的五家(`None` = 识别得出但本启动器装不了)。 /// /// 穷尽 match:core 加一支就编不过,逼作者决定「它能不能装」。 const fn from_core(kind: crate::game::LoaderKind) -> Option { @@ -86,9 +95,8 @@ impl LoaderKind { crate::game::LoaderKind::Quilt => Some(Self::Quilt), crate::game::LoaderKind::NeoForge => Some(Self::NeoForge), crate::game::LoaderKind::Forge => Some(Self::Forge), - crate::game::LoaderKind::Vanilla - | crate::game::LoaderKind::OptiFine - | crate::game::LoaderKind::LiteLoader => None, + crate::game::LoaderKind::LiteLoader => Some(Self::LiteLoader), + crate::game::LoaderKind::Vanilla | crate::game::LoaderKind::OptiFine => None, } } @@ -105,6 +113,7 @@ impl LoaderKind { Self::Quilt => crate::game::LoaderKind::Quilt.as_str(), Self::NeoForge => crate::game::LoaderKind::NeoForge.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::NeoForge => "NeoForge", Self::Forge => "Forge", + Self::LiteLoader => "LiteLoader", } } @@ -125,10 +135,25 @@ impl LoaderKind { 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` 枚举提示用)。 #[must_use] 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 的词根:`--`(`versions//` 与实例 `versionId` 都用它)。 diff --git a/crates/core/src/addon/loader/parent.rs b/crates/core/src/addon/loader/parent.rs new file mode 100644 index 0000000..982950e --- /dev/null +++ b/crates/core/src/addon/loader/parent.rs @@ -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 { + 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 { + 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}")) + }) +} diff --git a/crates/core/src/addon/loader/plan.rs b/crates/core/src/addon/loader/plan.rs index dc8aae8..087c3dc 100644 --- a/crates/core/src/addon/loader/plan.rs +++ b/crates/core/src/addon/loader/plan.rs @@ -13,7 +13,7 @@ use std::path::PathBuf; use serde_json::{json, Value}; use super::forge::InstallProfile; -use super::LoaderKind; +use super::{LoaderError, LoaderKind}; use crate::game::arguments::Environment; use crate::game::install::libraries; use crate::game::install::{InstallPlan, PlanFile}; @@ -71,17 +71,20 @@ impl LoaderSupport { pub struct LoaderRelease { /// 加载器种类。 pub kind: LoaderKind, - /// 目标原版版本(`inheritsFrom` 的父版本 id)。 + /// 目标原版版本(客户端 jar 与资源都在这一层)。 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, /// 生成的 loader 版本 id(`1.20.1-fabric-0.15.11`,即 `versions//` 的目录名)。 pub version_id: String, - /// loader 版本 JSON(`inheritsFrom` 指向原版)。 + /// loader 版本 JSON(`inheritsFrom` 指向父版本)。 pub version_json: VersionJson, /// 可走哪条路线。 pub support: LoaderSupport, - /// installer jar 的磁盘路径(M35 执行器的输入;Fabric 系为 `None`)。 + /// installer jar 的磁盘路径(M35 执行器的输入;Fabric 系与 LiteLoader 为 `None`)。 pub installer: Option, /// installer 里读出来的 install profile(执行器要 `processors` / `data` / `libraries`)。 pub install_profile: Option, @@ -96,6 +99,12 @@ impl LoaderRelease { self.support.is_installable() } + /// 父版本是不是原版本身(`false` = 叠在别的加载器层上)。 + #[must_use] + pub fn parent_is_vanilla(&self) -> bool { + self.parent_version_id == self.mc_version + } + /// 是否要外调 java 跑 installer processors。 #[must_use] pub const fn requires_installer(&self) -> bool { @@ -118,6 +127,7 @@ impl LoaderRelease { "kind": self.kind.as_str(), "label": self.kind.label(), "mc_version": self.mc_version, + "parent_version_id": self.parent_version_id, "loader_version": self.loader_version, "version_id": self.version_id, "inherits_from": self.version_json.inherits_from, @@ -138,8 +148,11 @@ pub struct LoaderPlanInput<'a> { pub release: &'a LoaderRelease, /// 规则求值环境(OS / 架构 / 特性开关)。 pub environment: &'a Environment, - /// 原版的 `versions//.json` 是否已在盘上。 - pub vanilla_installed: bool, + /// **父版本**([`LoaderRelease::parent_version_id`])的清单是否已在盘上。 + /// + /// 父版本是原版时缺了能自动补装([`LoaderInstallPlan::vanilla_install_needed`]); + /// 父版本是别的加载器层(Forge)时缺了**直接报错**——我们不能替用户决定装哪一版 Forge。 + pub parent_installed: bool, } /// 加载器安装计划:M27 的 [`InstallPlan`] + 加载器自己的上下文。 @@ -162,9 +175,25 @@ impl LoaderInstallPlan { /// /// 缺下载地址的库降级成告警(见 [`Self::summary_json`] 的 `warnings`),不硬失败—— /// 与 M27「宁可给出可修复的骨架,也不静默假装成功」一致。 - #[must_use] - pub fn build(input: LoaderPlanInput<'_>) -> Self { + /// + /// # Errors + /// + /// 父版本不是原版、而它又没装在盘上时返回 [`LoaderError::not_found`](具名说清是哪个 + /// 父版本缺了)。**不能默默回落成叠原版**:那会装出一份和用户意图不同的实例。 + pub fn build(input: LoaderPlanInput<'_>) -> Result { let release = input.release; + 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( input.layout, &release.version_id, @@ -181,7 +210,7 @@ impl LoaderInstallPlan { if let Some(reason) = release.installer_reason() { warnings.push(format!("需要外调 java 跑 installer processors:{reason}")); } - if !input.vanilla_installed { + if !input.parent_installed { warnings.push(format!( "原版 {} 尚未安装:执行时会先装原版再叠加载器", release.mc_version @@ -200,11 +229,11 @@ impl LoaderInstallPlan { warnings, }; - Self { + Ok(Self { release: release.clone(), install, - vanilla_install_needed: !input.vanilla_installed, - } + vanilla_install_needed: !input.parent_installed && release.parent_is_vanilla(), + }) } /// 计划里的待下载文件。 diff --git a/crates/core/src/addon/loader/tests/liteloader.rs b/crates/core/src/addon/loader/tests/liteloader.rs new file mode 100644 index 0000000..41c629c --- /dev/null +++ b/crates/core/src/addon/loader/tests/liteloader.rs @@ -0,0 +1,276 @@ +//! LiteLoader 数据源与合成:官方版本表(**真实响应的裁剪版**)→ 版本条目 → 合成清单。 +//! +//! 夹具 `LITELOADER_VERSIONS` 是 2026-11 从 `https://dl.liteloader.com/versions/versions.json` +//! 真取的一份响应(200 / 49129 字节 / 16 个原版版本)里挑两个原版版本原样留下: +//! `1.7.10`(release 分支)+ `1.12.2`(只有 snapshots 分支)。删掉的是另外 14 个原版版本与 +//! 每个版本下的多余分支;**字段名与结构一个没改**(键仍是 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.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#" + + com.mumfrey + liteloader + 1.12.2-SNAPSHOT + + + 20171128.144431 + 4 + + 20171128144431 + +"#; + +#[test] +fn parses_the_real_version_table() { + let table = parse_versions(LITELOADER_VERSIONS).expect("真实响应必须能解析"); + assert_eq!(table.len(), 2, "只留了两个原版版本"); + + // release 分支:按 version 去重、新的排前面。 + let releases = &table["1.7.10"]; + assert_eq!( + releases + .iter() + .map(|e| e.version.as_str()) + .collect::>(), + ["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" + ); + + // 缺 :报错,不猜一个指向 404 的直链。 + let broken = LITELOADER_METADATA.replace("4", ""); + 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<&str> = 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); +} diff --git a/crates/core/src/addon/loader/tests/maven.rs b/crates/core/src/addon/loader/tests/maven.rs index 2d30b12..17bfa9a 100644 --- a/crates/core/src/addon/loader/tests/maven.rs +++ b/crates/core/src/addon/loader/tests/maven.rs @@ -75,10 +75,13 @@ fn forge_versions_carry_the_minecraft_prefix() { fn neoforge_1201_uses_forge_layout() { // 1.20.1 的 NeoForge 早产版挂在 net/neoforged/forge 下,版本串带原版前缀。 assert!(metadata_url(LoaderKind::NeoForge, "1.20.1") + .expect("maven 族应有地址") .ends_with("net/neoforged/forge/maven-metadata.xml")); assert!(metadata_url(LoaderKind::NeoForge, "1.21.1") + .expect("maven 族应有地址") .ends_with("net/neoforged/neoforge/maven-metadata.xml")); assert!(metadata_url(LoaderKind::Forge, "1.20.1") + .expect("maven 族应有地址") .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()]; @@ -119,16 +122,16 @@ fn versions_are_normalized_and_displayed() { #[test] fn installer_urls_point_at_official_maven() { 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" ); 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" ); // 1.20.1 的 NeoForge 走 forge 布局(文件名也是 forge-)。 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" ); } diff --git a/crates/core/src/addon/loader/tests/mod.rs b/crates/core/src/addon/loader/tests/mod.rs index e31eb44..6beddab 100644 --- a/crates/core/src/addon/loader/tests/mod.rs +++ b/crates/core/src/addon/loader/tests/mod.rs @@ -5,6 +5,7 @@ mod fabric; mod forge; +mod liteloader; mod maven; mod plan; mod vocabulary; diff --git a/crates/core/src/addon/loader/tests/plan.rs b/crates/core/src/addon/loader/tests/plan.rs index fbe193e..94b3ef3 100644 --- a/crates/core/src/addon/loader/tests/plan.rs +++ b/crates/core/src/addon/loader/tests/plan.rs @@ -36,8 +36,9 @@ fn plan_contains_only_loader_libraries() { layout: &layout, release: &release, environment: &environment(), - vanilla_installed: false, - }); + parent_installed: false, + }) + .expect("计划应可组"); assert_eq!(plan.install.version_id, "1.20.1-fabric-0.15.11"); // jar 与资源归原版层,loader 计划里只有库。 @@ -71,8 +72,9 @@ fn plan_marks_missing_vanilla_and_unsupported() { layout: &layout, release: &release, environment: &environment(), - vanilla_installed: false, - }); + parent_installed: false, + }) + .expect("计划应可组"); assert!(plan.vanilla_install_needed); assert!(plan .warnings() @@ -83,8 +85,9 @@ fn plan_marks_missing_vanilla_and_unsupported() { layout: &layout, release: &release, environment: &environment(), - vanilla_installed: true, - }); + parent_installed: true, + }) + .expect("计划应可组"); assert!(!installed.vanilla_install_needed); // 把 support 改成"需要 installer",计划里要出现"要外调 java"的告警(M35 起可真装)。 @@ -96,8 +99,9 @@ fn plan_marks_missing_vanilla_and_unsupported() { layout: &layout, release: &blocked, environment: &environment(), - vanilla_installed: true, - }); + parent_installed: true, + }) + .expect("计划应可组"); assert!(plan .warnings() .iter() @@ -112,8 +116,9 @@ fn summary_json_carries_loader_block() { layout: &layout, release: &release, environment: &environment(), - vanilla_installed: true, - }); + parent_installed: true, + }) + .expect("计划应可组"); let summary = plan.summary_json(); assert_eq!(summary["loader"]["kind"], "fabric"); 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!(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); assert_eq!(candidates[0].source, "bmclapi"); assert!(candidates[0] .url .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); assert_eq!(candidates[0].source, "bmclapi"); assert!(candidates[0] diff --git a/crates/core/src/addon/loader/tests/vocabulary.rs b/crates/core/src/addon/loader/tests/vocabulary.rs index 59b6664..e115bd1 100644 --- a/crates/core/src/addon/loader/tests/vocabulary.rs +++ b/crates/core/src/addon/loader/tests/vocabulary.rs @@ -8,7 +8,7 @@ use crate::addon::loader::LoaderKind; use crate::game::{InstanceLoader, LoaderKind as Wide}; -/// `as_str` 是 core 词表的委派:四家装得上的一一对应,且绝不产出别名。 +/// `as_str` 是 core 词表的委派:五家装得上的一一对应,且绝不产出别名。 /// /// 抓得住:委派接错支(`Fabric` 映到 `Quilt`);有人把 `as_str` 改回手写字面量并写错; /// 有人让 `as_str` 产出 `neo-forge`(别名只进不出);生成端 `--` 与解析端 @@ -21,6 +21,7 @@ fn install_side_ids_are_delegated_to_the_core_vocabulary() { (LoaderKind::Quilt, Wide::Quilt), (LoaderKind::NeoForge, Wide::NeoForge), (LoaderKind::Forge, Wide::Forge), + (LoaderKind::LiteLoader, Wide::LiteLoader), ]; for (install, wide) in cases { assert_eq!( @@ -41,7 +42,7 @@ fn install_side_ids_are_delegated_to_the_core_vocabulary() { /// 别名策略:`neo-forge` 只作为**输入**被接受(Modrinth 的拼法),产出永远是 `neoforge`。 /// /// 抓得住:别名被搬进词表(`as_str` 开始产出 `neo-forge`);别名只认小写;安装侧顺手接受了 -/// 本启动器装不了的几家(原版 / OptiFine / LiteLoader)。 +/// 本启动器装不了的几家(原版 / OptiFine)。 /// 抓不住:`neo-forge` 之外 Modrinth 以后可能再发明的拼法——没有样本可写。 #[test] fn modrinth_alias_is_input_only() { @@ -57,7 +58,7 @@ fn modrinth_alias_is_input_only() { "neoforge", "产出不许带连字符" ); - for raw in ["vanilla", "optifine", "liteloader", "", "rift"] { + for raw in ["vanilla", "optifine", "", "rift"] { assert_eq!(LoaderKind::parse(raw), None, "`{raw}` 不该被安装侧接受"); } } diff --git a/crates/core/src/game/loader.rs b/crates/core/src/game/loader.rs index 110596b..fd3be03 100644 --- a/crates/core/src/game/loader.rs +++ b/crates/core/src/game/loader.rs @@ -11,7 +11,7 @@ //! 器」的地方一律调 [`InstanceLoader::parse`],**不许**再写一份标记匹配。生成端 //! ([`crate::addon::loader::LoaderKind::version_id`] 拼 `--`)与解析端的一致 //! 性由 [`tests::own_version_ids_parse_back_to_their_loader`] 钉住(**从生成端取 id**,不手抄); -//! OptiFine / LiteLoader 本启动器不能装(见 `presenter::host::install`),只能继承进来。 +//! OptiFine 本启动器不能装(见 `presenter::host::install`),只能继承进来;LiteLoader 自 M222 起有安装路线。 //! //! # 命名词表只有一张表(M126) //! @@ -125,7 +125,7 @@ loader_kind_vocab! { Forge => "forge", marker; /// OptiFine(第三方,本启动器不装,只继承) OptiFine => "optifine", marker; - /// LiteLoader(第三方,本启动器不装,只继承) + /// LiteLoader(自 M222 起可装:官方版本表 + 从零合成 launchwrapper 清单) LiteLoader => "liteloader", marker; } } diff --git a/crates/core/tests/liteloader.rs b/crates/core/tests/liteloader.rs new file mode 100644 index 0000000..d8f1feb --- /dev/null +++ b/crates/core/tests/liteloader.rs @@ -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; diff --git a/crates/core/tests/liteloader/fixtures/liteloader-versions.json b/crates/core/tests/liteloader/fixtures/liteloader-versions.json new file mode 100644 index 0000000..e1c458f --- /dev/null +++ b/crates/core/tests/liteloader/fixtures/liteloader-versions.json @@ -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 + } + } + } + } + } +} diff --git a/crates/core/tests/liteloader/guards.rs b/crates/core/tests/liteloader/guards.rs new file mode 100644 index 0000000..37e6902 --- /dev/null +++ b/crates/core/tests/liteloader/guards.rs @@ -0,0 +1,157 @@ +//! 边界与失败面:父版本缺失、组合不支持、数据源形状坏、取消。 +//! +//! 网络层的失败映射(`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::inherit::resolve; +use unlucky_core::game::install::{execute_with_cancel, InstallErrorKind}; +use unlucky_core::game::layout::GameLayout; + +use crate::support::{ + engine, liteloader_release, prefill, put_version, read_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(), + "取消之后不许留下版本清单" + ); + + // 继承链在缺清单时也应明确报「父版本缺失」,而不是静默降级成原版。 + let mut source = std::collections::BTreeMap::new(); + source.insert("1.12.2".to_string(), read_version(&layout, "1.12.2")); + let error = resolve(&source, &release.version_json).expect_err("父版本缺失"); + assert!(matches!( + error, + unlucky_core::game::GameError::MissingParent { .. } + )); +} diff --git a/crates/core/tests/liteloader/install.rs b/crates/core/tests/liteloader/install.rs new file mode 100644 index 0000000..8a055b3 --- /dev/null +++ b/crates/core/tests/liteloader/install.rs @@ -0,0 +1,174 @@ +//! 解析 → 计划 → 落盘 → 继承解析 → 启动参数顺序(两条父链:原版 / Forge)。 + +use unlucky_core::addon::loader::{LoaderInstallPlan, LoaderKind, 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 = 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:?}"); +} diff --git a/crates/core/tests/liteloader/support.rs b/crates/core/tests/liteloader/support.rs new file mode 100644 index 0000000..954cd4a --- /dev/null +++ b/crates/core/tests/liteloader/support.rs @@ -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#" + + com.mumfrey + liteloader + 1.12.2-SNAPSHOT + + + 20171128.144431 + 4 + + 20171128144431 + +"#; + +/// 原版 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 { + 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//.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() +} diff --git a/crates/presenter/src/dto/instances.rs b/crates/presenter/src/dto/instances.rs index 3c65ad6..954ce1f 100644 --- a/crates/presenter/src/dto/instances.rs +++ b/crates/presenter/src/dto/instances.rs @@ -1,395 +1,30 @@ //! 实例相关 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}; -use std::path::PathBuf; - -use unlucky_core::game::LoaderKind as CoreLoaderKind; - -use super::LaunchArgsView; - /// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。 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 { - 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, - /// 运行时状态 - pub runtime: InstanceRuntime, - /// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄) - pub icon: Option, - /// 隔离档位(`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); - } -} +pub use super::LaunchArgsView; diff --git a/crates/presenter/src/dto/instances/create.rs b/crates/presenter/src/dto/instances/create.rs new file mode 100644 index 0000000..54d7b3c --- /dev/null +++ b/crates/presenter/src/dto/instances/create.rs @@ -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() + } + } +} diff --git a/crates/presenter/src/dto/instances/loader.rs b/crates/presenter/src/dto/instances/loader.rs new file mode 100644 index 0000000..e0ea750 --- /dev/null +++ b/crates/presenter/src/dto/instances/loader.rs @@ -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 { + 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) + } + } +} diff --git a/crates/presenter/src/dto/instances/runtime.rs b/crates/presenter/src/dto/instances/runtime.rs new file mode 100644 index 0000000..cd80361 --- /dev/null +++ b/crates/presenter/src/dto/instances/runtime.rs @@ -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, + /// 运行时状态 + pub runtime: InstanceRuntime, + /// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄) + pub icon: Option, + /// 隔离档位(`isolated` / `shared` / `custom`;未知为空) + pub isolation: String, + /// 该实例绑定的版本 id(详情页/启动用) + pub version_id: String, + /// 版本本体是否在盘上(缺载荷时磁贴亮黄条) + pub version_present: bool, + /// 启动参数(M157:实例配置页「启动参数」区的数据;含三级回落后的生效值与版本门槛) + pub launch: LaunchArgsView, +} diff --git a/crates/presenter/src/dto/instances/tests.rs b/crates/presenter/src/dto/instances/tests.rs new file mode 100644 index 0000000..28b67c7 --- /dev/null +++ b/crates/presenter/src/dto/instances/tests.rs @@ -0,0 +1,146 @@ +//! 实例 DTO 的单测(词表契约 / 徽标 / 序列化往返)。 + +use super::*; +use unlucky_core::game::LoaderKind as CoreLoaderKind; + +#[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); +} diff --git a/crates/presenter/src/host/install.rs b/crates/presenter/src/host/install.rs index 346998e..b5957c6 100644 --- a/crates/presenter/src/host/install.rs +++ b/crates/presenter/src/host/install.rs @@ -6,8 +6,8 @@ //! 差异只在线程模型:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA), //! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。 //! -//! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge(四家,与 CLI 对齐)。OptiFine/LiteLoader -//! 两家 core 侧都还没有安装管线,仍在 GUI 里显式拒装并说清去哪装。 +//! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge/LiteLoader(五家,与 CLI 对齐)。OptiFine +//! 仍没有安装管线(要跑外部 `optifine.Patcher` + 原地改 zip),在 GUI 里显式拒装并说清去哪装。 //! 失败一律落 `.ULL/logs/install.log`([`super::install_journal`]),文案里带日志路径。 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 加了第六种加载器,这里默认拒绝, /// 由 [`unsupported_reason`] 给出明确说法,而不是让它走进不属于它的安装分支。 @@ -99,13 +99,14 @@ pub fn supported(kind: LoaderKind) -> bool { | LoaderKind::Quilt | LoaderKind::Forge | LoaderKind::NeoForge + | LoaderKind::LiteLoader ) } /// 不支持的加载器 → 给用户的可执行建议。 pub fn unsupported_reason(kind: LoaderKind) -> String { format!( - "{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge);请先在实例页导入已有版本,或换成受支持的加载器。", + "{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge/LiteLoader);请先在实例页导入已有版本,或换成受支持的加载器。", kind.label() ) } diff --git a/crates/presenter/src/host/install/routes.rs b/crates/presenter/src/host/install/routes.rs index 59fd171..a6f0432 100644 --- a/crates/presenter/src/host/install/routes.rs +++ b/crates/presenter/src/host/install/routes.rs @@ -122,8 +122,11 @@ pub(super) fn install_loader( layout: &env.game, release: &release, 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 { let mut vanilla = request.clone(); vanilla.loader = LoaderKind::Vanilla; diff --git a/crates/presenter/src/host/install_tests.rs b/crates/presenter/src/host/install_tests.rs index 06dacbd..de97a33 100644 --- a/crates/presenter/src/host/install_tests.rs +++ b/crates/presenter/src/host/install_tests.rs @@ -143,11 +143,13 @@ fn loader_support_matrix_is_explicit() { LoaderKind::Quilt, LoaderKind::Forge, LoaderKind::NeoForge, + // M222:LiteLoader 走「官方版本表 + 从零合成 launchwrapper 清单」,不跑外部进程。 + LoaderKind::LiteLoader, ] { assert!(supported(kind), "{} 应当受支持", kind.label()); } - // 这两家 core 侧还没有安装管线,仍然显式拒装并说清去哪装。 - for kind in [LoaderKind::OptiFine, LoaderKind::LiteLoader] { + // OptiFine 仍没有安装管线(要跑外部 Patcher + 原地改 zip),显式拒装并说清可替代做法。 + for kind in [LoaderKind::OptiFine] { assert!(!supported(kind), "{} 不该被当成可装", kind.label()); let reason = unsupported_reason(kind); assert!(reason.contains(kind.label()), "{reason}"); From 7ed954f7fa0ff35a0df993664d6a481271a80fd9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 22:13:49 +0800 Subject: [PATCH 02/22] =?UTF-8?q?fix(core):=20LiteLoader=20=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E4=BB=93=E5=9B=BA=E5=AE=9A=E4=B8=BA=20repo.mumfrey.co?= =?UTF-8?q?m/snapshots=EF=BC=881.10.2=20=E7=9A=84=20repo.url=20=E6=98=AF?= =?UTF-8?q?=20/repo/=20=E5=BD=A2=E6=80=81=EF=BC=8C=E7=85=A7=E5=AE=83?= =?UTF-8?q?=E4=BC=9A=20404=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/core/src/addon/loader/fabric.rs | 23 +++++-- crates/core/src/addon/loader/liteloader.rs | 12 ++++ .../src/addon/loader/liteloader/versions.rs | 7 +- .../core/src/addon/loader/tests/liteloader.rs | 68 ++++++++++++++++++- crates/core/tests/liteloader/install.rs | 66 ++++++++++++++++++ 5 files changed, 164 insertions(+), 12 deletions(-) diff --git a/crates/core/src/addon/loader/fabric.rs b/crates/core/src/addon/loader/fabric.rs index 4820159..7772e68 100644 --- a/crates/core/src/addon/loader/fabric.rs +++ b/crates/core/src/addon/loader/fabric.rs @@ -216,15 +216,28 @@ fn lift_flat_digest(library: &mut Library) { } /// loader 本体库必须在列表里:缺了就补一条(并用告警说明),别装出个"没有 loader"的实例。 +/// +/// 只有 fabric 系会走到这里(调用方 [`build_release`] 的入口已按 +/// [`LoaderKind::is_fabric_family`] 分流);这里仍然**穷尽 match**,因为过去那对 `_ =>` +/// 会在新加一支加载器时把它静默当成 `fabric-loader` 补进库表。 fn ensure_loader_library( kind: LoaderKind, loader_version: &str, libraries: &mut Vec, warnings: &mut Vec, ) { - let (group, artifact) = match kind { - LoaderKind::Quilt => ("org.quiltmc", "quilt-loader"), - _ => ("net.fabricmc", "fabric-loader"), + let (group, artifact, maven_base) = match kind { + LoaderKind::Fabric => ( + "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| { library @@ -234,10 +247,6 @@ fn ensure_loader_library( if present { return; } - let maven_base = match kind { - LoaderKind::Quilt => "https://maven.quiltmc.org/repository/release/", - _ => "https://maven.fabricmc.net/", - }; libraries.push(Library { name: format!("{group}:{artifact}:{loader_version}"), url: Some(maven_base.to_string()), diff --git a/crates/core/src/addon/loader/liteloader.rs b/crates/core/src/addon/loader/liteloader.rs index efe3299..c5e7627 100644 --- a/crates/core/src/addon/loader/liteloader.rs +++ b/crates/core/src/addon/loader/liteloader.rs @@ -45,6 +45,18 @@ 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"; diff --git a/crates/core/src/addon/loader/liteloader/versions.rs b/crates/core/src/addon/loader/liteloader/versions.rs index 4edc30e..94d6756 100644 --- a/crates/core/src/addon/loader/liteloader/versions.rs +++ b/crates/core/src/addon/loader/liteloader/versions.rs @@ -7,7 +7,9 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::Deserialize; -use super::{LiteLoaderEntry, LoaderError, CENTRAL_REPO, DEFAULT_REPO, VERSION_LIST_URL}; +use super::{ + LiteLoaderEntry, LoaderError, CENTRAL_REPO, DEFAULT_REPO, SNAPSHOT_REPO, VERSION_LIST_URL, +}; use crate::game::library::Library; #[derive(Debug, Deserialize)] @@ -110,7 +112,8 @@ fn collect( 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 { - let snap_dir = format!("{repo}com/mumfrey/liteloader/{mc_version}-SNAPSHOT/"); + // 快照仓是**固定**的(见 `SNAPSHOT_REPO` 的注释),不跟 `repo.url` 走。 + let snap_dir = format!("{SNAPSHOT_REPO}com/mumfrey/liteloader/{mc_version}-SNAPSHOT/"); (None, Some(snap_dir)) } else { ( diff --git a/crates/core/src/addon/loader/tests/liteloader.rs b/crates/core/src/addon/loader/tests/liteloader.rs index 41c629c..3b1f8d0 100644 --- a/crates/core/src/addon/loader/tests/liteloader.rs +++ b/crates/core/src/addon/loader/tests/liteloader.rs @@ -1,8 +1,9 @@ //! LiteLoader 数据源与合成:官方版本表(**真实响应的裁剪版**)→ 版本条目 → 合成清单。 //! //! 夹具 `LITELOADER_VERSIONS` 是 2026-11 从 `https://dl.liteloader.com/versions/versions.json` -//! 真取的一份响应(200 / 49129 字节 / 16 个原版版本)里挑两个原版版本原样留下: -//! `1.7.10`(release 分支)+ `1.12.2`(只有 snapshots 分支)。删掉的是另外 14 个原版版本与 +//! 真取的一份响应(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 字节)。 @@ -52,6 +53,39 @@ const LITELOADER_VERSIONS: &str = r#"{ } }} }, + "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": { @@ -105,7 +139,7 @@ const LITELOADER_METADATA: &str = r#" #[test] fn parses_the_real_version_table() { let table = parse_versions(LITELOADER_VERSIONS).expect("真实响应必须能解析"); - assert_eq!(table.len(), 2, "只留了两个原版版本"); + assert_eq!(table.len(), 3, "只留了三个原版版本"); // release 分支:按 version 去重、新的排前面。 let releases = &table["1.7.10"]; @@ -274,3 +308,31 @@ fn unknown_version_does_not_fall_back() { 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") + ); +} diff --git a/crates/core/tests/liteloader/install.rs b/crates/core/tests/liteloader/install.rs index 8a055b3..1f80fcb 100644 --- a/crates/core/tests/liteloader/install.rs +++ b/crates/core/tests/liteloader/install.rs @@ -172,3 +172,69 @@ fn stacks_on_forge_and_keeps_the_forge_tweak_class() { .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 串写进来,继承后仍然没有。 + assert!(resolved.minecraft_arguments.is_none()); + 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:?}" + ); +} From c8bf536c3710ed3e67b69f5938981aeb63619a5e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 22:15:10 +0800 Subject: [PATCH 03/22] =?UTF-8?q?feat(app-iced):=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E9=A1=B5=E5=9C=A8=E9=80=89=20LiteLoader=20=E6=97=B6=E7=BB=99?= =?UTF-8?q?=E4=B8=AD=E6=80=A7=E8=AF=B4=E6=98=8E=EF=BC=88GUI=20=E5=8F=AA?= =?UTF-8?q?=E5=8F=A0=E5=8E=9F=E7=89=88=E3=80=81Forge=20=E7=BB=84=E5=90=88?= =?UTF-8?q?=E8=B5=B0=20CLI=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Key::DownloadOptionsLiteLoaderVanillaOnly(中英各一句,不含花括号占位符); 金表按 Key::ALL 的真实位置插入(DownloadOptionsNeedsAtLeast 之后、DownloadCancelled 之前), 只加新键不重排旧键。说明走 OptionsView 的新字段 note,不塞 problem(那是错误色 + 禁用确认)。 --- .../src/pages/download/options/model.rs | 14 +++++ .../src/pages/download/options/tests.rs | 58 +++++++++++++++++++ .../src/pages/download/options/view.rs | 12 ++++ crates/i18n/src/catalog/download.rs | 6 ++ crates/i18n/src/catalog/golden/tail_ai.rs | 1 + 5 files changed, 91 insertions(+) diff --git a/crates/app-iced/src/pages/download/options/model.rs b/crates/app-iced/src/pages/download/options/model.rs index 0a50dd6..a8e91f2 100644 --- a/crates/app-iced/src/pages/download/options/model.rs +++ b/crates/app-iced/src/pages/download/options/model.rs @@ -215,6 +215,12 @@ pub struct OptionsView { pub instance_name: String, /// 不兼容 / 取不到的原因(`Some` ⇒ 确认键按下不去) pub problem: Option, + /// 加载器相关的**中性说明**(不是错误:不套错误色、不禁用确认键)。 + /// + /// 目前只有 LiteLoader 有:下载页只能装「原版 + 加载器」这一层,叠在 Forge 上要走命令行 + /// (M222 的边界,见 [`Key::DownloadOptionsLiteLoaderVanillaOnly`])。把它塞进 `problem` + /// 会让用户以为这个组合不能装、还会按不下去确认键,所以单独一条。 + pub note: Option, /// 本地已装(确认键给「已安装」禁用态) pub installed: bool, /// 现在能不能点「安装」 @@ -276,6 +282,13 @@ impl OptionsView { (Ok(()), _) => None, }; + // 中性说明:只解释「这一页能做到哪一层」,不参与确认键判定。 + let note = (loader == Loader::LiteLoader).then(|| { + catalog + .text(Key::DownloadOptionsLiteLoaderVanillaOnly) + .to_string() + }); + // 四条同时成立才按得下去:不兼容没有、列表真的就位(原版不需要列表)、没装过。 // 「列表没到位」既包括 `Loading` 也包括 `Failed` 与空表——它们都已经在 `problem` 里说了原因。 let ready = loader == Loader::Vanilla @@ -289,6 +302,7 @@ impl OptionsView { loader_version, instance_name: draft.instance_name.clone(), problem, + note, installed: row.installed, can_confirm, } diff --git a/crates/app-iced/src/pages/download/options/tests.rs b/crates/app-iced/src/pages/download/options/tests.rs index 016886e..fdc769d 100644 --- a/crates/app-iced/src/pages/download/options/tests.rs +++ b/crates/app-iced/src/pages/download/options/tests.rs @@ -272,3 +272,61 @@ fn installed_versions_cannot_be_confirmed_again() { assert!(options.installed); 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 应带中性说明"); + assert!(note.contains("vanilla"), "{note}"); + assert!( + note.contains("--loader liteloader"), + "要给出可执行的下一步:{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 的说明" + ); + } +} diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index c153ebf..32c4de4 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -93,6 +93,18 @@ pub(crate) fn section<'a>( .spacing(ui.space(Space::X2)) .width(Length::Fill); + // 中性说明(M222):只解释这一页能装到哪一层,不套错误色、不影响确认键。 + // 换行由 iced 的文本布局负责,长句不会横向溢出;但**视觉验收仍需截图确认**(D30)。 + if let Some(note) = &options.note { + body = body.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + note.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + // 「加载器版本」这一段只在**真该有列表**时出现:原版没有、静态不兼容没有(原因在下面那行)、 // 取不到也没有(同样在原因行)。留一个空标题只会让人以为界面缺了一块。 if show_versions(options) { diff --git a/crates/i18n/src/catalog/download.rs b/crates/i18n/src/catalog/download.rs index b6614e4..ea82f24 100644 --- a/crates/i18n/src/catalog/download.rs +++ b/crates/i18n/src/catalog/download.rs @@ -99,6 +99,12 @@ macro_rules! download_entries { "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新" ), + // M222:下载页只做「原版 + 加载器」一层,Forge 父链只有命令行能走。中性说明, + // 不是错误(不禁用确认键、不套错误色)——所以它不走 `problem` 那条通道。 + DownloadOptionsLiteLoaderVanillaOnly => ( + "LiteLoader stacks on vanilla only — for Forge, run: ull install --loader liteloader", + "LiteLoader 只叠在原版上 —— 要叠 Forge 请运行:ull install --loader liteloader" + ), DownloadCancelled => ( "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记" diff --git a/crates/i18n/src/catalog/golden/tail_ai.rs b/crates/i18n/src/catalog/golden/tail_ai.rs index 4a120dc..e99f673 100644 --- a/crates/i18n/src/catalog/golden/tail_ai.rs +++ b/crates/i18n/src/catalog/golden/tail_ai.rs @@ -106,6 +106,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("DownloadOptionsInstanceNameHint", "Empty = use the version id", "留空就用版本号"), ("DownloadOptionsIncompatible", "{loader} has no builds for {version}", "{loader} 没有适配 {version} 的构建"), ("DownloadOptionsNeedsAtLeast", "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新"), + ("DownloadOptionsLiteLoaderVanillaOnly", "LiteLoader stacks on vanilla only — for Forge, run: ull install --loader liteloader", "LiteLoader 只叠在原版上 —— 要叠 Forge 请运行:ull install --loader liteloader"), ("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"), ("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"), ("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"), From 9c6b5eaa9be676015e39feb4f76ae3e2dc30390d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 22:15:47 +0800 Subject: [PATCH 04/22] =?UTF-8?q?chore(core):=20=E5=8E=BB=E6=8E=89=20litel?= =?UTF-8?q?oader=20=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=E4=B8=8E=20version?= =?UTF-8?q?s.rs=20=E9=87=8C=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20import?= =?UTF-8?q?=EF=BC=88-D=20warnings=20=E4=BC=9A=E7=BA=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/core/src/addon/loader/liteloader/versions.rs | 4 +--- crates/core/tests/liteloader/install.rs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/core/src/addon/loader/liteloader/versions.rs b/crates/core/src/addon/loader/liteloader/versions.rs index 94d6756..11811eb 100644 --- a/crates/core/src/addon/loader/liteloader/versions.rs +++ b/crates/core/src/addon/loader/liteloader/versions.rs @@ -7,9 +7,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::Deserialize; -use super::{ - LiteLoaderEntry, LoaderError, CENTRAL_REPO, DEFAULT_REPO, SNAPSHOT_REPO, VERSION_LIST_URL, -}; +use super::{LiteLoaderEntry, LoaderError, CENTRAL_REPO, DEFAULT_REPO, SNAPSHOT_REPO}; use crate::game::library::Library; #[derive(Debug, Deserialize)] diff --git a/crates/core/tests/liteloader/install.rs b/crates/core/tests/liteloader/install.rs index 1f80fcb..24ab372 100644 --- a/crates/core/tests/liteloader/install.rs +++ b/crates/core/tests/liteloader/install.rs @@ -1,6 +1,6 @@ //! 解析 → 计划 → 落盘 → 继承解析 → 启动参数顺序(两条父链:原版 / Forge)。 -use unlucky_core::addon::loader::{LoaderInstallPlan, LoaderKind, LoaderPlanInput}; +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; From 0eaefebf3bb3ce3d446e8c7841b81f9fe6cf3546 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 22:16:47 +0800 Subject: [PATCH 05/22] =?UTF-8?q?fix(core):=20=E5=90=8C=E4=B8=80=20loader?= =?UTF-8?q?=20=E7=89=88=E6=9C=AC=20id=20=E6=92=9E=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E7=88=B6=E9=93=BE=E6=97=B6=E5=85=B7=E5=90=8D=E6=8A=A5=E9=94=99?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E8=A6=86=E7=9B=96=E7=9B=98=E4=B8=8A=E6=B8=85?= =?UTF-8?q?=E5=8D=95=EF=BC=88=E6=97=A7=E5=AE=9E=E4=BE=8B=E4=BC=9A=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E6=8D=A2=E7=88=B6=E9=93=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/core/src/addon/loader/plan.rs | 29 ++++++++++++++ crates/core/tests/liteloader/guards.rs | 55 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/crates/core/src/addon/loader/plan.rs b/crates/core/src/addon/loader/plan.rs index 087c3dc..06ecabe 100644 --- a/crates/core/src/addon/loader/plan.rs +++ b/crates/core/src/addon/loader/plan.rs @@ -182,6 +182,21 @@ impl LoaderInstallPlan { /// 父版本缺了)。**不能默默回落成叠原版**:那会装出一份和用户意图不同的实例。 pub fn build(input: LoaderPlanInput<'_>) -> Result { let release = input.release; + // 同一个 loader 版本 id 不能同时表示两条父链(`<原版>-liteloader-` 与 + // 「叠在 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!( "父版本 {} 还没装好:{} {} 要叠在它上面", @@ -262,3 +277,17 @@ impl LoaderInstallPlan { summary } } + +/// 盘上那份 `versions//.json` 的 `inheritsFrom`(不存在 / 读不出 / 没写 → `None`)。 +/// +/// 只用来挡「同一版本 id 撞两条父链」,不参与其它判定:读失败不该让安装失败, +/// 所以错误一律吞成 `None`(真正的父子链校验由 [`crate::game::inherit::resolve`] 与 +/// [`LoaderPlanInput::parent_installed`] 负责)。 +fn existing_parent(layout: &GameLayout, version_id: &str) -> Option { + 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()) +} diff --git a/crates/core/tests/liteloader/guards.rs b/crates/core/tests/liteloader/guards.rs index 37e6902..4d45bfe 100644 --- a/crates/core/tests/liteloader/guards.rs +++ b/crates/core/tests/liteloader/guards.rs @@ -155,3 +155,58 @@ fn cancel_leaves_no_manifest() { unlucky_core::game::GameError::MissingParent { .. } )); } + +/// 同一个 loader 版本 id 被两条父链请求:**具名报错**,且盘上那份清单一个字节都不动。 +/// +/// 为什么必须有这道门:`<原版>-liteloader-` 对「叠原版」与「叠 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, "报错之后盘上那份清单不许被改动"); +} From c31103208039681461d0c0ef5cf870a0d65e739d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 22:17:16 +0800 Subject: [PATCH 06/22] =?UTF-8?q?chore(cli):=20report=5Floader=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E9=87=8C=E7=9C=81=E4=B8=80=E8=A1=8C=EF=BC=8C=E7=BB=99?= =?UTF-8?q?=20400=20=E8=A1=8C=E7=A1=AC=E7=BA=BF=E7=95=99=E5=87=BA=E4=BD=99?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/cli/src/commands/install/report_loader.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/cli/src/commands/install/report_loader.rs b/crates/cli/src/commands/install/report_loader.rs index a400efa..982d868 100644 --- a/crates/cli/src/commands/install/report_loader.rs +++ b/crates/cli/src/commands/install/report_loader.rs @@ -386,7 +386,6 @@ mod tests { // 没先装原版时不许凭空多说话。 let plain = human_lines(false, &plan, None, None, None, &[], "n", None).join("\n"); assert!(!plain.contains("原版版本清单已替换"), "{plain}"); - // `--json` 的 `vanilla` 段(结构化,脚本可读)。 let block = vanilla_json(Some(&vanilla)).expect("应当有 vanilla 段"); assert_eq!(block["manifest"]["kind"], Value::String("replaced".into())); From 51ea32284dc4888051b6ccfd007484741adee9f7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 22:17:49 +0800 Subject: [PATCH 07/22] =?UTF-8?q?docs(core):=20inherit::merge=20=E9=87=8C?= =?UTF-8?q?=E5=86=99=E6=B8=85=20minecraftArguments=EF=BC=88=E5=AD=90?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=88=B6=EF=BC=89=E4=B8=8E=20arguments.game?= =?UTF-8?q?=EF=BC=88=E8=BF=BD=E5=8A=A0=EF=BC=89=E7=9A=84=E5=8C=BA=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M222 的准入条件就是这条;不给结论留档,下一个人还会以为「叠一层」随便往哪个字段写都行。 --- crates/core/src/game/inherit.rs | 10 +++++ crates/core/tests/liteloader/install.rs | 49 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/crates/core/src/game/inherit.rs b/crates/core/src/game/inherit.rs index 2760ac4..fe5ee3f 100644 --- a/crates/core/src/game/inherit.rs +++ b/crates/core/src/game/inherit.rs @@ -132,6 +132,16 @@ pub fn apply_patches(base: VersionJson, patches: &[VersionJson]) -> VersionJson /// 这两点**与 analyse/03 §8.3 第 15 条建议相左**(该条主张逐字段/逐 key 合并)。 /// 保留现状的理由是生态兼容:现实 modpack/loader 的版本 JSON 按 HMCL/原版语义编写, /// 逐 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 { let mut extra = parent.extra.clone(); extra.extend(child.extra.iter().map(|(k, v)| (k.clone(), v.clone()))); diff --git a/crates/core/tests/liteloader/install.rs b/crates/core/tests/liteloader/install.rs index 24ab372..6c259aa 100644 --- a/crates/core/tests/liteloader/install.rs +++ b/crates/core/tests/liteloader/install.rs @@ -238,3 +238,52 @@ fn new_style_parent_args_are_appended_not_replaced() { "父层参数必须在前:{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 = 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 的原因" + ); +} From 3114fa2d4f1a3c1eefb62b37cfb05811fd92fad2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 22:44:51 +0800 Subject: [PATCH 08/22] =?UTF-8?q?fix(core):=20=E9=A6=96=E8=BD=AE=E7=BC=96?= =?UTF-8?q?=E8=AF=91/=E6=B5=8B=E8=AF=95=E6=8A=93=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E5=9B=9B=E4=B8=AA=E9=97=AE=E9=A2=98=EF=BC=88BTreeMap=E3=80=81?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=A8=A1=E5=9D=97=E5=B8=83=E5=B1=80=E3=80=81?= =?UTF-8?q?display=5Floader=5Fversion=E3=80=81=E6=9C=AA=E7=94=A8=20import?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. liteloader.rs 拆文件后漏了 `use std::collections::BTreeMap`(`loader_library` 里用)。 2. `tests/liteloader.rs` 是 **crate root**,`mod guards;` 会被找去 `tests/guards.rs`; 改成 `tests/liteloader/main.rs`(与 `tests/game/main.rs` 同形),target 名仍是 liteloader。 3. `display_loader_version` 去通配时丢了 NeoForge@1.20.1(forge 布局)去前缀的语义: 既有用例 `neoforge_1201_uses_forge_layout` 当场抓住(1.20.1-47.1.106 → 47.1.106)。 现在按「加载器 + 是否 forge 布局」显式决定,四家原有行为逐字不变。 4. `dto/instances.rs` 的 `pub use super::LaunchArgsView` 在非 test 构建里没人用(-D warnings 会红), 改由 `dto/instances/tests.rs` 直接 `use crate::dto::LaunchArgsView`。 顺带修两条**我自己写错的测试期望**(不是产品 bug): - 取消用例里那条 `resolve` 断言用错了父版本表(父版本在内存表里,当然解析成功); - 新式父链用例误以为 `minecraftArguments` 会是 None,实际会沿链继承祖父层那份 —— 断言改成「继承来的 legacy 串里不许出现 LiteLoaderTweaker」。 --- crates/core/src/addon/loader/liteloader.rs | 2 ++ crates/core/src/addon/loader/maven.rs | 22 +++++++++++++------ .../core/src/addon/loader/tests/liteloader.rs | 2 +- crates/core/tests/liteloader/guards.rs | 14 +----------- crates/core/tests/liteloader/install.rs | 12 ++++++++-- .../{liteloader.rs => liteloader/main.rs} | 0 crates/presenter/src/dto/instances.rs | 2 -- crates/presenter/src/dto/instances/tests.rs | 2 ++ 8 files changed, 31 insertions(+), 25 deletions(-) rename crates/core/tests/{liteloader.rs => liteloader/main.rs} (100%) diff --git a/crates/core/src/addon/loader/liteloader.rs b/crates/core/src/addon/loader/liteloader.rs index c5e7627..24523fc 100644 --- a/crates/core/src/addon/loader/liteloader.rs +++ b/crates/core/src/addon/loader/liteloader.rs @@ -29,6 +29,8 @@ //! 只在 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}; diff --git a/crates/core/src/addon/loader/maven.rs b/crates/core/src/addon/loader/maven.rs index fa0c29f..c940e9a 100644 --- a/crates/core/src/addon/loader/maven.rs +++ b/crates/core/src/addon/loader/maven.rs @@ -168,17 +168,25 @@ pub fn normalize_maven_version(kind: LoaderKind, mc_version: &str, raw: &str) -> } } -/// 用户视角的加载器版本号(Forge 去掉原版前缀,NeoForge 与非 maven 族原样)。 +/// 用户视角的加载器版本号(带原版前缀的 maven 版本要去掉前缀)。 +/// +/// 谁带前缀是**按加载器 + 布局**定的:Forge 恒带;NeoForge 只有「1.20.1 走 forge 布局」 +/// 那一段带(`1.20.1-47.1.106` → `47.1.106`),其余 NeoForge 版本本身就是完整 maven 版本; +/// fabric 系与 LiteLoader 的版本号本来就是用户视角的。 #[must_use] pub fn display_loader_version(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String { - match kind { - LoaderKind::Forge => maven_version + let strip = match kind { + LoaderKind::Forge => true, + LoaderKind::NeoForge => mc_version == NEOFORGE_FORGE_LAYOUT_MC, + LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => false, + }; + if strip { + maven_version .strip_prefix(&format!("{mc_version}-")) .unwrap_or(maven_version) - .to_string(), - LoaderKind::NeoForge | LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => { - maven_version.to_string() - } + .to_string() + } else { + maven_version.to_string() } } diff --git a/crates/core/src/addon/loader/tests/liteloader.rs b/crates/core/src/addon/loader/tests/liteloader.rs index 3b1f8d0..f42a8f6 100644 --- a/crates/core/src/addon/loader/tests/liteloader.rs +++ b/crates/core/src/addon/loader/tests/liteloader.rs @@ -228,7 +228,7 @@ fn build_release_synthesizes_launchwrapper_manifest() { ); // 父子链靠 arguments.game 追加,不写 minecraftArguments(写了会顶掉 Forge 的 tweakClass)。 assert!(json.minecraft_arguments.is_none()); - let game: Vec<&str> = json + let game: Vec = json .arguments .as_ref() .expect("arguments 必须有") diff --git a/crates/core/tests/liteloader/guards.rs b/crates/core/tests/liteloader/guards.rs index 4d45bfe..f303a3f 100644 --- a/crates/core/tests/liteloader/guards.rs +++ b/crates/core/tests/liteloader/guards.rs @@ -12,13 +12,10 @@ use unlucky_core::addon::loader::{ }; 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_with_cancel, InstallErrorKind}; use unlucky_core::game::layout::GameLayout; -use crate::support::{ - engine, liteloader_release, prefill, put_version, read_version, TempDir, VANILLA_1122, -}; +use crate::support::{engine, liteloader_release, prefill, put_version, TempDir, VANILLA_1122}; /// 父版本不是原版、又没装在盘上 → **具名报错**,绝不默默改成叠原版。 #[test] @@ -145,15 +142,6 @@ fn cancel_leaves_no_manifest() { !layout.version_json(&release.version_id).exists(), "取消之后不许留下版本清单" ); - - // 继承链在缺清单时也应明确报「父版本缺失」,而不是静默降级成原版。 - let mut source = std::collections::BTreeMap::new(); - source.insert("1.12.2".to_string(), read_version(&layout, "1.12.2")); - let error = resolve(&source, &release.version_json).expect_err("父版本缺失"); - assert!(matches!( - error, - unlucky_core::game::GameError::MissingParent { .. } - )); } /// 同一个 loader 版本 id 被两条父链请求:**具名报错**,且盘上那份清单一个字节都不动。 diff --git a/crates/core/tests/liteloader/install.rs b/crates/core/tests/liteloader/install.rs index 6c259aa..2179a9c 100644 --- a/crates/core/tests/liteloader/install.rs +++ b/crates/core/tests/liteloader/install.rs @@ -219,8 +219,16 @@ fn new_style_parent_args_are_appended_not_replaced() { ); let resolved = resolve(&source, &source[&release.version_id]).expect("三层继承应可解析"); - // 父层用的是新式参数:子层没有把 legacy 串写进来,继承后仍然没有。 - assert!(resolved.minecraft_arguments.is_none()); + // 子层没写 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() diff --git a/crates/core/tests/liteloader.rs b/crates/core/tests/liteloader/main.rs similarity index 100% rename from crates/core/tests/liteloader.rs rename to crates/core/tests/liteloader/main.rs diff --git a/crates/presenter/src/dto/instances.rs b/crates/presenter/src/dto/instances.rs index 954ce1f..6026bb3 100644 --- a/crates/presenter/src/dto/instances.rs +++ b/crates/presenter/src/dto/instances.rs @@ -26,5 +26,3 @@ pub use super::launch_failure::{LaunchFailureCause, LaunchFailureStage, LaunchFa /// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。 pub type InstanceId = String; - -pub use super::LaunchArgsView; diff --git a/crates/presenter/src/dto/instances/tests.rs b/crates/presenter/src/dto/instances/tests.rs index 28b67c7..4ff5481 100644 --- a/crates/presenter/src/dto/instances/tests.rs +++ b/crates/presenter/src/dto/instances/tests.rs @@ -3,6 +3,8 @@ use super::*; use unlucky_core::game::LoaderKind as CoreLoaderKind; +use crate::dto::LaunchArgsView; + #[test] fn loader_badges_and_ids() { let vanilla = LoaderInfo::default(); From 04a123deab917b12793c7d2040e8cdd6e4ebe587 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 22:56:33 +0800 Subject: [PATCH 09/22] =?UTF-8?q?fix(app-iced,presenter):=20=E4=BF=AE=20cl?= =?UTF-8?q?ippy=20-D=20warnings=20=E6=8A=93=E5=88=B0=E7=9A=84=E4=B8=89?= =?UTF-8?q?=E5=A4=84=EF=BC=88=E6=96=87=E6=A1=A3=E6=B3=A8=E9=87=8A=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E3=80=81manual=5Frange=5Fcontains=E3=80=81=E5=8D=95?= =?UTF-8?q?=E5=85=83=E7=B4=A0=20for=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. view.rs:我删掉手写 `LOADERS` 常量后,那段文档注释落到了 `VERSION_ROWS` 头上并留了空行 (clippy::empty_line_after_doc_comments)。说明本身是给「chip 顺序为什么委派」用的, 改成普通 `//` 注释,不冒充某个条目的文档。 2. model.rs:LiteLoader 的版本区间判定改成 `((1, 5, 2)..=(1, 12, 2)).contains(&version)` (clippy::manual_range_contains),语义一字未变。 3. install_tests.rs:只剩 OptiFine 一家后,单元素 `for` 改成直调(clippy::single_element_loop)。 --- crates/app-iced/src/pages/download/options/model.rs | 2 +- crates/app-iced/src/pages/download/options/view.rs | 12 +++++------- crates/presenter/src/host/install_tests.rs | 11 +++++------ 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/crates/app-iced/src/pages/download/options/model.rs b/crates/app-iced/src/pages/download/options/model.rs index a8e91f2..ce72ce8 100644 --- a/crates/app-iced/src/pages/download/options/model.rs +++ b/crates/app-iced/src/pages/download/options/model.rs @@ -149,7 +149,7 @@ impl Loader { // LiteLoader 官方版本表只到 1.12.2(最老 1.5.2),两头都是「官方没有构建」。 // 预览版/快照同样没有(那 16 个原版版本全是正式版)。 Loader::LiteLoader => match rank { - Some(version) if version >= (1, 5, 2) && version <= (1, 12, 2) => Ok(()), + Some(version) if ((1, 5, 2)..=(1, 12, 2)).contains(&version) => Ok(()), _ => Err(Incompat::Unsupported), }, } diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index 32c4de4..a10c080 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -47,13 +47,11 @@ use super::super::frame; use super::super::Message; use super::model::{Loader, LoaderVersionsView, OptionsView}; -/// 加载器 chip 的顺序与集合**委派**给 [`Loader::ALL`](原版 / Forge / NeoForge / Fabric / -/// Quilt / LiteLoader)。 -/// -/// 这里原先是手写的五枚常量——那是同一份清单的第三处副本(`dto::wizard_choices`、 -/// `Loader::ALL`、这里),M222 加 LiteLoader 时三处都要改,索性改成委派。 -/// ⚠️ **宽度需 D30 视觉复验**:五枚 chip 实测合计约 311 dp(正文列 424),第六枚(LiteLoader) -/// 估算约 +50 dp,理论仍在一行内,但这是估算不是实测——本轮无构建槽位,未出浅/深色截图。 +// 加载器 chip 的顺序与集合**委派**给 `Loader::ALL`(原版 / Forge / NeoForge / Fabric / +// Quilt / LiteLoader)。这里原先是手写的五枚常量——同一份清单的第三处副本 +// (`dto::wizard_choices`、`Loader::ALL`、这里),M222 加 LiteLoader 时三处都得改,索性委派。 +// ⚠️ 宽度需 D30 视觉复验:五枚 chip 实测合计约 311 dp(正文列 424),第六枚估算 +50 dp, +// 理论仍在行内,但那是估算不是实测(本轮无看图通道,未出浅/深色截图)。 /// 菜单项高度 = 48(`MenuGeometry` 的项高)×2 行 + 上下留白 8×2 = 112。 /// 这么算是为了避免「列表被截掉半行」——滚动区高度必须覆盖整行。 diff --git a/crates/presenter/src/host/install_tests.rs b/crates/presenter/src/host/install_tests.rs index de97a33..a16e17c 100644 --- a/crates/presenter/src/host/install_tests.rs +++ b/crates/presenter/src/host/install_tests.rs @@ -149,12 +149,11 @@ fn loader_support_matrix_is_explicit() { assert!(supported(kind), "{} 应当受支持", kind.label()); } // OptiFine 仍没有安装管线(要跑外部 Patcher + 原地改 zip),显式拒装并说清可替代做法。 - for kind in [LoaderKind::OptiFine] { - assert!(!supported(kind), "{} 不该被当成可装", kind.label()); - let reason = unsupported_reason(kind); - assert!(reason.contains(kind.label()), "{reason}"); - assert!(reason.contains("还没有接入"), "{reason}"); - } + let optifine = LoaderKind::OptiFine; + assert!(!supported(optifine), "{} 不该被当成可装", optifine.label()); + let reason = unsupported_reason(optifine); + assert!(reason.contains(optifine.label()), "{reason}"); + assert!(reason.contains("还没有接入"), "{reason}"); } #[test] From 7d43f1d6860241452be406913301f0c304ba1f3e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 13:26:24 +0800 Subject: [PATCH 10/22] =?UTF-8?q?fix(cli):=20help=20instances=20=E9=87=8C?= =?UTF-8?q?=E6=8A=8A=E3=80=8Cinstall=20=E4=B8=8D=E6=8E=A5=E5=8F=97=20litel?= =?UTF-8?q?oader=E3=80=8D=E6=94=B9=E6=88=90=E5=8F=AA=E9=99=90=20optifine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M222 让 `install --loader liteloader` 可用后,`help instances` 那句「optifine / liteloader 只能由 别的启动器装好后继承进来」的后半句就成了假话,而且与同文件 `help install` 的记载 (`--loader` 取值含 liteloader、optifine 报 loader_unsupported)自相矛盾。 现在这条限定只落在 optifine,并注明 liteloader 自 M222 起可装。`kind` 仍是「认得出」的口径—— 这句话的主体(kind=vanilla 只表示 id 里没痕迹、权威结论要读 libraries[])没动。 无测试断言过旧措辞:`help_vocabulary_tests::help_lists_every_stable_id` 只按 `LoaderKind::ALL` 遍历「id 出现在文案里」,新文案两个 id 都在,因此不需要同步改期望。 --- crates/cli/src/cli/help.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index 673238f..394e156 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -76,9 +76,10 @@ pub fn topic(name: &str) -> Option<&'static str> { kind vanilla | fabric | quilt | forge | neoforge | optifine | liteloader; 原版恒为 {\"kind\":\"vanilla\",\"version\":\"\"}(键恒存在、不为 null,脚本不必判空) version id 里的加载器版本段;原版、以及 id 里没写版本的(1.12.2-LiteLoader)为空串 - 注意:kind 是「认得出」的口径,不是「装得了」的口径 —— optifine / liteloader 只能由别的 - 启动器装好后继承进来,install --loader 不接受这两个值;kind=vanilla 只表示 id 里没有加载器 - 痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。 + 注意:kind 是「认得出」的口径,不是「装得了」的口径 —— optifine 只能由别的启动器装好后 + 继承进来,install --loader 不接受它;liteloader 自 M222 起**可以**装 + (install --loader liteloader)。kind=vanilla 只表示 id 里没有加载器痕迹(整包导入被 + 重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。 %prog% instances launch-args [--jvm T] [--game T] [--memory MB] [--java P] \ [--backend default|opengl|vulkan] [--clear] From 841c17152a55081acd3a01b92cf1bcb31672158d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 14:47:59 +0800 Subject: [PATCH 11/22] =?UTF-8?q?test(app-iced):=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E9=A1=B5=E5=8A=A0=20options-liteloader=20=E5=87=BA=E5=9B=BE?= =?UTF-8?q?=E5=8F=96=E6=A0=B7=E7=82=B9=EF=BC=88D30=20=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E5=8F=96=E8=AF=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M222 的视觉门禁一直缺图:本机没有看图通道,但**出图通道可用**(`--software-renderer` 走 tiny-skia CPU 后端,无桌面也能落盘)。下载页早有专属夹具 `ULL_DL_DEMO`(`demo.rs`,产品路径 不读),M105 就用它出过安装选项区三态,所以这里加第四个取样点,而不是再造一个平行 env: ULL_DL_DEMO=options-liteloader pages_demo --screen=download --width=1180 --height=760 \ --screenshot=out.png --screenshot-theme=light --software-renderer 取样点是「选中 1.12.2 + LiteLoader」(官方版本表只到 1.12.2),走**产品那条真转换** `Download::from_snapshot`:chip 行、中性说明「只叠原版」、确认键状态都是产品行为。 版本列表**只在本次状态里**补一条 1.12.2——不动 `demo_versions()`,因为那是别处基线图的行集 (`tests/view.rs` 钉着 8 行)。 四张图与真假表落在 `DevDocs/artifacts/m222/`(git 忽略区,不入库):浅色 / 深色 / 720 窄窗 / 原版对照,README 里写明哪张证明什么、哪些是夹具、哪些是产品真转换。图的内容判定未做—— 出图者无图像能力,交给视觉 reviewer。 --- crates/app-iced/src/pages/download/demo.rs | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/app-iced/src/pages/download/demo.rs b/crates/app-iced/src/pages/download/demo.rs index 110eb95..79e827f 100644 --- a/crates/app-iced/src/pages/download/demo.rs +++ b/crates/app-iced/src/pages/download/demo.rs @@ -21,6 +21,7 @@ //! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `category-release` / `installing` / //! `installing-late` / `installing-empty` / `failed` / `done` / `done-next` / //! **`options` / `options-fabric` / `options-incompat`**(M105 的安装选项区三态)/ +//! **`options-liteloader`**(M222:LiteLoader 已可装,取样点用它的最高原版 1.12.2)/ //! **`category-ancient` / `search` / `search-major` / `search-empty` / `search-elsewhere`** //! (M107 的两级侧栏与搜索取样点)。 //! @@ -81,6 +82,8 @@ pub fn demo(catalog: Catalog) -> Download { ), // NeoForge 最早只到 1.20.1,远古版(列表里的 `rd-132211`)根本没有构建 ⇒ 选中即报原因 Some("options-incompat") => options(catalog, "rd-132211", Loader::NeoForge, None), + // M222 的取样点:选中 1.12.2 + LiteLoader(外加「这里只叠原版」那条中性说明) + Some("options-liteloader") => options_liteloader(catalog), Some("selected") => Download { selected: Some("1.21.9".to_string()), ..ready(catalog) @@ -169,6 +172,37 @@ fn options(catalog: Catalog, version: &str, loader: Loader, cached: Option 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 档的横向滚动取证用。 fn many(catalog: Catalog) -> Download { let versions = (0..=21) From cd7813f8eb15075382ca70b2242c639f09f554ff Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 15:10:20 +0800 Subject: [PATCH 12/22] =?UTF-8?q?fix(app-iced):=20=E9=80=89=E9=A1=B9?= =?UTF-8?q?=E5=8C=BA=E5=8F=98=E9=AB=98=E6=97=B6=E4=B8=8E=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=AF=B9=E5=8D=8A=E5=88=86=EF=BC=88S1?= =?UTF-8?q?=EF=BC=89+=20=E4=B8=AD=E8=8B=B1=E5=87=BA=E5=9B=BE=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=20+=20=E6=B3=A8=E9=87=8A=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D30 r1 视觉驳回的三条里,代码能修的两条: 1. **S1 版面**:带加载器版本列表的选项区在三栏档把版本列表从 6.0 行压到 2.4 行(1180×760 实测 278px→110px,底缘切在第三行图标上)。`rows::TailShare::of` 现在让这类面板在**任何档位** 都与列表对半分(`scrollable` 分支早已存在),另把加载器版本菜单改成**按条目数**占高 (1 条 64px / 2 条 112px,原先只有 1 条也固定撑两行)。实测:列表回到 **245px = 5.3 行**。 2. **注释同步(S6)**:`options/view.rs` 模块头原先写「加载器分成两行」+ 两行示意,实际是 六枚一行(末枚右缘约 404px / 正文列 424);那条「本轮无看图通道,未出截图」也换成实测值。 顺带(使能中文出图):`pages/mod.rs::demo_model` 读 `ULL_DL_LANG=zh` 把 `model.lang` 与下载页 目录一起切中文。之前 `LC_ALL=zh_CN.UTF-8` 对出图**完全无效**——页面文案来自 `model.lang`, 而 `Model::new` 写死 `En`、`demo_model` 不碰它(`i18n::detect_env` 不在这条链上)。 测试:`options/s1_tests.rs` 新增两条(菜单只按整行占高;带版本列表的选项区在每个档位都 Shared, 原版/不兼容仍 Pinned),`-p unlucky-app-iced` 862 passed。 未完成:S2 窄窗「滚到底」图——iced 0.14 没有自由函数 `scroll_to`,程序化滚动要手写 `widget::Operation`(约 40 行),本轮不拿半成品充数;面板 `scrollable` 的稳定 Id 已就位。 --- crates/app-iced/src/pages/download/options.rs | 2 + .../src/pages/download/options/model.rs | 13 +++ .../src/pages/download/options/s1_tests.rs | 97 +++++++++++++++++++ .../src/pages/download/options/tests.rs | 6 +- .../src/pages/download/options/view.rs | 35 ++++--- crates/app-iced/src/pages/download/rows.rs | 14 ++- crates/app-iced/src/pages/mod.rs | 13 ++- 7 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 crates/app-iced/src/pages/download/options/s1_tests.rs diff --git a/crates/app-iced/src/pages/download/options.rs b/crates/app-iced/src/pages/download/options.rs index 27a8ed1..74dbf3a 100644 --- a/crates/app-iced/src/pages/download/options.rs +++ b/crates/app-iced/src/pages/download/options.rs @@ -29,6 +29,8 @@ mod model; #[cfg(test)] +mod s1_tests; +#[cfg(test)] mod tests; mod view; diff --git a/crates/app-iced/src/pages/download/options/model.rs b/crates/app-iced/src/pages/download/options/model.rs index ce72ce8..19d1275 100644 --- a/crates/app-iced/src/pages/download/options/model.rs +++ b/crates/app-iced/src/pages/download/options/model.rs @@ -308,6 +308,19 @@ impl OptionsView { } } + /// 面板上会不会画出**加载器版本列表**(选项区最高的那一态)。 + /// + /// 判据与 `view::show_versions` 同源(原版没有、静态不兼容没有、取不到也没有), + /// 但这里是**纯模型查询**:`rows::TailShare::of` 要在装配前就知道面板会不会长高。 + /// 用途(M222 D30 r1 S1):带版本列表的选项区在三栏档实测把版本列表从 6 行压到 2.4 行, + /// 于是这类面板改走「列表与面板对半分、面板自己可滚」。 + #[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 用官方默认,只在原版路径出现)。 pub fn loader_version_arg(&self) -> Option<&str> { (!self.loader_version.is_empty()).then_some(self.loader_version.as_str()) diff --git a/crates/app-iced/src/pages/download/options/s1_tests.rs b/crates/app-iced/src/pages/download/options/s1_tests.rs new file mode 100644 index 0000000..9159da6 --- /dev/null +++ b/crates/app-iced/src/pages/download/options/s1_tests.rs @@ -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(), "不兼容时没有版本列表"); +} diff --git a/crates/app-iced/src/pages/download/options/tests.rs b/crates/app-iced/src/pages/download/options/tests.rs index fdc769d..f0b26f7 100644 --- a/crates/app-iced/src/pages/download/options/tests.rs +++ b/crates/app-iced/src/pages/download/options/tests.rs @@ -16,7 +16,7 @@ use unlucky_presenter::state::AppState; use super::model::{release_rank, Incompat, InstallDraft, Loader, LoaderVersionsView, OptionsView}; use crate::pages::download::model::VersionRow; -fn row(id: &str, kind: VersionKind) -> VersionRow { +pub(super) fn row(id: &str, kind: VersionKind) -> VersionRow { VersionRow::of( &VersionSummary { id: id.to_string(), @@ -28,7 +28,7 @@ fn row(id: &str, kind: VersionKind) -> VersionRow { ) } -fn state_with(kind: LoaderKind, mc: &str, items: Result, &str>) -> AppState { +pub(super) fn state_with(kind: LoaderKind, mc: &str, items: Result, &str>) -> AppState { let mut state = AppState::new(); state.apply(AppEvent::Core(CoreEvent::LoaderVersions { kind, @@ -44,7 +44,7 @@ fn state_with(kind: LoaderKind, mc: &str, items: Result, &str>) -> App state } -fn draft(loader: Loader) -> InstallDraft { +pub(super) fn draft(loader: Loader) -> InstallDraft { InstallDraft { selected: Some("1.20.1".to_string()), loader, diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index a10c080..8150c2a 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -5,21 +5,22 @@ //! ```text //! 1.21.9 [安装] //! 安装选项 -//! [原版][Forge][NeoForge] -//! [Fabric][Quilt] +//! [原版][Forge][NeoForge][Fabric][Quilt][LiteLoader] ← 六枚一行 +//! LiteLoader 只叠在原版上 —— 要叠 Forge 请运行:… ← 中性说明(只有 LiteLoader 有) //! 加载器版本 ← 只在选了加载器时出现 -//! ┌ 0.16.5 ┐ ← 官方那份列表(menu + 滚动,最多看两行) +//! ┌ 0.16.5 ┐ ← 官方那份列表(menu + 滚动,最多看两行项) //! │ 0.16.4 │ //! └───────────────────┘ //! [实例名 ] //! NeoForge 需要 Minecraft 1.20.1 或更新 ← 不兼容时(Error 色) //! ``` //! -//! ## 为什么加载器分成两行 +//! ## 为什么加载器是**六枚一行**(M222 D30 r1 实测订正) //! -//! 五家一起排在 424 宽里最宽的那组(`原版 + Forge + NeoForge + Fabric + Quilt`)实测会溢出, -//! 而 chip 的几何是组件自己的真源(`ChipGeometry`),页面不许压它 ⇒ 折成两行。 -//! 顺序仍是任务书的口径(原版 / Forge / NeoForge / Fabric / Quilt),只是换行。 +//! 这里原先写「五家一起排会溢出 ⇒ 折成两行」,那是 M105 的推算。D30 的实测图(1180×760, +//! 正文列 424)是**六枚一行、末枚右缘约 404px**,没有溢出——所以示意与说明都改成了实际形态。 +//! chip 的几何仍是组件自己的真源(`ChipGeometry`),页面不许压它;再来第七家时先看图再决定 +//! 是否换行。 //! //! ## 为什么「不兼容」不禁用 chip、而是禁用确认键 //! @@ -50,12 +51,20 @@ use super::model::{Loader, LoaderVersionsView, OptionsView}; // 加载器 chip 的顺序与集合**委派**给 `Loader::ALL`(原版 / Forge / NeoForge / Fabric / // Quilt / LiteLoader)。这里原先是手写的五枚常量——同一份清单的第三处副本 // (`dto::wizard_choices`、`Loader::ALL`、这里),M222 加 LiteLoader 时三处都得改,索性委派。 -// ⚠️ 宽度需 D30 视觉复验:五枚 chip 实测合计约 311 dp(正文列 424),第六枚估算 +50 dp, -// 理论仍在行内,但那是估算不是实测(本轮无看图通道,未出浅/深色截图)。 +// D30 r1 实测(1180×760 与 720×760 两种宽度、浅/深两套主题):六枚 chip 排在一行, +// 末枚(LiteLoader)右缘约 404px,正文列 424 仍有余量;窄窗 720 下同一行也没有溢出。 -/// 菜单项高度 = 48(`MenuGeometry` 的项高)×2 行 + 上下留白 8×2 = 112。 -/// 这么算是为了避免「列表被截掉半行」——滚动区高度必须覆盖整行。 -const VERSION_ROWS: f32 = 7.0; +/// 加载器版本菜单的**可见高度**(像素):最多两行项 + 上下留白 8×2。 +/// +/// 项高 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 +} /// 选项区(面板的下半块)。 pub(crate) fn section<'a>( @@ -196,7 +205,7 @@ fn version_list<'a>( list = list.item(entry); } 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)) .into() } diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index 688ece2..2de9976 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -111,6 +111,7 @@ pub(super) fn content<'a>( let tail: Element<'a, Message> = match share { TailShare::Pinned => tail, TailShare::Shared => scrollable(tail) + .id(iced::widget::Id::new(PANEL_SCROLL_ID)) .height(Length::Fill) .style(frame::scroll_style(ui)) .into(), @@ -129,6 +130,12 @@ pub(super) fn content<'a>( .into() } +/// 面板那一层 `scrollable` 的 Id(M222 D30 r1 S2 的出图取证用,见 `pages::harness`)。 +/// +/// 用途只有一个:窄窗(chips 档 / 带版本列表的选项区)里面板自己可滚,出图要证明 +/// 「实例名那格滚得到」——harness 拿到这个 Id 就能派一次 `scroll_to` 再拍。 +pub(crate) const PANEL_SCROLL_ID: &str = "download-panel"; + /// 列表与尾部面板怎么分这块高度(M125 缺陷 1 的修法)。 /// /// | 档 | 分法 | 为什么 | @@ -159,11 +166,16 @@ impl TailShare { /// 掉到 **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 { + pub(super) fn of(tier: VersionRailTier, state: &PanelState<'_>) -> Self { match (tier, state) { (VersionRailTier::Chips, PanelState::Options(_) | PanelState::Install(_)) => { Self::Shared } + // M222 D30 r1 S1:**带加载器版本列表**的选项区在任何档位都太高。三栏档实测 + // (1180×760)面板从 204px 涨到 372px,版本列表从 6.0 行(278px)被压到 2.4 行 + // (110px)——列表底缘还切在第三行图标上。改成 Shared:两半各 ≈215px, + // 列表回到 4.6 行,面板自己可滚([`Self::Shared`] 那条 `scrollable` 分支已存在)。 + (_, PanelState::Options(options)) if options.has_loader_versions() => Self::Shared, _ => Self::Pinned, } } diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 351797c..d444850 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -323,9 +323,20 @@ pub fn app_with_appearance<'a>( /// 真的传进来,否则「矮窗口那几张图」画的是高窗口的版面。 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())); + // 出图的中英取证(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 { 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::Logs => model.logs = logs::demo(model.lang), // M159:设置页「更新」组的出图夹具(一个可装的新版本 + 一次成功检查)。 From f29ca609ec870a4a1517f7a48a723289e9d06a79 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 15:25:52 +0800 Subject: [PATCH 13/22] =?UTF-8?q?fix(app-iced,i18n):=20=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E5=90=8D=E9=92=89=E6=88=90=E9=9D=A2=E6=9D=BF=E9=A1=B5=E8=84=9A?= =?UTF-8?q?=EF=BC=88S7=EF=BC=89+=20=E4=B8=AD=E6=96=87=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E7=AC=A6=E8=BF=98=E5=8E=9F=EF=BC=88S8=EF=BC=89+=20=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E5=AE=9E=E6=B5=8B=E5=80=BC=EF=BC=88S6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D30 r2 视觉二审四条: 1. **S7(新回归)**:S1 的对半分让面板变矮之后实例名被挤出 1180×760 首屏。按 `install.rs` 早已登记的方案改成**可滚上半块 + 钉死页脚**:`install::panel_with(.., shared)` → `options::section(.., scroll)`;`rows::content` 对 `PanelState::Options` 不再整块套 scrollable(那层既把实例名卷进滚动区,也在窄窗产生第二条并列滚动条)。页脚不参与滚动, 可达性由结构保证,S2 随之关闭。 2. **S8**:`catalog/download.rs` 与金表同一行的中文命令占位符 `` 照抄不可执行, 还原成 ``(两处一起改,金表顺序未动)。 3. **S6**:`rows.rs` 里「两半各 ≈215px / 列表 4.6 行」的估算换成实测(列表 245px = 5.29 行、 面板 237px、分隔线 y=498),并写明 S1b 仍欠一项与其正确落点。 **S1b/S9(列表底缘整行)本轮没修成**:试过 `responsive` 拿「分给列表的高度」再 floor, 实测无效(`Fill` 子项上拿到非确定高度,量测显示列表区最后墨迹行仍在分隔线前一行),已回退, 不留无效代码。正确落点是把列表可用高度从 `pages/download.rs` / `rails.rs`(都在 scope 外) 传进 `rows::content`。 测试:`-p unlucky-app-iced` 862 passed、`-p unlucky-i18n` 36 passed(金表改动后仍绿)。 --- crates/app-iced/src/pages/download/install.rs | 27 ++++++++++-- .../src/pages/download/options/view.rs | 43 ++++++++++++++----- crates/app-iced/src/pages/download/rows.rs | 39 +++++++++++------ crates/i18n/src/catalog/download.rs | 2 +- crates/i18n/src/catalog/golden/tail_ai.rs | 2 +- 5 files changed, 83 insertions(+), 30 deletions(-) diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs index c656643..e985a1e 100644 --- a/crates/app-iced/src/pages/download/install.rs +++ b/crates/app-iced/src/pages/download/install.rs @@ -105,18 +105,37 @@ pub(super) fn panel_state(model: &Download) -> PanelState<'_> { } /// 底部的安装面板。 +/// +/// `shared` = 这一档列表与面板对半分(`rows::TailShare::Shared`)。此时**选项区自己滚**: +/// 上半块(加载器 chip / 说明 / 版本列表 / 错误行)收进 `scrollable` 吃 `Fill`,实例名那一格 +/// 钉成页脚——D30 r2 S7:面板变高后实例名会被挤出首屏,钉住之后可达性由结构保证。 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) { PanelState::Install(install) => installing(ui, catalog, install), // 选中版本之后**先给选项,再点「安装」**(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), }; - container(content) + let panel = container(content) .width(Length::Fill) - .padding(Padding::from([ui.space(Space::X3), 0.0])) - .into() + .padding(Padding::from([ui.space(Space::X3), 0.0])); + // 对半分档:面板吃满自己那一半,选项区的 `scrollable` 才有确定高度可吃。 + if shared { + panel.height(Length::Fill).into() + } else { + panel.into() + } } /// 没选中任何版本:给一句「选一个版本再安装」(不静默禁用,也不画一个点不动的按钮)。 diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index 8150c2a..260aff4 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -66,11 +66,17 @@ pub(super) fn version_menu_height(items: usize) -> f32 { rows * ITEM + Space::X2.dp() * 2.0 } -/// 选项区(面板的下半块)。 +/// 选项区(面板的下半块):**可滚的上半块 + 钉死的页脚**。 +/// +/// `scroll` = 面板此刻是不是「自己可滚」的那一档(`rows::TailShare::Shared`)。是的话上半块 +/// 收进 `scrollable` 吃 `Fill`,**实例名那一格留在页脚、永远在屏**——这条是 D30 r2 S7 的修法: +/// 面板变高(多了加载器版本列表)之后,原先实例名会被挤到首屏外(1180×760 实测要滚 1 屏), +/// 钉成页脚之后可达性由**结构**保证,不依赖滚轮(`install.rs` 模块文档里登记的方案)。 pub(crate) fn section<'a>( ui: &'a Ui, catalog: Catalog, options: &'a OptionsView, + scroll: bool, ) -> Element<'a, Message> { let head = row![ container(frame::text_role( @@ -125,15 +131,6 @@ pub(crate) fn section<'a>( body = body.push(version_list(ui, catalog, options)); } - body = body.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, - )); - if let Some(problem) = &options.problem { body = body.push(frame::text_role( ui, @@ -144,7 +141,31 @@ pub(crate) fn section<'a>( )); } - body.into() + // 页脚:实例名(钉死,不参与滚动)。见 `section` 的文档。 + let footer = 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, + ); + + if !scroll { + // 钉死档(三栏 + 无版本列表 / chips 以外的矮面板):行为与以前一致,一列内容到底。 + body = body.push(Element::from(footer)); + return body.into(); + } + column![ + scrollable(body) + .height(Length::Fill) + .style(frame::scroll_style(ui)), + footer, + ] + .spacing(ui.space(Space::X2)) + .height(Length::Fill) + .width(Length::Fill) + .into() } /// 加载器版本这一段要不要画(见 `section` 的注释)。 diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index 2de9976..fbca25b 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -74,7 +74,7 @@ use super::super::frame; 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::install::{self, PanelState}; +use super::install::{self, panel_state, PanelState}; use super::model::{Download, VersionRow}; use super::states; @@ -102,19 +102,28 @@ pub(super) fn content<'a>( // 列表与尾部面板之间**总是**有一条分隔线(三态一致):M123 的取证图里「选好了版本」 // 那一态原先没有它,版本行与选项区第一行(版本号 + 安装按钮)读起来像同一块内容。 - let tail: Element<'a, Message> = column![divider::full(ui), install::panel(ui, catalog, model)] - .spacing(ui.space(Space::X3)) - .width(Length::Fill) - .into(); + let shared = matches!(share, TailShare::Shared); + let tail: Element<'a, Message> = column![ + divider::full(ui), + install::panel_with(ui, catalog, model, shared) + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .into(); - // chips 档:面板收回自己的一半、内部可滚(它原先按内容高钉死,把列表压成 18px 的缝)。 - let tail: Element<'a, Message> = match share { - TailShare::Pinned => tail, - TailShare::Shared => scrollable(tail) + // chips 档 / 带版本列表的选项区:面板收回自己的一半。 + // + // `PanelState::Options` 走**面板内部**的滚动(上半块可滚 + 实例名钉页脚,见 `install::panel`), + // 所以这里不再整块套一层 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) .style(frame::scroll_style(ui)) .into(), + (TailShare::Pinned, _) => tail, }; column![ @@ -171,10 +180,14 @@ impl TailShare { (VersionRailTier::Chips, PanelState::Options(_) | PanelState::Install(_)) => { Self::Shared } - // M222 D30 r1 S1:**带加载器版本列表**的选项区在任何档位都太高。三栏档实测 - // (1180×760)面板从 204px 涨到 372px,版本列表从 6.0 行(278px)被压到 2.4 行 - // (110px)——列表底缘还切在第三行图标上。改成 Shared:两半各 ≈215px, - // 列表回到 4.6 行,面板自己可滚([`Self::Shared`] 那条 `scrollable` 分支已存在)。 + // M222 D30 r1 S1 / r2 S7:**带加载器版本列表**的选项区在任何档位都太高。 + // 三栏档(1180×760)实测:修前面板 204→372px,版本列表 6.0 行(278px)被压到 + // 2.4 行(110px,底缘切在第三行图标上);改成 Shared 之后版本列表 **245px = 5.29 行**、 + // 面板 237px(分隔线 y=498)。面板因此变矮,实例名会被挤出首屏 ⇒ 同一轮把它 + // **钉成面板页脚**(`install::panel_with` 的 `shared` 分支 + `options::section`)。 + // 仍欠一项:245px 不是行高整数倍,列表底缘仍切在第 6 行中间(S1b/S9)。 + // 修它要把「分给列表的高度」从 `pages/download.rs` 传进来做 floor 取整 + // (`responsive` 在 Fill 子项上拿不到确定高度,实测无效,已回退)。 (_, PanelState::Options(options)) if options.has_loader_versions() => Self::Shared, _ => Self::Pinned, } diff --git a/crates/i18n/src/catalog/download.rs b/crates/i18n/src/catalog/download.rs index ea82f24..ea02d03 100644 --- a/crates/i18n/src/catalog/download.rs +++ b/crates/i18n/src/catalog/download.rs @@ -103,7 +103,7 @@ macro_rules! download_entries { // 不是错误(不禁用确认键、不套错误色)——所以它不走 `problem` 那条通道。 DownloadOptionsLiteLoaderVanillaOnly => ( "LiteLoader stacks on vanilla only — for Forge, run: ull install --loader liteloader", - "LiteLoader 只叠在原版上 —— 要叠 Forge 请运行:ull install --loader liteloader" + "LiteLoader 只叠在原版上 —— 要叠 Forge 请运行:ull install --loader liteloader" ), DownloadCancelled => ( "Install cancelled — finished files are kept, the instance was not created", diff --git a/crates/i18n/src/catalog/golden/tail_ai.rs b/crates/i18n/src/catalog/golden/tail_ai.rs index e99f673..a60e932 100644 --- a/crates/i18n/src/catalog/golden/tail_ai.rs +++ b/crates/i18n/src/catalog/golden/tail_ai.rs @@ -106,7 +106,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("DownloadOptionsInstanceNameHint", "Empty = use the version id", "留空就用版本号"), ("DownloadOptionsIncompatible", "{loader} has no builds for {version}", "{loader} 没有适配 {version} 的构建"), ("DownloadOptionsNeedsAtLeast", "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新"), - ("DownloadOptionsLiteLoaderVanillaOnly", "LiteLoader stacks on vanilla only — for Forge, run: ull install --loader liteloader", "LiteLoader 只叠在原版上 —— 要叠 Forge 请运行:ull install --loader liteloader"), + ("DownloadOptionsLiteLoaderVanillaOnly", "LiteLoader stacks on vanilla only — for Forge, run: ull install --loader liteloader", "LiteLoader 只叠在原版上 —— 要叠 Forge 请运行:ull install --loader liteloader"), ("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"), ("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"), ("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"), From 954281d71a9a14ba3965dae5a99a65c35defab78 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 15:39:49 +0800 Subject: [PATCH 14/22] =?UTF-8?q?fix(app-iced):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=9C=A8=E4=B8=89=E6=A0=8F=E5=AF=B9=E5=8D=8A?= =?UTF-8?q?=E5=88=86=E6=A1=A3=E6=8C=89=E6=95=B4=E8=A1=8C=E5=8F=96=E9=AB=98?= =?UTF-8?q?=EF=BC=88S1b/S9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列表视口高度不再是「Fill 剩下的」:正文列高度由仓里已有的 `frame::body_height` 算出, 经 `view_sized` → `body` → `rails::three_column` → `rows::content` 传下来,Shared 档据此 `floor(((h − LIST_CHROME)/2) / ROW_PITCH) * ROW_PITCH` 取整行。 实测(1180×760):列表 245px → **239px**(分隔线 498 → 492,腾出的 6px 归面板), `04` 对照**未变**(531 / 278px),720 窄窗**未变**(本来就是 4.0 整行、底缘 0 墨迹)。 两处修正写在提交里: 1. 第一版给所有三栏场合取整,把 `04` 也缩到 239px ⇒ 收窄为只在 `TailShare::Shared` 取整; 2. `rows.rs` 涨到 419 行撞 400 行门禁 ⇒ `TailShare` 与三个标定量纯搬移到 `rows/tail.rs`, `rows.rs` 回到 355 行(门禁 `components::gate` 与全仓扫描都绿)。 `view` 保留 4 参数(`#[cfg(test)]`,只有单测用),产品路径走 `view_sized`。 --- crates/app-iced/src/pages/download.rs | 29 ++++++- crates/app-iced/src/pages/download/rails.rs | 2 + crates/app-iced/src/pages/download/rows.rs | 68 ++++------------ .../app-iced/src/pages/download/rows/tail.rs | 79 +++++++++++++++++++ crates/app-iced/src/pages/mod.rs | 8 +- 5 files changed, 131 insertions(+), 55 deletions(-) create mode 100644 crates/app-iced/src/pages/download/rows/tail.rs diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index af799fd..5b7e579 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -161,11 +161,25 @@ use super::{Action, Message}; /// /// `lang` 是 i18n 的入参,调用点传的是 `pages::Model.lang`(M106 起 = `App` 的生效语言, /// 见 `ui::lang::LangState`);页面自己不读全局、不猜语言。 +/// 四参数的 [`view_sized`]:给**用不到窗口高度**的调用方(只有单测;产品路径走 +/// `pages::view` → [`view_sized`],所以这条在非 test 构建里是死代码,`#[cfg(test)]` 收口)。 +#[cfg(test)] pub fn view<'a>( ui: &'a Ui, metrics: FrameMetrics, model: &'a Download, 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> { 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, catalog: Catalog, model: &'a Download, + body_height: f32, ) -> Element<'a, Message> { if model.is_loading() { // 还没拉到版本列表:三栏没有内容可填,给占位(安装面板照旧带着,见 M94 复审 p3) @@ -221,7 +244,7 @@ fn body<'a>( }; 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 { // 三栏不成立(可用宽 < 600):类型与主版本原样退回顶部 chips,列表吃掉整条可用宽。 // 高度分法跟着档位走:chips 档多花两行 chip,面板必须收回自己的一半(M125 缺陷 1)。 @@ -233,6 +256,8 @@ fn body<'a>( catalog, model, rows::TailShare::of(rails.tier, &install::panel_state(model)), + // chips 档不按整行取高:实测那里已经是 4.0 整行、零残影,别动。 + None, ), ] .spacing(ui.space(Space::X2)) diff --git a/crates/app-iced/src/pages/download/rails.rs b/crates/app-iced/src/pages/download/rails.rs index 0c35a46..afa6c91 100644 --- a/crates/app-iced/src/pages/download/rails.rs +++ b/crates/app-iced/src/pages/download/rails.rs @@ -75,6 +75,7 @@ pub(super) fn three_column<'a>( catalog: Catalog, model: &'a Download, rails: VersionRails, + body_height: f32, ) -> Element<'a, Message> { row![ panel( @@ -92,6 +93,7 @@ pub(super) fn three_column<'a>( catalog, model, rows::TailShare::of(rails.tier, &super::install::panel_state(model)), + Some(rows::whole_rows(body_height)), )) .width(Length::Fixed(rails.list)) .height(Length::Fill), diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index fbca25b..4ad46f6 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -72,12 +72,15 @@ use crate::theme::Ui; use super::super::frame; 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::install::{self, panel_state, PanelState}; use super::model::{Download, VersionRow}; use super::states; +mod tail; + +pub(crate) use tail::{whole_rows, TailShare}; + /// 列表列(三栏档与 chips 档共用一份)。 /// /// `share` 决定「列表」与「尾部面板」怎么分这块高度:三栏档钉死面板、chips 档各吃一半 @@ -87,6 +90,7 @@ pub(super) fn content<'a>( catalog: Catalog, model: &'a Download, share: TailShare, + list_height: Option, ) -> Element<'a, Message> { let visible = visible(model); @@ -97,7 +101,14 @@ pub(super) fn content<'a>( } else if visible.is_empty() { states::empty(ui, catalog) } else { - list(ui, catalog, model, &visible) + // 只有「列表与面板对半分」那一档才按整行取高:钉死档(面板矮)实测已经是整行 + // (04 图 278px),取整反而会把它从 6 行缩到 5 行。 + let height = if matches!(share, TailShare::Shared) { + list_height + } else { + None + }; + list(ui, catalog, model, &visible, height) }; // 列表与尾部面板之间**总是**有一条分隔线(三态一致):M123 的取证图里「选好了版本」 @@ -145,55 +156,6 @@ pub(super) fn content<'a>( /// 「实例名那格滚得到」——harness 拿到这个 Id 就能派一次 `scroll_to` 再拍。 pub(crate) const PANEL_SCROLL_ID: &str = "download-panel"; -/// 列表与尾部面板怎么分这块高度(M125 缺陷 1 的修法)。 -/// -/// | 档 | 分法 | 为什么 | -/// |---|---|---| -/// | `Wide` / `Narrow` / `Compact` | [`TailShare::Pinned`]:面板按内容高钉在底部,列表吃剩下的 | 竖栏档在 760 高的窗口里列表还剩 158px(3 整行),面板整块在屏内——两样都对 | -/// | `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) fn of(tier: VersionRailTier, state: &PanelState<'_>) -> Self { - match (tier, state) { - (VersionRailTier::Chips, PanelState::Options(_) | PanelState::Install(_)) => { - Self::Shared - } - // M222 D30 r1 S1 / r2 S7:**带加载器版本列表**的选项区在任何档位都太高。 - // 三栏档(1180×760)实测:修前面板 204→372px,版本列表 6.0 行(278px)被压到 - // 2.4 行(110px,底缘切在第三行图标上);改成 Shared 之后版本列表 **245px = 5.29 行**、 - // 面板 237px(分隔线 y=498)。面板因此变矮,实例名会被挤出首屏 ⇒ 同一轮把它 - // **钉成面板页脚**(`install::panel_with` 的 `shared` 分支 + `options::section`)。 - // 仍欠一项:245px 不是行高整数倍,列表底缘仍切在第 6 行中间(S1b/S9)。 - // 修它要把「分给列表的高度」从 `pages/download.rs` 传进来做 floor 取整 - // (`responsive` 在 Fill 子项上拿不到确定高度,实测无效,已回退)。 - (_, PanelState::Options(options)) if options.has_loader_versions() => Self::Shared, - _ => Self::Pinned, - } - } -} - /// 搜索条 + 清空按钮 + 命中数(三者一行;命中数只在有查询词时出现)。 pub(super) fn search_row<'a>( ui: &'a Ui, @@ -288,6 +250,7 @@ fn list<'a>( catalog: Catalog, model: &'a Download, visible: &[&'a VersionRow], + height: Option, ) -> Element<'a, Message> { let entries = column( visible @@ -306,7 +269,8 @@ fn list<'a>( .scroller_width(frame::SCROLLBAR_WIDTH) .margin(frame::SCROLLBAR_MARGIN), )) - .height(Length::Fill) + // `Some` = 三栏档按**整行**取高(见 `tail::whole_rows`);`None` = chips 档照旧 Fill。 + .height(height.map_or(Length::Fill, Length::Fixed)) .style(frame::scroll_style(ui)) .into() } diff --git a/crates/app-iced/src/pages/download/rows/tail.rs b/crates/app-iced/src/pages/download/rows/tail.rs new file mode 100644 index 0000000..37cc802 --- /dev/null +++ b/crates/app-iced/src/pages/download/rows/tail.rs @@ -0,0 +1,79 @@ +//! 「列表与尾巴面板怎么分高」+ 版本行的整行取整(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`]:面板按内容高钉在底部,列表吃剩下的 | 竖栏档在 760 高的窗口里列表还剩 158px(3 整行),面板整块在屏内——两样都对 | +/// | `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(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 S1 / r2 S7:**带加载器版本列表**的选项区在任何档位都太高。 + // 三栏档(1180×760)实测:修前面板 204→372px,版本列表 6.0 行(278px)被压到 + // 2.4 行(110px,底缘切在第三行图标上);改成 Shared 之后版本列表 **245px = 5.29 行**、 + // 面板 237px(分隔线 y=498)。面板因此变矮,实例名会被挤出首屏 ⇒ 同一轮把它 + // **钉成面板页脚**(`install::panel_with` 的 `shared` 分支 + `options::section`)。 + // 仍欠一项:245px 不是行高整数倍,列表底缘仍切在第 6 行中间(S1b/S9)。 + // 修它要把「分给列表的高度」从 `pages/download.rs` 传进来做 floor 取整 + // (`responsive` 在 Fill 子项上拿不到确定高度,实测无效,已回退)。 + (_, PanelState::Options(options)) if options.has_loader_versions() => Self::Shared, + _ => Self::Pinned, + } + } +} + +/// 版本行的**实测节距**(D30:`04` 图列表 278px / 6 行 = 46.33px)。 +/// +/// 列表是 `Fill + scrollable` 时,视口高度不是节距整数倍就会切在某一行中间——D30 r2 实测 +/// `01` 是 245px(5.3 行),英文态露出 6px 竖干、中文态露出「预览版」上半截。三栏档的 +/// 「列表与面板对半分」那一档现在按整行取高([`whole_rows`]),底缘落在行边界上。 +pub(crate) const ROW_PITCH: f32 = 278.0 / 6.0; + +/// 列表列内部、列表以外要扣掉的固定占位(搜索条 + 分隔线 + 表头 + 列间距)。 +/// +/// D30 标定:1180×760 的正文列高([`frame::body_height`](crate::pages::frame::body_height)) +/// 扣掉它再对半分,正好是 5 整行。**改搜索条 / 表头高度时要同步这个数。** +pub(crate) const LIST_CHROME: f32 = 93.0; + +/// 正文列高度 → 列表视口的**整行高度**(只给三栏档的 Shared 分支用)。 +#[must_use] +pub(crate) fn whole_rows(column_height: f32) -> f32 { + let half = ((column_height - LIST_CHROME) / 2.0).max(ROW_PITCH); + (half / ROW_PITCH).floor() * ROW_PITCH +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index d444850..bba5cb1 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -232,7 +232,13 @@ fn render<'a>( let page = match model.screen { Screen::Instances => instances::view(ui, metrics, model, launch_form), // 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` // M161:内容库要按**窗口高度**选版面(矮窗口不能把列表压扁), // 正文可用高由 `frame::body_height` 从窗口高减掉外壳算出(唯一来源)。 From 0ca28d24ff1cb52d2208c6cbcf1b33d13fbdc18b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 15:54:05 +0800 Subject: [PATCH 15/22] =?UTF-8?q?fix(app-iced):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E8=A1=8C=E8=8A=82=E8=B7=9D/=E5=88=97=E8=A1=A8=20chrome=20?= =?UTF-8?q?=E6=A0=87=E5=AE=9A=E9=87=8D=E7=AE=97=EF=BC=8844/97=EF=BC=89+=20?= =?UTF-8?q?=E4=B8=AD=E6=80=A7=E8=AF=B4=E6=98=8E=E6=8C=AA=E5=88=B0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=8F=9C=E5=8D=95=E4=B9=8B=E5=90=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三审给了独立量测,两处都按它的数字收敛: 1. `ROW_PITCH` 278/6=46.33 → **44.0**(278 里含列表与面板之间的 8px `Space::X2`); `LIST_CHROME` 93 → **97**(列顶 156 → 列表顶 253)。1180×760 分隔线 492 → **481**、 列表视口 **220px = 5 整行**;1180×900 加测 = 308px = 7 整行,三审报的 900 高残影不复发。 2. S7 钉页脚引入的新问题:面板上半块的滚动切点压在字形上(宽窗切「加载器版本」标签上沿、 窄窗把 chips 切成 4px 条)。把中性说明从菜单**之前**移到**之后**——它两行高,排前面正是把 唯一的加载器专属控件顶出首屏的元凶。 顺带纠正一处文档:面板结构不是「头部 + 可滚上半 + 钉页脚」,head(版本 id + 安装按钮)是上半块 的第一个子项、**安装按钮也在滚动区**,只有实例名是钉死的。 --- .../src/pages/download/options/view.rs | 23 +++++++++++-------- .../app-iced/src/pages/download/rows/tail.rs | 14 ++++++----- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index 260aff4..4b3fd6f 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -108,16 +108,6 @@ pub(crate) fn section<'a>( // 中性说明(M222):只解释这一页能装到哪一层,不套错误色、不影响确认键。 // 换行由 iced 的文本布局负责,长句不会横向溢出;但**视觉验收仍需截图确认**(D30)。 - if let Some(note) = &options.note { - body = body.push(frame::text_role( - ui, - TypeRole::BodySmall, - false, - note.as_str(), - ui.color(Role::OnSurfaceVariant), - )); - } - // 「加载器版本」这一段只在**真该有列表**时出现:原版没有、静态不兼容没有(原因在下面那行)、 // 取不到也没有(同样在原因行)。留一个空标题只会让人以为界面缺了一块。 if show_versions(options) { @@ -131,6 +121,19 @@ pub(crate) fn section<'a>( body = body.push(version_list(ui, catalog, options)); } + // 中性说明排在**版本菜单之后**(D30 三审 S7-clip):说明两行高,排在菜单前面会把 + // 「加载器版本」这个本页唯一的加载器专属控件顶出首屏,而面板上半块是滚动区,切点会落在 + // 字形上。挪到菜单后面之后 1180×760 首屏能装下 head + chips + 标签 + 菜单,切点不再切字。 + if let Some(note) = &options.note { + body = body.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + note.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + if let Some(problem) = &options.problem { body = body.push(frame::text_role( ui, diff --git a/crates/app-iced/src/pages/download/rows/tail.rs b/crates/app-iced/src/pages/download/rows/tail.rs index 37cc802..8bff2f2 100644 --- a/crates/app-iced/src/pages/download/rows/tail.rs +++ b/crates/app-iced/src/pages/download/rows/tail.rs @@ -58,18 +58,20 @@ impl TailShare { } } -/// 版本行的**实测节距**(D30:`04` 图列表 278px / 6 行 = 46.33px)。 +/// 版本行的**实测节距**(D30 三审:`04` 图行内下载图标的行首 265/309/353/397/441/485, +/// 节距恰 **44.0**;首行选中高亮矩形 253–296 也是 44px)。 /// /// 列表是 `Fill + scrollable` 时,视口高度不是节距整数倍就会切在某一行中间——D30 r2 实测 /// `01` 是 245px(5.3 行),英文态露出 6px 竖干、中文态露出「预览版」上半截。三栏档的 /// 「列表与面板对半分」那一档现在按整行取高([`whole_rows`]),底缘落在行边界上。 -pub(crate) const ROW_PITCH: f32 = 278.0 / 6.0; +pub(crate) const ROW_PITCH: f32 = 44.0; -/// 列表列内部、列表以外要扣掉的固定占位(搜索条 + 分隔线 + 表头 + 列间距)。 +/// 列表以外、列内要扣掉的固定占位(**列顶 156 → 列表顶 253**,D30 三审实测 = 97)。 /// -/// D30 标定:1180×760 的正文列高([`frame::body_height`](crate::pages::frame::body_height)) -/// 扣掉它再对半分,正好是 5 整行。**改搜索条 / 表头高度时要同步这个数。** -pub(crate) const LIST_CHROME: f32 = 93.0; +/// 1180×760:正文列 580 ⇒ floor((580−97)/2 / 44) × 44 = **220px = 5 整行**,分隔线落在 481。 +/// 1180×900:floor((720−97)/2 / 44) × 44 = **308px = 7 整行**(三审在 900 高实测过:旧标定 +/// 会在 y529–530 复发残影,新标定不复发)。**改搜索条 / 表头高度时要同步这个数。** +pub(crate) const LIST_CHROME: f32 = 97.0; /// 正文列高度 → 列表视口的**整行高度**(只给三栏档的 Shared 分支用)。 #[must_use] From 6eb7132ab067204e540bc1be795a142ad38c8935 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 17:41:55 +0800 Subject: [PATCH 16/22] =?UTF-8?q?fix(app-iced):=20=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=99=A8=E7=89=88=E6=9C=AC=E8=8F=9C=E5=8D=95=E6=90=AC=E8=BF=9B?= =?UTF-8?q?=E9=92=89=E6=AD=BB=E5=8C=BA=EF=BC=88=E8=A3=81=E5=88=87=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E7=94=B1=E9=92=89=E6=AD=BB=E5=8C=BA=E9=AB=98=E5=BA=A6?= =?UTF-8?q?=E5=86=B3=E5=AE=9A=EF=BC=8C=E4=B8=8D=E6=98=AF=E9=A6=96=E5=B1=8F?= =?UTF-8?q?=E5=88=86=E9=85=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四审点破根因:面板的裁切 y 由**钉死区高度**决定、与窗宽无关(1180 与 720 都切在同一处), 所以上一轮「把说明挪到菜单之后」只是换了被切的对象——菜单容器 642–706 只露 18px、条目文字 一个都没画,说明整块不可见。 按 tower 裁决改成结构方案: - **滚动区**:head(版本 id + 安装按钮)+ 加载器 chips + 中性说明 + 错误行; - **钉死区**:加载器版本菜单(标签 + 菜单,按条目数占高)+ 实例名输入框。 菜单不再依赖首屏剩余高度分配,可达性由结构保证;溢出时被滚走的是说明/错误行(非控件)。 实测(未变的部分也一并核对):01/02/05 分隔线 **481**、列表 **220px = 5 整行**;04 对照 **531 未变**; 03/06 窄窗 **268 未变**(本轮结构改动没有挤动列表);900 高加测 569 ⇒ **308px = 7 整行**。 文档按四审改正:`section` 的文档不再写「首屏能装下菜单/切点不再切字」,改成「裁切位置由钉死区 高度决定」;面板结构写清 head 在滚动区、菜单与实例名在钉死区。 --- .../src/pages/download/options/view.rs | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index 4b3fd6f..9d08cdb 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -66,12 +66,17 @@ pub(super) fn version_menu_height(items: usize) -> f32 { rows * ITEM + Space::X2.dp() * 2.0 } -/// 选项区(面板的下半块):**可滚的上半块 + 钉死的页脚**。 +/// 选项区(面板的下半块):**可滚的上半块 + 钉死的下半块**。 /// -/// `scroll` = 面板此刻是不是「自己可滚」的那一档(`rows::TailShare::Shared`)。是的话上半块 -/// 收进 `scrollable` 吃 `Fill`,**实例名那一格留在页脚、永远在屏**——这条是 D30 r2 S7 的修法: -/// 面板变高(多了加载器版本列表)之后,原先实例名会被挤到首屏外(1180×760 实测要滚 1 屏), -/// 钉成页脚之后可达性由**结构**保证,不依赖滚轮(`install.rs` 模块文档里登记的方案)。 +/// `scroll` = 面板此刻是不是「自己可滚」的那一档(`rows::TailShare::Shared`)。是的话: +/// +/// - **滚动区**:head(版本 id + 安装按钮)+ 加载器 chips + 中性说明 + 错误行; +/// - **钉死区**:加载器版本菜单 + 实例名输入框。 +/// +/// 为什么菜单也要钉(D30 四审):裁切位置由**钉死区高度**决定,与窗宽无关(1180 与 720 都切在 +/// 同一个 y)。之前只钉实例名,菜单就正好落在切线上——760 下菜单容器 642–706 只有 18px 可见、 +/// 条目文字一格都没画出来。把菜单一起钉住之后,它不再依赖首屏剩余高度分配,可达性由结构保证; +/// 滚动区溢出时滚的是说明/错误行,那是可以接受被滚走的内容。 pub(crate) fn section<'a>( ui: &'a Ui, catalog: Catalog, @@ -110,20 +115,8 @@ pub(crate) fn section<'a>( // 换行由 iced 的文本布局负责,长句不会横向溢出;但**视觉验收仍需截图确认**(D30)。 // 「加载器版本」这一段只在**真该有列表**时出现:原版没有、静态不兼容没有(原因在下面那行)、 // 取不到也没有(同样在原因行)。留一个空标题只会让人以为界面缺了一块。 - 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)); - } - - // 中性说明排在**版本菜单之后**(D30 三审 S7-clip):说明两行高,排在菜单前面会把 - // 「加载器版本」这个本页唯一的加载器专属控件顶出首屏,而面板上半块是滚动区,切点会落在 - // 字形上。挪到菜单后面之后 1180×760 首屏能装下 head + chips + 标签 + 菜单,切点不再切字。 + // 中性说明留在**滚动区**(D30 四审裁决):它两行高、且不是控件,溢出时被滚走可以接受; + // 加载器版本菜单是控件,已经搬到钉死区(见下面的 `pinned`),不再和它抢首屏高度。 if let Some(note) = &options.note { body = body.push(frame::text_role( ui, @@ -144,26 +137,37 @@ pub(crate) fn section<'a>( )); } - // 页脚:实例名(钉死,不参与滚动)。见 `section` 的文档。 - let footer = text_field( + // 钉死区:加载器版本菜单 + 实例名(都不参与滚动)。见 `section` 的文档。 + let mut pinned = column![].spacing(ui.space(Space::X2)).width(Length::Fill); + if show_versions(options) { + pinned = pinned.push(frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::DownloadOptionsLoaderVersion), + ui.color(Role::OnSurfaceVariant), + )); + 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, - ); + )); if !scroll { // 钉死档(三栏 + 无版本列表 / chips 以外的矮面板):行为与以前一致,一列内容到底。 - body = body.push(Element::from(footer)); + body = body.push(Element::from(pinned)); return body.into(); } column![ scrollable(body) .height(Length::Fill) .style(frame::scroll_style(ui)), - footer, + pinned, ] .spacing(ui.space(Space::X2)) .height(Length::Fill) From cb2cffb3caf27489b598023ddad72e4e96e02c04 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 17:58:05 +0800 Subject: [PATCH 17/22] =?UTF-8?q?fix(app-iced,i18n):=20=E5=88=86=E9=AB=98?= =?UTF-8?q?=E6=94=B9=E3=80=8C=E9=9D=A2=E6=9D=BF=E5=85=88=E4=BF=9D=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E3=80=8D+=20=E8=AF=B4=E6=98=8E=E5=8E=8B=E6=88=90?= =?UTF-8?q?=E5=8D=95=E8=A1=8C=EF=BC=88D30=20=E4=BA=94=E5=AE=A1=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E6=80=A7=E9=87=8D=E5=88=86=E9=85=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 五审:菜单文字/720 列表/44-97 标定/04 都过,但滚动区被压到 82px(宽)/24px(窄)——宽档切 「安装选项」字身、六枚 chip 0 像素。塔裁决停止逐轮调顺序,一次性重分配: 1. **分高从「对半分」改成「面板先保预算」**:`PANEL_BUDGET = 300`(滚动区 128 + 钉死区 136), `whole_rows = floor((column − LIST_CHROME − PANEL_BUDGET)/44) × 44`。 1180×760:580−97−300 = 183 ⇒ **176px = 4 整行**(原 5 行),面板 307px ⇒ 滚动区 ≈150px; 1180×900:323 ⇒ **308px = 7 整行**(未变)。720 档走 `None`(Fill),未动。 2. **说明压成单行**(`DownloadOptionsLiteLoaderVanillaOnly`):两行 28px 是滚动区最大的可压项。 en `Vanilla parent only — Forge goes through the CLI` / zh `只叠原版;叠 Forge 请走命令行`; 完整命令行说法留在 `ull help install`。单测断言随之改成「含该句 + 只有一行」并写明原因。 文档按六审要求改口:滚走的是说明/错误行(非控件);说明变单行后 760 档滚动区已能装下 head+标签+chips+单行说明,**无需滚动**。 --- .../src/pages/download/options/tests.rs | 11 +++++------ .../app-iced/src/pages/download/options/view.rs | 3 ++- crates/app-iced/src/pages/download/rows/tail.rs | 17 +++++++++++++++-- crates/i18n/src/catalog/download.rs | 4 ++-- crates/i18n/src/catalog/golden/tail_ai.rs | 2 +- 5 files changed, 25 insertions(+), 12 deletions(-) diff --git a/crates/app-iced/src/pages/download/options/tests.rs b/crates/app-iced/src/pages/download/options/tests.rs index f0b26f7..6084dbd 100644 --- a/crates/app-iced/src/pages/download/options/tests.rs +++ b/crates/app-iced/src/pages/download/options/tests.rs @@ -291,11 +291,10 @@ fn liteloader_carries_a_neutral_note_without_blocking_confirm() { Lang::En.catalog(), ); let note = options.note.as_deref().expect("LiteLoader 应带中性说明"); - assert!(note.contains("vanilla"), "{note}"); - assert!( - note.contains("--loader liteloader"), - "要给出可执行的下一步:{note}" - ); + // 文案在 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, "说明不该禁用确认键"); @@ -312,7 +311,7 @@ fn liteloader_carries_a_neutral_note_without_blocking_confirm() { Lang::ZhCn.catalog(), ); let note = options.note.as_deref().expect("中文也要有说明"); - assert!(note.contains("原版"), "{note}"); + assert!(note.contains("只叠原版"), "{note}"); assert!(options.can_confirm); // 别家加载器不带这条说明。 diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index 9d08cdb..5d8ec8b 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -76,7 +76,8 @@ pub(super) fn version_menu_height(items: usize) -> f32 { /// 为什么菜单也要钉(D30 四审):裁切位置由**钉死区高度**决定,与窗宽无关(1180 与 720 都切在 /// 同一个 y)。之前只钉实例名,菜单就正好落在切线上——760 下菜单容器 642–706 只有 18px 可见、 /// 条目文字一格都没画出来。把菜单一起钉住之后,它不再依赖首屏剩余高度分配,可达性由结构保证; -/// 滚动区溢出时滚的是说明/错误行,那是可以接受被滚走的内容。 +/// 滚动区溢出时滚走的是**说明/错误行**(非控件);D30 六审把说明压成单行之后, +/// 1180×760 下滚动区 ≈150px,head + 标签 + 六枚 chip + 单行说明全在首屏、**无需滚动**。 pub(crate) fn section<'a>( ui: &'a Ui, catalog: Catalog, diff --git a/crates/app-iced/src/pages/download/rows/tail.rs b/crates/app-iced/src/pages/download/rows/tail.rs index 8bff2f2..107b554 100644 --- a/crates/app-iced/src/pages/download/rows/tail.rs +++ b/crates/app-iced/src/pages/download/rows/tail.rs @@ -73,9 +73,22 @@ pub(crate) const ROW_PITCH: f32 = 44.0; /// 会在 y529–530 复发残影,新标定不复发)。**改搜索条 / 表头高度时要同步这个数。** pub(crate) const LIST_CHROME: f32 = 97.0; +/// 面板(滚动区 + 钉死区)的**最小预算**。 +/// +/// 滚动区要装下 head 40 + 标签 16 + 六枚 chips 32 + 说明单行 20 + 间距约 20 ≈ 128; +/// 钉死区 = 菜单 64 + 实例名 56 + 间距 16 ≈ 136。取 300 是两者之和的整档。 +/// 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 half = ((column_height - LIST_CHROME) / 2.0).max(ROW_PITCH); - (half / ROW_PITCH).floor() * ROW_PITCH + let room = (column_height - LIST_CHROME - PANEL_BUDGET).max(ROW_PITCH); + (room / ROW_PITCH).floor() * ROW_PITCH } diff --git a/crates/i18n/src/catalog/download.rs b/crates/i18n/src/catalog/download.rs index ea02d03..6e3ee35 100644 --- a/crates/i18n/src/catalog/download.rs +++ b/crates/i18n/src/catalog/download.rs @@ -102,8 +102,8 @@ macro_rules! download_entries { // M222:下载页只做「原版 + 加载器」一层,Forge 父链只有命令行能走。中性说明, // 不是错误(不禁用确认键、不套错误色)——所以它不走 `problem` 那条通道。 DownloadOptionsLiteLoaderVanillaOnly => ( - "LiteLoader stacks on vanilla only — for Forge, run: ull install --loader liteloader", - "LiteLoader 只叠在原版上 —— 要叠 Forge 请运行:ull install --loader liteloader" + "Vanilla parent only — Forge goes through the CLI", + "只叠原版;叠 Forge 请走命令行" ), DownloadCancelled => ( "Install cancelled — finished files are kept, the instance was not created", diff --git a/crates/i18n/src/catalog/golden/tail_ai.rs b/crates/i18n/src/catalog/golden/tail_ai.rs index a60e932..9cd5dcc 100644 --- a/crates/i18n/src/catalog/golden/tail_ai.rs +++ b/crates/i18n/src/catalog/golden/tail_ai.rs @@ -106,7 +106,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("DownloadOptionsInstanceNameHint", "Empty = use the version id", "留空就用版本号"), ("DownloadOptionsIncompatible", "{loader} has no builds for {version}", "{loader} 没有适配 {version} 的构建"), ("DownloadOptionsNeedsAtLeast", "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新"), - ("DownloadOptionsLiteLoaderVanillaOnly", "LiteLoader stacks on vanilla only — for Forge, run: ull install --loader liteloader", "LiteLoader 只叠在原版上 —— 要叠 Forge 请运行:ull install --loader liteloader"), + ("DownloadOptionsLiteLoaderVanillaOnly", "Vanilla parent only — Forge goes through the CLI", "只叠原版;叠 Forge 请走命令行"), ("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"), ("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"), ("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"), From 7ea3dbf8f095746ff24c26400ded742570365b29 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 18:12:34 +0800 Subject: [PATCH 18/22] =?UTF-8?q?fix(app-iced):=20=E6=96=B9=E6=A1=88=20B?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=AF=B4=E6=98=8E=E4=B8=8E=E3=80=8C=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E9=80=89=E9=A1=B9=E3=80=8D=E6=A0=87=E7=AD=BE=E6=92=A4?= =?UTF-8?q?=E5=87=BA=E6=BB=9A=E5=8A=A8=E5=8C=BA=EF=BC=8C=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E5=8C=BA=E5=8F=AA=E8=A3=85=E6=8E=A7=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 六审算术:滚动视口 103px < 内容 130px,说明 0 像素 + chip 底边框被裁 2 行。方案 C(标签并排) 不可行——六枚 chip 已占 404px、正文列 424px,并排必横向溢出裁掉第六枚,等于用一个已完成项 换另一个。按塔授权走 B: - **滚动区**(仅 Shared 档):head + 六枚 chips,间距 X2→X1; - **钉死区**:说明行(有说明时占「加载器版本」标签那一行,否则显示标签)+ 版本菜单 + 实例名。 钉死区高度不变(两者都是一行),列表仍 4 行; - **钉死档保持旧结构**:把标签撤出会让原版面板矮 26px、列表涨到 302px(6.86 行、切出半行), 实测把 `04` 从 531 挪到 563 —— 第一版就是这个坑,已按此回退。 实测:01/02/05 分隔线 437、列表 **176px = 4.00 整行**、说明墨迹带 y 576–587(真像素); 04 **531 未变**;03/06 268 未变;900 档 569 / 308px = 7.00 整行。 --- .../src/pages/download/options/view.rs | 110 ++++++++++++++---- 1 file changed, 86 insertions(+), 24 deletions(-) diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index 5d8ec8b..ed13569 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -98,19 +98,73 @@ pub(crate) fn section<'a>( .width(Length::Fill) .align_y(Alignment::Center); - let mut body = column![ - head, - frame::text_role( + // 钉死档(原版 / 没有加载器版本列表)**保持旧结构**:标签与说明都在列内。 + // 为什么不一并改:`04` 是别处基线图的对照,它的面板矮、列表 6 行——把标签撤出会 + // 让面板矮 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, - TypeRole::LabelMedium, - false, - catalog.text(Key::DownloadOptionsTitle), - ui.color(Role::OnSurfaceVariant), - ), - loader_row(ui, catalog, options), - ] - .spacing(ui.space(Space::X2)) - .width(Length::Fill); + Field::new(options.instance_name.as_str()) + .variant(FieldVariant::Outlined) + .label(catalog.text(Key::DownloadOptionsInstanceName)) + .placeholder(catalog.text(Key::DownloadOptionsInstanceNameHint)), + Message::SetInstanceName, + )); + return plain.into(); + } + + // 滚动区只剩 head + 六枚 chips(方案 B,D30 六审裁决)。 + // + // 为什么把「安装选项」标签与中性说明都撤出滚动区: + // 五审 82px → 六审 103px 的视口里,head(40) + 标签(17) + chips(32) + 两处间距(16) = 105px, + // 差 2px 就把 chip 的底边框裁掉;再塞说明(20px)就差 27~29px,说明整块 0 像素。 + // 并排标签(方案 C)在这里不可行:六枚 chip 实测已占 404px,正文列只有 424px, + // 加上「安装选项」必然横向溢出、把第六枚裁掉——那是拿一个已完成项换另一个。 + // 所以按塔授权的 B 落地:标签与说明都搬到钉死区(见下面的 `pinned`),滚动区只装控件。 + let mut body = column![head, loader_row(ui, catalog, options)] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); // 中性说明(M222):只解释这一页能装到哪一层,不套错误色、不影响确认键。 // 换行由 iced 的文本布局负责,长句不会横向溢出;但**视觉验收仍需截图确认**(D30)。 @@ -141,13 +195,26 @@ pub(crate) fn section<'a>( // 钉死区:加载器版本菜单 + 实例名(都不参与滚动)。见 `section` 的文档。 let mut pinned = column![].spacing(ui.space(Space::X2)).width(Length::Fill); if show_versions(options) { - pinned = pinned.push(frame::text_role( - ui, - TypeRole::LabelMedium, - false, - catalog.text(Key::DownloadOptionsLoaderVersion), - ui.color(Role::OnSurfaceVariant), - )); + // 菜单上方那一行:**有中性说明就显示说明**(它讲的是「这一家的叠法」, + // 位置就在版本菜单正上方,语义对得上),没有说明时才退回通用标签。 + // 高度相同(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( @@ -159,11 +226,6 @@ pub(crate) fn section<'a>( Message::SetInstanceName, )); - if !scroll { - // 钉死档(三栏 + 无版本列表 / chips 以外的矮面板):行为与以前一致,一列内容到底。 - body = body.push(Element::from(pinned)); - return body.into(); - } column![ scrollable(body) .height(Length::Fill) From e2e3c39f61db788b3f97725257ba1ed9ee8e8762 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 18:16:16 +0800 Subject: [PATCH 19/22] =?UTF-8?q?docs(app-iced):=20=E6=89=AB=E6=8E=89=20r5?= =?UTF-8?q?=20=E7=9A=84=E4=BA=94=E5=A4=84=E6=96=87=E6=A1=A3=E6=BC=82?= =?UTF-8?q?=E7=A7=BB=EF=BC=88=E8=BF=94=E5=B7=A5=E9=93=BE=E7=95=99=E4=B8=8B?= =?UTF-8?q?=E7=9A=84=E6=97=A7=E6=8F=8F=E8=BF=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. install.rs 模块文档:删掉「钉页脚本轮没做」(6f87c8b 已做:钉死区现在是菜单 + 实例名), 三层滚动的第 3 层改引 `version_menu_height`(VERSION_ROWS 已改),并写明「选项区自己滚」 这层是 M222 之后才有的、`Options` 态不再套外层 scrollable(只有 `Install` 态用)。 2. rows.rs:五个 loader chip → 六枚;「实例名那一格滚一下就到」→ 已钉死;分法描述改成 「面板先保预算 + 列表按整行」(PANEL_BUDGET / whole_rows)。 3. rows/tail.rs 的 TailShare 表:两半各 Fill → 面板先吃预算;补上「带加载器版本列表」这一例外行; 判据注释改成「像素由 window_height 算」(view_sized 已接)。 4. rows/tail.rs 的 M222 分支注释:删「仍欠一项 S1b」(107d619 已修、26ec3f4 已换预算公式), 写清现在 1180×760 = 176px/4 行、900 = 308px/7 行。 5. options/view.rs 控件树示意:改成当前真实顺序(滚动区 = head + chips;钉死区 = 说明/标签 + 菜单 + 实例名),并补上钉死档另走一条路的说明。 --- crates/app-iced/src/pages/download/install.rs | 26 +++++++++++-------- .../src/pages/download/options/view.rs | 17 +++++++----- crates/app-iced/src/pages/download/rows.rs | 8 +++--- .../app-iced/src/pages/download/rows/tail.rs | 25 ++++++++---------- 4 files changed, 41 insertions(+), 35 deletions(-) diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs index e985a1e..bd97b5d 100644 --- a/crates/app-iced/src/pages/download/install.rs +++ b/crates/app-iced/src/pages/download/install.rs @@ -14,22 +14,26 @@ //! (`rows::TailShare::Shared`)——但只在**面板真的占高度**时(`Options` / `Install`); //! 只画一行提示的 `Hint` 态仍钉死,否则 8 个版本的列表会被砍到 4 行(r1 复审 P2-1)。 //! -//! ## 已知风险:chips 档最多会同时出现**三层**滚动(r1 复审 P3-2,未验) +//! ## 面板的滚动分层(M222 D30 六轮打磨后的实际形态) //! //! ```text -//! 版本行列表 scrollable ← 第 1 层(任何档位都在) -//! └ 面板 scrollable(chips 档才有) ← 第 2 层 -//! └ 加载器版本 menu 的 scrollable ← 第 3 层(`options/view.rs` 的 `VERSION_ROWS`) +//! 版本行列表 scrollable ← 第 1 层(任何档位都在) +//! └ 选项区 scrollable(Shared 档才有) ← 第 2 层(`options/view.rs::section`,只装 head + chips) +//! └ 加载器版本 menu 的 scrollable ← 第 3 层(`options/view.rs::version_menu_height`) //! ``` //! -//! **鼠标落在第 3 层上滚轮会不会冒泡到第 2 层,本仓没有实测通道**(静态图拍不到滚动嵌套)。 -//! iced 的 `scrollable` 在指针悬停于内层时由内层消费滚动,所以「滚到底之后继续滚会带动外层」 -//! 属于**未验证行为**;两个方向的坏结果分别是「面板卡住滚不动」与「一滚就跳到外层」。 +//! **选项区自己滚**这一层是 M222 S7/S1b 之后才有的:`rows::content` 对 `PanelState::Options` +//! 不再整块套外层 `scrollable`(只有 `Install` 态还用外层那层)。 //! -//! 缓解方案(本轮**没做**,理由:不是一处小改)——把「实例名」那一格从面板的滚动区里移出去, -//! 做成面板的固定页脚:那要把 `options::section` 拆成「可滚的上半块 + 钉死的下半块」, -//! 动 `options/view.rs` / `install.rs` / `rows.rs` 三处,而且改完仍然**无法**验证滚轮冒泡 -//! (没有通道),只是把风险从「某一格被挡」换成「面板永不挡那一格」。所以先登记为已知风险。 +//! **鼠标落在内层上滚轮会不会冒泡到外层,本仓仍没有实测通道**(静态图拍不到滚动嵌套)。 +//! iced 在指针悬停于内层时由内层消费滚动,所以「滚到底之后继续滚会带动外层」属**未验证行为**; +//! 两个方向的坏结果分别是「面板卡住滚不动」与「一滚就跳到外层」。 +//! +//! ## 钉死页脚**已经做了**(M222 `6f87c8b` 起) +//! +//! `options::section` 现在是「可滚的上半块 + 钉死的下半块」:钉死区内是**加载器版本菜单 + +//! 实例名输入框**(D30 四审:菜单也钉住,否则它正好落在裁切线上)。所以「实例名被滚走」 +//! 这个风险已经消除,只剩上面那条滚轮冒泡未验。 //! //! ## 只有意图,没有乐观回显 //! diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index ed13569..d0eed27 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -3,18 +3,21 @@ //! ## 版面(424 正文列里的四段) //! //! ```text -//! 1.21.9 [安装] -//! 安装选项 +//! ── 滚动区(Shared 档)──────────────────────────── +//! 1.12.2 [安装] //! [原版][Forge][NeoForge][Fabric][Quilt][LiteLoader] ← 六枚一行 -//! LiteLoader 只叠在原版上 —— 要叠 Forge 请运行:… ← 中性说明(只有 LiteLoader 有) -//! 加载器版本 ← 只在选了加载器时出现 -//! ┌ 0.16.5 ┐ ← 官方那份列表(menu + 滚动,最多看两行项) -//! │ 0.16.4 │ +//! ── 钉死区 ────────────────────────────────────── +//! Vanilla parent only — Forge goes through the CLI ← 说明行;没有说明时是「加载器版本」标签 +//! ┌ 1.12.2-SNAPSHOT ┐ ← 官方那份列表(menu + 滚动,按条目数占高:1 条 64 / 2 条 112) //! └───────────────────┘ //! [实例名 ] -//! NeoForge 需要 Minecraft 1.20.1 或更新 ← 不兼容时(Error 色) //! ``` //! +//! - **钉死档**(原版等不带版本列表的面板)走另一条路:标签、说明、菜单、实例名都在同一列里 +//! 顺序铺开(`04` 基线图就是这一档,不能改); +//! - 不兼容时的原因行(Error 色)在滚动区末尾; +//! - 说明只有 LiteLoader 有(`Key::DownloadOptionsLiteLoaderVanillaOnly`)。 +//! //! ## 为什么加载器是**六枚一行**(M222 D30 r1 实测订正) //! //! 这里原先写「五家一起排会溢出 ⇒ 折成两行」,那是 M105 的推算。D30 的实测图(1180×760, diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index 4ad46f6..507b691 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -25,12 +25,14 @@ //! 可视高(行高 44),版本号一个字都读不出来,用户想换版本点不到。 //! //! 病根是 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。 //! //! 修法是 [`TailShare`] 两档:三栏档照旧钉死(面板整块在屏内),chips 档**且面板真的占高度** -//! (选项区展开 / 安装中)时改成两半各 `Fill`——列表拿到 ≈182px(**4 整行**),面板拿到同样一半、 -//! **自己可滚**(顶端那个实心「安装」按钮与五个 loader chip 仍在屏内,实例名那一格滚一下就到)。 +//! (选项区展开 / 安装中)时改成「**面板先保预算、剩下按整行给列表**」 +//! (`tail::whole_rows` + `PANEL_BUDGET = 300`):1180×760 下列表 **176px = 4 整行**, +//! 面板拿走其余(≈307px)。实例名那格**已经钉死**在面板底部、不需要滚(M222 `6f87c8b`)。 //! 「刚进页面还没选版本」(`Hint`,面板只有一行提示)**不共享**:那一态 main 本来就有 //! **7 整行**在屏(不是 8:第 8 条墨迹其实是 `Pick a version, then install` 那句 hint, //! 横向范围对不上版本号;700×760 上放 8 行要 352px,实测只剩 309~322px),五五分会砍成 diff --git a/crates/app-iced/src/pages/download/rows/tail.rs b/crates/app-iced/src/pages/download/rows/tail.rs index 107b554..8bc9465 100644 --- a/crates/app-iced/src/pages/download/rows/tail.rs +++ b/crates/app-iced/src/pages/download/rows/tail.rs @@ -13,18 +13,18 @@ use crate::pages::download::install::PanelState; /// /// | 档 | 分法 | 为什么 | /// |---|---|---| -/// | `Wide` / `Narrow` / `Compact` | [`TailShare::Pinned`]:面板按内容高钉在底部,列表吃剩下的 | 竖栏档在 760 高的窗口里列表还剩 158px(3 整行),面板整块在屏内——两样都对 | -/// | `Chips` + 面板**占高度**(选项区展开 / 安装中) | [`TailShare::Shared`]:两半各 `Fill`,面板自己可滚 | chips 档比三栏档多花两行 chip(tabs 48 + chips 48 + 间距),列表只剩 **18px**(M123 实测 0.4 行),面板 316px 全在屏内也没用——版本号读不出来就换不了版本 | +/// | `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) | /// -/// 判据是**档位**不是高度:页面的模型里只有 `window_width`(`pages/model.rs`),窗口高度 -/// 没有接进页面(要接得改 `pages/mod.rs` 的入口签名,超出本页的边界)。所以这里按「哪一档 -/// 的竖排 chrome 会把列表压没」来分,并在报告里登记这条判据的边界。 +/// 分高方式按「档位 + 面板形态」判;**具体像素**由窗口高度算(`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 档)。 + /// 面板先吃预算、列表按整行吃剩下的(带版本列表的选项区 / chips 档占高度时)。 Shared, } @@ -44,14 +44,11 @@ impl TailShare { (VersionRailTier::Chips, PanelState::Options(_) | PanelState::Install(_)) => { Self::Shared } - // M222 D30 r1 S1 / r2 S7:**带加载器版本列表**的选项区在任何档位都太高。 - // 三栏档(1180×760)实测:修前面板 204→372px,版本列表 6.0 行(278px)被压到 - // 2.4 行(110px,底缘切在第三行图标上);改成 Shared 之后版本列表 **245px = 5.29 行**、 - // 面板 237px(分隔线 y=498)。面板因此变矮,实例名会被挤出首屏 ⇒ 同一轮把它 - // **钉成面板页脚**(`install::panel_with` 的 `shared` 分支 + `options::section`)。 - // 仍欠一项:245px 不是行高整数倍,列表底缘仍切在第 6 行中间(S1b/S9)。 - // 修它要把「分给列表的高度」从 `pages/download.rs` 传进来做 floor 取整 - // (`responsive` 在 Fill 子项上拿不到确定高度,实测无效,已回退)。 + // 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, } From 33d05689e8e61d98107165a6317600f13220dc21 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 18:29:19 +0800 Subject: [PATCH 20/22] =?UTF-8?q?fix(app-iced):=20=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E5=8F=AA=E7=94=BB=E4=B8=80=E4=BB=BD=EF=BC=88=E5=88=A0=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E5=8C=BA=E9=87=8D=E5=A4=8D=EF=BC=89+=20=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E4=B8=89=E5=A4=84=E6=94=B9=E7=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 八审唯一阻断:中性说明画了两份(滚动区 546–557 与钉死区 576–587 像素级相同、重影)。 删掉滚动区那份,钉死区保留;01/02/05 与 900 的说明墨迹带现在只有一条(576–587 / 716–727)。 口径三处: 1. `section` 文档的滚动区内容改成实际(head + 六枚 chips + 错误行); 2. 旧的「≈150px / 无需滚动」改成八审实测(可见 96–104px、内容 76px、head 墨迹 462–501、 chips 506–537 四角闭合); 3. 钉死区注释「标签与说明都搬到钉死区」改成事实:Shared 档的「安装选项」标签是**删掉**的 (设计决定,八审裁决),只有说明是搬下去的;Pinned 档仍保留标签。 --- .../src/pages/download/options/view.rs | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index d0eed27..76cb0d9 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -73,14 +73,15 @@ pub(super) fn version_menu_height(items: usize) -> f32 { /// /// `scroll` = 面板此刻是不是「自己可滚」的那一档(`rows::TailShare::Shared`)。是的话: /// -/// - **滚动区**:head(版本 id + 安装按钮)+ 加载器 chips + 中性说明 + 错误行; -/// - **钉死区**:加载器版本菜单 + 实例名输入框。 +/// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips + 错误行; +/// - **钉死区**:说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框。 /// /// 为什么菜单也要钉(D30 四审):裁切位置由**钉死区高度**决定,与窗宽无关(1180 与 720 都切在 /// 同一个 y)。之前只钉实例名,菜单就正好落在切线上——760 下菜单容器 642–706 只有 18px 可见、 /// 条目文字一格都没画出来。把菜单一起钉住之后,它不再依赖首屏剩余高度分配,可达性由结构保证; -/// 滚动区溢出时滚走的是**说明/错误行**(非控件);D30 六审把说明压成单行之后, -/// 1180×760 下滚动区 ≈150px,head + 标签 + 六枚 chip + 单行说明全在首屏、**无需滚动**。 +/// 滚动区溢出时被滚走的是错误行(说明与菜单都在钉死区,不参与滚动)。 +/// D30 八审实测:滚动区可见高 ≈96–104px,内容(head 40 + chips 32 + 间距 4)≈76px ⇒ +/// 1180×760 下 head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。 pub(crate) fn section<'a>( ui: &'a Ui, catalog: Catalog, @@ -165,26 +166,16 @@ pub(crate) fn section<'a>( // 并排标签(方案 C)在这里不可行:六枚 chip 实测已占 404px,正文列只有 424px, // 加上「安装选项」必然横向溢出、把第六枚裁掉——那是拿一个已完成项换另一个。 // 所以按塔授权的 B 落地:标签与说明都搬到钉死区(见下面的 `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); - // 中性说明(M222):只解释这一页能装到哪一层,不套错误色、不影响确认键。 - // 换行由 iced 的文本布局负责,长句不会横向溢出;但**视觉验收仍需截图确认**(D30)。 + // 中性说明**只在钉死区画一份**(八审:这里原先也画了一份,两处像素级重影)。 // 「加载器版本」这一段只在**真该有列表**时出现:原版没有、静态不兼容没有(原因在下面那行)、 // 取不到也没有(同样在原因行)。留一个空标题只会让人以为界面缺了一块。 - // 中性说明留在**滚动区**(D30 四审裁决):它两行高、且不是控件,溢出时被滚走可以接受; - // 加载器版本菜单是控件,已经搬到钉死区(见下面的 `pinned`),不再和它抢首屏高度。 - if let Some(note) = &options.note { - body = body.push(frame::text_role( - ui, - TypeRole::BodySmall, - false, - note.as_str(), - ui.color(Role::OnSurfaceVariant), - )); - } - if let Some(problem) = &options.problem { body = body.push(frame::text_role( ui, @@ -198,8 +189,9 @@ pub(crate) fn section<'a>( // 钉死区:加载器版本菜单 + 实例名(都不参与滚动)。见 `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( From 409c742aa8224f85a946b357511a95f2faf5e35f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 18:52:02 +0800 Subject: [PATCH 21/22] =?UTF-8?q?docs(app-iced):=20=E6=89=AB=E6=8E=89=20r6?= =?UTF-8?q?=20=E7=9A=84=E4=B8=89=E5=A4=84=E6=96=87=E6=A1=A3=E6=AE=8B?= =?UTF-8?q?=E7=95=99=EF=BC=88B=20=E7=BB=93=E6=9E=84=E5=90=88=E5=85=A5?= =?UTF-8?q?=E5=89=8D=E6=9C=80=E5=90=8E=E4=B8=80=E6=94=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (a) install.rs 的 panel/panel_with 文档按当前结构重写:`shared` 不再是「对半分」(现为 面板先保预算 + 列表按整行,见 rows/tail.rs::whole_rows);滚动区实际只有 head + 六枚 chips (菜单 9358668 进钉死区、说明 a6f7974 进钉死区),钉死区 = 说明或标签 + 菜单 + 实例名; 并写明错误行在 Shared 档恒为 None(静态不兼容时不画版本菜单、走不滚动那条路)。 (b) options/view.rs 里「标签与说明都搬到钉死区」自相矛盾:改成「**说明**搬到钉死区; 『安装选项』标签在 Shared 档**删掉**(八审裁决),Pinned 档保留」。 (c) 同文件 section() 文档里「滚动区溢出时被滚走的是错误行」描述的是不可达情形:改成 「滚动区只有 head + chips,用户可见内容不会被滚走(错误行在 Shared 档恒为 None)」。 零代码零测试变更。 --- crates/app-iced/src/pages/download/install.rs | 13 ++++++++++--- crates/app-iced/src/pages/download/options/view.rs | 6 ++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs index bd97b5d..35656d0 100644 --- a/crates/app-iced/src/pages/download/install.rs +++ b/crates/app-iced/src/pages/download/install.rs @@ -110,9 +110,16 @@ pub(super) fn panel_state(model: &Download) -> PanelState<'_> { /// 底部的安装面板。 /// -/// `shared` = 这一档列表与面板对半分(`rows::TailShare::Shared`)。此时**选项区自己滚**: -/// 上半块(加载器 chip / 说明 / 版本列表 / 错误行)收进 `scrollable` 吃 `Fill`,实例名那一格 -/// 钉成页脚——D30 r2 S7:面板变高后实例名会被挤出首屏,钉住之后可达性由结构保证。 +/// `shared` = 选项区走「面板先保预算、列表按整行吃剩下的」那一档(`rows::TailShare::Shared` +/// 的带版本列表分支;分高规则见 `rows/tail.rs::whole_rows`,**不是**早期的「对半分」)。 +/// 此时选项区自己分成两段(`options::section`): +/// +/// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips; +/// - **钉死区**:说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框。 +/// +/// 菜单与说明分别是 D30 四审 / 八审搬进钉死区的(裁切位置由钉死区高度决定,与窗宽无关); +/// 错误行在 Shared 档恒为 `None`(`OptionsView::problem` 只在静态不兼容时出现,而那时 +/// 不画版本菜单、走的是不滚动的那一路),所以滚动区里只会有 head + chips 这两块。 pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> { panel_with(ui, catalog, model, false) } diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index 76cb0d9..4051f58 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -79,7 +79,8 @@ pub(super) fn version_menu_height(items: usize) -> f32 { /// 为什么菜单也要钉(D30 四审):裁切位置由**钉死区高度**决定,与窗宽无关(1180 与 720 都切在 /// 同一个 y)。之前只钉实例名,菜单就正好落在切线上——760 下菜单容器 642–706 只有 18px 可见、 /// 条目文字一格都没画出来。把菜单一起钉住之后,它不再依赖首屏剩余高度分配,可达性由结构保证; -/// 滚动区溢出时被滚走的是错误行(说明与菜单都在钉死区,不参与滚动)。 +/// 滚动区里只有 head 与 chips 两块,用户可见内容**不会被滚走**(错误行在 Shared 档恒为 +/// `None`:静态不兼容时不画版本菜单、走的是上面 `!scroll` 那条路)。 /// D30 八审实测:滚动区可见高 ≈96–104px,内容(head 40 + chips 32 + 间距 4)≈76px ⇒ /// 1180×760 下 head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。 pub(crate) fn section<'a>( @@ -165,7 +166,8 @@ pub(crate) fn section<'a>( // 差 2px 就把 chip 的底边框裁掉;再塞说明(20px)就差 27~29px,说明整块 0 像素。 // 并排标签(方案 C)在这里不可行:六枚 chip 实测已占 404px,正文列只有 424px, // 加上「安装选项」必然横向溢出、把第六枚裁掉——那是拿一个已完成项换另一个。 - // 所以按塔授权的 B 落地:标签与说明都搬到钉死区(见下面的 `pinned`),滚动区只装控件。 + // 所以按塔授权的 B 落地:**说明**搬到钉死区(见下面的 `pinned`);「安装选项」标签在 + // Shared 档**删掉**(不是搬——八审裁决,见下三行),Pinned 档保留。滚动区只装控件。 // Shared 档**没有**「安装选项」标签:这是设计决定(D30 八审裁决)——六枚 chip 自解释, // 且正文列只有 424px、六枚 chip 已占 404px,加标签必然横向裁掉最后一枚。 // 原版等 Pinned 档仍保留该标签(见上面 `!scroll` 那条路)。 From a16aadd467e1ba5c4fff5557d645e14fa401d8fd Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 19:12:59 +0800 Subject: [PATCH 22/22] =?UTF-8?q?docs(app-iced):=20=E6=8C=89=20TailShare::?= =?UTF-8?q?of=20=E7=9A=84=E7=9C=9F=E5=AE=9E=E5=88=86=E6=94=AF=E6=B8=85?= =?UTF-8?q?=E6=89=AB=E4=B8=8B=E8=BD=BD=E9=A1=B5=E9=9D=A2=E6=9D=BF=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=EF=BC=88r7=20=E4=B8=89=E6=9D=A1=20+=20=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E6=89=AB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (i) 上轮新引入的错误声明 rescope:错误行「恒为 None / 滚动区只有 head+chips」只在**三栏档 + 带版本列表**这一支成立(has_loader_versions() 要求 problem.is_none())。**chips 档**(可用宽 < 600,TailShare::of 对 Options/Install 无条件 Shared)里 problem 可以是 Some(静态不兼容 / 列表取不到 / 空表三种),错误行会进滚动区且可能被滚走;show_versions() 为假时钉死区只剩 实例名。install.rs 与 options/view.rs 都按这两支分别写清,并注明该行为域由 M227 重设计。 (ii) install.rs 的「显式给对半分档标志」「对半分:面板吃满自己那一半」改成预算优先口径 (与同文件十行上方「不是早期的对半分」不再矛盾)。 (iii) rows.rs 三处:三栏档「照旧钉死」限定为「**不带版本列表**的选项区」;`content` 的 share 说明改成引用 TailShare::of 的真实两支,不再复述「三栏钉死 / chips 各吃一半」; 「只有对半分那一档才按整行取高」改成「只有 Shared(面板先保预算)那一档」。 全量扫附带修正:tail.rs 的 ROW_PITCH / PANEL_BUDGET 账(760 例改成 176/437、900 改成 569; 预算拆成滚动区 76 + 钉死区 153);options/model.rs 的 has_loader_versions 文档改成预算口径。 零代码零测试变更。 --- crates/app-iced/src/pages/download/install.rs | 18 +++++++++++++----- .../src/pages/download/options/model.rs | 2 +- .../src/pages/download/options/view.rs | 14 +++++++++----- crates/app-iced/src/pages/download/rows.rs | 14 ++++++++------ .../app-iced/src/pages/download/rows/tail.rs | 13 +++++++------ 5 files changed, 38 insertions(+), 23 deletions(-) diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs index 35656d0..5464aa2 100644 --- a/crates/app-iced/src/pages/download/install.rs +++ b/crates/app-iced/src/pages/download/install.rs @@ -117,14 +117,21 @@ pub(super) fn panel_state(model: &Download) -> PanelState<'_> { /// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips; /// - **钉死区**:说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框。 /// -/// 菜单与说明分别是 D30 四审 / 八审搬进钉死区的(裁切位置由钉死区高度决定,与窗宽无关); -/// 错误行在 Shared 档恒为 `None`(`OptionsView::problem` 只在静态不兼容时出现,而那时 -/// 不画版本菜单、走的是不滚动的那一路),所以滚动区里只会有 head + 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> { panel_with(ui, catalog, model, false) } -/// 同 [`panel`],但显式给「对半分档」标志(只有 `rows::content` 需要)。 +/// 同 [`panel`],但显式给「面板先保预算」这一档的标志(只有 `rows::content` 需要)。 pub(super) fn panel_with<'a>( ui: &'a Ui, catalog: Catalog, @@ -141,7 +148,8 @@ pub(super) fn panel_with<'a>( let panel = container(content) .width(Length::Fill) .padding(Padding::from([ui.space(Space::X3), 0.0])); - // 对半分档:面板吃满自己那一半,选项区的 `scrollable` 才有确定高度可吃。 + // 面板先保预算档:面板吃满自己那份(`rows/tail.rs::whole_rows` 之后剩下的高度), + // 选项区的 `scrollable` 才有确定高度可吃。 if shared { panel.height(Length::Fill).into() } else { diff --git a/crates/app-iced/src/pages/download/options/model.rs b/crates/app-iced/src/pages/download/options/model.rs index 19d1275..7f36bbd 100644 --- a/crates/app-iced/src/pages/download/options/model.rs +++ b/crates/app-iced/src/pages/download/options/model.rs @@ -313,7 +313,7 @@ impl OptionsView { /// 判据与 `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 diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index 4051f58..8ad1f00 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -15,7 +15,8 @@ //! //! - **钉死档**(原版等不带版本列表的面板)走另一条路:标签、说明、菜单、实例名都在同一列里 //! 顺序铺开(`04` 基线图就是这一档,不能改); -//! - 不兼容时的原因行(Error 色)在滚动区末尾; +//! - 不兼容时的原因行(Error 色)在滚动区末尾(**chips 档**可达;三栏档 + 带版本列表时 +//! `problem` 恒为 `None`,所以那支看不到它); //! - 说明只有 LiteLoader 有(`Key::DownloadOptionsLiteLoaderVanillaOnly`)。 //! //! ## 为什么加载器是**六枚一行**(M222 D30 r1 实测订正) @@ -73,14 +74,17 @@ pub(super) fn version_menu_height(items: usize) -> f32 { /// /// `scroll` = 面板此刻是不是「自己可滚」的那一档(`rows::TailShare::Shared`)。是的话: /// -/// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips + 错误行; +/// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips(**chips 档**还可能有错误行,见下); /// - **钉死区**:说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框。 /// /// 为什么菜单也要钉(D30 四审):裁切位置由**钉死区高度**决定,与窗宽无关(1180 与 720 都切在 /// 同一个 y)。之前只钉实例名,菜单就正好落在切线上——760 下菜单容器 642–706 只有 18px 可见、 /// 条目文字一格都没画出来。把菜单一起钉住之后,它不再依赖首屏剩余高度分配,可达性由结构保证; -/// 滚动区里只有 head 与 chips 两块,用户可见内容**不会被滚走**(错误行在 Shared 档恒为 -/// `None`:静态不兼容时不画版本菜单、走的是上面 `!scroll` 那条路)。 +/// **两个 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>( @@ -159,7 +163,7 @@ pub(crate) fn section<'a>( return plain.into(); } - // 滚动区只剩 head + 六枚 chips(方案 B,D30 六审裁决)。 + // 滚动区只剩 head + 六枚 chips(方案 B,D30 六审裁决;chips 档还可能有错误行,见 `section` 文档)。 // // 为什么把「安装选项」标签与中性说明都撤出滚动区: // 五审 82px → 六审 103px 的视口里,head(40) + 标签(17) + chips(32) + 两处间距(16) = 105px, diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index 507b691..7019961 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -29,7 +29,8 @@ //! 都在面板钉死区,见 `options/view.rs::section`)。 //! 同样的 760 高窗口里:三栏档列表还剩 158px(3 整行),chips 档只剩 18px。 //! -//! 修法是 [`TailShare`] 两档:三栏档照旧钉死(面板整块在屏内),chips 档**且面板真的占高度** +//! 修法是 [`TailShare`] 两档:**不带加载器版本列表**的选项区在三栏档照旧钉死(面板整块在屏内), +//! chips 档**且面板真的占高度** //! (选项区展开 / 安装中)时改成「**面板先保预算、剩下按整行给列表**」 //! (`tail::whole_rows` + `PANEL_BUDGET = 300`):1180×760 下列表 **176px = 4 整行**, //! 面板拿走其余(≈307px)。实例名那格**已经钉死**在面板底部、不需要滚(M222 `6f87c8b`)。 @@ -85,8 +86,9 @@ pub(crate) use tail::{whole_rows, TailShare}; /// 列表列(三栏档与 chips 档共用一份)。 /// -/// `share` 决定「列表」与「尾部面板」怎么分这块高度:三栏档钉死面板、chips 档各吃一半 -/// (见 [`TailShare`] 与模块文档「尾巴不能把列表压成缝」)。 +/// `share` 决定「列表」与「尾部面板」怎么分这块高度(真实分支见 `tail::TailShare::of`): +/// `Pinned` = 面板按内容高钉在底部、列表吃剩下的;`Shared` = 面板先吃 `tail::PANEL_BUDGET`、 +/// 列表按**整行**吃剩下的(`tail::whole_rows`)。哪些组合走哪一支由那一处决定,这里不重复判。 pub(super) fn content<'a>( ui: &'a Ui, catalog: Catalog, @@ -103,8 +105,8 @@ pub(super) fn content<'a>( } else if visible.is_empty() { states::empty(ui, catalog) } else { - // 只有「列表与面板对半分」那一档才按整行取高:钉死档(面板矮)实测已经是整行 - // (04 图 278px),取整反而会把它从 6 行缩到 5 行。 + // 只有 `Shared`(面板先保预算)那一档才按整行取高:`Pinned` 档(面板矮)实测已经 + // 是整行(`04` 图),取整反而会把它从 6 行缩到 5 行。 let height = if matches!(share, TailShare::Shared) { list_height } else { @@ -271,7 +273,7 @@ fn list<'a>( .scroller_width(frame::SCROLLBAR_WIDTH) .margin(frame::SCROLLBAR_MARGIN), )) - // `Some` = 三栏档按**整行**取高(见 `tail::whole_rows`);`None` = chips 档照旧 Fill。 + // `Some` = 三栏档的 `Shared` 分支按**整行**取高(见 `tail::whole_rows`);`None` = 其它(含 chips 档)用 Fill。 .height(height.map_or(Length::Fill, Length::Fixed)) .style(frame::scroll_style(ui)) .into() diff --git a/crates/app-iced/src/pages/download/rows/tail.rs b/crates/app-iced/src/pages/download/rows/tail.rs index 8bc9465..b5ad9a3 100644 --- a/crates/app-iced/src/pages/download/rows/tail.rs +++ b/crates/app-iced/src/pages/download/rows/tail.rs @@ -60,20 +60,21 @@ impl TailShare { /// /// 列表是 `Fill + scrollable` 时,视口高度不是节距整数倍就会切在某一行中间——D30 r2 实测 /// `01` 是 245px(5.3 行),英文态露出 6px 竖干、中文态露出「预览版」上半截。三栏档的 -/// 「列表与面板对半分」那一档现在按整行取高([`whole_rows`]),底缘落在行边界上。 +/// `Shared`(面板先保预算)那一支现在按整行取高([`whole_rows`]),底缘落在行边界上。 pub(crate) const ROW_PITCH: f32 = 44.0; /// 列表以外、列内要扣掉的固定占位(**列顶 156 → 列表顶 253**,D30 三审实测 = 97)。 /// -/// 1180×760:正文列 580 ⇒ floor((580−97)/2 / 44) × 44 = **220px = 5 整行**,分隔线落在 481。 -/// 1180×900:floor((720−97)/2 / 44) × 44 = **308px = 7 整行**(三审在 900 高实测过:旧标定 -/// 会在 y529–530 复发残影,新标定不复发)。**改搜索条 / 表头高度时要同步这个数。** +/// 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 + 标签 16 + 六枚 chips 32 + 说明单行 20 + 间距约 20 ≈ 128; -/// 钉死区 = 菜单 64 + 实例名 56 + 间距 16 ≈ 136。取 300 是两者之和的整档。 +/// 滚动区(八审后只剩 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;