From 211e07b470fa7152ceb230e8d94b3c8fddff24b1 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 7 Nov 2026 10:27:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(launch):=20M157=20r3=20=E5=91=A8=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E9=97=A8=E9=99=90=E6=8C=89=E7=BB=A7=E6=89=BF=E6=A0=B9?= =?UTF-8?q?=E7=89=88=E6=9C=AC=20+=20=E4=BF=9D=E5=AD=98=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E8=8D=89=E7=A8=BF=EF=BC=88=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=20P2-1/P2-2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1 周快照 + 加载器的 --graphicsBackend 静默丢失:presenter host::launch::build_request 与 cli snapshot::prepare 给 SnapshotInput.game_version 传 None,装配按带加载器后缀的 version_id(26w14a-fabric-0.16.5)判门限,GameVersion::is_weekly_snapshot 的 6 字符形态认不出来。两个调用点改为传继承链的 MC 根版本(25 行注释说明「门槛与拼装必须同一口径」)。回归:core launch::overrides 的周快照实际装配用例(正/负两档)、presenter host::launch::tests、cli snapshot::tests 各一条根版本断言。 P2-2 保存失败丢草稿:app/update/instances.rs 的 Save 分支派发前就把 launch_form.dirty 清掉,失败事件后 pump 用旧快照重建视图并覆盖表单。改为派发不清脏;LaunchForm::sync 只在「回程快照已等于表单」(= 落盘确认)时清脏并重播种,否则保留草稿。回归:failed_save_keeps_the_draft / confirmed_save_clears_dirty_and_reseeds / save_dispatch_keeps_the_dirty_flag。 顺带:launch::overrides 测试拆到 overrides/tests.rs(守 400 行)。 --- crates/app-iced/src/app/update/instances.rs | 22 +- crates/app-iced/src/pages/instances/state.rs | 60 ++++ crates/cli/src/commands/instance_launch.rs | 9 +- crates/cli/src/commands/snapshot.rs | 96 ++++- crates/core/src/launch/overrides.rs | 300 +--------------- crates/core/src/launch/overrides/tests.rs | 353 +++++++++++++++++++ crates/presenter/src/host/launch.rs | 10 +- crates/presenter/src/host/launch/tests.rs | 96 +++++ crates/presenter/src/host/launch_args.rs | 5 +- 9 files changed, 640 insertions(+), 311 deletions(-) create mode 100644 crates/core/src/launch/overrides/tests.rs create mode 100644 crates/presenter/src/host/launch/tests.rs diff --git a/crates/app-iced/src/app/update/instances.rs b/crates/app-iced/src/app/update/instances.rs index f3cd807..0abfbe2 100644 --- a/crates/app-iced/src/app/update/instances.rs +++ b/crates/app-iced/src/app/update/instances.rs @@ -53,8 +53,11 @@ impl App { self.launch_form.reset(); } InstanceMessage::Save => { + // **不在这里清脏**:`dispatch` 只是把命令送出去,落盘成败要等回程事件。 + // 失败时(内存 `4g` / 写盘失败)pump 会带**旧快照**重建视图,脏标记若已清掉, + // `LaunchForm::sync` 就会用旧值覆盖用户刚敲的草稿(M157 r3 复审 P2-2)。 + // 清脏的唯一时机是「回程快照已经等于表单」——见 `LaunchForm::sync`。 let patch: LaunchArgsPatch = self.launch_form.patch(); - self.launch_form.dirty = false; self.dispatch(AppCommand::set_launch_args(&instance, patch)); } } @@ -149,13 +152,22 @@ mod tests { assert!(!app.launch_form.dirty); } - /// 保存之后脏标记清掉(否则下一次快照刷新不敢重播种,界面会一直显示旧值)。 + /// **保存只派发、不清脏**(M157 r3 复审 P2-2):落盘成败要等回程事件, + /// 派发前清掉脏标记会让失败后的重播覆盖用户草稿。 + /// + /// 抓得住:Save 分支里又写回 `dirty = false`(那条路径在失败时丢草稿)。 + /// 抓不住:回程事件的真实时序——清脏判据在 `LaunchForm::sync` 的 + /// `confirmed_save_clears_dirty_and_reseeds` / `failed_save_keeps_the_draft` 里。 #[test] - fn save_clears_the_dirty_flag() { + fn save_dispatch_keeps_the_dirty_flag() { let mut app = app_with_form(); - let _ = app.on_instance_message(InstanceMessage::Java("D:/jdk21/bin/java.exe".to_string())); + let _ = app.on_instance_message(InstanceMessage::Memory("4g".to_string())); assert!(app.launch_form.dirty); let _ = app.on_instance_message(InstanceMessage::Save); - assert!(!app.launch_form.dirty); + assert!( + app.launch_form.dirty, + "派发后仍要保留脏标记,等回程快照确认" + ); + assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容"); } } diff --git a/crates/app-iced/src/pages/instances/state.rs b/crates/app-iced/src/pages/instances/state.rs index 2b4ebbe..0c0951e 100644 --- a/crates/app-iced/src/pages/instances/state.rs +++ b/crates/app-iced/src/pages/instances/state.rs @@ -68,14 +68,36 @@ impl LaunchForm { } /// 重播种(见模块文档的三档)。 + /// + /// **脏草稿只在「落盘已确认」时被替换**:保存把命令派发出去之后,回程可能是一条错误事件 + /// (内存写了 `4g`、实例文件写不进去),那时 pump 会带着**旧快照**重建视图——如果这里无条件 + /// 覆盖,用户刚敲的东西就没了(M157 r3 复审 P2-2)。判据是「快照已经等于表单」: + /// 相等 ⇒ 这次保存落了盘,清脏并重播种;不相等 ⇒ 保留草稿等用户改。 pub fn sync(&mut self, view: &LaunchArgsView) { let same_instance = self.bound_to(&view.instance_id); if same_instance && self.dirty { + if self.matches(view) { + self.dirty = false; + *self = Self::from_view(Some(&view.instance_id), view); + } return; } *self = Self::from_view(Some(&view.instance_id), view); } + /// 表单当前内容是否与一份快照(= 盘上已持久化的值)完全一致。 + /// + /// 比较的是**文本口径**:内存按「快照有值 → 十进制串,无值 → 空串」对齐,其余直接比原文。 + #[must_use] + pub fn matches(&self, view: &LaunchArgsView) -> bool { + let memory = view.memory_mb.map(|mb| mb.to_string()).unwrap_or_default(); + self.jvm.trim() == view.jvm_args.trim() + && self.game.trim() == view.game_args.trim() + && self.memory.trim() == memory + && self.java.trim() == view.java_path.trim() + && self.backend == view.graphics_backend + } + /// 提交补丁:**五项全给**(表单是整份可见的,不用增量语义会让「清空」无法表达)。 #[must_use] pub fn patch(&self) -> LaunchArgsPatch { @@ -169,6 +191,44 @@ mod tests { assert!(form.bound_to("b")); } + /// **保存失败不许吃掉草稿**(M157 r3 复审 P2-2):脏表单 + 旧快照回来 ⇒ 原样保留。 + /// + /// 抓得住:`sync` 在「同实例 + 脏」时无条件重播种(用户敲的 `4g` 被磁盘上的 4096 覆盖)。 + /// 抓不住:真实事件时序(那在 `app/update/instances.rs` 的用例里按消息顺序模拟)。 + #[test] + fn failed_save_keeps_the_draft() { + let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default")); + // 用户把内存改成非法值(host 会拒),此时表单是脏的。 + form.memory = "4g".to_string(); + form.jvm = "-Xmx3g".to_string(); + form.dirty = true; + + // 回程事件带来的仍是**旧**快照(保存失败 ⇒ 盘上没变)。 + form.sync(&view("a", "", Some(4096), "default")); + + assert_eq!(form.memory, "4g", "失败后的重播把用户正在改的内存吃掉了"); + assert_eq!(form.jvm, "-Xmx3g"); + assert!(form.dirty, "草稿还在,脏标记也不能掉"); + } + + /// **保存成功要清脏**:回程快照等于表单 ⇒ 这一步算落盘确认,之后可以跟着外部变化重播种。 + #[test] + fn confirmed_save_clears_dirty_and_reseeds() { + let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default")); + form.memory = "8192".to_string(); + form.backend = "vulkan".to_string(); + form.dirty = true; + + form.sync(&view("a", "", Some(8192), "vulkan")); + assert!(!form.dirty, "盘上已经是这份值了,脏标记该清"); + assert_eq!(form.memory, "8192"); + + // 之后外部改了配置(比如 CLI 写入)⇒ 干净的表格跟着更新。 + form.sync(&view("a", "-Xss4m", Some(2048), "opengl")); + assert_eq!(form.memory, "2048"); + assert_eq!(form.jvm, "-Xss4m"); + } + /// 补丁五项全给:空串就是「清空」,不留 `None` 让 host 猜。 #[test] fn patch_always_covers_all_five_fields() { diff --git a/crates/cli/src/commands/instance_launch.rs b/crates/cli/src/commands/instance_launch.rs index 425c1a8..19d88d4 100644 --- a/crates/cli/src/commands/instance_launch.rs +++ b/crates/cli/src/commands/instance_launch.rs @@ -241,7 +241,14 @@ fn backend_gate(ctx: &Ctx, version_id: &str) -> (bool, String) { } /// 继承链根(原版层)的 id——能力位与版本门限都以它为准(loader 层的 id 带加载器后缀)。 -fn root_version_id(layout: &unlucky_core::game::GameLayout, manifest: &VersionJson) -> String { +/// +/// `pub(crate)`:`snapshot::prepare` 的装配(`SnapshotInput.game_version`)也要这一份口径, +/// 门槛判定(本模块的 `backend_gate`)与拼装必须用**同一个**根版本,否则会出现 +/// 「UI 说可用、命令行里没有这个参数」的静默偏差。 +pub(crate) fn root_version_id( + layout: &unlucky_core::game::GameLayout, + manifest: &VersionJson, +) -> String { let mut current = manifest.id.clone().unwrap_or_default(); let mut parent = manifest.inherits_from.clone().filter(|id| !id.is_empty()); for _ in 0..16 { diff --git a/crates/cli/src/commands/snapshot.rs b/crates/cli/src/commands/snapshot.rs index ac3857e..004a5bd 100644 --- a/crates/cli/src/commands/snapshot.rs +++ b/crates/cli/src/commands/snapshot.rs @@ -131,11 +131,14 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result { .clone() .or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone())); + // 同 presenter:装配门限按**继承链的 MC 根版本**判(loader 实例的 version_id 带加载器后缀, + // 周快照的 6 字符形态判据在那儿失效,`--graphicsBackend` 会被静默跳过)。 + let root_version = super::instance_launch::root_version_id(&game, &manifest); let snapshot = SnapshotInput { version_id: &version_id, version_name: Some(&input.instance_name), version_type: None, - game_version: None, + game_version: Some(root_version.as_str()), main_class: resolved.main_class.as_deref(), jvm_arguments: &expanded.jvm, game_arguments: &expanded.game, @@ -235,3 +238,94 @@ fn describe_warning(warning: &AdapterWarning) -> String { } } } + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + + /// 一次性临时数据根(`--data-dir` 的等价物:custom 模式 → `/.ULL` + `/.minecraft`)。 + struct TempDir(PathBuf); + + impl TempDir { + fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-cli-snapshot-{tag}-{}-{seq}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn ctx_at(dir: &TempDir) -> Ctx { + let globals = crate::cli::Globals { + data_dir: Some(dir.path().to_path_buf()), + ..crate::cli::Globals::default() + }; + Ctx::resolve(&globals).expect("解析数据根") + } + + fn write_version(ctx: &Ctx, id: &str, json: &str) { + let path = ctx.game().version_json(id); + std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录"); + std::fs::write(&path, json).expect("写版本 JSON"); + } + + /// **周快照 + 加载器实例**(M157 r3 复审 P2-1):`prepare` 交给装配的游戏版本是 + /// 继承链根的 `26w14a`,不是 `26w14a-fabric-0.16.5`。 + /// + /// 抓得住:`SnapshotInput.game_version` 回退成 `None`(装配用带后缀的 id 判门限, + /// `--graphicsBackend` 会被静默跳过,而 CLI 自己的门槛显示「可下发」)。 + /// 抓不住:完整 argv(`crates/core/src/launch/overrides/tests.rs` 的周快照用例)。 + #[test] + fn weekly_loader_instance_is_assembled_against_the_mc_root_version() { + let dir = TempDir::new("weekly"); + let ctx = ctx_at(&dir); + write_version( + &ctx, + "26w14a", + r#"{"id":"26w14a","mainClass":"net.minecraft.client.main.Main", + "libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#, + ); + write_version( + &ctx, + "26w14a-fabric-0.16.5", + r#"{"id":"26w14a-fabric-0.16.5","inheritsFrom":"26w14a", + "mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient"}"#, + ); + + let plan = prepare( + &ctx, + &PlanInput { + version_id: "26w14a-fabric-0.16.5".to_string(), + instance_name: "周快照 Fabric".to_string(), + ..PlanInput::default() + }, + ) + .expect("装配应成功"); + + assert_eq!( + plan.request.instance.version.raw(), + "26w14a", + "装配的游戏版本必须是继承链的 MC 根版本(CLI 门槛与拼装必须同一口径)" + ); + assert!(plan.request.instance.version.is_weekly_snapshot()); + assert!(plan.request.manifest.has_vulkan_backend); + } +} diff --git a/crates/core/src/launch/overrides.rs b/crates/core/src/launch/overrides.rs index a6950d6..d6b7f4d 100644 --- a/crates/core/src/launch/overrides.rs +++ b/crates/core/src/launch/overrides.rs @@ -71,302 +71,4 @@ fn tokens(text: Option<&str>) -> Vec { } #[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::*; - use crate::game::launch_overrides::{ - resolve, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides, - }; - use crate::launch::args_table::{assemble, AssemblyWarning}; - use crate::launch::game_version::GameVersion; - use crate::launch::platform::{OsFamily, PlatformCtx}; - use crate::launch::request::{ - AuthPlaceholders, InstanceInfo, JavaSelection, LaunchDirs, LauncherInfo, Log4jOptions, - ManifestArgs, OptimizationOptions, - }; - - fn request(version: &str) -> LaunchRequest { - LaunchRequest { - instance: InstanceInfo { - id: "inst-1".to_string(), - name: version.to_string(), - version: GameVersion::parse(version), - version_type: "release".to_string(), - }, - launcher: LauncherInfo { - name: "UnluckyLauncher".to_string(), - version: "0.2.2".to_string(), - }, - java: JavaSelection::new("C:/java/bin/java.exe", 21, 64), - memory: MemoryOptions { - min_mb: None, - max_mb: None, - }, - manifest: ManifestArgs { - main_class: Some("net.minecraft.client.main.Main".to_string()), - ..ManifestArgs::default() - }, - dirs: LaunchDirs { - game_dir: PathBuf::from("C:/mc"), - primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"), - ..LaunchDirs::default() - }, - classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")], - auth: AuthPlaceholders::default(), - log4j: Log4jOptions { - enabled: false, - ..Log4jOptions::default() - }, - optimization: OptimizationOptions { - enabled: false, - physical_memory_mb: None, - }, - language: "zh-CN".to_string(), - ..LaunchRequest::default() - } - } - - fn ctx() -> PlatformCtx { - PlatformCtx::new(OsFamily::Windows, "x86_64") - } - - fn argvs(req: &LaunchRequest) -> Vec { - assemble(req, &ctx()).expect("组装应成功").args - } - - fn position(args: &[String], needle: &str) -> Option { - args.iter().position(|arg| arg == needle) - } - - /// **叠加规则**:实例段在 `-Xmx` 之前、全局段在之后;两段都下发(不替换)。 - #[test] - fn instance_and_global_jvm_args_stack_in_order() { - let instance = LaunchOverrides { - jvm_args: Some("-XX:+UseG1GC".to_string()), - ..LaunchOverrides::default() - }; - let global = GlobalLaunchDefaults { - jvm_args: Some("-Xss4m".to_string()), - ..GlobalLaunchDefaults::default() - }; - let resolved = resolve(Some(&instance), &global); - let mut req = request("1.20.4"); - apply(&mut req, &resolved); - let args = argvs(&req); - - let instance_at = position(&args, "-XX:+UseG1GC").expect("实例参数要下发"); - let memory_at = position(&args, "-Xmx2048m").expect("内存字段要产 -Xmx"); - let global_at = position(&args, "-Xss4m").expect("全局参数要下发"); - assert!( - instance_at < memory_at, - "实例段(override_jvm)必须在 -Xmx 之前:{args:?}" - ); - assert!( - global_at > memory_at, - "全局段(user.jvm)在 -Xmx 之后(基线不抢内存字段):{args:?}" - ); - } - - /// **-Xmx 冲突**:实例文本写 `-Xmx` ⇒ 内存字段不再产出(文本赢);全局文本写 `-Xmx` ⇒ 被内存字段抑制。 - #[test] - fn xmx_conflict_follows_the_assembly_order() { - let instance = LaunchOverrides { - jvm_args: Some("-Xmx3g".to_string()), - ..LaunchOverrides::default() - }; - let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); - let mut req = request("1.20.4"); - apply(&mut req, &resolved); - let args = argvs(&req); - assert!( - args.iter().any(|arg| arg == "-Xmx3g"), - "实例文本的 -Xmx 必须生效:{args:?}" - ); - assert!( - !args.iter().any(|arg| arg == "-Xmx2048m"), - "内存字段的 -Xmx 必须被抑制:{args:?}" - ); - - let resolved = resolve(None, &GlobalLaunchDefaults::with_jvm_args("-Xmx3g")); - let mut req = request("1.20.4"); - apply(&mut req, &resolved); - let args = argvs(&req); - assert!( - args.iter().any(|arg| arg == "-Xmx2048m"), - "全局文本的 -Xmx 不生效(第 7 项晚于第 5 项):{args:?}" - ); - assert!(!args.iter().any(|arg| arg == "-Xmx3g"), "{args:?}"); - } - - /// 内存三级回落落进 `-Xmx`:实例 → 全局 → 默认。 - #[test] - fn memory_falls_back_into_xmx() { - let global = GlobalLaunchDefaults { - memory_mb: Some(6144), - ..GlobalLaunchDefaults::default() - }; - let instance = LaunchOverrides { - memory_mb: Some(8192), - ..LaunchOverrides::default() - }; - for (overrides, expected) in [(Some(instance), "-Xmx8192m"), (None, "-Xmx6144m")] { - let resolved = resolve(overrides.as_ref(), &global); - let mut req = request("1.20.4"); - apply(&mut req, &resolved); - assert!( - argvs(&req).iter().any(|arg| arg == expected), - "期望 {expected}" - ); - } - let resolved = resolve(None, &GlobalLaunchDefaults::default()); - let mut req = request("1.20.4"); - apply(&mut req, &resolved); - assert!(argvs(&req).iter().any(|arg| arg == "-Xmx2048m")); - } - - /// 游戏参数最后追加(可覆盖前面一切游戏参数)。 - #[test] - fn game_args_land_at_the_tail() { - let instance = LaunchOverrides { - game_args: Some("--fullscreen --width 1920".to_string()), - ..LaunchOverrides::default() - }; - let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); - let mut req = request("1.20.4"); - apply(&mut req, &resolved); - let args = argvs(&req); - let tail = &args[args.len() - 3..]; - assert_eq!(tail, ["--fullscreen", "--width", "1920"], "{args:?}"); - } - - /// **版本门槛**:26.2-snapshot-2+ 才下发;老版本不下发但必须给诊断告警(不静默)。 - #[test] - fn graphics_backend_is_version_gated_with_a_warning() { - let instance = LaunchOverrides { - graphics_backend: Some(GraphicsBackend::Vulkan), - ..LaunchOverrides::default() - }; - let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); - - let mut supported = request("26.2-snapshot-2"); - apply(&mut supported, &resolved); - let command = assemble(&supported, &ctx()).expect("组装"); - let at = position(&command.args, "--graphicsBackend").expect("支持版本要下发"); - assert_eq!(command.args[at + 1], "vulkan"); - assert!( - !command - .warnings - .iter() - .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })), - "支持版本不该有跳过告警:{:?}", - command.warnings - ); - - let mut old = request("1.20.4"); - apply(&mut old, &resolved); - let command = assemble(&old, &ctx()).expect("组装"); - assert!( - !command.args.iter().any(|arg| arg == "--graphicsBackend"), - "老版本不许下发:{:?}", - command.args - ); - assert!( - command - .warnings - .iter() - .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })), - "跳过必须留诊断:{:?}", - command.warnings - ); - } - - /// `default` = 不下发、不告警(三态里的「跟随游戏默认」)。 - #[test] - fn default_backend_emits_nothing() { - for instance in [None, Some(LaunchOverrides::default())] { - let resolved = resolve(instance.as_ref(), &GlobalLaunchDefaults::default()); - let mut req = request("26.2"); - apply(&mut req, &resolved); - let command = assemble(&req, &ctx()).expect("组装"); - assert!(!command.args.iter().any(|arg| arg == "--graphicsBackend")); - assert!(command.warnings.is_empty(), "{:?}", command.warnings); - } - } - - /// 启动器自带的 `--fullscreen`(第 29 项)与实例游戏参数共存:实例在最后,可覆盖。 - #[test] - fn instance_game_args_win_over_launcher_defaults() { - let instance = LaunchOverrides { - game_args: Some("--fullscreen".to_string()), - ..LaunchOverrides::default() - }; - let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); - let mut req = request("1.20.4"); - req.fullscreen = true; - apply(&mut req, &resolved); - let args = argvs(&req); - assert_eq!( - args.iter() - .filter(|arg| arg.as_str() == "--fullscreen") - .count(), - 2, - "两处都给 --fullscreen,由 MC 取后者(实例段在最后):{args:?}" - ); - assert_eq!(args.last().map(String::as_str), Some("--fullscreen")); - assert!( - position(&args, "net.minecraft.client.main.Main").unwrap() < args.len() - 1, - "实例游戏参数必须在主类之后" - ); - } - - /// Java 路径不进请求、只作为调用方的选择依据。 - #[test] - fn java_path_is_a_scalar_for_the_caller() { - let instance = LaunchOverrides { - java_path: Some(PathBuf::from("D:/jdk21/bin/java.exe")), - ..LaunchOverrides::default() - }; - let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); - assert_eq!( - java_binary(&resolved), - Some(Path::new("D:/jdk21/bin/java.exe")) - ); - let mut req = request("1.20.4"); - let before = req.java.binary.clone(); - apply(&mut req, &resolved); - assert_eq!(req.java.binary, before, "apply 不改 Java 选择"); - } - - /// `backend_allowed`:继承解析后的库表 + 版本门限,两条判据的**唯一组合入口**。 - /// - /// 它是 UI 灰化与拼装链共用的那一格:调用方只负责把「继承解析后的版本 JSON」和 - /// 「游戏版本号」递进来,不许自己拼 `has_vulkan_backend && is_at_least(..)`。 - #[test] - fn backend_allowed_combines_capability_and_threshold() { - use crate::game::VersionJson; - - let with_vulkan = VersionJson::from_json_str( - r#"{"id":"x","libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#, - ) - .expect("解析"); - let without = VersionJson::from_json_str(r#"{"id":"x"}"#).expect("解析"); - - // 可解析版本:精确门限说了算(库表只作周快照兜底)。 - assert!(backend_allowed( - &GameVersion::parse("26.2-snapshot-2"), - &without - )); - assert!(!backend_allowed( - &GameVersion::parse("26.2-snapshot-1"), - &with_vulkan - )); - assert!(!backend_allowed( - &GameVersion::parse("1.20.4"), - &with_vulkan - )); - // 周快照:看库表。 - assert!(backend_allowed(&GameVersion::parse("26w14a"), &with_vulkan)); - assert!(!backend_allowed(&GameVersion::parse("26w14a"), &without)); - } -} +mod tests; diff --git a/crates/core/src/launch/overrides/tests.rs b/crates/core/src/launch/overrides/tests.rs new file mode 100644 index 0000000..590f50f --- /dev/null +++ b/crates/core/src/launch/overrides/tests.rs @@ -0,0 +1,353 @@ +use std::path::PathBuf; + +use super::*; +use crate::game::launch_overrides::{ + resolve, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides, +}; +use crate::launch::adapter::{build_request, AdapterOptions, LibraryInput, SnapshotInput}; +use crate::launch::args_table::{assemble, AssemblyWarning}; +use crate::launch::game_version::GameVersion; +use crate::launch::platform::{OsFamily, PlatformCtx}; +use crate::launch::request::{ + AuthPlaceholders, InstanceInfo, JavaSelection, LaunchDirs, LauncherInfo, Log4jOptions, + ManifestArgs, OptimizationOptions, +}; + +fn request(version: &str) -> LaunchRequest { + LaunchRequest { + instance: InstanceInfo { + id: "inst-1".to_string(), + name: version.to_string(), + version: GameVersion::parse(version), + version_type: "release".to_string(), + }, + launcher: LauncherInfo { + name: "UnluckyLauncher".to_string(), + version: "0.2.2".to_string(), + }, + java: JavaSelection::new("C:/java/bin/java.exe", 21, 64), + memory: MemoryOptions { + min_mb: None, + max_mb: None, + }, + manifest: ManifestArgs { + main_class: Some("net.minecraft.client.main.Main".to_string()), + ..ManifestArgs::default() + }, + dirs: LaunchDirs { + game_dir: PathBuf::from("C:/mc"), + primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"), + ..LaunchDirs::default() + }, + classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")], + auth: AuthPlaceholders::default(), + log4j: Log4jOptions { + enabled: false, + ..Log4jOptions::default() + }, + optimization: OptimizationOptions { + enabled: false, + physical_memory_mb: None, + }, + language: "zh-CN".to_string(), + ..LaunchRequest::default() + } +} + +fn ctx() -> PlatformCtx { + PlatformCtx::new(OsFamily::Windows, "x86_64") +} + +fn argvs(req: &LaunchRequest) -> Vec { + assemble(req, &ctx()).expect("组装应成功").args +} + +fn position(args: &[String], needle: &str) -> Option { + args.iter().position(|arg| arg == needle) +} + +/// **叠加规则**:实例段在 `-Xmx` 之前、全局段在之后;两段都下发(不替换)。 +#[test] +fn instance_and_global_jvm_args_stack_in_order() { + let instance = LaunchOverrides { + jvm_args: Some("-XX:+UseG1GC".to_string()), + ..LaunchOverrides::default() + }; + let global = GlobalLaunchDefaults { + jvm_args: Some("-Xss4m".to_string()), + ..GlobalLaunchDefaults::default() + }; + let resolved = resolve(Some(&instance), &global); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + let args = argvs(&req); + + let instance_at = position(&args, "-XX:+UseG1GC").expect("实例参数要下发"); + let memory_at = position(&args, "-Xmx2048m").expect("内存字段要产 -Xmx"); + let global_at = position(&args, "-Xss4m").expect("全局参数要下发"); + assert!( + instance_at < memory_at, + "实例段(override_jvm)必须在 -Xmx 之前:{args:?}" + ); + assert!( + global_at > memory_at, + "全局段(user.jvm)在 -Xmx 之后(基线不抢内存字段):{args:?}" + ); +} + +/// **-Xmx 冲突**:实例文本写 `-Xmx` ⇒ 内存字段不再产出(文本赢);全局文本写 `-Xmx` ⇒ 被内存字段抑制。 +#[test] +fn xmx_conflict_follows_the_assembly_order() { + let instance = LaunchOverrides { + jvm_args: Some("-Xmx3g".to_string()), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + let args = argvs(&req); + assert!( + args.iter().any(|arg| arg == "-Xmx3g"), + "实例文本的 -Xmx 必须生效:{args:?}" + ); + assert!( + !args.iter().any(|arg| arg == "-Xmx2048m"), + "内存字段的 -Xmx 必须被抑制:{args:?}" + ); + + let resolved = resolve(None, &GlobalLaunchDefaults::with_jvm_args("-Xmx3g")); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + let args = argvs(&req); + assert!( + args.iter().any(|arg| arg == "-Xmx2048m"), + "全局文本的 -Xmx 不生效(第 7 项晚于第 5 项):{args:?}" + ); + assert!(!args.iter().any(|arg| arg == "-Xmx3g"), "{args:?}"); +} + +/// 内存三级回落落进 `-Xmx`:实例 → 全局 → 默认。 +#[test] +fn memory_falls_back_into_xmx() { + let global = GlobalLaunchDefaults { + memory_mb: Some(6144), + ..GlobalLaunchDefaults::default() + }; + let instance = LaunchOverrides { + memory_mb: Some(8192), + ..LaunchOverrides::default() + }; + for (overrides, expected) in [(Some(instance), "-Xmx8192m"), (None, "-Xmx6144m")] { + let resolved = resolve(overrides.as_ref(), &global); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + assert!( + argvs(&req).iter().any(|arg| arg == expected), + "期望 {expected}" + ); + } + let resolved = resolve(None, &GlobalLaunchDefaults::default()); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + assert!(argvs(&req).iter().any(|arg| arg == "-Xmx2048m")); +} + +/// 游戏参数最后追加(可覆盖前面一切游戏参数)。 +#[test] +fn game_args_land_at_the_tail() { + let instance = LaunchOverrides { + game_args: Some("--fullscreen --width 1920".to_string()), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + let args = argvs(&req); + let tail = &args[args.len() - 3..]; + assert_eq!(tail, ["--fullscreen", "--width", "1920"], "{args:?}"); +} + +/// **版本门槛**:26.2-snapshot-2+ 才下发;老版本不下发但必须给诊断告警(不静默)。 +#[test] +fn graphics_backend_is_version_gated_with_a_warning() { + let instance = LaunchOverrides { + graphics_backend: Some(GraphicsBackend::Vulkan), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + + let mut supported = request("26.2-snapshot-2"); + apply(&mut supported, &resolved); + let command = assemble(&supported, &ctx()).expect("组装"); + let at = position(&command.args, "--graphicsBackend").expect("支持版本要下发"); + assert_eq!(command.args[at + 1], "vulkan"); + assert!( + !command + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })), + "支持版本不该有跳过告警:{:?}", + command.warnings + ); + + let mut old = request("1.20.4"); + apply(&mut old, &resolved); + let command = assemble(&old, &ctx()).expect("组装"); + assert!( + !command.args.iter().any(|arg| arg == "--graphicsBackend"), + "老版本不许下发:{:?}", + command.args + ); + assert!( + command + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })), + "跳过必须留诊断:{:?}", + command.warnings + ); +} + +/// `default` = 不下发、不告警(三态里的「跟随游戏默认」)。 +#[test] +fn default_backend_emits_nothing() { + for instance in [None, Some(LaunchOverrides::default())] { + let resolved = resolve(instance.as_ref(), &GlobalLaunchDefaults::default()); + let mut req = request("26.2"); + apply(&mut req, &resolved); + let command = assemble(&req, &ctx()).expect("组装"); + assert!(!command.args.iter().any(|arg| arg == "--graphicsBackend")); + assert!(command.warnings.is_empty(), "{:?}", command.warnings); + } +} + +/// 启动器自带的 `--fullscreen`(第 29 项)与实例游戏参数共存:实例在最后,可覆盖。 +#[test] +fn instance_game_args_win_over_launcher_defaults() { + let instance = LaunchOverrides { + game_args: Some("--fullscreen".to_string()), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + let mut req = request("1.20.4"); + req.fullscreen = true; + apply(&mut req, &resolved); + let args = argvs(&req); + assert_eq!( + args.iter() + .filter(|arg| arg.as_str() == "--fullscreen") + .count(), + 2, + "两处都给 --fullscreen,由 MC 取后者(实例段在最后):{args:?}" + ); + assert_eq!(args.last().map(String::as_str), Some("--fullscreen")); + assert!( + position(&args, "net.minecraft.client.main.Main").unwrap() < args.len() - 1, + "实例游戏参数必须在主类之后" + ); +} + +/// Java 路径不进请求、只作为调用方的选择依据。 +#[test] +fn java_path_is_a_scalar_for_the_caller() { + let instance = LaunchOverrides { + java_path: Some(PathBuf::from("D:/jdk21/bin/java.exe")), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + assert_eq!( + java_binary(&resolved), + Some(Path::new("D:/jdk21/bin/java.exe")) + ); + let mut req = request("1.20.4"); + let before = req.java.binary.clone(); + apply(&mut req, &resolved); + assert_eq!(req.java.binary, before, "apply 不改 Java 选择"); +} + +/// `backend_allowed`:继承解析后的库表 + 版本门限,两条判据的**唯一组合入口**。 +/// +/// 它是 UI 灰化与拼装链共用的那一格:调用方只负责把「继承解析后的版本 JSON」和 +/// 「游戏版本号」递进来,不许自己拼 `has_vulkan_backend && is_at_least(..)`。 +#[test] +fn backend_allowed_combines_capability_and_threshold() { + use crate::game::VersionJson; + + let with_vulkan = VersionJson::from_json_str( + r#"{"id":"x","libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#, + ) + .expect("解析"); + let without = VersionJson::from_json_str(r#"{"id":"x"}"#).expect("解析"); + + // 可解析版本:精确门限说了算(库表只作周快照兜底)。 + assert!(backend_allowed( + &GameVersion::parse("26.2-snapshot-2"), + &without + )); + assert!(!backend_allowed( + &GameVersion::parse("26.2-snapshot-1"), + &with_vulkan + )); + assert!(!backend_allowed( + &GameVersion::parse("1.20.4"), + &with_vulkan + )); + // 周快照:看库表。 + assert!(backend_allowed(&GameVersion::parse("26w14a"), &with_vulkan)); + assert!(!backend_allowed(&GameVersion::parse("26w14a"), &without)); + + /// **周快照 + 加载器**(M157 r3 复审 P2-1):装配门限必须按**继承链的 MC 根版本**判。 + /// + /// 造一个 Fabric 实例(id `26w14a-fabric-0.16.5`,继承周快照 `26w14a`),库表里有 + /// `org.lwjgl:lwjgl-vulkan`:`game_version = Some("26w14a")` ⇒ 命令行带 `--graphicsBackend vulkan`; + /// 退回到「拿带加载器后缀的 version_id 当游戏版本」⇒ `GameVersion::is_weekly_snapshot` + /// 的 6 字符形态认不出来,参数被跳过(**负向锚点**:这正是复审复现出来的静默丢失)。 + /// + /// 抓得住:adapter 的 `game_version` 传错(调用方又回退成 `None`)——周快照 loader 实例 + /// 「UI 说可用、命令行里没有」。 + /// 抓不住:两个调用点自己有没有传(那由 presenter `host::launch::tests` 与 + /// CLI `snapshot::tests` 各自的根版本断言守)。 + #[test] + fn modded_weekly_snapshot_needs_the_root_version_for_the_gate() { + let version_id = "26w14a-fabric-0.16.5"; + let libraries = [LibraryInput::new( + "C:/mc/libraries/org/lwjgl/lwjgl-vulkan/3.4.3/lwjgl-vulkan-3.4.3.jar", + "org.lwjgl:lwjgl-vulkan:3.4.3", + )]; + + for (game_version, expected) in [(Some("26w14a"), true), (None, false)] { + let snapshot = SnapshotInput { + game_version, + libraries: &libraries, + primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"), + ..SnapshotInput::minimal(version_id, "net.minecraft.client.main.Main") + }; + let options = AdapterOptions { + check_classpath: false, + language: "zh-CN".to_string(), + ..AdapterOptions::default() + }; + let mut out = build_request(&snapshot, options).request; + let resolved = resolve( + Some(&LaunchOverrides { + graphics_backend: Some(GraphicsBackend::Vulkan), + ..LaunchOverrides::default() + }), + &GlobalLaunchDefaults::default(), + ); + apply(&mut out, &resolved); + let command = assemble(&out, &ctx()).expect("组装应成功"); + let sent = command.args.iter().any(|arg| arg == "--graphicsBackend"); + assert_eq!( + sent, expected, + "game_version={game_version:?} 时是否下发不对:{:?}", + command.args + ); + let skipped = command + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })); + assert_eq!(skipped, !expected, "跳过与否必须与下发相反(不静默)"); + } + } +} diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 1ea2e43..8f30240 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -199,11 +199,16 @@ pub fn build_request( .assets .clone() .or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone())); + // 装配的门限 / 分支按**继承链的 MC 根版本**判,不是带加载器后缀的 version_id: + // `26w14a-fabric-0.16.5` 这种 id 在 `GameVersion::is_weekly_snapshot`(6 字符形态)上判不出来, + // 周快照的 `--graphicsBackend` 会在拼装链里被静默跳过——而详情页的门槛 + // (`host::launch_args::backend_gate`)与 CLI 用的都是根版本,两边必须同一口径。 + let (mc_version, _) = super::game::describe_version(env, &version_id); let snapshot = SnapshotInput { version_id: &version_id, version_name: Some(&target.name), version_type: None, - game_version: None, + game_version: Some(mc_version.as_str()), main_class: resolved.main_class.as_deref(), jvm_arguments: &expanded.jvm, game_arguments: &expanded.game, @@ -372,3 +377,6 @@ fn describe_warning(warning: &AdapterWarning) -> String { pub fn isolation_label(mode: IsolationMode) -> &'static str { mode.as_str() } + +#[cfg(test)] +mod tests; diff --git a/crates/presenter/src/host/launch/tests.rs b/crates/presenter/src/host/launch/tests.rs new file mode 100644 index 0000000..93207b2 --- /dev/null +++ b/crates/presenter/src/host/launch/tests.rs @@ -0,0 +1,96 @@ +//! `host::launch` 的回归用例(M157 r3 复审 P2-1):装配用的游戏版本必须是**继承链的 MC 根版本**。 + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::*; + +/// 一次性临时目录(同 `host::launch_args` 测试:不引第三方 tempfile)。 +struct TempDir(PathBuf); + +impl TempDir { + fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-host-launch-{tag}-{}-{seq}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn write_version(env: &HostEnv, id: &str, json: &str) { + let path = env.game.version_json(id); + std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录"); + std::fs::write(&path, json).expect("写版本 JSON"); +} + +/// **周快照 + 加载器实例**的装配:`SnapshotInput.game_version` 要拿继承链根的 `26w14a`, +/// 不是带加载器后缀的 `26w14a-fabric-0.16.5`。 +/// +/// 抓得住:`build_request` 回退成 `game_version: None`(装配链用 `version_id` 判门限)—— +/// 那时 `GameVersion::is_weekly_snapshot` 的 6 字符形态认不出这个 id,`--graphicsBackend` +/// 会在拼装链里被静默跳过,而详情页的门槛(`host::launch_args::backend_gate`,用根版本) +/// 显示「可用」——正是复审复现的「UI 说能开、命令行里没有」。 +/// 抓不住:拼装本身(`crates/core/src/launch/overrides/tests.rs` 的 +/// `modded_weekly_snapshot_needs_the_root_version_for_the_gate` 出完整 argv)。 +#[test] +fn weekly_loader_instance_assembles_against_the_mc_root_version() { + let dir = TempDir::new("weekly"); + let env = HostEnv::at_root(dir.path()); + std::fs::create_dir_all(env.layout.config()).expect("config 目录"); + + // 周快照本体:库表带 lwjgl-vulkan(能力位),主类齐全。 + write_version( + &env, + "26w14a", + r#"{"id":"26w14a","type":"snapshot","mainClass":"net.minecraft.client.main.Main", + "libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#, + ); + // Fabric 层:只写自己的主类 + inheritsFrom(真实落盘形状)。 + write_version( + &env, + "26w14a-fabric-0.16.5", + r#"{"id":"26w14a-fabric-0.16.5","inheritsFrom":"26w14a", + "mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient"}"#, + ); + + let target = LaunchTarget { + id: "inst-1".to_string(), + name: "周快照 Fabric".to_string(), + version_id: "26w14a-fabric-0.16.5".to_string(), + account: None, + }; + let (request, _warnings) = build_request(&env, &target).expect("装配应成功"); + + assert_eq!( + request.instance.version.raw(), + "26w14a", + "装配用的游戏版本必须是继承链的 MC 根版本(带加载器后缀的 id 会让周快照门限失效)" + ); + assert!( + request.instance.version.is_weekly_snapshot(), + "根版本要能被识别成周快照形态" + ); + assert!( + request.manifest.has_vulkan_backend, + "能力位来自继承链解析后的库表(lwjgl-vulkan 在父层)" + ); + assert!( + !request.instance.version.is_known(), + "周快照刻意不可排序(is_known=false),门限只能靠库表兜底" + ); +} diff --git a/crates/presenter/src/host/launch_args.rs b/crates/presenter/src/host/launch_args.rs index 404be34..2ab059c 100644 --- a/crates/presenter/src/host/launch_args.rs +++ b/crates/presenter/src/host/launch_args.rs @@ -215,10 +215,7 @@ mod tests { // (并行跑就是「系统找不到指定的路径」——M157 r2 实测)。 static COUNTER: AtomicU64 = AtomicU64::new(0); let seq = COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "{tag}-{}-{seq}", - std::process::id() - )); + let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id())); let _ = std::fs::remove_dir_all(&path); std::fs::create_dir_all(&path).expect("建临时目录"); Self { path }