Files
Unlucky-Launcher/crates/core/src/launch/overrides.rs
T
AskaEth e2021645d7 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 中英文案同步。
2026-11-07 10:36:35 +08:00

373 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 实例级启动参数的**落位**:`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));
}
}