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

+9
View File
@@ -98,6 +98,15 @@ impl CoreHost {
Err(reason) => self.toast(ToastKind::Error, reason),
}
}
AppCommand::SetInstanceLaunchArgs { id, patch } => {
match self.apply_launch_args(&id, &patch) {
Ok(()) => {
self.toast(ToastKind::Success, "启动参数已保存");
self.refresh_instances();
}
Err(reason) => self.toast(ToastKind::Error, reason),
}
}
AppCommand::RefreshLogSessions => {
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
}
+6 -196
View File
@@ -126,16 +126,18 @@ pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
.map(|profile| {
let version_id = profile.version_id.clone();
let (mc_version, loader) = describe_version(env, &version_id);
let id = profile.id.as_str();
InstanceSummary {
id: profile.id.as_str().to_string(),
id: id.to_string(),
name: profile.name.clone(),
mc_version,
loader,
last_played: played.get(profile.id.as_str()).cloned(),
last_played: played.get(id).cloned(),
runtime: InstanceRuntime::Idle,
icon: None,
isolation: isolation_of(env, profile.id.as_str()),
isolation: isolation_of(env, id),
version_present: env.game.is_version_installed(&version_id),
launch: super::launch_args::view(env, id, &version_id),
version_id,
}
})
@@ -205,196 +207,4 @@ pub fn short_path(path: &Path) -> String {
}
#[cfg(test)]
mod tests {
use super::*;
/// 版本 id → DTO 加载器的接口面(解析在 core,这里只验翻译没走样)。
///
/// 抓得住:显示路径把某个种类翻错/翻塌(例如 Quilt 写成 Forge)——`dto_kind` 的委派与
/// `dto` 的 `from_core` 都落在这一条上;以及 core 解析端退回老口径(`fabric-loader-…` 才认、
/// 自家 `1.20.4-fabric-0.16.5` 认不出)——后两条正是 M122 的 bug 面。
/// 抓不住:core 解析规则本身(真值表在 `unlucky_core::game::loader` 的测试里,那边才是
/// 真相源);也抓不住「版本号为空时不显示品牌名」那条(在 `LoaderInfo::badge` 与
/// app-iced 的 `loader_badge`,M120 已修,别在这里重复)。
#[test]
fn loader_parsing_covers_all_shapes() {
let cases = [
("1.20.4", LoaderKind::Vanilla, ""),
// 自家口径(生成端 `LoaderKind::version_id`):`<mc>-<kind>-<loader>`
("1.20.4-fabric-0.16.5", LoaderKind::Fabric, "0.16.5"),
("1.20.4-quilt-0.27.0", LoaderKind::Quilt, "0.27.0"),
("1.20.1-forge-47.2.0", LoaderKind::Forge, "47.2.0"),
("1.21.1-neoforge-21.1.80", LoaderKind::NeoForge, "21.1.80"),
// 老 HMCL 口径(存量实例还会带着它们)
("fabric-loader-0.16.5-1.20.1", LoaderKind::Fabric, "0.16.5"),
("quilt-loader-0.24.0-1.20.1", LoaderKind::Quilt, "0.24.0"),
// 第三方口径
("1.13.2-OptiFine_HD_U_E6", LoaderKind::OptiFine, "HD_U_E6"),
("1.12.2-LiteLoader", LoaderKind::LiteLoader, ""),
];
for (id, kind, version) in cases {
let info = loader_from_id(id);
assert_eq!(info.kind, kind, "{id}");
assert_eq!(info.version, version, "{id}");
assert!(!info.badge().is_empty());
}
}
/// 显示路径的**全量**锚:core 的每一种加载器,从自家版本 id 走到磁贴时仍然是**它自己**。
///
/// 清单取自 [`unlucky_core::game::LoaderKind::ALL`],**不手抄**:M126 r3 复审实测出的缺口正是
/// 「手抄的清单跟着漏」——core 加第八家、契约与 help 都补齐、只在 `dto_kind` 那一支写
/// `=> Vanilla`,三个 crate 全绿而磁贴静默显示「原版」。`ALL` 由 core 的宏表长出来,加一家
/// 自动进圈,测试作者不需要(也没法)记得补一行。
///
/// 抓得住:显示路径任何一环把种类翻塌成原版——`dto_kind` 又变回手写映射、`dto` 的
/// `from_core` 那一支接错、`label()` 被写成「原版」;**包括新加的那种**(那时 `ALL` 先变长)。
/// 抓不住:core 的 id 解析规则本身(真值表在 `unlucky_core::game::loader`);版本号为空的
/// 徽标判据(M120 的 `loader_badge`,本 crate 不渲染)。
#[test]
fn every_core_loader_kind_reaches_the_tile_as_itself() {
use unlucky_core::game::LoaderKind as Core;
for kind in Core::ALL {
let info = loader_from_id(&format!("1.20.4-{}-0.16.5", kind.as_str()));
assert_eq!(
info.kind.as_str(),
kind.as_str(),
"core 的 {kind:?} 走到磁贴时变成了 `{}`(徽标 `{}`)",
info.kind.as_str(),
info.badge()
);
assert_eq!(
info.badge().starts_with(LoaderKind::Vanilla.label()),
kind == Core::Vanilla,
"core 的 {kind:?} 在磁贴上的徽标是 `{}`",
info.badge()
);
}
}
/// 端到端(数据层):真装出来的四个加载器实例,走 [`instances`] 的快照各自带对加载器。
///
/// 造的是本启动器**真实产物**的形状:`profiles.json` 里 `versionId` 是自家 id,
/// `versions/<id>/<id>.json` 里 `inheritsFrom` 指向原版层(生成端见
/// `unlucky_core::addon::loader`)。原版版本号由此取自继承链根,不是从 id 里切的。
///
/// 抓得住:`describe_version` ↔ `loader_from_id` ↔ `instances` 这条链上任何一环把加载器
/// 丢掉(磁贴会显示「原版」——M122 的用户可见症状);以及继承链根取错(磁贴的版本号)。
/// 抓不住:实例页真出图(走 app-iced 的视图模型与 `loader_badge`,本 crate 不渲染);
/// 也抓不住「真下载安装了 Fabric/Quilt/Forge/NeoForge」——这里只伪造落盘结果。
#[test]
fn own_installed_instances_snapshot_carries_their_loader() {
let dir = std::env::temp_dir().join(format!("ull-host-game-own-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
let cases = [
(
"1.20.4-fabric-0.16.5",
"1.20.4",
LoaderKind::Fabric,
"Fabric 0.16.5",
),
(
"1.20.4-quilt-0.27.0",
"1.20.4",
LoaderKind::Quilt,
"Quilt 0.27.0",
),
(
"1.20.1-forge-47.2.0",
"1.20.1",
LoaderKind::Forge,
"Forge 47.2.0",
),
(
"1.21.1-neoforge-21.1.80",
"1.21.1",
LoaderKind::NeoForge,
"NeoForge 21.1.80",
),
];
for (version_id, mc_version, _, _) in cases {
for (id, inherits) in [(mc_version, None), (version_id, Some(mc_version))] {
let json = match inherits {
Some(parent) => format!(r#"{{"id":"{id}","inheritsFrom":"{parent}"}}"#),
None => format!(r#"{{"id":"{id}"}}"#),
};
let path = env.game.version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, json).expect("写版本 JSON");
}
}
let profiles = ProfileList {
profiles: cases
.iter()
.map(
|(version_id, _, _, _)| unlucky_core::game::InstanceProfile {
id: unlucky_core::game::ProfileId::generate(),
name: format!("装好的 {version_id}"),
version_id: (*version_id).to_string(),
game_dir: None,
running_dir: None,
java_path: None,
created_at: None,
last_played_at: None,
extra: BTreeMap::new(),
},
)
.collect(),
..ProfileList::default()
};
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles)
.expect("写清单");
let snapshot = instances(&env);
assert_eq!(snapshot.len(), cases.len());
for ((version_id, mc_version, kind, badge), tile) in cases.iter().zip(&snapshot) {
assert_eq!(tile.version_id.as_str(), *version_id);
assert_eq!(
tile.mc_version.as_str(),
*mc_version,
"{version_id} 的版本号取错"
);
assert_eq!(tile.loader.kind, *kind, "{version_id} 的加载器");
assert_eq!(tile.loader.badge().as_str(), *badge, "{version_id} 的徽标");
assert!(tile.version_present, "{version_id} 的载荷应当存在");
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn isolation_write_then_read_round_trips() {
let dir = std::env::temp_dir().join(format!("ull-host-game-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
// 直接建一份 profiles.json(register 需要版本本体,这里只测隔离写读)
let profiles = ProfileList {
profiles: vec![unlucky_core::game::InstanceProfile {
id: unlucky_core::game::ProfileId::generate(),
name: "demo".to_string(),
version_id: "1.20.1".to_string(),
game_dir: None,
running_dir: None,
java_path: None,
created_at: None,
last_played_at: None,
extra: BTreeMap::new(),
}],
..ProfileList::default()
};
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles)
.expect("写清单");
let id = profiles.profiles[0].id.as_str().to_string();
assert_eq!(isolation_of(&env, &id), "isolated");
set_isolation(&env, &id, "shared").expect("切档位");
assert_eq!(isolation_of(&env, &id), "shared");
assert!(set_isolation(&env, &id, "nope").is_err());
std::fs::remove_dir_all(&dir).ok();
}
}
mod tests;
+189
View File
@@ -0,0 +1,189 @@
use super::*;
/// 版本 id → DTO 加载器的接口面(解析在 core,这里只验翻译没走样)。
///
/// 抓得住:显示路径把某个种类翻错/翻塌(例如 Quilt 写成 Forge)——`dto_kind` 的委派与
/// `dto` 的 `from_core` 都落在这一条上;以及 core 解析端退回老口径(`fabric-loader-…` 才认、
/// 自家 `1.20.4-fabric-0.16.5` 认不出)——后两条正是 M122 的 bug 面。
/// 抓不住:core 解析规则本身(真值表在 `unlucky_core::game::loader` 的测试里,那边才是
/// 真相源);也抓不住「版本号为空时不显示品牌名」那条(在 `LoaderInfo::badge` 与
/// app-iced 的 `loader_badge`,M120 已修,别在这里重复)。
#[test]
fn loader_parsing_covers_all_shapes() {
let cases = [
("1.20.4", LoaderKind::Vanilla, ""),
// 自家口径(生成端 `LoaderKind::version_id`):`<mc>-<kind>-<loader>`
("1.20.4-fabric-0.16.5", LoaderKind::Fabric, "0.16.5"),
("1.20.4-quilt-0.27.0", LoaderKind::Quilt, "0.27.0"),
("1.20.1-forge-47.2.0", LoaderKind::Forge, "47.2.0"),
("1.21.1-neoforge-21.1.80", LoaderKind::NeoForge, "21.1.80"),
// 老 HMCL 口径(存量实例还会带着它们)
("fabric-loader-0.16.5-1.20.1", LoaderKind::Fabric, "0.16.5"),
("quilt-loader-0.24.0-1.20.1", LoaderKind::Quilt, "0.24.0"),
// 第三方口径
("1.13.2-OptiFine_HD_U_E6", LoaderKind::OptiFine, "HD_U_E6"),
("1.12.2-LiteLoader", LoaderKind::LiteLoader, ""),
];
for (id, kind, version) in cases {
let info = loader_from_id(id);
assert_eq!(info.kind, kind, "{id}");
assert_eq!(info.version, version, "{id}");
assert!(!info.badge().is_empty());
}
}
/// 显示路径的**全量**锚:core 的每一种加载器,从自家版本 id 走到磁贴时仍然是**它自己**。
///
/// 清单取自 [`unlucky_core::game::LoaderKind::ALL`],**不手抄**:M126 r3 复审实测出的缺口正是
/// 「手抄的清单跟着漏」——core 加第八家、契约与 help 都补齐、只在 `dto_kind` 那一支写
/// `=> Vanilla`,三个 crate 全绿而磁贴静默显示「原版」。`ALL` 由 core 的宏表长出来,加一家
/// 自动进圈,测试作者不需要(也没法)记得补一行。
///
/// 抓得住:显示路径任何一环把种类翻塌成原版——`dto_kind` 又变回手写映射、`dto` 的
/// `from_core` 那一支接错、`label()` 被写成「原版」;**包括新加的那种**(那时 `ALL` 先变长)。
/// 抓不住:core 的 id 解析规则本身(真值表在 `unlucky_core::game::loader`);版本号为空的
/// 徽标判据(M120 的 `loader_badge`,本 crate 不渲染)。
#[test]
fn every_core_loader_kind_reaches_the_tile_as_itself() {
use unlucky_core::game::LoaderKind as Core;
for kind in Core::ALL {
let info = loader_from_id(&format!("1.20.4-{}-0.16.5", kind.as_str()));
assert_eq!(
info.kind.as_str(),
kind.as_str(),
"core 的 {kind:?} 走到磁贴时变成了 `{}`(徽标 `{}`)",
info.kind.as_str(),
info.badge()
);
assert_eq!(
info.badge().starts_with(LoaderKind::Vanilla.label()),
kind == Core::Vanilla,
"core 的 {kind:?} 在磁贴上的徽标是 `{}`",
info.badge()
);
}
}
/// 端到端(数据层):真装出来的四个加载器实例,走 [`instances`] 的快照各自带对加载器。
///
/// 造的是本启动器**真实产物**的形状:`profiles.json` 里 `versionId` 是自家 id,
/// `versions/<id>/<id>.json` 里 `inheritsFrom` 指向原版层(生成端见
/// `unlucky_core::addon::loader`)。原版版本号由此取自继承链根,不是从 id 里切的。
///
/// 抓得住:`describe_version` ↔ `loader_from_id` ↔ `instances` 这条链上任何一环把加载器
/// 丢掉(磁贴会显示「原版」——M122 的用户可见症状);以及继承链根取错(磁贴的版本号)。
/// 抓不住:实例页真出图(走 app-iced 的视图模型与 `loader_badge`,本 crate 不渲染);
/// 也抓不住「真下载安装了 Fabric/Quilt/Forge/NeoForge」——这里只伪造落盘结果。
#[test]
fn own_installed_instances_snapshot_carries_their_loader() {
let dir = std::env::temp_dir().join(format!("ull-host-game-own-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
let cases = [
(
"1.20.4-fabric-0.16.5",
"1.20.4",
LoaderKind::Fabric,
"Fabric 0.16.5",
),
(
"1.20.4-quilt-0.27.0",
"1.20.4",
LoaderKind::Quilt,
"Quilt 0.27.0",
),
(
"1.20.1-forge-47.2.0",
"1.20.1",
LoaderKind::Forge,
"Forge 47.2.0",
),
(
"1.21.1-neoforge-21.1.80",
"1.21.1",
LoaderKind::NeoForge,
"NeoForge 21.1.80",
),
];
for (version_id, mc_version, _, _) in cases {
for (id, inherits) in [(mc_version, None), (version_id, Some(mc_version))] {
let json = match inherits {
Some(parent) => format!(r#"{{"id":"{id}","inheritsFrom":"{parent}"}}"#),
None => format!(r#"{{"id":"{id}"}}"#),
};
let path = env.game.version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, json).expect("写版本 JSON");
}
}
let profiles = ProfileList {
profiles: cases
.iter()
.map(
|(version_id, _, _, _)| unlucky_core::game::InstanceProfile {
id: unlucky_core::game::ProfileId::generate(),
name: format!("装好的 {version_id}"),
version_id: (*version_id).to_string(),
game_dir: None,
running_dir: None,
java_path: None,
created_at: None,
last_played_at: None,
extra: BTreeMap::new(),
},
)
.collect(),
..ProfileList::default()
};
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles).expect("写清单");
let snapshot = instances(&env);
assert_eq!(snapshot.len(), cases.len());
for ((version_id, mc_version, kind, badge), tile) in cases.iter().zip(&snapshot) {
assert_eq!(tile.version_id.as_str(), *version_id);
assert_eq!(
tile.mc_version.as_str(),
*mc_version,
"{version_id} 的版本号取错"
);
assert_eq!(tile.loader.kind, *kind, "{version_id} 的加载器");
assert_eq!(tile.loader.badge().as_str(), *badge, "{version_id} 的徽标");
assert!(tile.version_present, "{version_id} 的载荷应当存在");
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn isolation_write_then_read_round_trips() {
let dir = std::env::temp_dir().join(format!("ull-host-game-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
// 直接建一份 profiles.json(register 需要版本本体,这里只测隔离写读)
let profiles = ProfileList {
profiles: vec![unlucky_core::game::InstanceProfile {
id: unlucky_core::game::ProfileId::generate(),
name: "demo".to_string(),
version_id: "1.20.1".to_string(),
game_dir: None,
running_dir: None,
java_path: None,
created_at: None,
last_played_at: None,
extra: BTreeMap::new(),
}],
..ProfileList::default()
};
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles).expect("写清单");
let id = profiles.profiles[0].id.as_str().to_string();
assert_eq!(isolation_of(&env, &id), "isolated");
set_isolation(&env, &id, "shared").expect("切档位");
assert_eq!(isolation_of(&env, &id), "shared");
assert!(set_isolation(&env, &id, "nope").is_err());
std::fs::remove_dir_all(&dir).ok();
}
+26 -10
View File
@@ -52,9 +52,28 @@ pub struct JavaPick {
pub summary: String,
}
/// 选一个能用的 Java:托管 > JAVA_HOME > PATH(`core::game::java` 的探测与优先级)。
pub fn pick_java(env: &HostEnv, version_id: &str) -> JavaPick {
/// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。
///
/// 显式路径**先探测**(`java -version`):探测失败不报错,按版本要求的基准主版本兜底——
/// 与自动挑的退路同一口径(启动路径不该因为探测不到就拒绝启动)。
pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick {
let store = env.store();
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
if let Some(path) = explicit {
let runtime = unlucky_core::game::java::probe::probe(
path,
unlucky_core::game::java::probe::JavaOrigin::Instance,
);
let major = runtime.major.unwrap_or(required.minimum);
return JavaPick {
selection: JavaSelection::new(runtime.binary.clone(), major, runtime.bits),
summary: format!(
"实例指定 Java {}({})",
runtime.version.clone().unwrap_or_else(|| major.to_string()),
path.display()
),
};
}
let install_root = store
.doc()
.effective_str("java.installRoot")
@@ -69,7 +88,6 @@ pub fn pick_java(env: &HostEnv, version_id: &str) -> JavaPick {
&ProcessEnv,
);
let candidates = unlucky_core::game::java::candidates_in_roots(&roots);
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
let picked = unlucky_core::game::java::pick(&candidates, &required).cloned();
match picked {
Some(runtime) => {
@@ -201,18 +219,14 @@ pub fn build_request(
game_assets: assets_root,
};
let java = pick_java(env, &version_id);
let overrides = super::launch_args::resolved(env, &target.id);
let java = pick_java(env, &version_id, overrides.java_path.as_deref());
let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
let memory = env
.store()
.effective("java.default_memory_mb")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok());
let options = AdapterOptions {
java: java.selection,
memory: MemoryOptions {
min_mb: None,
max_mb: memory,
max_mb: Some(overrides.memory_mb),
},
auth,
launcher_name: LAUNCHER_NAME.to_string(),
@@ -227,6 +241,8 @@ pub fn build_request(
let mut request = output.request;
request.instance.id = target.id.clone();
request.instance.name = target.name.clone();
// 实例级启动参数(三级回落后的生效值)最后落位:user.* / memory / graphics_backend。
unlucky_core::launch::overrides::apply(&mut request, &overrides);
Ok((request, warnings))
}
+351
View File
@@ -0,0 +1,351 @@
//! 实例启动参数:读(三级回落 + 版本门槛)与写(`launch` 段)。
//!
//! 本模块是 M157 的 host 侧唯一入口:
//! - [`view`] → [`LaunchArgsView`](详情页「启动参数」区);
//! - [`apply`] → 写回实例文件(`launch.*`),空输入清成「跟随」;
//! - [`resolved`] → 启动装配用的生效值([`ResolvedLaunch`]),CLI 的 `snapshot::prepare`
//! 与 GUI 的 `host::launch` 都从这里取,避免两条装配链各算一遍回落。
//!
//! 判据都在 core,本文件只做三件事:读设置文档填 [`GlobalLaunchDefaults`]、读实例文件、
//! 把 core 的结论翻成 DTO。
use std::path::PathBuf;
use unlucky_core::game::install::instance::{
read_instance_config, write_instance_config, InstanceConfig,
};
use unlucky_core::game::launch_overrides::{
resolve as resolve_overrides, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides,
LaunchOverridesPatch, ResolvedLaunch,
};
use unlucky_core::game::{self, VersionCapabilities, VersionJson};
use unlucky_core::launch::game_args::{graphics_backend_allowed, GRAPHICS_BACKEND_MIN_VERSION};
use unlucky_core::launch::game_version::GameVersion;
use crate::dto::{LaunchArgsPatch, LaunchArgsView};
use super::env::HostEnv;
/// 全局层:`java.extra_args` / `java.default_memory_mb` / `java.selected_path`。
///
/// 另外两项(游戏参数、图形后端)本轮没有全局键——回落链照样成立(全局层为 `None`),
/// 理由见 M157 的定案(不为它们单开全局键,免得与实例语义打架)。
pub fn global_defaults(env: &HostEnv) -> GlobalLaunchDefaults {
let store = env.store();
let text = |key: &str| -> Option<String> {
store
.doc()
.effective_str(key)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
GlobalLaunchDefaults {
jvm_args: text("java.extra_args"),
game_args: None,
memory_mb: store
.effective("java.default_memory_mb")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok()),
java_path: text("java.selected_path").map(PathBuf::from),
graphics_backend: None,
}
}
/// 读实例文件的 `launch` 段(文件缺失 / 读失败 = 空段,不让详情页因此打不开)。
fn instance_overrides(env: &HostEnv, instance_id: &str) -> LaunchOverrides {
if instance_id.is_empty() {
return LaunchOverrides::default();
}
read_instance_config(&env.layout, instance_id)
.ok()
.flatten()
.map(|config| config.launch)
.unwrap_or_default()
}
/// 三级回落后的生效值(启动装配与详情页共用同一份)。
pub fn resolved(env: &HostEnv, instance_id: &str) -> ResolvedLaunch {
resolve_overrides(
Some(&instance_overrides(env, instance_id)),
&global_defaults(env),
)
}
/// 版本能力位(解析继承链之后取库表;读不到版本 JSON = 什么能力都没有)。
fn capabilities(env: &HostEnv, version_id: &str) -> Option<VersionJson> {
let text = std::fs::read_to_string(env.game.version_json(version_id)).ok()?;
let manifest = VersionJson::from_json_str(&text).ok()?;
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(env.game.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
game::resolve(&source, &manifest).ok()
}
/// 该实例版本能不能下发 `--graphicsBackend`,以及判定所依据的游戏版本。
///
/// 判据是 core 的两件套(库表能力位 + 版本门槛),**UI 不复制**:
/// `graphics_backend_allowed(GameVersion, has_vulkan_backend)`。
pub fn backend_gate(env: &HostEnv, version_id: &str) -> (bool, String) {
let mc_version = super::game::describe_version(env, version_id).0;
let has_vulkan = capabilities(env, version_id)
.map(|json| VersionCapabilities::from_json(&json).has_vulkan_backend)
.unwrap_or(false);
let allowed = graphics_backend_allowed(&GameVersion::parse(&mc_version), has_vulkan);
(allowed, mc_version)
}
/// 详情页「启动参数」区的读取视图。
pub fn view(env: &HostEnv, instance_id: &str, version_id: &str) -> LaunchArgsView {
let instance = instance_overrides(env, instance_id);
let effective = resolved(env, instance_id);
let (allowed, mc_version) = backend_gate(env, version_id);
LaunchArgsView {
instance_id: instance_id.to_string(),
jvm_args: instance.jvm_args.unwrap_or_default(),
game_args: instance.game_args.unwrap_or_default(),
memory_mb: instance.memory_mb,
memory_effective_mb: effective.memory_mb,
java_path: instance
.java_path
.map(|path| path.display().to_string())
.unwrap_or_default(),
java_effective: effective
.java_path
.map(|path| path.display().to_string())
.unwrap_or_default(),
graphics_backend: instance
.graphics_backend
.unwrap_or_default()
.as_str()
.to_string(),
graphics_backend_effective: effective.graphics_backend.as_str().to_string(),
graphics_backend_allowed: allowed,
mc_version,
graphics_backend_min_version: GRAPHICS_BACKEND_MIN_VERSION.to_string(),
graphics_backend_options: GraphicsBackend::IDS
.iter()
.map(|id| (*id).to_string())
.collect(),
}
}
/// 写回实例启动参数(`None` = 不改;`Some("")` = 清空回「跟随」)。
///
/// 校验(内存整数/范围、后端词表)在 core 的 [`LaunchOverridesPatch`] 里做一次,
/// GUI 与 CLI 拿同一条错误文案。
///
/// # Errors
///
/// 实例不存在、值非法(内存越界 / 未知图形后端)、读写实例文件失败时返回用户可读文案。
pub fn apply(
env: &HostEnv,
instance_id: &str,
version_id: &str,
patch: &LaunchArgsPatch,
) -> Result<(), String> {
let core_patch = LaunchOverridesPatch {
jvm_args: patch.jvm_args.clone(),
game_args: patch.game_args.clone(),
memory_mb: patch.memory_mb.clone(),
java_path: patch.java_path.clone(),
graphics_backend: patch.graphics_backend.clone(),
};
let overrides = instance_overrides(env, instance_id).patched(&core_patch)?;
let mut config = read_instance_config(&env.layout, instance_id)
.map_err(|error| format!("读实例设置失败:{error}"))?
.unwrap_or_else(|| InstanceConfig::new(instance_id, version_id));
config.launch = overrides;
write_instance_config(&env.layout, &config)
.map(|_| ())
.map_err(|error| format!("写实例设置失败:{error}"))
}
/// 门槛判据里的最低版本常量(提示文案用;判据本身在 core)。
pub fn min_version_hint() -> &'static str {
GRAPHICS_BACKEND_MIN_VERSION
}
impl super::CoreHost {
/// 写实例启动参数(先按 id 找档案拿 `versionId`,再落盘)。
///
/// 实例必须已登记:没有档案时写 `config/instances/<id>.json` 会造出一个孤立的设置文件,
/// 启动链根本不会读它(它按 `profiles.json` 找实例)。
pub(super) fn apply_launch_args(
&mut self,
id: &str,
patch: &LaunchArgsPatch,
) -> Result<(), String> {
let profiles = super::game::profiles(&self.env);
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == id)
.ok_or_else(|| format!("找不到实例 `{id}`"))?;
apply(&self.env, id, &profile.version_id, patch)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> (HostEnv, tempdir::Dir) {
let dir = tempdir::Dir::new("ull-launch-args");
let env = HostEnv::at_root(&dir.path);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
(env, dir)
}
/// 最小的 tempdir(与本 crate 其它 host 测试同款:不引第三方 tempfile)。
mod tempdir {
use std::path::{Path, PathBuf};
pub struct Dir {
pub path: PathBuf,
}
impl Dir {
pub fn new(tag: &str) -> Self {
let path = std::env::temp_dir().join(format!("{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self { path }
}
}
impl Drop for Dir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(Path::new(&self.path));
}
}
}
fn write_version(env: &HostEnv, id: &str, json: &str) {
let path = env.game.version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, json).expect("写版本 JSON");
}
/// 26.2-snapshot-2 的库表里带 `lwjgl-vulkan`(与 `research/05-mc-vulkan.md` 一致)。
const VULKAN_VERSION: &str = r#"{
"id": "26.2-snapshot-2",
"libraries": [{"name": "org.lwjgl:lwjgl-vulkan:3.4.3"}]
}"#;
#[test]
fn gate_uses_core_capability_and_version_threshold() {
let (env, _dir) = env();
write_version(&env, "26.2-snapshot-2", VULKAN_VERSION);
let (allowed, mc) = backend_gate(&env, "26.2-snapshot-2");
assert!(allowed, "26.2-snapshot-2 应当放行");
assert_eq!(mc, "26.2-snapshot-2");
// 26.1:版本不够(即使库表里硬塞了 vulkan 也不放行——精确门限优先)。
write_version(&env, "26.1", VULKAN_VERSION);
let (allowed, mc) = backend_gate(&env, "26.1");
assert!(!allowed, "26.1 不该放行");
assert_eq!(mc, "26.1");
// 没有版本 JSON:能力位全假 ⇒ 不放行(不 panic)。
let (allowed, _) = backend_gate(&env, "1.20.4");
assert!(!allowed);
}
#[test]
fn write_then_read_round_trips_through_the_instance_file() {
let (env, _dir) = env();
let patch = LaunchArgsPatch {
jvm_args: Some(" -XX:+UseG1GC ".to_string()),
game_args: Some("--fullscreen".to_string()),
memory_mb: Some("6144".to_string()),
java_path: Some("D:/jdk21/bin/java.exe".to_string()),
graphics_backend: Some("vulkan".to_string()),
};
apply(&env, "inst-1", "1.20.4", &patch).expect("写回");
let config = read_instance_config(&env.layout, "inst-1")
.expect("读回")
.expect("文件应存在");
assert_eq!(config.launch.jvm_args.as_deref(), Some("-XX:+UseG1GC"));
assert_eq!(config.launch.memory_mb, Some(6144));
assert_eq!(
config.launch.graphics_backend,
Some(GraphicsBackend::Vulkan)
);
assert_eq!(config.version_id, "1.20.4");
// 清空 = 跟随:`default` 与空串都把键抹掉。
let clear = LaunchArgsPatch {
jvm_args: Some(String::new()),
game_args: Some(" ".to_string()),
memory_mb: Some(String::new()),
java_path: Some(String::new()),
graphics_backend: Some("default".to_string()),
};
apply(&env, "inst-1", "1.20.4", &clear).expect("清空");
let config = read_instance_config(&env.layout, "inst-1")
.expect("读回")
.expect("文件应存在");
assert!(
config.launch.is_empty(),
"清空之后 launch 段应为空:{:?}",
config.launch
);
let raw = config.to_json_string().expect("序列化");
assert!(!raw.contains("\"launch\""), "空段不落盘:{raw}");
}
#[test]
fn invalid_values_are_rejected_not_silently_dropped() {
let (env, _dir) = env();
let bad_backend = LaunchArgsPatch::backend("vulkan2");
let error = apply(&env, "inst-1", "1.20.4", &bad_backend).expect_err("未知后端要报错");
assert!(error.contains("vulkan2"), "{error}");
let bad_memory = LaunchArgsPatch {
memory_mb: Some("999999".to_string()),
..LaunchArgsPatch::default()
};
let error = apply(&env, "inst-1", "1.20.4", &bad_memory).expect_err("越界内存要报错");
assert!(error.contains("256"), "{error}");
let not_a_number = LaunchArgsPatch {
memory_mb: Some("4g".to_string()),
..LaunchArgsPatch::default()
};
assert!(apply(&env, "inst-1", "1.20.4", &not_a_number).is_err());
// 失败的写入不许留下半截文件。
assert!(read_instance_config(&env.layout, "inst-1")
.expect("读")
.is_none());
}
#[test]
fn view_reports_instance_layer_and_effective_values_apart() {
let (env, _dir) = env();
write_version(&env, "26.2-snapshot-2", VULKAN_VERSION);
// 只填图形后端:其余各项都是「跟随」,生效值要给得出来。
apply(
&env,
"inst-1",
"26.2-snapshot-2",
&LaunchArgsPatch::backend("vulkan"),
)
.expect("写回");
let view = view(&env, "inst-1", "26.2-snapshot-2");
assert_eq!(view.graphics_backend, "vulkan");
assert_eq!(view.graphics_backend_effective, "vulkan");
assert!(view.graphics_backend_allowed);
assert_eq!(view.instance_id, "inst-1");
assert_eq!(view.memory_mb, None);
assert_eq!(
view.memory_effective_mb,
unlucky_core::game::launch_overrides::DEFAULT_MAX_MEMORY_MB
);
assert_eq!(view.graphics_backend_options.len(), 3);
}
}
+1
View File
@@ -26,6 +26,7 @@ mod install_processors;
mod java;
mod job;
mod launch;
mod launch_args;
mod loader_versions;
mod logs;
mod meter;