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

+262
View File
@@ -0,0 +1,262 @@
//! `instances launch-args <instance>`(M157):实例级启动参数的读/写。
//!
//! 五项的**含义与回落规则**都在 core([`unlucky_core::game::launch_overrides`]),本命令只做
//! 三件事:找实例、把命令行选项拼成 [`LaunchOverridesPatch`]、把生效值印出来。
//!
//! ```text
//! ull instances launch-args 生存 # 读:实例层 + 生效值 + 后端门槛
//! ull instances launch-args 生存 --jvm "-XX:+UseG1GC" --backend vulkan
//! ull instances launch-args 生存 --memory 8192 --game "--fullscreen"
//! ull instances launch-args 生存 --clear # 五项全部回「跟随全局」
//! ```
//!
//! 判定「版本够不够 Vulkan」用 core 的组合判据
//! [`unlucky_core::launch::overrides::backend_allowed`](继承解析后的库表 + 版本门槛),
//! 与 GUI 灰化、拼装链的告警是同一个函数——CLI 不另写一条。
//!
//! 全局层(`java.extra_args` / `java.default_memory_mb` / `java.selected_path`)在这里读一次
//! 设置文档填 [`GlobalLaunchDefaults`];presenter 的 host 侧有一份同形的读法(两个 crate 各自
//! 依赖 core,没法共享那三行 IO),字段口径以 core 的回落表为准。
use serde_json::json;
use unlucky_core::game::install::instance::{
read_instance_config, write_instance_config, InstanceConfig,
};
use unlucky_core::game::launch_overrides::{
resolve, GlobalLaunchDefaults, GraphicsBackend, LaunchOverridesPatch, ResolvedLaunch,
};
use unlucky_core::game::{self, VersionJson};
use unlucky_core::launch::game_args::GRAPHICS_BACKEND_MIN_VERSION;
use unlucky_core::launch::game_version::GameVersion;
use unlucky_core::launch::overrides::backend_allowed;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::InstanceLaunchArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
use super::instances::read_profiles;
/// `ull instances launch-args`。
///
/// # Errors
///
/// 实例不存在、值非法(内存越界 / 未知后端)、实例文件读写失败时返回用户错误。
pub fn run(ctx: &Ctx, args: &InstanceLaunchArgs) -> Result<Report, CliError> {
let profiles = read_profiles(ctx)?;
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == args.instance || profile.name == args.instance)
.cloned()
.ok_or_else(|| {
CliError::user("not_found", format!("找不到实例 `{}`", args.instance))
.with_hint("用 `%prog% instances list` 看已登记的实例")
})?;
let id = profile.id.as_str().to_string();
let layout = ctx.layout();
let mut config = read_instance_config(&layout, &id)
.map_err(|error| CliError::user("instance_config", format!("读实例设置失败:{error}")))?
.unwrap_or_else(|| InstanceConfig::new(&id, &profile.version_id));
let mut updated = false;
if !args.is_read_only() {
config.launch = config
.launch
.patched(&patch_from(args))
.map_err(CliError::usage)?;
write_instance_config(&layout, &config).map_err(|error| {
CliError::user("instance_config", format!("写实例设置失败:{error}"))
})?;
updated = true;
}
let effective = resolve(Some(&config.launch), &global_defaults(ctx));
let (allowed, mc_version) = backend_gate(ctx, &profile.version_id);
let layer = &config.launch;
let data = json!({
"instance_id": id,
"name": profile.name,
"version_id": profile.version_id,
"updated": updated,
"launch": {
"jvm_args": layer.jvm_args.clone().unwrap_or_default(),
"game_args": layer.game_args.clone().unwrap_or_default(),
"memory_mb": layer.memory_mb,
"java_path": layer.java_path.as_ref().map(|path| path.display().to_string()),
"graphics_backend": layer.graphics_backend.unwrap_or_default().as_str(),
},
"effective": {
"jvm_args": effective.instance_jvm_args.clone().unwrap_or_default(),
"global_jvm_args": effective.global_jvm_args.clone().unwrap_or_default(),
"game_args": effective.game_args.clone().unwrap_or_default(),
"memory_mb": effective.memory_mb,
"java_path": effective.java_path.as_ref().map(|path| path.display().to_string()),
"graphics_backend": effective.graphics_backend.as_str(),
"graphics_backend_allowed": allowed,
"graphics_backend_min_version": GRAPHICS_BACKEND_MIN_VERSION,
"mc_version": mc_version,
},
"graphics_backend_options": GraphicsBackend::IDS,
});
let mut human = vec![format!(
"{}({} · {}){}",
profile.name,
profile.version_id,
id,
if updated { " ← 已保存" } else { "" }
)];
human.push(format!(
" JVM 参数(实例): {}",
layer
.jvm_args
.clone()
.unwrap_or_else(|| "(跟随全局)".to_string())
));
human.push(format!(
" 游戏参数(实例): {}",
layer
.game_args
.clone()
.unwrap_or_else(|| "(无)".to_string())
));
human.push(format!(
" 最大内存: {} MiB{}",
effective.memory_mb,
match layer.memory_mb {
Some(_) => "(实例覆盖)",
None => "(跟随全局 / 出厂默认)",
}
));
human.push(format!(
" Java: {}",
effective
.java_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "(自动挑:托管 > JAVA_HOME > PATH)".to_string())
));
human.push(format!(
" 图形后端: {}{}",
effective.graphics_backend.as_str(),
if effective.graphics_backend.is_default() {
"(不下发 --graphicsBackend)".to_string()
} else if allowed {
format!("({} 可下发)", mc_version)
} else {
format!("(版本 {mc_version} 不支持,不会下发;需 MC {GRAPHICS_BACKEND_MIN_VERSION}+)")
}
));
Ok(Report::new(data, human))
}
/// 命令行选项 → core 的文本补丁(`--clear` 先清五项,随后给出的项覆盖)。
fn patch_from(args: &InstanceLaunchArgs) -> LaunchOverridesPatch {
let mut patch = if args.clear {
LaunchOverridesPatch::clear_all()
} else {
LaunchOverridesPatch::default()
};
if let Some(value) = &args.jvm_args {
patch.jvm_args = Some(value.clone());
}
if let Some(value) = &args.game_args {
patch.game_args = Some(value.clone());
}
if let Some(value) = &args.memory_mb {
patch.memory_mb = Some(value.clone());
}
if let Some(value) = &args.java {
patch.java_path = Some(value.clone());
}
if let Some(value) = &args.backend {
patch.graphics_backend = Some(value.clone());
}
patch
}
/// 全局层(三个设置键)。
pub(crate) fn global_defaults(ctx: &Ctx) -> GlobalLaunchDefaults {
let store = SettingStore::load(&ctx.launcher_config(), launcher_schema());
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(std::path::PathBuf::from),
graphics_backend: None,
}
}
/// 三级回落后的生效值(启动装配用;`instance_id` 为空 = 没有实例档案,只用全局层)。
///
/// 与 presenter 的 `host::launch_args::resolved` 同义:那边给 GUI 用,这边给 `ull launch` 用,
/// 规则都在 core 的 [`resolve`],两处只剩「读哪几份文件」的差别。
pub(crate) fn resolved(ctx: &Ctx, instance_id: &str) -> ResolvedLaunch {
let overrides = if instance_id.is_empty() {
None
} else {
read_instance_config(&ctx.layout(), instance_id)
.ok()
.flatten()
.map(|config| config.launch)
};
resolve(overrides.as_ref(), &global_defaults(ctx))
}
/// 版本能不能用 `--graphicsBackend`,以及判定依据的游戏版本(继承链根)。
fn backend_gate(ctx: &Ctx, version_id: &str) -> (bool, String) {
let game_layout = ctx.game();
let Ok(text) = std::fs::read_to_string(game_layout.version_json(version_id)) else {
return (false, version_id.to_string());
};
let Ok(manifest) = VersionJson::from_json_str(&text) else {
return (false, version_id.to_string());
};
let mc_version = root_version_id(&game_layout, &manifest);
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(game_layout.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
let resolved = game::resolve(&source, &manifest).unwrap_or(manifest);
let allowed = backend_allowed(&GameVersion::parse(&mc_version), &resolved);
(allowed, mc_version)
}
/// 继承链根(原版层)的 id——能力位与版本门限都以它为准(loader 层的 id 带加载器后缀)。
fn root_version_id(layout: &unlucky_core::game::GameLayout, manifest: &VersionJson) -> String {
let mut current = manifest.id.clone().unwrap_or_default();
let mut parent = manifest.inherits_from.clone().filter(|id| !id.is_empty());
for _ in 0..16 {
let Some(next) = parent else { break };
let Ok(text) = std::fs::read_to_string(layout.version_json(&next)) else {
return next;
};
let Ok(json) = VersionJson::from_json_str(&text) else {
return next;
};
current = json.id.clone().unwrap_or(next);
parent = json.inherits_from.clone().filter(|id| !id.is_empty());
}
current
}
#[cfg(test)]
mod tests;