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 crate::dto::{
|
||||
InstanceRuntime,
|
||||
InstanceSummary,
|
||||
JavaPageView,
|
||||
LaunchArgsPatch,
|
||||
LoaderKind,
|
||||
LogLine,
|
||||
LogOutcome,
|
||||
@@ -177,6 +178,15 @@ pub enum AppCommand {
|
||||
/// `isolated` / `shared`
|
||||
mode: String,
|
||||
},
|
||||
/// 写实例启动参数(M157 的「启动参数」区:JVM/游戏参数、内存、Java、图形后端)
|
||||
///
|
||||
/// 补丁语义:`None` = 这一项不改;`Some("")` = 清空回「跟随全局」。
|
||||
SetInstanceLaunchArgs {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 五项补丁
|
||||
patch: LaunchArgsPatch,
|
||||
},
|
||||
/// 刷新日志会话列表
|
||||
RefreshLogSessions,
|
||||
/// 打开某个会话(拉取日志行 + 触发诊断)
|
||||
@@ -237,6 +247,14 @@ impl AppCommand {
|
||||
mode: mode.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造「写实例启动参数」指令(详情页「启动参数」区)。
|
||||
pub fn set_launch_args(id: &str, patch: LaunchArgsPatch) -> Self {
|
||||
Self::SetInstanceLaunchArgs {
|
||||
id: id.to_string(),
|
||||
patch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。
|
||||
|
||||
@@ -6,6 +6,8 @@ use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::game::LoaderKind as CoreLoaderKind;
|
||||
|
||||
use super::LaunchArgsView;
|
||||
|
||||
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
|
||||
pub type InstanceId = String;
|
||||
|
||||
@@ -235,6 +237,8 @@ pub struct InstanceSummary {
|
||||
pub version_id: String,
|
||||
/// 版本本体是否在盘上(缺载荷时磁贴亮黄条)
|
||||
pub version_present: bool,
|
||||
/// 启动参数(M157:实例配置页「启动参数」区的数据;含三级回落后的生效值与版本门槛)
|
||||
pub launch: LaunchArgsView,
|
||||
}
|
||||
|
||||
/// 新建实例向导提交给 host 的请求(已通过向导校验)。
|
||||
@@ -375,6 +379,7 @@ mod tests {
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
launch: LaunchArgsView::default(),
|
||||
};
|
||||
let text = serde_json::to_string(&instance).expect("序列化");
|
||||
let back: InstanceSummary = serde_json::from_str(&text).expect("反序列化");
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! 实例启动参数 DTO(M157):实例配置页「启动参数」区的唯一数据源 + 写回补丁。
|
||||
//!
|
||||
//! 口径三条:
|
||||
//! 1. **实例层与生效值分开给**:`jvm_args` 等是实例文件里的原文(空 = 跟随全局),
|
||||
//! `*_effective` 是三级回落(实例 → 全局 → 默认)之后真正会下发的值。UI 要把两件事同时
|
||||
//! 画出来(「留空 = 跟随全局 {global}」这种提示与「生效值」是两回事);
|
||||
//! 2. **图形后端的可选值与门槛由 host 算好**:`graphics_backend_options` 来自 core 的词表
|
||||
//! ([`LaunchArgsView::graphics_backend_options`]),`graphics_backend_allowed` 来自 core 的
|
||||
//! 版本门槛判定——UI 不复制任何一条判据(任务书:判定用 core capability);
|
||||
//! 3. **本模块不含 core 类型**(D42:dto 只放扁平 IPC 类型)。
|
||||
//!
|
||||
//! 补丁的「不改 / 清空」二分:`None` = 这一项不动;`Some("")` = 清空(回到跟随)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 实例启动参数的**读取**视图(详情页「启动参数」区)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct LaunchArgsView {
|
||||
/// 实例 id。
|
||||
pub instance_id: String,
|
||||
/// 实例层 JVM 参数原文(空 = 只用全局 `java.extra_args`)。
|
||||
pub jvm_args: String,
|
||||
/// 实例层游戏参数原文(空 = 不追加)。
|
||||
pub game_args: String,
|
||||
/// 实例层最大堆(`None` = 跟随全局)。
|
||||
pub memory_mb: Option<u32>,
|
||||
/// 生效最大堆(三级回落 + 出厂默认,恒有值)。
|
||||
pub memory_effective_mb: u32,
|
||||
/// 实例层 Java 可执行文件原文(空 = 跟随全局 / 自动挑)。
|
||||
pub java_path: String,
|
||||
/// 生效 Java 可执行文件(空 = 自动挑;展示用,不代表已探测)。
|
||||
pub java_effective: String,
|
||||
/// 实例层图形后端(`default` / `opengl` / `vulkan`)。
|
||||
pub graphics_backend: String,
|
||||
/// 生效图形后端(三级回落之后)。
|
||||
pub graphics_backend_effective: String,
|
||||
/// 该实例版本能不能用 `--graphicsBackend`(core 的版本门槛判定;`false` ⇒ UI 灰化 + 提示)。
|
||||
pub graphics_backend_allowed: bool,
|
||||
/// 判定所依据的游戏版本(提示文案「需 MC 26.2+」旁边给出实际版本)。
|
||||
pub mc_version: String,
|
||||
/// 门槛的最低版本(core 的 `GRAPHICS_BACKEND_MIN_VERSION`;文案与测试共用一份来源)。
|
||||
pub graphics_backend_min_version: String,
|
||||
/// 图形后端三态的词表(顺序即下拉顺序;来自 core,不让 UI 手抄)。
|
||||
pub graphics_backend_options: Vec<String>,
|
||||
}
|
||||
|
||||
impl LaunchArgsView {
|
||||
/// 三态下拉的 id → 是不是当前生效值(UI 高亮用;未知值原样比较)。
|
||||
#[must_use]
|
||||
pub fn is_effective_backend(&self, id: &str) -> bool {
|
||||
self.graphics_backend_effective == id
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例启动参数的**写回**补丁(GUI 表单与 CLI 共用)。
|
||||
///
|
||||
/// 每个字段:`None` = 不改;`Some("")` = 清空(回到跟随下一层)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct LaunchArgsPatch {
|
||||
/// 实例层 JVM 参数(文本,空白分隔)。
|
||||
pub jvm_args: Option<String>,
|
||||
/// 实例层游戏参数(文本)。
|
||||
pub game_args: Option<String>,
|
||||
/// 实例层最大堆(文本形态,host 解析;空 = 跟随全局)。
|
||||
pub memory_mb: Option<String>,
|
||||
/// 实例层 Java 可执行文件。
|
||||
pub java_path: Option<String>,
|
||||
/// 实例层图形后端(`default` / `opengl` / `vulkan`)。
|
||||
pub graphics_backend: Option<String>,
|
||||
}
|
||||
|
||||
impl LaunchArgsPatch {
|
||||
/// 是否什么都不改(UI 没动过表单时的短路)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.jvm_args.is_none()
|
||||
&& self.game_args.is_none()
|
||||
&& self.memory_mb.is_none()
|
||||
&& self.java_path.is_none()
|
||||
&& self.graphics_backend.is_none()
|
||||
}
|
||||
|
||||
/// 只改图形后端(下拉切换的最小补丁)。
|
||||
#[must_use]
|
||||
pub fn backend(id: &str) -> Self {
|
||||
Self {
|
||||
graphics_backend: Some(id.to_string()),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_patch_touches_nothing() {
|
||||
assert!(LaunchArgsPatch::default().is_empty());
|
||||
assert!(!LaunchArgsPatch::backend("vulkan").is_empty());
|
||||
// `Some("")` 是「清空」而不是「不改」——两者不能折叠成一个判据。
|
||||
let clear = LaunchArgsPatch {
|
||||
jvm_args: Some(String::new()),
|
||||
..LaunchArgsPatch::default()
|
||||
};
|
||||
assert!(!clear.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_round_trips_as_ipc() {
|
||||
let view = LaunchArgsView {
|
||||
instance_id: "inst-1".to_string(),
|
||||
jvm_args: "-XX:+UseG1GC".to_string(),
|
||||
game_args: String::new(),
|
||||
memory_mb: Some(4096),
|
||||
memory_effective_mb: 4096,
|
||||
java_path: String::new(),
|
||||
java_effective: "C:/jdk21/bin/java.exe".to_string(),
|
||||
graphics_backend: "vulkan".to_string(),
|
||||
graphics_backend_effective: "vulkan".to_string(),
|
||||
graphics_backend_allowed: true,
|
||||
mc_version: "26.2-snapshot-2".to_string(),
|
||||
graphics_backend_min_version: "26.2-snapshot-2".to_string(),
|
||||
graphics_backend_options: vec![
|
||||
"default".to_string(),
|
||||
"opengl".to_string(),
|
||||
"vulkan".to_string(),
|
||||
],
|
||||
};
|
||||
let text = serde_json::to_string(&view).expect("序列化");
|
||||
let back: LaunchArgsView = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, view);
|
||||
assert!(back.is_effective_backend("vulkan"));
|
||||
assert!(!back.is_effective_backend("opengl"));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ mod events;
|
||||
mod install;
|
||||
mod instances;
|
||||
mod java;
|
||||
// M157: 实例启动参数(启动参数区的读取视图 + 写回补丁)
|
||||
mod launch_args;
|
||||
mod logs;
|
||||
mod notices;
|
||||
// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面)
|
||||
@@ -45,6 +47,7 @@ pub use instances::{
|
||||
LoaderKind, Progress,
|
||||
};
|
||||
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
||||
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
|
||||
pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId};
|
||||
pub use notices::{NoticeLevel, NoticeView};
|
||||
// M113: import/export
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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", ¬_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);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ mod install_processors;
|
||||
mod java;
|
||||
mod job;
|
||||
mod launch;
|
||||
mod launch_args;
|
||||
mod loader_versions;
|
||||
mod logs;
|
||||
mod meter;
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
use crate::dto::{
|
||||
AncientKind, ChatMessage, ChatRole, Diagnosis, DiagnosisReport, InstanceRuntime,
|
||||
InstanceSummary, LoaderInfo, LoaderKind, LogLevel, LogLine, LogOutcome, LogSession, SessionId,
|
||||
ToolCallView, VersionCategory, VersionKind, VersionSummary,
|
||||
InstanceSummary, LaunchArgsView, LoaderInfo, LoaderKind, LogLevel, LogLine, LogOutcome,
|
||||
LogSession, SessionId, ToolCallView, VersionCategory, VersionKind, VersionSummary,
|
||||
};
|
||||
|
||||
/// P0 演示实例:覆盖空闲 / 失败两种磁贴状态(运行中由启动脚本产生)。
|
||||
@@ -25,6 +25,7 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
launch: mock_launch(),
|
||||
},
|
||||
InstanceSummary {
|
||||
id: "vanilla-latest".to_string(),
|
||||
@@ -40,6 +41,7 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.21.9".to_string(),
|
||||
version_present: true,
|
||||
launch: mock_launch(),
|
||||
},
|
||||
InstanceSummary {
|
||||
id: "ancient-1.7.10".to_string(),
|
||||
@@ -55,6 +57,7 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
|
||||
isolation: "shared".to_string(),
|
||||
version_id: "1.7.10".to_string(),
|
||||
version_present: false,
|
||||
launch: mock_launch(),
|
||||
},
|
||||
InstanceSummary {
|
||||
id: "modded-dev".to_string(),
|
||||
@@ -72,6 +75,7 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.21.1-neoforge-21.1.72".to_string(),
|
||||
version_present: true,
|
||||
launch: mock_launch(),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -236,3 +240,26 @@ mod tests {
|
||||
assert!(mock_versions_for(VersionCategory::Mod).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// 演示用的启动参数视图(M157):一条真实形状的实例(有 JVM 参数、Vulkan、门槛放行)。
|
||||
fn mock_launch() -> LaunchArgsView {
|
||||
LaunchArgsView {
|
||||
instance_id: "survival".to_string(),
|
||||
jvm_args: "-XX:+UseG1GC".to_string(),
|
||||
game_args: String::new(),
|
||||
memory_mb: Some(4096),
|
||||
memory_effective_mb: 4096,
|
||||
java_path: String::new(),
|
||||
java_effective: "C:/Java/temurin-21/bin/java.exe".to_string(),
|
||||
graphics_backend: "vulkan".to_string(),
|
||||
graphics_backend_effective: "vulkan".to_string(),
|
||||
graphics_backend_allowed: true,
|
||||
mc_version: "26.2-snapshot-2".to_string(),
|
||||
graphics_backend_min_version: "26.2-snapshot-2".to_string(),
|
||||
graphics_backend_options: vec![
|
||||
"default".to_string(),
|
||||
"opengl".to_string(),
|
||||
"vulkan".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ fn instance(id: &str, name: &str) -> InstanceSummary {
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
launch: crate::dto::LaunchArgsView::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user