Files
Unlucky-Launcher/crates/core/src/setting/instance_keys.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

252 lines
10 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.
//! `config/profiles.json` 与 `config/instances/<id>.json` 的键表。
//!
//! 实例模型预告见 `DevDocs/PlanDoc/04-data-and-config.md` §4:
//! 每实例 = 一个 `.minecraft` 目录绑定 + 独立设置覆盖链(全局 → 实例 → 临时启动参数),
//! 实例 id 用稳定 UUID(不用路径——路径会变)。
//!
//! # 实例文件的形状(D20 定案,research/04 §5.1)
//!
//! 实例文件是**稀疏 camelCase 扁平结构**,与 `game::install::instance::InstanceConfig`
//! 的 serde 形状逐键一致(那份结构才是写入者,本文件是它的 schema 白名单视图):
//!
//! ```jsonc
//! {
//! "schemaVersion": 1,
//! "id": "<uuid>",
//! "versionId": "1.20.1",
//! "isolation": "custom", // 缺键 = inherit(跟随全局,不落盘)
//! "gameDir": "D:/mcs/farm", // 仅 custom 时有值
//! "overrides": ["maxMemory"],
//! "lastModeChange": { "at": "2026-10-30T12:00:00Z", "from": "shared", "to": "isolated",
//! "probe": { "isolatedSide": 0, "sharedSide": 12 }, "warned": true }
//! }
//! ```
//!
//! 三条不变量:
//! 1. `isolation` 默认 `inherit` 且**不落盘**——改全局模式对存量实例立即生效
//! (`document` 的「默认值不进文件」保证这一点,直接避开 PCL2 的默认值固化缺陷);
//! 显式写 `"isolation": "inherit"` 也合法:`InstanceConfig` 的反序列化把它归一成
//! 「键不存在」(等价语义,不会出现两层读写回环不一致);
//! 2. `isolation`/`lastModeChange` 由用户(前者)与启动器(后者)维护,**AI 不可写**;
//! 3. 派生的绝对路径不入档:`isolated` 只是「运行目录 = `versions/<versionId>`」的语法糖,
//! 派生实现归 `game::isolation`。
use super::catalog::SettingSchema;
use super::schema::{SchemaId, SchemaVersion};
use super::spec::{AiPolicy, SettingSpec};
/// 实例级 `isolation` 允许值(`inherit` = 键缺失,跟随全局 `instances.mode`)。
pub const ISOLATION_OVERRIDES: &[&str] = &["inherit", "isolated", "shared", "custom"];
/// 切换检测记录里 `from`/`to` 的允许值(`inherit` 不是生效档位,故不在表里)。
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]
pub fn profiles_version() -> SchemaVersion {
SchemaVersion::new(1, 0, 0)
}
/// instances/<id>.json 当前 schema 版本。
///
/// `1.1.0` 登记 D20 的 `isolation` 档位(`inherit`/`isolated`/`shared`/`custom`)与
/// `lastModeChange` 切换检测键;老文件缺键即默认值,不需要迁移步骤(major 未变)。
#[must_use]
pub fn instance_version() -> SchemaVersion {
SchemaVersion::new(1, 1, 0)
}
/// `config/profiles.json` 的 schema。
#[must_use]
pub fn profiles_schema() -> SettingSchema {
SettingSchema::new(SchemaId::PROFILES, profiles_version())
.with(SettingSpec::text(
"selected_profile_id",
"",
64,
AiPolicy::audited(),
"上次选中的实例 id",
))
.with(SettingSpec::any(
"profiles",
AiPolicy::locked(),
"实例档案列表(数据而非配置,由 game 模块维护;AI 不得整体改写)",
))
}
/// `config/instances/<id>.json` 的 schema。
#[must_use]
pub fn instance_schema() -> SettingSchema {
SettingSchema::new(SchemaId::INSTANCE, instance_version())
.with(SettingSpec::text(
"id",
"",
64,
AiPolicy::locked(),
"实例稳定 id(UUID,与文件名一致;不允许改写身份)",
))
.with(SettingSpec::text(
"versionId",
"",
128,
AiPolicy::audited(),
"绑定的版本 id(versions/<id>)",
))
.with(SettingSpec::enumeration(
"isolation",
"inherit",
ISOLATION_OVERRIDES,
AiPolicy::sensitive(),
"实例隔离档位:inherit(跟随全局 instances.mode,不落盘)/ isolated(运行目录派生 versions/<versionId>)/ shared(共用 .minecraft 根)/ custom(用 gameDir)",
))
.with(SettingSpec::path(
"gameDir",
AiPolicy::sensitive(),
"自定义运行目录(仅 isolation=custom 生效)",
))
.with(SettingSpec::enum_list(
"overrides",
OVERRIDE_KEYS,
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",
"",
40,
AiPolicy::locked(),
"上次切换隔离档位的时间(RFC-3339,启动器写入)",
))
.with(SettingSpec::enumeration(
"lastModeChange.from",
"isolated",
ISOLATION_RESOLVED,
AiPolicy::locked(),
"切换前的生效档位(启动器写入)",
))
.with(SettingSpec::enumeration(
"lastModeChange.to",
"isolated",
ISOLATION_RESOLVED,
AiPolicy::locked(),
"切换后的生效档位(启动器写入)",
))
.with(SettingSpec::int(
"lastModeChange.probe.isolatedSide",
0,
(0, 1_000_000_000),
AiPolicy::locked(),
"切换时隔离侧(versions/<versionId>)的内容条目数(只探测,不迁移)",
))
.with(SettingSpec::int(
"lastModeChange.probe.sharedSide",
0,
(0, 1_000_000_000),
AiPolicy::locked(),
"切换时共享侧(.minecraft 根)的内容条目数(只探测,不迁移)",
))
.with(SettingSpec::bool(
"lastModeChange.warned",
false,
AiPolicy::locked(),
"该次切换的提示是否已展示过(保证切换后首次启动只提示一次)",
))
}
#[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());
}
}