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

+21 -1
View File
@@ -14,6 +14,9 @@ pub const fn general() -> &'static str {
命令:
instances list 列出实例与已安装版本
instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P]
[--backend default|opengl|vulkan] [--clear]
读/写实例级启动参数(不带选项 = 只读;--clear = 全部回跟随全局)
install <version> [--loader X] [--loader-version V] [--name N] [--dry-run]
下载并安装版本本体(--loader fabric|quilt|neoforge|forge
时再叠一层加载器)→ 登记实例;--dry-run 只出计划
@@ -69,7 +72,24 @@ pub fn topic(name: &str) -> Option<&'static str> {
version id 里的加载器版本段;原版、以及 id 里没写版本的(1.12.2-LiteLoader)为空串
注意:kind 是「认得出」的口径,不是「装得了」的口径 —— optifine / liteloader 只能由别的
启动器装好后继承进来,install --loader 不接受这两个值;kind=vanilla 只表示 id 里没有加载器
痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。",
痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。
%prog% instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P] \
[--backend default|opengl|vulkan] [--clear]
读/写实例配置页「启动参数」区(M157)。不带取写选项时只读:
--jvm <text> 实例级 JVM 参数(空白分隔、支持引号);与全局 java.extra_args **叠加**,
实例这层在最高优先级段(同名选项实例赢,`-Xmx` 也由它赢过内存字段)
--game <text> 实例级游戏参数(追加在命令行最后,可覆盖前面一切游戏参数)
--memory <MB> 实例级最大堆(256~65536);留空/不写 = 跟随全局 java.default_memory_mb
--java <path> 实例级 Java 可执行文件;留空 = 跟随全局 java.selected_path 再退到自动挑
--backend <id> default(不下发)/ opengl / vulkan;只对 MC 26.2-snapshot-2+ 下发,
老版本会被版本门槛拦下(启动时给诊断告警,不静默)
--clear 先把五项清空再应用其它选项(= 全部回「跟随全局」)
每一项都是「实例 → 全局 → 默认」的独立回落,只填一项不会顶掉别的项。
值以 `--` 开头时(多数游戏参数都是)写成 `--game=--fullscreen`:单独一个 `--fullscreen`
会被当成下一个选项(缺少取值),这是全项目 CLI 的既有解析口径。
--json 输出 {\"data\":{\"launch\":{…实例层…},\"effective\":{…回落后的生效值…}, …}}",
),
"pack" => Some(
"\
+2 -1
View File
@@ -13,7 +13,8 @@ pub mod program;
pub use model::{
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs, PackImportArgs,
DiagArgs, Globals, InstallArgs, InstanceLaunchArgs, Invocation, LaunchArgs, LogsArgs,
PackImportArgs,
};
pub use parse::parse;
+34
View File
@@ -26,6 +26,8 @@ pub enum Command {
Help(Option<String>),
/// `instances list`。
InstancesList,
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
InstancesLaunchArgs(InstanceLaunchArgs),
/// `install <version> [--loader X] [--name N]`。
Install(InstallArgs),
/// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。
@@ -185,6 +187,38 @@ pub struct DiagArgs {
pub tail: usize,
}
/// `instances launch-args` 的参数(M157:实例级启动参数)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InstanceLaunchArgs {
/// 实例 id 或实例名。
pub instance: String,
/// 实例级 JVM 参数(空白分隔,支持引号)。
pub jvm_args: Option<String>,
/// 实例级游戏参数。
pub game_args: Option<String>,
/// 实例级最大堆(MiB 文本;空串由 `--memory ""` 表达「跟随全局」)。
pub memory_mb: Option<String>,
/// 实例级 Java 可执行文件。
pub java: Option<String>,
/// 图形后端(`default` / `opengl` / `vulkan`)。
pub backend: Option<String>,
/// 先清空五项再应用给出的项(`--clear`;不带其它选项时等于「全部跟随全局」)。
pub clear: bool,
}
impl InstanceLaunchArgs {
/// 这次调用是不是纯读取(没有任何写入口)。
#[must_use]
pub fn is_read_only(&self) -> bool {
!self.clear
&& self.jvm_args.is_none()
&& self.game_args.is_none()
&& self.memory_mb.is_none()
&& self.java.is_none()
&& self.backend.is_none()
}
}
/// `config get` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigGetArgs {
+2 -15
View File
@@ -9,6 +9,7 @@ use crate::error::CliError;
mod accounts;
mod agent;
mod instances;
use super::model::{
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
@@ -81,7 +82,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
match name.as_str() {
"version" => Ok(Command::Version),
"help" => Ok(Command::Help(tail.first().cloned())),
"instances" => parse_instances(tail),
"instances" => instances::parse_instances(tail),
"install" => parse_install(tail, globals),
"pack" => parse_pack(tail),
"launch" => parse_launch(tail),
@@ -99,20 +100,6 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
}
}
fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
let args = match tail.first().map(String::as_str) {
None | Some("list") => tail.get(1..).unwrap_or_default(),
Some(other) => {
return Err(CliError::usage(format!(
"instances 的子命令只有 list,收到 `{other}`"
)));
}
};
let parsed = parse_flags(args, &[], &[])?;
ensure_no_positionals(&parsed, "instances list")?;
Ok(Command::InstancesList)
}
fn parse_install(tail: &[String], globals: &Globals) -> Result<Command, CliError> {
let parsed = parse_flags(tail, &["loader", "loader-version", "name"], &["dry-run"])?;
let version = require_single_positional(&parsed, "install <version>")?;
+45
View File
@@ -0,0 +1,45 @@
//! `instances` 的两个子命令解析(`list` / `launch-args`)。
//!
//! 从 `parse.rs` 拆出来只为守 400 行硬线(M157 的 `launch-args` 让那边到 415 行);
//! 纯搬移,语义未变。
use super::super::model::InstanceLaunchArgs;
use super::*;
pub(super) fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
let args = match tail.first().map(String::as_str) {
None | Some("list") => tail.get(1..).unwrap_or_default(),
Some("launch-args") => {
return parse_instances_launch_args(tail.get(1..).unwrap_or_default())
}
Some(other) => {
return Err(CliError::usage(format!(
"instances 的子命令只有 list / launch-args,收到 `{other}`"
)));
}
};
let parsed = parse_flags(args, &[], &[])?;
ensure_no_positionals(&parsed, "instances list")?;
Ok(Command::InstancesList)
}
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
///
/// 五个取值选项都接受空串(`--jvm ""`)= 该清空,与 `--clear` 的「先清后改」互补。
fn parse_instances_launch_args(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(
tail,
&["jvm", "game", "memory", "java", "backend"],
&["clear"],
)?;
let instance = require_single_positional(&parsed, "instances launch-args <instance>")?;
Ok(Command::InstancesLaunchArgs(InstanceLaunchArgs {
instance,
jvm_args: parsed.value("jvm"),
game_args: parsed.value("game"),
memory_mb: parsed.value("memory"),
java: parsed.value("java"),
backend: parsed.value("backend"),
clear: parsed.has("clear"),
}))
}
+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;
@@ -0,0 +1,212 @@
//! [`super`] 的单测:走真入口 [`crate::run`],断言用户/脚本拿到的那份输出。
//!
//! 口径与 `instances/tests.rs` 一致:临时 `--data-dir` + 真 `profiles.json`,只看 stdout 的
//! JSON 信封(`--json`)与退出码。回落规则的真值表在 core 的
//! `game::launch_overrides` / `launch::overrides` 测试里,这里只验 CLI 有没有如实搬运。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use unlucky_core::platform::layout::{LauncherLayout, LAUNCHER_HOME_DIR};
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-instance-launch-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn arg(&self) -> String {
self.0.display().to_string()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 写 `.ULL/config/profiles.json` 并返回第一个实例的 id。
fn write_profiles(data_root: &Path, name: &str, version_id: &str) -> String {
let dir = data_root.join(LAUNCHER_HOME_DIR).join("config");
std::fs::create_dir_all(&dir).expect("建配置目录");
let id = "11111111-2222-4333-8444-555555555555".to_string();
let document = json!({
"schema_version": 1,
"profiles": [{ "id": id, "name": name, "version_id": version_id }],
});
std::fs::write(
dir.join("profiles.json"),
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
)
.expect("写 profiles.json");
id
}
/// 写 `<data>/.minecraft/versions/<id>/<id>.json`(库表可带 vulkan)。
fn write_version(data_root: &Path, id: &str, vulkan: bool) {
let path = data_root
.join(".minecraft")
.join("versions")
.join(id)
.join(format!("{id}.json"));
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
let libs = if vulkan {
r#"[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]"#
} else {
"[]"
};
std::fs::write(&path, format!(r#"{{"id":"{id}","libraries":{libs}}}"#)).expect("写版本 JSON");
}
/// 跑一次 CLI,返回(退出码, stdout)。
fn run_cli(args: &[&str]) -> (i32, String) {
let owned: Vec<String> = args.iter().map(|arg| (*arg).to_string()).collect();
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
let code = crate::run(&owned, &mut out, &mut err);
(code, String::from_utf8_lossy(&out).into_owned())
}
/// 跑 `instances launch-args … --json`,返回 `data` 载荷。
fn launch_args(dir: &TempDir, rest: &[&str]) -> Value {
let root = dir.arg();
let mut args = vec!["instances", "launch-args", "--json", "--data-dir", &root];
args.extend_from_slice(rest);
let (code, stdout) = run_cli(&args);
assert_eq!(code, 0, "{stdout}");
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["ok"], true, "{stdout}");
value["data"].clone()
}
#[test]
fn read_reports_instance_layer_and_effective_values() {
let dir = TempDir::new("read");
let id = write_profiles(dir.path(), "生存", "1.20.4");
let data = launch_args(&dir, &[&id]);
assert_eq!(data["instance_id"], id.as_str());
assert_eq!(data["updated"], false);
assert_eq!(data["launch"]["graphics_backend"], "default");
// 没配内存 ⇒ 生效值是出厂默认 2048(三级回落的最后一级)。
assert_eq!(data["effective"]["memory_mb"], 2048);
assert_eq!(data["effective"]["graphics_backend_allowed"], false);
assert_eq!(
data["graphics_backend_options"],
json!(["default", "opengl", "vulkan"])
);
}
#[test]
fn write_from_flags_then_read_back() {
let dir = TempDir::new("write");
let id = write_profiles(dir.path(), "生存", "26.2-snapshot-2");
write_version(dir.path(), "26.2-snapshot-2", true);
let data = launch_args(
&dir,
&[
&id,
"--jvm=-XX:+UseG1GC",
// 值以 `--` 开头时必须用 `--key=value` 形式(`--game --fullscreen` 会被解析器
// 当成「缺少取值」——那是全项目 CLI 的既有口径,见 help 里的提示)。
"--game=--fullscreen",
"--memory",
"6144",
"--backend",
"vulkan",
],
);
assert_eq!(data["updated"], true);
assert_eq!(data["launch"]["jvm_args"], "-XX:+UseG1GC");
assert_eq!(data["launch"]["memory_mb"], 6144);
assert_eq!(data["launch"]["graphics_backend"], "vulkan");
// 26.2-snapshot-2 过了门槛(判定来自 core 的能力位 + 版本门限)。
assert_eq!(data["effective"]["graphics_backend_allowed"], true);
// 再读一次:落盘之后依然在(`--json` 是事实上的对外契约)。
let again = launch_args(&dir, &[&id]);
assert_eq!(again["updated"], false);
assert_eq!(again["launch"]["jvm_args"], "-XX:+UseG1GC");
assert_eq!(again["launch"]["game_args"], "--fullscreen");
}
#[test]
fn clear_restores_follow_the_global() {
let dir = TempDir::new("clear");
let id = write_profiles(dir.path(), "生存", "1.20.4");
launch_args(&dir, &[&id, "--memory", "4096", "--backend", "opengl"]);
let cleared = launch_args(&dir, &[&id, "--clear"]);
assert_eq!(cleared["launch"]["memory_mb"], Value::Null);
assert_eq!(cleared["launch"]["graphics_backend"], "default");
assert_eq!(cleared["effective"]["memory_mb"], 2048);
}
#[test]
fn invalid_values_fail_with_a_usage_error() {
let dir = TempDir::new("invalid");
let id = write_profiles(dir.path(), "生存", "1.20.4");
let root = dir.arg();
for (flag, value) in [
("--backend", "vulkan2"),
("--memory", "4g"),
("--memory", "1"),
] {
let (code, stdout) = run_cli(&[
"instances",
"launch-args",
"--json",
"--data-dir",
&root,
&id,
flag,
value,
]);
assert_ne!(code, 0, "{flag} {value} 应当失败:{stdout}");
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["error"]["code"], "usage", "{stdout}");
}
// 失败的写入不许留下半截设置文件。
let layout = LauncherLayout::new(dir.path().join(LAUNCHER_HOME_DIR));
assert!(
unlucky_core::game::install::instance::read_instance_config(&layout, &id)
.expect("读")
.is_none(),
"非法补丁不该落盘"
);
}
#[test]
fn unknown_instance_is_a_user_error() {
let dir = TempDir::new("missing");
let root = dir.arg();
let (code, stdout) = run_cli(&[
"instances",
"launch-args",
"--json",
"--data-dir",
&root,
"nope",
]);
assert_ne!(code, 0);
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["error"]["code"], "not_found", "{stdout}");
}
+4
View File
@@ -5,6 +5,7 @@ pub mod agent;
pub mod config;
pub mod identity;
pub mod install;
pub mod instance_launch;
pub mod instances;
pub mod java;
pub mod java_launch;
@@ -32,6 +33,9 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
Command::Version => Ok(Outcome::new(meta::version())),
Command::Help(topic) => Ok(Outcome::new(meta::help(topic.as_deref()))),
Command::InstancesList => with_ctx(&invocation.globals, instances::list),
Command::InstancesLaunchArgs(args) => {
with_ctx(&invocation.globals, |ctx| instance_launch::run(ctx, args))
}
Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)),
Command::PackImport(args) => {
with_ctx_outcome(&invocation.globals, |ctx| pack::run(ctx, args))
+14 -2
View File
@@ -151,11 +151,21 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
game_assets: assets_root,
};
// 实例级启动参数的三级回落(实例 → 全局 → 默认)。临时启动参数(`--max-memory` /
// `--java`)在 D20 的覆盖链里位于实例之上,所以这里先把它们叠上去再落位。
let mut effective = super::instance_launch::resolved(ctx, &input.instance_id);
if let Some(mb) = input.max_memory {
effective.memory_mb = mb;
}
if let Some(path) = &input.java {
effective.java_path = Some(path.clone());
}
let options = AdapterOptions {
java: super::java::selection(input.java.as_deref()),
java: super::java::selection(effective.java_path.as_deref()),
memory: MemoryOptions {
min_mb: None,
max_mb: input.max_memory,
max_mb: Some(effective.memory_mb),
},
auth: input.auth.clone().unwrap_or_else(|| AuthPlaceholders {
player_name: input
@@ -181,6 +191,8 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
request.instance.id = input.instance_id.clone();
}
request.instance.name = input.instance_name.clone();
// 实例级启动参数(三级回落后的生效值)最后落位:user.* / memory / graphics_backend。
unlucky_core::launch::overrides::apply(&mut request, &effective);
Ok(Plan {
version_id,