feat(launch): M157 实例级启动参数(五项回落 + graphicsBackend 三态)

core: 新增 game/launch_overrides(LaunchOverrides 五项 All-Option + 三级回落 + 文本补丁校验)与 launch/overrides(拼装落位:全局 java.extra_args 进 user.jvm、实例 jvmArgs 进 override_jvm、gameArgs 进 user.game、memory 进 -Xmx、backend 进 --graphicsBackend 并复用既有版本门槛告警);实例 schema 登记 launch.* 五键。

presenter: dto/LaunchArgsView+LaunchArgsPatch,主机侧读/写与门槛判定(backend_allowed),AppCommand::SetInstanceLaunchArgs。

cli: 新命令 instances launch-args(读/写/--clear),launch 装配链消费回落值。

app-iced: 实例详情页「启动参数」区(JVM/游戏参数、内存、Java、三态后端 chips + 灰化提示 + 保存/恢复),表单状态归 App、i18n 中英文案同步。
This commit is contained in:
AskaEth committed 2026-11-07 10:36:35 +08:00
1 parent d68523b02a
commit e2021645d7
56 files changed
+3776 -607

No files matched your search

@@ -0,0 +1,295 @@
use super::*;
fn instance_with(field: &str) -> LaunchOverrides {
match field {
"jvm" => LaunchOverrides {
jvm_args: Some("-XX:+UseG1GC".to_string()),
..LaunchOverrides::default()
},
"game" => LaunchOverrides {
game_args: Some("--fullscreen".to_string()),
..LaunchOverrides::default()
},
"memory" => LaunchOverrides {
memory_mb: Some(6144),
..LaunchOverrides::default()
},
"java" => LaunchOverrides {
java_path: Some(PathBuf::from("D:/jdk21/bin/java.exe")),
..LaunchOverrides::default()
},
"backend" => LaunchOverrides {
graphics_backend: Some(GraphicsBackend::Vulkan),
..LaunchOverrides::default()
},
other => panic!("未知字段 {other}"),
}
}
fn global_with(field: &str) -> GlobalLaunchDefaults {
match field {
"jvm" => GlobalLaunchDefaults {
jvm_args: Some("-Xss4m".to_string()),
..GlobalLaunchDefaults::default()
},
"game" => GlobalLaunchDefaults {
game_args: Some("--foo".to_string()),
..GlobalLaunchDefaults::default()
},
"memory" => GlobalLaunchDefaults {
memory_mb: Some(3072),
..GlobalLaunchDefaults::default()
},
"java" => GlobalLaunchDefaults {
java_path: Some(PathBuf::from("C:/jdk17/bin/java.exe")),
..GlobalLaunchDefaults::default()
},
"backend" => GlobalLaunchDefaults {
graphics_backend: Some(GraphicsBackend::Opengl),
..GlobalLaunchDefaults::default()
},
other => panic!("未知字段 {other}"),
}
}
/// **三级回落逐项**:五项各自走「实例 → 全局 → 默认」,一项不得连坐另一项。
#[test]
fn every_field_falls_back_on_its_own() {
for field in ["jvm", "game", "memory", "java", "backend"] {
// ① 实例有值 → 实例赢(jvm 例外:两层叠加,见下一条测试)。
let from_instance = resolve(
Some(&instance_with(field)),
&GlobalLaunchDefaults::default(),
);
let from_global = resolve(None, &global_with(field));
let neither = resolve(None, &GlobalLaunchDefaults::default());
match field {
"jvm" => {
assert_eq!(
from_instance.instance_jvm_args.as_deref(),
Some("-XX:+UseG1GC")
);
assert_eq!(from_global.global_jvm_args.as_deref(), Some("-Xss4m"));
assert!(neither.global_jvm_args.is_none());
assert!(neither.instance_jvm_args.is_none());
}
"game" => {
assert_eq!(from_instance.game_args.as_deref(), Some("--fullscreen"));
assert_eq!(from_global.game_args.as_deref(), Some("--foo"));
assert!(neither.game_args.is_none());
}
"memory" => {
assert_eq!(from_instance.memory_mb, 6144);
assert_eq!(from_global.memory_mb, 3072);
assert_eq!(neither.memory_mb, DEFAULT_MAX_MEMORY_MB);
}
"java" => {
assert_eq!(
from_instance.java_path.as_deref(),
Some(std::path::Path::new("D:/jdk21/bin/java.exe"))
);
assert_eq!(
from_global.java_path.as_deref(),
Some(std::path::Path::new("C:/jdk17/bin/java.exe"))
);
assert!(neither.java_path.is_none());
}
"backend" => {
assert_eq!(from_instance.graphics_backend, GraphicsBackend::Vulkan);
assert_eq!(from_global.graphics_backend, GraphicsBackend::Opengl);
assert_eq!(neither.graphics_backend, GraphicsBackend::Default);
}
_ => unreachable!(),
}
}
}
/// 只填了一项时,其余各项**不受牵连**(稀疏语义的真判据)。
#[test]
fn one_field_does_not_reset_the_others() {
let instance = LaunchOverrides {
memory_mb: Some(8192),
..LaunchOverrides::default()
};
let global = GlobalLaunchDefaults {
jvm_args: Some("-XX:+UseZGC".to_string()),
game_args: Some("--width 1280".to_string()),
memory_mb: Some(2048),
java_path: Some(PathBuf::from("C:/java.exe")),
graphics_backend: Some(GraphicsBackend::Opengl),
};
let resolved = resolve(Some(&instance), &global);
assert_eq!(resolved.memory_mb, 8192, "实例内存赢");
assert_eq!(
resolved.global_jvm_args.as_deref(),
Some("-XX:+UseZGC"),
"全局 JVM 参数照旧下发(追加语义)"
);
assert_eq!(resolved.game_args.as_deref(), Some("--width 1280"));
assert_eq!(
resolved.java_path.as_deref(),
Some(std::path::Path::new("C:/java.exe"))
);
assert_eq!(
resolved.graphics_backend,
GraphicsBackend::Opengl,
"实例没给后端 → 落到全局层"
);
}
/// 空白字符串 = 没填:用户清空输入框必须回到跟随全局,而不是下发一个空参数。
#[test]
fn blank_text_falls_back_to_the_next_layer() {
let instance = LaunchOverrides {
jvm_args: Some(" ".to_string()),
game_args: Some(String::new()),
java_path: Some(PathBuf::from(" ")),
..LaunchOverrides::default()
};
assert!(instance.is_empty(), "全空白视为空段");
let resolved = resolve(
Some(&instance),
&GlobalLaunchDefaults::with_jvm_args("-Xmx1g"),
);
assert_eq!(resolved.global_jvm_args.as_deref(), Some("-Xmx1g"));
assert!(resolved.instance_jvm_args.is_none());
assert!(resolved.game_args.is_none());
assert!(resolved.java_path.is_none());
}
/// 越界的内存值不算覆盖(手改文件绕得过 schema,绕不过这里)。
#[test]
fn out_of_range_memory_falls_back() {
for bad in [0, 1, MIN_MAX_MEMORY_MB - 1, MAX_MAX_MEMORY_MB + 1] {
let instance = LaunchOverrides {
memory_mb: Some(bad),
..LaunchOverrides::default()
};
let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default());
assert_eq!(
resolved.memory_mb, DEFAULT_MAX_MEMORY_MB,
"{bad} MiB 不该生效"
);
}
let ok = resolve(
Some(&instance_with("memory")),
&GlobalLaunchDefaults::default(),
);
assert_eq!(ok.memory_mb, 6144);
}
/// 无效 `graphicsBackend` 值**拒绝**(不是静默落成 default)。
#[test]
fn unknown_graphics_backend_is_rejected() {
let error = serde_json::from_str::<LaunchOverrides>(r#"{"graphicsBackend":"vulkan2"}"#)
.expect_err("未知取值必须报错");
assert!(
error.to_string().contains("unknown variant"),
"错误文案要指出是变体不明:{error}"
);
// 大小写与空白容错只发生在 `parse` 这条显式路径上。
assert_eq!(
GraphicsBackend::parse(" VULKAN "),
Some(GraphicsBackend::Vulkan)
);
assert_eq!(GraphicsBackend::parse("metal"), None);
assert_eq!(GraphicsBackend::parse(""), None);
}
/// 三态词表与 `--graphicsBackend` 取值:`default` 不下发。
#[test]
fn backend_flag_values_skip_default() {
assert_eq!(GraphicsBackend::Default.flag_value(), None);
assert_eq!(GraphicsBackend::Opengl.flag_value(), Some("opengl"));
assert_eq!(GraphicsBackend::Vulkan.flag_value(), Some("vulkan"));
for (backend, id) in GraphicsBackend::ALL.into_iter().zip(GraphicsBackend::IDS) {
assert_eq!(backend.as_str(), id);
assert_eq!(GraphicsBackend::parse(id), Some(backend));
}
}
/// 落盘形状:空段不写键;有值才出现(稀疏),且能原样读回来。
#[test]
fn empty_section_is_not_serialized() {
let empty = serde_json::to_string(&LaunchOverrides::default()).expect("序列化");
assert_eq!(empty, "{}");
let filled = LaunchOverrides {
memory_mb: Some(4096),
graphics_backend: Some(GraphicsBackend::Vulkan),
..LaunchOverrides::default()
};
let json = serde_json::to_string(&filled).expect("序列化");
assert_eq!(json, r#"{"memoryMb":4096,"graphicsBackend":"vulkan"}"#);
let back: LaunchOverrides = serde_json::from_str(&json).expect("反序列化");
assert_eq!(back, filled);
}
/// **补丁语义**:`None` 不动、`Some("")` 清空、非法值报错(GUI 与 CLI 共用这一条实现)。
#[test]
fn patch_touches_only_the_fields_it_is_given() {
let current = LaunchOverrides {
jvm_args: Some("-Xss4m".to_string()),
game_args: Some("--fullscreen".to_string()),
memory_mb: Some(4096),
java_path: Some(PathBuf::from("C:/jdk21/bin/java.exe")),
graphics_backend: Some(GraphicsBackend::Opengl),
};
// 只给内存:其余四项原样保留。
let patched = current
.patched(&LaunchOverridesPatch {
memory_mb: Some("8192".to_string()),
..LaunchOverridesPatch::default()
})
.expect("合法补丁");
assert_eq!(patched.memory_mb, Some(8192));
assert_eq!(patched.jvm_args.as_deref(), Some("-Xss4m"));
assert_eq!(patched.graphics_backend, Some(GraphicsBackend::Opengl));
// 空串清空;空白也算清空。
let cleared = current
.patched(&LaunchOverridesPatch {
jvm_args: Some(" ".to_string()),
game_args: Some(String::new()),
..LaunchOverridesPatch::default()
})
.expect("清空");
assert!(cleared.jvm_args.is_none());
assert!(cleared.game_args.is_none());
assert_eq!(cleared.memory_mb, Some(4096), "没给的项不许被连带清掉");
// `clear_all` 把五项一起清掉(`default` 后端同样落成「没有覆盖」)。
let cleared = current
.patched(&LaunchOverridesPatch::clear_all())
.expect("全清");
assert!(cleared.is_empty(), "{cleared:?}");
}
/// 非法补丁值必须报错:内存非整数/越界、后端词表外。
#[test]
fn patch_rejects_invalid_values() {
let current = LaunchOverrides::default();
for bad in ["4g", "0", "1", "999999"] {
let patch = LaunchOverridesPatch {
memory_mb: Some(bad.to_string()),
..LaunchOverridesPatch::default()
};
assert!(current.patched(&patch).is_err(), "`{bad}` 不该通过内存校验");
}
for bad in ["vulkan2", "metal", "gl"] {
let error = current
.patched(&LaunchOverridesPatch::backend(bad))
.expect_err("未知后端要报错");
assert!(error.contains(bad), "{error}");
assert!(error.contains("vulkan"), "错误文案要给出可选值:{error}");
}
// 大小写容错走 `parse`(显式路径),落盘仍是小写稳定 id。
let patched = current
.patched(&LaunchOverridesPatch::backend(" VULKAN "))
.expect("大小写容错");
assert_eq!(patched.graphics_backend, Some(GraphicsBackend::Vulkan));
assert_eq!(
serde_json::to_string(&patched).expect("序列化"),
r#"{"graphicsBackend":"vulkan"}"#
);
}