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
@@ -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(
|
||||
"\
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>")?;
|
||||
|
||||
@@ -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"),
|
||||
}))
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user