Files
Unlucky-Launcher/crates/cli/src/commands/instance_launch.rs
T
AskaEth ee67965b9b fix(m157): r4 修复未收集的周快照用例 + 两处 clippy 红,并补整环回归(复审 p1-2)
P1-1 crates/core/src/launch/overrides/tests.rs:modded_weekly_snapshot_needs_the_root_version_for_the_gate 原先写在 backend_allowed_combines_capability_and_threshold 函数体内(unnameable_test_items,永不执行)。移到模块层;移动后首次真正执行暴露出用例自身的 EmptyJavaPath(AdapterOptions 默认 java 为空),补 JavaSelection 后绿。--list 现在 10 条 launch::overrides::tests::(含该名字)。

P1-2 clippy -D warnings 两处:cli/commands/instance_launch.rs needless_borrows_for_generic_args(去 &);presenter/mock_data.rs items_after_test_module(mock_launch 移到 mod tests 之前)。

P3-a 新增整环回归 app/tests/instances_save_loop.rs:真 Message → dispatch → 后端回错误 Toast → pump → refresh_view → sync_launch_form,断言 4g 草稿与脏标记都还在。
P3-b LaunchForm::matches 内存改按数值比(解析失败退文本),补 memory_is_compared_by_value_not_by_text(04096 / +4096 / 清空三档)。
P3-c 门槛文案不再在 catalog 硬编码 26.2:InstancesLaunchBackendGated 改用 {min} 占位符,页面从 LaunchFacts::min_version(← core GRAPHICS_BACKEND_MIN_VERSION)代入;金表同步;灰化态截图重拍。
2026-11-07 11:06:18 +08:00

270 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.
//! `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 带加载器后缀)。
///
/// `pub(crate)`:`snapshot::prepare` 的装配(`SnapshotInput.game_version`)也要这一份口径,
/// 门槛判定(本模块的 `backend_gate`)与拼装必须用**同一个**根版本,否则会出现
/// 「UI 说可用、命令行里没有这个参数」的静默偏差。
pub(crate) 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;