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 中英文案同步。
373 lines
15 KiB
Rust
373 lines
15 KiB
Rust
//! 实例级启动参数的**落位**:`ResolvedLaunch` → [`LaunchRequest`]。
|
||
//!
|
||
//! 三级回落本身在 [`crate::game::launch_overrides`](纯计算);本模块只回答「落到哪一段」,
|
||
//! 规则与理由见 M157 的定案(叠加/冲突),这里给出可执行的对照表:
|
||
//!
|
||
//! | 生效值 | 落点 | 组装表 | 为什么在这儿 |
|
||
//! |---|---|---|---|
|
||
//! | 全局 `java.extra_args` | `user.jvm` | 第 7 项 | 基线:按“默认值”处理,可被清单参数抑制 |
|
||
//! | 实例 `launch.jvmArgs` | `user.override_jvm` | 第 4 项 | 精调:最高优先级,`-Xmx`/编码/代理默认值全被它抑制 |
|
||
//! | 实例 `launch.gameArgs` | `user.game` | 第 32 项 | 最后追加,可覆盖前面一切游戏参数 |
|
||
//! | `memoryMb`(已回落) | `memory.max_mb` | 第 5 项 | `-Xmx` 只从这里长出来 |
|
||
//! | `graphicsBackend` | `graphics_backend` | 第 31 项 | 仍过 `graphics_backend_allowed` 门槛,不放行给诊断告警 |
|
||
//! | `javaPath` | **不进来** | — | Java 选择在调用方(`JavaSelection`),见标量注释 |
|
||
//!
|
||
//! # 两条必须一起读的结论
|
||
//!
|
||
//! 1. **追加,不替换**:全局与实例的 JVM 参数都下发。实例段在前、全局段在后,同名选项由 JVM
|
||
//! 取后出现者——但注意组装表第 4 项在 `-Xmx` **之前**:实例文本里的 `-Xmx` 会抑制内存字段
|
||
//! 产出的 `-Xmx`(这是「文本比字段更精确」的表达,不是 bug);
|
||
//! 2. **全局文本里的 `-Xmx` 不生效**:第 7 项晚于第 5 项,`-Xmx` 已被内存字段占住
|
||
//! (`add_default` 语义)。要改堆请用内存字段或实例 JVM 文本——写进注释与测试,别当特例。
|
||
//!
|
||
//! `javaPath` 不进 `LaunchRequest`:那份结构只收选好的 [`crate::launch::request::JavaSelection`],
|
||
//! 调用方用 `resolved.java_path` 决定「显式路径优先 / 自动挑」。
|
||
|
||
use std::path::Path;
|
||
|
||
use crate::game::launch_overrides::ResolvedLaunch;
|
||
use crate::launch::command_builder::tokenize;
|
||
use crate::launch::request::{LaunchRequest, MemoryOptions};
|
||
|
||
/// 把三级回落后的生效值写进请求(覆盖 `user.*` / `memory` / `graphics_backend` 三处)。
|
||
///
|
||
/// 调用方在 [`crate::launch::adapter::build_request`] 之后调用一次即可;它**接管**这三处,
|
||
/// 所以别在此之前再往 `user` 里塞东西(目前 CLI 与 GUI 都没有别的来源,契约就是这一段)。
|
||
pub fn apply(request: &mut LaunchRequest, resolved: &ResolvedLaunch) {
|
||
request.user.jvm = tokens(resolved.global_jvm_args.as_deref());
|
||
request.user.override_jvm = tokens(resolved.instance_jvm_args.as_deref());
|
||
request.user.game = tokens(resolved.game_args.as_deref());
|
||
request.memory = MemoryOptions {
|
||
min_mb: request.memory.min_mb,
|
||
max_mb: Some(resolved.memory_mb),
|
||
};
|
||
request.graphics_backend = resolved.graphics_backend.flag_value().map(str::to_string);
|
||
}
|
||
|
||
/// 实例/全局给的显式 Java 路径(`None` = 调用方走自动挑)。
|
||
#[must_use]
|
||
pub fn java_binary(resolved: &ResolvedLaunch) -> Option<&Path> {
|
||
resolved.java_path.as_deref()
|
||
}
|
||
|
||
/// 版本能不能下发 `--graphicsBackend`:**解析过继承链的**版本 JSON + 游戏版本号。
|
||
///
|
||
/// `resolved_json` 必须是继承解析之后的那份(26.2 的 `lwjgl-vulkan` 在父层,
|
||
/// 只看 loader 层会漏)。判据本身仍是 [`graphics_backend_allowed`] 一处实现——
|
||
/// UI 与拼装链共用它,别在调用方再抄一遍能力位判断。
|
||
#[must_use]
|
||
pub fn backend_allowed(
|
||
version: &crate::launch::game_version::GameVersion,
|
||
resolved_json: &crate::game::VersionJson,
|
||
) -> bool {
|
||
crate::launch::game_args::graphics_backend_allowed(
|
||
version,
|
||
crate::game::VersionCapabilities::from_json(resolved_json).has_vulkan_backend,
|
||
)
|
||
}
|
||
|
||
fn tokens(text: Option<&str>) -> Vec<String> {
|
||
text.map(tokenize).unwrap_or_default()
|
||
}
|
||
|
||
#[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<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));
|
||
}
|
||
}
|