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:
1 parent
d68523b02a
commit
e2021645d7
56 files changed
+3776
-607
No files matched your search
@@ -25,6 +25,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::error::GameError;
|
||||
use super::super::isolation::{IsolationMode, ModeChange, ModeChangeWarning};
|
||||
use super::super::launch_overrides::LaunchOverrides;
|
||||
use super::super::profiles::{InstanceProfile, ProfileList};
|
||||
use crate::platform::atomic::write_atomic;
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
@@ -122,6 +123,12 @@ pub struct InstanceConfig {
|
||||
/// 该实例覆盖了哪些设置键。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub overrides: Vec<OverrideKey>,
|
||||
/// 实例级启动参数覆盖(M157):JVM/游戏参数、内存、Java、图形后端五项,逐项回落。
|
||||
///
|
||||
/// 空段(全 `None`/空白)**不落盘**——与 `isolation` 同一套稀疏语义,清空输入框即「跟随全局」。
|
||||
/// 回落规则与生效值见 [`crate::game::launch_overrides`]。
|
||||
#[serde(default, skip_serializing_if = "LaunchOverrides::is_empty")]
|
||||
pub launch: LaunchOverrides,
|
||||
/// 上次隔离档位切换的记录(`lastModeChange`;没切过就不落盘)。
|
||||
///
|
||||
/// 由 [`crate::game::isolation::observe_mode`] 维护,只记事实(含双侧探测),**不迁移数据**。
|
||||
@@ -166,6 +173,7 @@ impl InstanceConfig {
|
||||
isolation: None,
|
||||
game_dir: None,
|
||||
overrides: Vec::new(),
|
||||
launch: LaunchOverrides::default(),
|
||||
last_mode_change: None,
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
//! 实例级启动参数覆盖(五项)+ **三级回落**(实例 → 全局 → 默认)。
|
||||
//!
|
||||
//! 依据:M157 的定案(叠加/冲突规则见该 mission 的 notes;用户 2026-11-07 拍板实例配置页
|
||||
//! 支持启动参数,含 MC 26.2+ 的 `--graphicsBackend vulkan`)。
|
||||
//!
|
||||
//! # 为什么是「五项全 Option」
|
||||
//!
|
||||
//! 每个字段独立回落:只填了内存的实例不能把全局 JVM 参数一起顶掉。`None`(或空白字符串)
|
||||
//! = 这一项跟随下一层,与 D20 的 `isolation` 同一套稀疏语义([`super::install::instance`])。
|
||||
//!
|
||||
//! # 键名为什么叫 `launch` 而不是 `overrides`
|
||||
//!
|
||||
//! `overrides` 已经被 D20 占用(`["maxMemory", "javaType", "runningDirectory"]` 这份
|
||||
//! 「该实例覆盖了哪些全局键」的清单),抢占会让存量实例文件的语义漂移。五项**取值**段落
|
||||
//! 落在 `launch` 对象里,互不干扰。
|
||||
//!
|
||||
//! # 三级回落逐项(**不是整段覆盖**)
|
||||
//!
|
||||
//! | 字段 | 实例层 | 全局层 | 出厂默认 |
|
||||
//! |---|---|---|---|
|
||||
//! | `jvmArgs` | `launch.jvmArgs` | `java.extra_args` | 空 |
|
||||
//! | `gameArgs` | `launch.gameArgs` | (无全局键,留 `None`) | 空 |
|
||||
//! | `memoryMb` | `launch.memoryMb` | `java.default_memory_mb` | [`DEFAULT_MAX_MEMORY_MB`] |
|
||||
//! | `javaPath` | `launch.javaPath` | `java.selected_path` | 自动挑(托管 > JAVA_HOME > PATH) |
|
||||
//! | `graphicsBackend` | `launch.graphicsBackend` | (无全局键,留 `None`) | `default` |
|
||||
//!
|
||||
//! `jvmArgs` 是**追加**而不是替换:全局是基线(进组装表第 7 项 `user.jvm`),实例是精调
|
||||
//! (进第 4 项 `user.override_jvm`),两者都下发,同名选项 JVM 取后出现者 ⇒ 实例赢。
|
||||
//! 落位实现在 [`crate::launch::overrides`],本文件只给**生效值**(纯计算、零 IO)。
|
||||
//!
|
||||
//! # 越界的内存值不算「覆盖」
|
||||
//!
|
||||
//! 手改文件能绕过 schema 校验,所以 `memoryMb` 在这个模块里也过一遍范围:
|
||||
//! 越界 ⇒ 当作没填(落到下一层),而不是拿 `-Xmx0m` 去把 JVM 启动弄崩。判据与
|
||||
//! `setting::instance_keys` 登记的区间同源([`MIN_MAX_MEMORY_MB`] / [`MAX_MAX_MEMORY_MB`])。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 出厂默认最大堆(MiB):与 `setting::keys` 的 `java.default_memory_mb` 默认值一致。
|
||||
pub const DEFAULT_MAX_MEMORY_MB: u32 = 2048;
|
||||
/// `memoryMb` 允许的下限(MiB)。
|
||||
pub const MIN_MAX_MEMORY_MB: u32 = 256;
|
||||
/// `memoryMb` 允许的上限(MiB)。
|
||||
pub const MAX_MAX_MEMORY_MB: u32 = 65536;
|
||||
|
||||
/// 图形后端三态(任务书:默认 / OpenGL / Vulkan)。
|
||||
///
|
||||
/// `Default` **不是**「交给 JVM 决定」的另一个参数,而是「不下发 `--graphicsBackend`」——
|
||||
/// 26.2 以前根本没有这个参数,下发即 `UnrecognizedOptionException`。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GraphicsBackend {
|
||||
/// 不下发(游戏按自己的默认后端启动)。
|
||||
#[default]
|
||||
Default,
|
||||
/// `--graphicsBackend opengl`。
|
||||
Opengl,
|
||||
/// `--graphicsBackend vulkan`。
|
||||
Vulkan,
|
||||
}
|
||||
|
||||
impl GraphicsBackend {
|
||||
/// 全部三态(顺序即 UI 下拉的展示顺序)。
|
||||
pub const ALL: [Self; 3] = [Self::Default, Self::Opengl, Self::Vulkan];
|
||||
/// 中文档位说明里的稳定 id 之一。
|
||||
pub const IDS: [&'static str; 3] = ["default", "opengl", "vulkan"];
|
||||
|
||||
/// 稳定 id(`default` / `opengl` / `vulkan`;GUI 与 `--json` 同词表)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "default",
|
||||
Self::Opengl => "opengl",
|
||||
Self::Vulkan => "vulkan",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从稳定 id 解析;未知值返回 `None`(**调用方必须报错**,不许静默退化成 `default`)。
|
||||
#[must_use]
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
let wanted = raw.trim();
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|backend| backend.as_str().eq_ignore_ascii_case(wanted))
|
||||
}
|
||||
|
||||
/// `--graphicsBackend` 的取值;`Default` = 不下发(`None`)。
|
||||
#[must_use]
|
||||
pub const fn flag_value(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Default => None,
|
||||
Self::Opengl => Some("opengl"),
|
||||
Self::Vulkan => Some("vulkan"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否需要下发参数(`default` 不需要)。
|
||||
#[must_use]
|
||||
pub const fn is_default(self) -> bool {
|
||||
matches!(self, Self::Default)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例级启动参数覆盖(`config/instances/<id>.json` 的 `launch` 段)。
|
||||
///
|
||||
/// 稀疏:只写用户显式改过的项,键不存在即「跟随下一层」。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct LaunchOverrides {
|
||||
/// 实例级 JVM 参数(空白分隔的文本,支持引号);空 = 只用全局 `java.extra_args`。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jvm_args: Option<String>,
|
||||
/// 实例级游戏参数(同上);空 = 不追加。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub game_args: Option<String>,
|
||||
/// 最大堆(MiB);空或越界 = 跟随全局 `java.default_memory_mb`。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memory_mb: Option<u32>,
|
||||
/// 实例级 Java 可执行文件;空 = 跟随全局 `java.selected_path` 再退到自动挑。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub java_path: Option<PathBuf>,
|
||||
/// 图形后端;空 = `default`(不下发 `--graphicsBackend`)。
|
||||
///
|
||||
/// 反序列化对未知值**报错**(`unknown variant`),不静默落成 `default`。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub graphics_backend: Option<GraphicsBackend>,
|
||||
}
|
||||
|
||||
impl LaunchOverrides {
|
||||
/// 整段是否为空(序列化时用来决定 `launch` 键出不出现)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.normalized() == Self::default()
|
||||
}
|
||||
|
||||
/// 归一:空白字符串与空路径当作没填(用户清空输入框 = 回到跟随)。
|
||||
#[must_use]
|
||||
pub fn normalized(&self) -> Self {
|
||||
Self {
|
||||
jvm_args: non_blank(self.jvm_args.as_deref()).map(str::to_string),
|
||||
game_args: non_blank(self.game_args.as_deref()).map(str::to_string),
|
||||
memory_mb: self.memory_mb.filter(|value| valid_memory(*value)),
|
||||
java_path: self
|
||||
.java_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.map(PathBuf::from),
|
||||
graphics_backend: self.graphics_backend,
|
||||
}
|
||||
}
|
||||
|
||||
/// 生效的最大堆(已过范围校验;`None` = 这一层没给)。
|
||||
#[must_use]
|
||||
pub fn effective_memory_mb(&self) -> Option<u32> {
|
||||
self.memory_mb.filter(|value| valid_memory(*value))
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局层(`config/launcher.json` 里与启动参数相关的键)。
|
||||
///
|
||||
/// 读配置归调用方(CLI / presenter 的 host),本结构只承载**值**——`game` 不 import
|
||||
/// `setting`,回落规则才能纯函数化、可纯单测。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct GlobalLaunchDefaults {
|
||||
/// `java.extra_args`(基线 JVM 参数)。
|
||||
pub jvm_args: Option<String>,
|
||||
/// 全局游戏参数(本轮没有对应设置键,留 `None` 占位,回落链照样逐项成立)。
|
||||
pub game_args: Option<String>,
|
||||
/// `java.default_memory_mb`。
|
||||
pub memory_mb: Option<u32>,
|
||||
/// `java.selected_path`。
|
||||
pub java_path: Option<PathBuf>,
|
||||
/// 全局图形后端(本轮没有对应设置键)。
|
||||
pub graphics_backend: Option<GraphicsBackend>,
|
||||
}
|
||||
|
||||
impl GlobalLaunchDefaults {
|
||||
/// 只带 `java.extra_args` 的最小构造(测试/CLI 常用)。
|
||||
#[must_use]
|
||||
pub fn with_jvm_args(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
jvm_args: Some(text.into()),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 三级回落之后的**生效值**(拼装链的输入,见 [`crate::launch::overrides::apply`])。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedLaunch {
|
||||
/// 全局层 JVM 参数(组装表第 7 项,`None` = 没有)。
|
||||
pub global_jvm_args: Option<String>,
|
||||
/// 实例层 JVM 参数(组装表第 4 项,最高优先级,`None` = 没有)。
|
||||
pub instance_jvm_args: Option<String>,
|
||||
/// 游戏参数(第 32 项,最后追加)。
|
||||
pub game_args: Option<String>,
|
||||
/// 生效最大堆(三级回落 + 范围校验 + 出厂默认,**恒有值**)。
|
||||
pub memory_mb: u32,
|
||||
/// 生效 Java 可执行文件;`None` = 调用方走自动挑。
|
||||
pub java_path: Option<PathBuf>,
|
||||
/// 生效图形后端(`Default` = 不下发)。
|
||||
pub graphics_backend: GraphicsBackend,
|
||||
}
|
||||
|
||||
impl ResolvedLaunch {
|
||||
/// 是否与「全默认」等价(测试与日志用)。
|
||||
#[must_use]
|
||||
pub fn is_defaults(&self) -> bool {
|
||||
self.global_jvm_args.is_none()
|
||||
&& self.instance_jvm_args.is_none()
|
||||
&& self.game_args.is_none()
|
||||
&& self.memory_mb == DEFAULT_MAX_MEMORY_MB
|
||||
&& self.java_path.is_none()
|
||||
&& self.graphics_backend.is_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 三级回落:`instance` 为 `None`/空项一律落到 `global`,再落到出厂默认。
|
||||
#[must_use]
|
||||
pub fn resolve(
|
||||
instance: Option<&LaunchOverrides>,
|
||||
global: &GlobalLaunchDefaults,
|
||||
) -> ResolvedLaunch {
|
||||
let instance = instance
|
||||
.map(LaunchOverrides::normalized)
|
||||
.unwrap_or_default();
|
||||
let global = GlobalLaunchDefaults {
|
||||
jvm_args: non_blank(global.jvm_args.as_deref()).map(str::to_string),
|
||||
game_args: non_blank(global.game_args.as_deref()).map(str::to_string),
|
||||
memory_mb: global.memory_mb.filter(|value| valid_memory(*value)),
|
||||
java_path: global.java_path.clone(),
|
||||
graphics_backend: global.graphics_backend,
|
||||
};
|
||||
|
||||
ResolvedLaunch {
|
||||
global_jvm_args: global.jvm_args,
|
||||
instance_jvm_args: instance.jvm_args,
|
||||
// gameArgs 是**替换**语义:实例给了就用实例的(全局层本轮恒空)。
|
||||
game_args: instance.game_args.or(global.game_args),
|
||||
memory_mb: instance
|
||||
.memory_mb
|
||||
.or(global.memory_mb)
|
||||
.unwrap_or(DEFAULT_MAX_MEMORY_MB),
|
||||
java_path: instance.java_path.or(global.java_path),
|
||||
graphics_backend: instance
|
||||
.graphics_backend
|
||||
.or(global.graphics_backend)
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 内存范围判据(与 `setting::instance_keys` 登记的区间同源)。
|
||||
#[must_use]
|
||||
pub fn valid_memory(mb: u32) -> bool {
|
||||
(MIN_MAX_MEMORY_MB..=MAX_MAX_MEMORY_MB).contains(&mb)
|
||||
}
|
||||
|
||||
/// 五项**文本**补丁(GUI 表单与 CLI 共用):`None` = 这一项不改;`Some("")` = 清空回「跟随」。
|
||||
///
|
||||
/// 校验只在这里做一次(内存整数/范围、图形后端词表),两个调用端都拿同一条错误文案——
|
||||
/// 否则「GUI 说合法、CLI 说非法」这类两套真相迟早出现。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct LaunchOverridesPatch {
|
||||
/// 实例层 JVM 参数原文。
|
||||
pub jvm_args: Option<String>,
|
||||
/// 实例层游戏参数原文。
|
||||
pub game_args: Option<String>,
|
||||
/// 实例层最大堆(文本形态,见 [`MIN_MAX_MEMORY_MB`] / [`MAX_MAX_MEMORY_MB`])。
|
||||
pub memory_mb: Option<String>,
|
||||
/// 实例层 Java 可执行文件。
|
||||
pub java_path: Option<String>,
|
||||
/// 实例层图形后端(`default` / `opengl` / `vulkan`)。
|
||||
pub graphics_backend: Option<String>,
|
||||
}
|
||||
|
||||
impl LaunchOverridesPatch {
|
||||
/// 只改图形后端(下拉切换的最小补丁)。
|
||||
#[must_use]
|
||||
pub fn backend(id: &str) -> Self {
|
||||
Self {
|
||||
graphics_backend: Some(id.to_string()),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空全部五项(`--clear` 的语义;随后可按需再给几项)。
|
||||
#[must_use]
|
||||
pub fn clear_all() -> Self {
|
||||
Self {
|
||||
jvm_args: Some(String::new()),
|
||||
game_args: Some(String::new()),
|
||||
memory_mb: Some(String::new()),
|
||||
java_path: Some(String::new()),
|
||||
graphics_backend: Some("default".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LaunchOverrides {
|
||||
/// 应用文本补丁:`None` 不动、空串清空;非法值**报错**(不静默丢弃)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 内存不是整数、越界,或图形后端不在 [`GraphicsBackend::IDS`] 里时返回用户可读文案。
|
||||
pub fn patched(&self, patch: &LaunchOverridesPatch) -> Result<Self, String> {
|
||||
let mut next = self.normalized();
|
||||
if let Some(text) = &patch.jvm_args {
|
||||
next.jvm_args = non_blank(Some(text)).map(str::to_string);
|
||||
}
|
||||
if let Some(text) = &patch.game_args {
|
||||
next.game_args = non_blank(Some(text)).map(str::to_string);
|
||||
}
|
||||
if let Some(text) = &patch.memory_mb {
|
||||
next.memory_mb = parse_memory(text)?;
|
||||
}
|
||||
if let Some(text) = &patch.java_path {
|
||||
next.java_path = non_blank(Some(text)).map(PathBuf::from);
|
||||
}
|
||||
if let Some(text) = &patch.graphics_backend {
|
||||
next.graphics_backend = match GraphicsBackend::parse(text) {
|
||||
// `default` 是「不下发」而不是要落盘的覆盖值:清成 None(稀疏语义)。
|
||||
Some(GraphicsBackend::Default) => None,
|
||||
Some(backend) => Some(backend),
|
||||
None => {
|
||||
return Err(format!(
|
||||
"无法识别的图形后端 `{text}`(只接受 {})",
|
||||
GraphicsBackend::IDS.join(" / ")
|
||||
))
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
}
|
||||
|
||||
/// 内存文本 → `Option<u32>`(空 = 清空;非整数/越界 = 错)。
|
||||
fn parse_memory(text: &str) -> Result<Option<u32>, String> {
|
||||
let Some(text) = non_blank(Some(text)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value: u32 = text
|
||||
.parse()
|
||||
.map_err(|_| format!("内存需要是整数(MiB),收到 `{text}`"))?;
|
||||
if !valid_memory(value) {
|
||||
return Err(format!(
|
||||
"内存超出范围({MIN_MAX_MEMORY_MB}~{MAX_MAX_MEMORY_MB} MiB),收到 {value}"
|
||||
));
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn non_blank(text: Option<&str>) -> Option<&str> {
|
||||
text.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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"}"#
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ pub mod inherit;
|
||||
pub mod install;
|
||||
pub mod isolation;
|
||||
pub mod java;
|
||||
pub mod launch_overrides;
|
||||
pub mod layout;
|
||||
pub mod library;
|
||||
pub mod loader;
|
||||
@@ -55,6 +56,11 @@ pub use isolation::{
|
||||
IsolationSource, ModeChange, ModeChangeProbe, ModeChangeWarning, SideContent,
|
||||
PROBED_CONTENT_DIRS, SHARED_LAYERS,
|
||||
};
|
||||
pub use launch_overrides::{
|
||||
resolve as resolve_launch_overrides, valid_memory, GlobalLaunchDefaults, GraphicsBackend,
|
||||
LaunchOverrides, LaunchOverridesPatch, ResolvedLaunch, DEFAULT_MAX_MEMORY_MB,
|
||||
MAX_MAX_MEMORY_MB, MIN_MAX_MEMORY_MB,
|
||||
};
|
||||
pub use layout::GameLayout;
|
||||
pub use library::{Library, MavenCoordinate};
|
||||
pub use loader::{InstanceLoader, LoaderKind};
|
||||
|
||||
@@ -40,6 +40,7 @@ pub mod launch_session;
|
||||
pub mod launcher;
|
||||
pub mod log_level;
|
||||
pub mod natives;
|
||||
pub mod overrides;
|
||||
pub mod placeholders;
|
||||
pub mod platform;
|
||||
pub mod process;
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! 实例级启动参数的**落位**:`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));
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,11 @@ pub const ISOLATION_OVERRIDES: &[&str] = &["inherit", "isolated", "shared", "cus
|
||||
pub const ISOLATION_RESOLVED: &[&str] = &["isolated", "shared", "custom"];
|
||||
/// 实例可覆盖的设置键(enum 白名单,拼错即校验失败而不是静默不生效)。
|
||||
pub const OVERRIDE_KEYS: &[&str] = &["maxMemory", "javaType", "runningDirectory"];
|
||||
/// `launch.graphicsBackend` 的允许值(`default` = 不下发 `--graphicsBackend`)。
|
||||
///
|
||||
/// 与 `crate::game::launch_overrides::GraphicsBackend::IDS` 必须是同一份集合——setting 层
|
||||
/// 不依赖 game 层(分层纪律),一致性由本文件的 `graphics_backends_match_the_game_layer` 固定。
|
||||
pub const GRAPHICS_BACKENDS: &[&str] = &["default", "opengl", "vulkan"];
|
||||
|
||||
/// profiles.json 当前 schema 版本。
|
||||
#[must_use]
|
||||
@@ -111,6 +116,42 @@ pub fn instance_schema() -> SettingSchema {
|
||||
AiPolicy::locked(),
|
||||
"该实例覆盖了全局哪些设置键(enum 白名单,拼错即校验失败)",
|
||||
))
|
||||
// M157:实例级启动参数(`launch` 段,五项全可选、逐项回落到全局)。
|
||||
// 作者是 `game::install::instance::InstanceConfig`(serde 形状与这些路径逐键对应),
|
||||
// 回落规则与生效值在 `game::launch_overrides`,拼装消费在 `launch::overrides`。
|
||||
.with(SettingSpec::text(
|
||||
"launch.jvmArgs",
|
||||
"",
|
||||
1024,
|
||||
AiPolicy::sensitive(),
|
||||
"实例级 JVM 参数(空白分隔、支持引号);留空 = 只用全局 java.extra_args(两者叠加,实例优先)",
|
||||
))
|
||||
.with(SettingSpec::text(
|
||||
"launch.gameArgs",
|
||||
"",
|
||||
1024,
|
||||
AiPolicy::audited(),
|
||||
"实例级游戏参数(追加在命令行末段,可覆盖前面一切游戏参数);留空 = 不追加",
|
||||
))
|
||||
.with(SettingSpec::int(
|
||||
"launch.memoryMb",
|
||||
0,
|
||||
(0, 65536),
|
||||
AiPolicy::sensitive(),
|
||||
"实例级最大堆(MiB);0/缺键 = 跟随全局 java.default_memory_mb(有效区间 256~65536)",
|
||||
))
|
||||
.with(SettingSpec::path(
|
||||
"launch.javaPath",
|
||||
AiPolicy::sensitive(),
|
||||
"实例级 Java 可执行文件;留空 = 跟随全局 java.selected_path 再退到自动挑(托管 > JAVA_HOME > PATH)",
|
||||
))
|
||||
.with(SettingSpec::enumeration(
|
||||
"launch.graphicsBackend",
|
||||
"default",
|
||||
GRAPHICS_BACKENDS,
|
||||
AiPolicy::audited(),
|
||||
"图形后端:default(不下发)/ opengl / vulkan。只对 MC 26.2-snapshot-2+ 下发,老版本会被门槛拦下并给出诊断告警",
|
||||
))
|
||||
.with(SettingSpec::text(
|
||||
"lastModeChange.at",
|
||||
"",
|
||||
@@ -153,3 +194,58 @@ pub fn instance_schema() -> SettingSchema {
|
||||
"该次切换的提示是否已展示过(保证切换后首次启动只提示一次)",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// M157:图形后端词表**只有一份真相源**(`game::launch_overrides::GraphicsBackend`),
|
||||
/// setting 层是它的白名单视图——两边脱节就是「写得进、读不出」或「UI 三态少一档」。
|
||||
#[test]
|
||||
fn graphics_backends_match_the_game_layer() {
|
||||
use crate::game::launch_overrides::GraphicsBackend;
|
||||
let mut setting: Vec<&str> = GRAPHICS_BACKENDS.to_vec();
|
||||
let mut game: Vec<&str> = GraphicsBackend::IDS.to_vec();
|
||||
setting.sort_unstable();
|
||||
game.sort_unstable();
|
||||
assert_eq!(setting, game, "setting 白名单与 game 层词表脱节了");
|
||||
for id in GRAPHICS_BACKENDS {
|
||||
assert!(
|
||||
GraphicsBackend::parse(id).is_some(),
|
||||
"`{id}` 在 game 层解析不出来"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 五项键真的进了实例 schema 白名单(且 `default` 之外的后端值被拒)。
|
||||
#[test]
|
||||
fn launch_section_keys_are_registered() {
|
||||
let schema = instance_schema();
|
||||
assert!(schema
|
||||
.validate("launch.jvmArgs", &serde_json::json!("-XX:+UseG1GC"))
|
||||
.is_ok());
|
||||
assert!(schema
|
||||
.validate("launch.gameArgs", &serde_json::json!("--fullscreen"))
|
||||
.is_ok());
|
||||
assert!(schema
|
||||
.validate("launch.memoryMb", &serde_json::json!(4096))
|
||||
.is_ok());
|
||||
assert!(schema
|
||||
.validate(
|
||||
"launch.javaPath",
|
||||
&serde_json::json!("D:/jdk21/bin/java.exe")
|
||||
)
|
||||
.is_ok());
|
||||
assert!(schema
|
||||
.validate("launch.graphicsBackend", &serde_json::json!("vulkan"))
|
||||
.is_ok());
|
||||
// 非法后端值:白名单外必须报错(手改文件绕不过校验)。
|
||||
assert!(schema
|
||||
.validate("launch.graphicsBackend", &serde_json::json!("vulkan2"))
|
||||
.is_err());
|
||||
// 越界内存:schema 层也拦。
|
||||
assert!(schema
|
||||
.validate("launch.memoryMb", &serde_json::json!(1_000_000))
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user