fix(launch): M157 r3 周快照门限按继承根版本 + 保存失败保留草稿(复审 P2-1/P2-2)

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 行)。
This commit is contained in:
AskaEth committed 2026-11-07 10:36:37 +08:00
1 parent c76a1437e6
commit 211e07b470
9 files changed
+640 -311

No files matched your search

+17 -5
View File
@@ -53,8 +53,11 @@ impl App {
self.launch_form.reset(); self.launch_form.reset();
} }
InstanceMessage::Save => { InstanceMessage::Save => {
// **不在这里清脏**:`dispatch` 只是把命令送出去,落盘成败要等回程事件。
// 失败时(内存 `4g` / 写盘失败)pump 会带**旧快照**重建视图,脏标记若已清掉,
// `LaunchForm::sync` 就会用旧值覆盖用户刚敲的草稿(M157 r3 复审 P2-2)。
// 清脏的唯一时机是「回程快照已经等于表单」——见 `LaunchForm::sync`。
let patch: LaunchArgsPatch = self.launch_form.patch(); let patch: LaunchArgsPatch = self.launch_form.patch();
self.launch_form.dirty = false;
self.dispatch(AppCommand::set_launch_args(&instance, patch)); self.dispatch(AppCommand::set_launch_args(&instance, patch));
} }
} }
@@ -149,13 +152,22 @@ mod tests {
assert!(!app.launch_form.dirty); 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] #[test]
fn save_clears_the_dirty_flag() { fn save_dispatch_keeps_the_dirty_flag() {
let mut app = app_with_form(); 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); assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Save); let _ = app.on_instance_message(InstanceMessage::Save);
assert!(!app.launch_form.dirty); assert!(
app.launch_form.dirty,
"派发后仍要保留脏标记,等回程快照确认"
);
assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容");
} }
} }
@@ -68,14 +68,36 @@ impl LaunchForm {
} }
/// 重播种(见模块文档的三档)。 /// 重播种(见模块文档的三档)。
///
/// **脏草稿只在「落盘已确认」时被替换**:保存把命令派发出去之后,回程可能是一条错误事件
/// (内存写了 `4g`、实例文件写不进去),那时 pump 会带着**旧快照**重建视图——如果这里无条件
/// 覆盖,用户刚敲的东西就没了(M157 r3 复审 P2-2)。判据是「快照已经等于表单」:
/// 相等 ⇒ 这次保存落了盘,清脏并重播种;不相等 ⇒ 保留草稿等用户改。
pub fn sync(&mut self, view: &LaunchArgsView) { pub fn sync(&mut self, view: &LaunchArgsView) {
let same_instance = self.bound_to(&view.instance_id); let same_instance = self.bound_to(&view.instance_id);
if same_instance && self.dirty { if same_instance && self.dirty {
if self.matches(view) {
self.dirty = false;
*self = Self::from_view(Some(&view.instance_id), view);
}
return; return;
} }
*self = Self::from_view(Some(&view.instance_id), view); *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] #[must_use]
pub fn patch(&self) -> LaunchArgsPatch { pub fn patch(&self) -> LaunchArgsPatch {
@@ -169,6 +191,44 @@ mod tests {
assert!(form.bound_to("b")); 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 猜。 /// 补丁五项全给:空串就是「清空」,不留 `None` 让 host 猜。
#[test] #[test]
fn patch_always_covers_all_five_fields() { fn patch_always_covers_all_five_fields() {
+8 -1
View File
@@ -241,7 +241,14 @@ fn backend_gate(ctx: &Ctx, version_id: &str) -> (bool, String) {
} }
/// 继承链根(原版层)的 id——能力位与版本门限都以它为准(loader 层的 id 带加载器后缀)。 /// 继承链根(原版层)的 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 current = manifest.id.clone().unwrap_or_default();
let mut parent = manifest.inherits_from.clone().filter(|id| !id.is_empty()); let mut parent = manifest.inherits_from.clone().filter(|id| !id.is_empty());
for _ in 0..16 { for _ in 0..16 {
+95 -1
View File
@@ -131,11 +131,14 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
.clone() .clone()
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.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 { let snapshot = SnapshotInput {
version_id: &version_id, version_id: &version_id,
version_name: Some(&input.instance_name), version_name: Some(&input.instance_name),
version_type: None, version_type: None,
game_version: None, game_version: Some(root_version.as_str()),
main_class: resolved.main_class.as_deref(), main_class: resolved.main_class.as_deref(),
jvm_arguments: &expanded.jvm, jvm_arguments: &expanded.jvm,
game_arguments: &expanded.game, 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 模式 → `<root>/.ULL` + `<root>/.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);
}
}
+1 -299
View File
@@ -71,302 +71,4 @@ fn tokens(text: Option<&str>) -> Vec<String> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { 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<String> {
assemble(req, &ctx()).expect("组装应成功").args
}
fn position(args: &[String], needle: &str) -> Option<usize> {
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));
}
}
+353
View File
@@ -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<String> {
assemble(req, &ctx()).expect("组装应成功").args
}
fn position(args: &[String], needle: &str) -> Option<usize> {
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, "跳过与否必须与下发相反(不静默)");
}
}
}
+9 -1
View File
@@ -199,11 +199,16 @@ pub fn build_request(
.assets .assets
.clone() .clone()
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.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 { let snapshot = SnapshotInput {
version_id: &version_id, version_id: &version_id,
version_name: Some(&target.name), version_name: Some(&target.name),
version_type: None, version_type: None,
game_version: None, game_version: Some(mc_version.as_str()),
main_class: resolved.main_class.as_deref(), main_class: resolved.main_class.as_deref(),
jvm_arguments: &expanded.jvm, jvm_arguments: &expanded.jvm,
game_arguments: &expanded.game, game_arguments: &expanded.game,
@@ -372,3 +377,6 @@ fn describe_warning(warning: &AdapterWarning) -> String {
pub fn isolation_label(mode: IsolationMode) -> &'static str { pub fn isolation_label(mode: IsolationMode) -> &'static str {
mode.as_str() mode.as_str()
} }
#[cfg(test)]
mod tests;
+96
View File
@@ -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),门限只能靠库表兜底"
);
}
+1 -4
View File
@@ -215,10 +215,7 @@ mod tests {
// (并行跑就是「系统找不到指定的路径」——M157 r2 实测)。 // (并行跑就是「系统找不到指定的路径」——M157 r2 实测)。
static COUNTER: AtomicU64 = AtomicU64::new(0); static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed); let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!( let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id()));
"{tag}-{}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&path); let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录"); std::fs::create_dir_all(&path).expect("建临时目录");
Self { path } Self { path }