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,212 @@
//! [`super`] 的单测:走真入口 [`crate::run`],断言用户/脚本拿到的那份输出。
//!
//! 口径与 `instances/tests.rs` 一致:临时 `--data-dir` + 真 `profiles.json`,只看 stdout 的
//! JSON 信封(`--json`)与退出码。回落规则的真值表在 core 的
//! `game::launch_overrides` / `launch::overrides` 测试里,这里只验 CLI 有没有如实搬运。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use unlucky_core::platform::layout::{LauncherLayout, LAUNCHER_HOME_DIR};
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-instance-launch-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn arg(&self) -> String {
self.0.display().to_string()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 写 `.ULL/config/profiles.json` 并返回第一个实例的 id。
fn write_profiles(data_root: &Path, name: &str, version_id: &str) -> String {
let dir = data_root.join(LAUNCHER_HOME_DIR).join("config");
std::fs::create_dir_all(&dir).expect("建配置目录");
let id = "11111111-2222-4333-8444-555555555555".to_string();
let document = json!({
"schema_version": 1,
"profiles": [{ "id": id, "name": name, "version_id": version_id }],
});
std::fs::write(
dir.join("profiles.json"),
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
)
.expect("写 profiles.json");
id
}
/// 写 `<data>/.minecraft/versions/<id>/<id>.json`(库表可带 vulkan)。
fn write_version(data_root: &Path, id: &str, vulkan: bool) {
let path = data_root
.join(".minecraft")
.join("versions")
.join(id)
.join(format!("{id}.json"));
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
let libs = if vulkan {
r#"[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]"#
} else {
"[]"
};
std::fs::write(&path, format!(r#"{{"id":"{id}","libraries":{libs}}}"#)).expect("写版本 JSON");
}
/// 跑一次 CLI,返回(退出码, stdout)。
fn run_cli(args: &[&str]) -> (i32, String) {
let owned: Vec<String> = args.iter().map(|arg| (*arg).to_string()).collect();
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
let code = crate::run(&owned, &mut out, &mut err);
(code, String::from_utf8_lossy(&out).into_owned())
}
/// 跑 `instances launch-args … --json`,返回 `data` 载荷。
fn launch_args(dir: &TempDir, rest: &[&str]) -> Value {
let root = dir.arg();
let mut args = vec!["instances", "launch-args", "--json", "--data-dir", &root];
args.extend_from_slice(rest);
let (code, stdout) = run_cli(&args);
assert_eq!(code, 0, "{stdout}");
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["ok"], true, "{stdout}");
value["data"].clone()
}
#[test]
fn read_reports_instance_layer_and_effective_values() {
let dir = TempDir::new("read");
let id = write_profiles(dir.path(), "生存", "1.20.4");
let data = launch_args(&dir, &[&id]);
assert_eq!(data["instance_id"], id.as_str());
assert_eq!(data["updated"], false);
assert_eq!(data["launch"]["graphics_backend"], "default");
// 没配内存 ⇒ 生效值是出厂默认 2048(三级回落的最后一级)。
assert_eq!(data["effective"]["memory_mb"], 2048);
assert_eq!(data["effective"]["graphics_backend_allowed"], false);
assert_eq!(
data["graphics_backend_options"],
json!(["default", "opengl", "vulkan"])
);
}
#[test]
fn write_from_flags_then_read_back() {
let dir = TempDir::new("write");
let id = write_profiles(dir.path(), "生存", "26.2-snapshot-2");
write_version(dir.path(), "26.2-snapshot-2", true);
let data = launch_args(
&dir,
&[
&id,
"--jvm=-XX:+UseG1GC",
// 值以 `--` 开头时必须用 `--key=value` 形式(`--game --fullscreen` 会被解析器
// 当成「缺少取值」——那是全项目 CLI 的既有口径,见 help 里的提示)。
"--game=--fullscreen",
"--memory",
"6144",
"--backend",
"vulkan",
],
);
assert_eq!(data["updated"], true);
assert_eq!(data["launch"]["jvm_args"], "-XX:+UseG1GC");
assert_eq!(data["launch"]["memory_mb"], 6144);
assert_eq!(data["launch"]["graphics_backend"], "vulkan");
// 26.2-snapshot-2 过了门槛(判定来自 core 的能力位 + 版本门限)。
assert_eq!(data["effective"]["graphics_backend_allowed"], true);
// 再读一次:落盘之后依然在(`--json` 是事实上的对外契约)。
let again = launch_args(&dir, &[&id]);
assert_eq!(again["updated"], false);
assert_eq!(again["launch"]["jvm_args"], "-XX:+UseG1GC");
assert_eq!(again["launch"]["game_args"], "--fullscreen");
}
#[test]
fn clear_restores_follow_the_global() {
let dir = TempDir::new("clear");
let id = write_profiles(dir.path(), "生存", "1.20.4");
launch_args(&dir, &[&id, "--memory", "4096", "--backend", "opengl"]);
let cleared = launch_args(&dir, &[&id, "--clear"]);
assert_eq!(cleared["launch"]["memory_mb"], Value::Null);
assert_eq!(cleared["launch"]["graphics_backend"], "default");
assert_eq!(cleared["effective"]["memory_mb"], 2048);
}
#[test]
fn invalid_values_fail_with_a_usage_error() {
let dir = TempDir::new("invalid");
let id = write_profiles(dir.path(), "生存", "1.20.4");
let root = dir.arg();
for (flag, value) in [
("--backend", "vulkan2"),
("--memory", "4g"),
("--memory", "1"),
] {
let (code, stdout) = run_cli(&[
"instances",
"launch-args",
"--json",
"--data-dir",
&root,
&id,
flag,
value,
]);
assert_ne!(code, 0, "{flag} {value} 应当失败:{stdout}");
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["error"]["code"], "usage", "{stdout}");
}
// 失败的写入不许留下半截设置文件。
let layout = LauncherLayout::new(dir.path().join(LAUNCHER_HOME_DIR));
assert!(
unlucky_core::game::install::instance::read_instance_config(&layout, &id)
.expect("读")
.is_none(),
"非法补丁不该落盘"
);
}
#[test]
fn unknown_instance_is_a_user_error() {
let dir = TempDir::new("missing");
let root = dir.arg();
let (code, stdout) = run_cli(&[
"instances",
"launch-args",
"--json",
"--data-dir",
&root,
"nope",
]);
assert_ne!(code, 0);
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["error"]["code"], "not_found", "{stdout}");
}