From e2021645d748f0f2b6c99f30fe9c3ef331753335 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 7 Nov 2026 09:50:32 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(launch):=20M157=20=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E7=BA=A7=E5=90=AF=E5=8A=A8=E5=8F=82=E6=95=B0=EF=BC=88=E4=BA=94?= =?UTF-8?q?=E9=A1=B9=E5=9B=9E=E8=90=BD=20+=20graphicsBackend=20=E4=B8=89?= =?UTF-8?q?=E6=80=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 中英文案同步。 --- crates/app-iced/src/app/state.rs | 6 + crates/app-iced/src/app/state/construct.rs | 1 + crates/app-iced/src/app/state/refresh.rs | 27 ++ crates/app-iced/src/app/tests/appearance.rs | 4 +- crates/app-iced/src/app/update.rs | 1 + crates/app-iced/src/app/update/instances.rs | 161 ++++++++ .../app-iced/src/app/update/page_messages.rs | 2 + crates/app-iced/src/app/view.rs | 2 + crates/app-iced/src/app/view/instances.rs | 13 +- crates/app-iced/src/app/view/tests.rs | 1 + .../app-iced/src/pages/content/model/tests.rs | 1 + crates/app-iced/src/pages/harness.rs | 6 +- crates/app-iced/src/pages/harness/app.rs | 1 + crates/app-iced/src/pages/instances.rs | 16 +- crates/app-iced/src/pages/instances/demo.rs | 62 ++- crates/app-iced/src/pages/instances/detail.rs | 6 + .../src/pages/instances/launch_args.rs | 218 ++++++++++ crates/app-iced/src/pages/instances/state.rs | 190 +++++++++ .../src/pages/instances/tests/i18n.rs | 34 +- crates/app-iced/src/pages/instances/text.rs | 61 +++ crates/app-iced/src/pages/message.rs | 2 + crates/app-iced/src/pages/mod.rs | 29 +- crates/app-iced/src/pages/model.rs | 24 ++ crates/app-iced/src/pages/tests.rs | 6 +- crates/cli/src/cli/help.rs | 22 +- crates/cli/src/cli/mod.rs | 3 +- crates/cli/src/cli/model.rs | 34 ++ crates/cli/src/cli/parse.rs | 17 +- crates/cli/src/cli/parse/instances.rs | 45 ++ crates/cli/src/commands/instance_launch.rs | 262 ++++++++++++ .../cli/src/commands/instance_launch/tests.rs | 212 ++++++++++ crates/cli/src/commands/mod.rs | 4 + crates/cli/src/commands/snapshot.rs | 16 +- crates/core/src/game/install/instance.rs | 8 + crates/core/src/game/launch_overrides.rs | 360 ++++++++++++++++ .../core/src/game/launch_overrides/tests.rs | 295 +++++++++++++ crates/core/src/game/mod.rs | 6 + crates/core/src/launch/mod.rs | 1 + crates/core/src/launch/overrides.rs | 372 +++++++++++++++++ crates/core/src/setting/instance_keys.rs | 96 +++++ crates/i18n/src/catalog/golden.rs | 388 ++---------------- crates/i18n/src/catalog/golden/tail.rs | 364 ++++++++++++++++ crates/i18n/src/catalog/instances.rs | 18 + crates/i18n/src/catalog/tests.rs | 4 +- crates/presenter/src/dto/events.rs | 18 + crates/presenter/src/dto/instances.rs | 5 + crates/presenter/src/dto/launch_args.rs | 136 ++++++ crates/presenter/src/dto/mod.rs | 3 + crates/presenter/src/host/dispatch.rs | 9 + crates/presenter/src/host/game.rs | 202 +-------- crates/presenter/src/host/game/tests.rs | 189 +++++++++ crates/presenter/src/host/launch.rs | 36 +- crates/presenter/src/host/launch_args.rs | 351 ++++++++++++++++ crates/presenter/src/host/mod.rs | 1 + crates/presenter/src/mock_data.rs | 31 +- crates/presenter/src/state_tests.rs | 1 + 56 files changed, 3776 insertions(+), 607 deletions(-) create mode 100644 crates/app-iced/src/app/update/instances.rs create mode 100644 crates/app-iced/src/pages/instances/launch_args.rs create mode 100644 crates/app-iced/src/pages/instances/state.rs create mode 100644 crates/cli/src/cli/parse/instances.rs create mode 100644 crates/cli/src/commands/instance_launch.rs create mode 100644 crates/cli/src/commands/instance_launch/tests.rs create mode 100644 crates/core/src/game/launch_overrides.rs create mode 100644 crates/core/src/game/launch_overrides/tests.rs create mode 100644 crates/core/src/launch/overrides.rs create mode 100644 crates/i18n/src/catalog/golden/tail.rs create mode 100644 crates/presenter/src/dto/launch_args.rs create mode 100644 crates/presenter/src/host/game/tests.rs create mode 100644 crates/presenter/src/host/launch_args.rs diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 2ab97b1..eb1bbd4 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -178,6 +178,12 @@ pub struct App { pub(super) account_form: pages::account::Form, /// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。 pub(super) account: pages::account::Page, + /// 实例页「启动参数」区的表单(M157):五项 + 脏标记 + 绑定的实例 id。 + /// + /// 住在 `App` 的理由与 `download_draft` 完全一致:`refresh_view` 每次都整棵重建 + /// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在 + /// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。 + pub(super) launch_form: pages::LaunchForm, /// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。 pub(super) avatars: pages::account::avatar::Cache, /// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。 diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 8d65d91..d76ce6b 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -124,6 +124,7 @@ impl App { agent_host: super::presenter::AgentHost::from_env(), // 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串 download_draft: pages::InstallDraft::default(), + launch_form: pages::LaunchForm::default(), install_cancelled_seen: 0, content: pages::content::PageState::default(), account_form: pages::account::Form::new(), diff --git a/crates/app-iced/src/app/state/refresh.rs b/crates/app-iced/src/app/state/refresh.rs index 855c3c8..9e5b1c8 100644 --- a/crates/app-iced/src/app/state/refresh.rs +++ b/crates/app-iced/src/app/state/refresh.rs @@ -8,6 +8,8 @@ use unlucky_presenter::toast::ToastKind; use crate::pages; +use unlucky_presenter::router::Route; + use super::App; impl App { @@ -52,10 +54,35 @@ impl App { self.lang(), &self.content, ); + // M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。 + self.sync_launch_form(); // 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`, // 皮肤解码走缓存,所以重复重建不重解图)。 self.rebuild_account_page(width); self.view_dirty = false; self.refreshes += 1; } + + /// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。 + /// + /// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种, + /// 免得「A 的草稿被当成 B 的初值」。 + fn sync_launch_form(&mut self) { + let Route::Detail(id) = self.state.route() else { + self.launch_form.bound = None; + return; + }; + match self + .state + .instances + .iter() + .find(|instance| &instance.id == id) + { + Some(instance) => { + let view = instance.launch.clone(); + self.launch_form.sync(&view); + } + None => self.launch_form.bound = None, + } + } } diff --git a/crates/app-iced/src/app/tests/appearance.rs b/crates/app-iced/src/app/tests/appearance.rs index cde13d8..ff7991e 100644 --- a/crates/app-iced/src/app/tests/appearance.rs +++ b/crates/app-iced/src/app/tests/appearance.rs @@ -202,13 +202,13 @@ fn the_settings_page_renders_the_real_appearance_group() { assert_eq!(app.view.screen, Screen::Settings); assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP); - let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance); + let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form); drop(_tree); // 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。 app.appearance.apply(Change::SelectGroup(0)); assert_eq!(app.appearance.selected_group, 0); - let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance); + let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form); } /// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。 diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index c81e4b5..fb904e6 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -48,6 +48,7 @@ mod boot_env; mod clock; mod content; mod download; +mod instances; mod page_messages; mod settings; diff --git a/crates/app-iced/src/app/update/instances.rs b/crates/app-iced/src/app/update/instances.rs new file mode 100644 index 0000000..f3cd807 --- /dev/null +++ b/crates/app-iced/src/app/update/instances.rs @@ -0,0 +1,161 @@ +//! 实例页「启动参数」区的消息落点(M157)。 +//! +//! 三件事: +//! 1. **编辑**只改页面状态(`App::launch_form`)并置脏——不 dispatch,不打网络; +//! 2. **保存**派一条 [`AppCommand::SetInstanceLaunchArgs`](整份五项补丁),回程由 +//! `InstancesChanged` 快照刷新页面(不做乐观回显,与 M90 的启动/停止同一条纪律); +//! 3. **恢复跟随全局**只清表单(仍需用户点保存)——「点一下就写盘」会让误触直接生效。 +//! +//! 脏标记的清理点在保存之后:`refresh_view` 的 `LaunchForm::sync` 看到「同实例 + 脏」不会覆盖, +//! 所以保存后要显式清脏,让下一次快照(host 写完文件回来的那份)重播种。 + +use iced::Task; +use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch}; + +use crate::pages::InstanceMessage; + +use super::super::message::Message; +use super::super::state::App; + +impl App { + /// 启动参数区的一条消息。 + pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task { + let Some(instance) = self.selected_instance_id() else { + return Task::none(); + }; + if !self.launch_form.bound_to(&instance) { + // 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。 + return Task::none(); + } + + match message { + InstanceMessage::Jvm(text) => { + self.launch_form.jvm = text; + self.launch_form.dirty = true; + } + InstanceMessage::Game(text) => { + self.launch_form.game = text; + self.launch_form.dirty = true; + } + InstanceMessage::Memory(text) => { + self.launch_form.memory = text; + self.launch_form.dirty = true; + } + InstanceMessage::Java(text) => { + self.launch_form.java = text; + self.launch_form.dirty = true; + } + InstanceMessage::Backend(id) => { + self.launch_form.backend = id; + self.launch_form.dirty = true; + } + InstanceMessage::Reset => { + self.launch_form.reset(); + } + InstanceMessage::Save => { + let patch: LaunchArgsPatch = self.launch_form.patch(); + self.launch_form.dirty = false; + self.dispatch(AppCommand::set_launch_args(&instance, patch)); + } + } + // 纯本地编辑:不重建视图模型(页面直接从表单画),只要一帧重绘即可——iced 在 + // 消息处理完之后本来就会画一帧,所以这里什么都不排。 + Task::none() + } + + /// 详情页当前选中的实例 id(不在详情页时为 `None`)。 + fn selected_instance_id(&self) -> Option { + match self.state.route() { + unlucky_presenter::router::Route::Detail(id) => Some(id.clone()), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use unlucky_design::color::SchemeFlavor; + use unlucky_presenter::dto::LaunchArgsView; + use unlucky_presenter::settings_bridge::SettingsBridge; + + use crate::app::state::App; + use crate::cli::Options; + use crate::pages::{InstanceMessage, LaunchForm}; + + use crate::theme::Families; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + fn app_with_form() -> App { + let mut app = App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(unlucky_presenter::mock::MockBackend::new()), + SettingsBridge::memory_only(), + ); + // 进详情页 + 直接放一份「已播种」的表单:本测试只验消息落点, + // 不验播种(那在 `LaunchForm::sync` 的单测里)。 + app.state.navigate(unlucky_presenter::router::Route::Detail( + INSTANCE.to_string(), + )); + app.launch_form = LaunchForm::from_view( + Some(INSTANCE), + &LaunchArgsView { + instance_id: INSTANCE.to_string(), + ..LaunchArgsView::default() + }, + ); + app + } + + /// 测试用的实例 id(mock 后端的实例快照里有它)。 + const INSTANCE: &str = "survival"; + + /// 编辑 → 脏;恢复 → 五项清空仍需保存;保存前不发命令。 + #[test] + fn editing_marks_dirty_and_reset_waits_for_save() { + let mut app = app_with_form(); + let _ = app.on_instance_message(InstanceMessage::Memory("6144".to_string())); + assert_eq!(app.launch_form.memory, "6144"); + assert!(app.launch_form.dirty); + + let _ = app.on_instance_message(InstanceMessage::Backend("vulkan".to_string())); + assert_eq!(app.launch_form.backend, "vulkan"); + + let _ = app.on_instance_message(InstanceMessage::Reset); + assert_eq!(app.launch_form.memory, ""); + assert_eq!(app.launch_form.backend, "default"); + assert!(app.launch_form.dirty, "恢复之后还得点保存才落盘"); + } + + /// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。 + #[test] + fn edits_are_ignored_when_the_form_is_bound_elsewhere() { + let mut app = app_with_form(); + app.launch_form = LaunchForm::from_view( + Some("other"), + &LaunchArgsView { + instance_id: "other".to_string(), + ..LaunchArgsView::default() + }, + ); + let _ = app.on_instance_message(InstanceMessage::Memory("9999".to_string())); + assert_eq!(app.launch_form.memory, ""); + assert!(!app.launch_form.dirty); + } + + /// 保存之后脏标记清掉(否则下一次快照刷新不敢重播种,界面会一直显示旧值)。 + #[test] + fn save_clears_the_dirty_flag() { + let mut app = app_with_form(); + let _ = app.on_instance_message(InstanceMessage::Java("D:/jdk21/bin/java.exe".to_string())); + assert!(app.launch_form.dirty); + let _ = app.on_instance_message(InstanceMessage::Save); + assert!(!app.launch_form.dirty); + } +} diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index 2247c88..e661e36 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -88,6 +88,8 @@ impl App { self.view_dirty = true; self.refresh_view(); } + // M157:实例页「启动参数」区(编辑写进页面状态;保存才派发命令) + pages::Message::Instances(message) => return self.on_instance_message(message), // M114:账户页(三轨登录 / 列表动作 / 表单输入) pages::Message::Account(message) => return self.on_account(message), // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 4576bd6..cc69040 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -92,6 +92,8 @@ impl App { }, // M106:设置页 General 组要画「当前档位 + 生效语言」。 self.language(), + // M157:实例页「启动参数」区的表单(编辑态归 `App`,页面只借一帧)。 + &self.launch_form, ) .map(Message::Page), agent::docked(&self.ui, session, agent_catalog, self.width) diff --git a/crates/app-iced/src/app/view/instances.rs b/crates/app-iced/src/app/view/instances.rs index da88dbf..9f353f2 100644 --- a/crates/app-iced/src/app/view/instances.rs +++ b/crates/app-iced/src/app/view/instances.rs @@ -21,7 +21,7 @@ use unlucky_i18n::{Catalog, Key}; use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind}; -use crate::pages::{Detail, Meta}; +use crate::pages::{Detail, LaunchFacts, Meta}; /// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。 pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String { @@ -135,6 +135,17 @@ pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail instance.runtime, InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. } ), + // M157:启动参数区的**只读事实**(门槛 / 生效值 / 三态词表全部来自 presenter 快照, + // 页面不复制任何一条判据;表单值在 `App::launch_form`)。 + launch: LaunchFacts { + allowed: instance.launch.graphics_backend_allowed, + mc_version: instance.launch.mc_version.clone(), + min_version: instance.launch.graphics_backend_min_version.clone(), + memory_effective_mb: instance.launch.memory_effective_mb, + java_effective: instance.launch.java_effective.clone(), + backend_effective: instance.launch.graphics_backend_effective.clone(), + options: instance.launch.graphics_backend_options.clone(), + }, } } diff --git a/crates/app-iced/src/app/view/tests.rs b/crates/app-iced/src/app/view/tests.rs index d011570..81fab12 100644 --- a/crates/app-iced/src/app/view/tests.rs +++ b/crates/app-iced/src/app/view/tests.rs @@ -22,6 +22,7 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary { isolation: "isolated".to_string(), version_id: "1.20.4".to_string(), version_present: true, + launch: unlucky_presenter::dto::LaunchArgsView::default(), } } diff --git a/crates/app-iced/src/pages/content/model/tests.rs b/crates/app-iced/src/pages/content/model/tests.rs index b20074b..ecff751 100644 --- a/crates/app-iced/src/pages/content/model/tests.rs +++ b/crates/app-iced/src/pages/content/model/tests.rs @@ -223,6 +223,7 @@ fn can_install_needs_a_selection_and_no_blockers() { isolation: "isolated".to_string(), version_id: "1.20.1".to_string(), version_present: true, + launch: unlucky_presenter::dto::LaunchArgsView::default(), }]; let catalog = Lang::En.catalog(); let page = PageState { diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index b97a376..d0d1a32 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -62,6 +62,7 @@ use crate::renderer; use crate::shot; use crate::theme::Ui; +use super::instances; use super::{app_with_appearance, demo_model, Model}; mod app; @@ -118,6 +119,8 @@ struct Harness { model: Model, /// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。 appearance: Option, + /// 实例页「启动参数」区的表单(M157):harness 没有 `App`,自己持有演示表单。 + form: instances::LaunchForm, window: Option, /// 窗口原生句柄(`window::raw_id` 的回值)。OS 侧密闭指标要在**真句柄**上读, /// 拿不到就留 `None` ⇒ 日志记 `unknown`(不是 `off`)。 @@ -308,7 +311,8 @@ impl Harness { } fn view(&self) -> Element<'_, Message> { - app_with_appearance(&self.ui, &self.model, self.appearance.as_ref()).map(Message::Page) + app_with_appearance(&self.ui, &self.model, self.appearance.as_ref(), &self.form) + .map(Message::Page) } } diff --git a/crates/app-iced/src/pages/harness/app.rs b/crates/app-iced/src/pages/harness/app.rs index 2e2dea1..bcb227d 100644 --- a/crates/app-iced/src/pages/harness/app.rs +++ b/crates/app-iced/src/pages/harness/app.rs @@ -144,6 +144,7 @@ pub fn run(request: Request) -> Result<(), String> { ui: Ui::new(boot.flavor, families), model, appearance: appearance.clone(), + form: crate::pages::demo_launch_form(), request: boot.clone(), window: None, hwnd: None, diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index 43b9ac6..203e120 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -48,6 +48,8 @@ mod data; mod demo; mod detail; +mod launch_args; +mod state; mod text; #[cfg(test)] @@ -55,7 +57,8 @@ mod tests; #[cfg(test)] pub(crate) use data::{DEMO, NOTE, SELECTED}; -pub(super) use demo::demo; +pub(crate) use demo::{demo, demo_form}; +pub use state::{LaunchForm, Message as InstanceMessage}; pub(super) use text::Texts; use iced::widget::{column, container, row, scrollable}; @@ -97,7 +100,12 @@ pub(super) const LIST_BOTTOM_INSET: Space = Space::X2; /// /// 文案全部走 [`Texts`](`model.lang` 的语言):这个函数是实例页唯一的取词入口, /// 页面里不留任何可见英文字面量(守门见 `instances/tests/i18n.rs`)。 -pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<'a, Message> { +pub fn view<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + model: &'a Model, + form: &'a LaunchForm, +) -> Element<'a, Message> { let block = &model.instances; let forced = model.forced; let texts = Texts::of(model.lang.catalog()); @@ -123,12 +131,12 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element< metrics, list_column(ui, block, forced, texts), match &block.detail { - Some(detail) => detail::detail_column(ui, metrics, detail, forced, texts), + Some(detail) => detail::detail_column(ui, metrics, detail, form, forced, texts), None => detail::detail_empty(ui, texts), }, ), _ => match &block.detail { - Some(detail) => detail::narrow_detail(ui, metrics, detail, forced, texts), + Some(detail) => detail::narrow_detail(ui, metrics, detail, form, forced, texts), None => frame::body(metrics, detail::detail_empty(ui, texts)), }, }; diff --git a/crates/app-iced/src/pages/instances/demo.rs b/crates/app-iced/src/pages/instances/demo.rs index 64443c9..7d3c2d6 100644 --- a/crates/app-iced/src/pages/instances/demo.rs +++ b/crates/app-iced/src/pages/instances/demo.rs @@ -4,9 +4,11 @@ //! 两份数据源喂同一个渲染函数,这是 [`crate::pages::model`] 存在的理由。 use unlucky_i18n::{Catalog, Key, Lang}; +use unlucky_presenter::dto::LaunchArgsView; -use super::super::model::{Detail, Instances, Meta, Row}; +use super::super::model::{Detail, Instances, LaunchFacts, Meta, Row}; use super::data::{DEMO, NOTE, SELECTED}; +use super::state::LaunchForm; /// 演示路径的目录:**恒英文**。 /// @@ -51,6 +53,7 @@ pub(crate) fn demo() -> Instances { status: None, can_launch: true, can_stop: false, + launch: launch_facts(), }; Instances { @@ -65,3 +68,60 @@ fn meta(label: &str, value: &str) -> Meta { value: value.to_string(), } } + +/// 启动参数区的演示事实(M157 的 D30 出图夹具)。 +/// +/// `ULL_LAUNCH_ARGS_FIXTURE=gated` 出「版本不够、三态灰化」那一屏;缺省(`open`)是 +/// 「26.2 实例 + Vulkan 可选」的正常态。**这是出图夹具,不是产品逻辑**:产品路径的门槛 +/// 来自 presenter 快照(core 的 `graphics_backend_allowed`),与这个环境变量无关。 +pub(crate) fn launch_facts() -> LaunchFacts { + let gated = gated_fixture(); + LaunchFacts { + allowed: !gated, + mc_version: if gated { "1.20.4" } else { "26.2-snapshot-2" }.to_string(), + min_version: "26.2-snapshot-2".to_string(), + memory_effective_mb: if gated { 2048 } else { 4096 }, + java_effective: "C:/Java/temurin-21/bin/java.exe".to_string(), + backend_effective: if gated { "default" } else { "vulkan" }.to_string(), + options: ["default", "opengl", "vulkan"] + .iter() + .map(|id| (*id).to_string()) + .collect(), + } +} + +/// 演示用的表单初值(页面状态的所有者是 `App`;harness 没有 `App`,自己持有一份)。 +/// +/// 返回**拥有**的表单而不是 `&'static`:`text_editor::Content` 内含 `RefCell`,不满足 +/// `Sync`,做不成 `static`;harness/测试各自持有即可(一帧借用一次)。 +pub(crate) fn demo_form() -> LaunchForm { + LaunchForm::from_view(Some(DEMO[SELECTED].name), &demo_view()) +} + +fn demo_view() -> LaunchArgsView { + let gated = gated_fixture(); + LaunchArgsView { + instance_id: DEMO[SELECTED].name.to_string(), + jvm_args: "-XX:+UseG1GC".to_string(), + game_args: String::new(), + memory_mb: (!gated).then_some(4096), + memory_effective_mb: if gated { 2048 } else { 4096 }, + java_path: String::new(), + java_effective: "C:/Java/temurin-21/bin/java.exe".to_string(), + graphics_backend: if gated { "default" } else { "vulkan" }.to_string(), + graphics_backend_effective: if gated { "default" } else { "vulkan" }.to_string(), + graphics_backend_allowed: !gated, + mc_version: if gated { "1.20.4" } else { "26.2-snapshot-2" }.to_string(), + graphics_backend_min_version: "26.2-snapshot-2".to_string(), + graphics_backend_options: ["default", "opengl", "vulkan"] + .iter() + .map(|id| (*id).to_string()) + .collect(), + } +} + +fn gated_fixture() -> bool { + std::env::var("ULL_LAUNCH_ARGS_FIXTURE") + .map(|value| value.trim().eq_ignore_ascii_case("gated")) + .unwrap_or(false) +} diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs index 4d6d41c..4c291b5 100644 --- a/crates/app-iced/src/pages/instances/detail.rs +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -23,6 +23,7 @@ use super::super::layout::{self, FrameMetrics}; use super::super::model::Detail; use super::super::{Action, Message}; use super::forced_state; +use super::state::LaunchForm; use super::Texts; // --------------------------------------------------------------- 详情列 @@ -31,6 +32,7 @@ pub(super) fn detail_column<'a>( ui: &'a Ui, metrics: FrameMetrics, detail: &'a Detail, + form: &'a LaunchForm, forced: Option, texts: Texts, ) -> Element<'a, Message> { @@ -43,6 +45,8 @@ pub(super) fn detail_column<'a>( .width(Length::Fill); body = push_text(ui, metrics, body, detail); + // M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。 + body = body.push(super::launch_args::section(ui, &detail.launch, form, texts)); // 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留) frame::left_aligned(metrics.column, body.push(action_row(ui, forced, texts))) @@ -52,6 +56,7 @@ pub(super) fn narrow_detail<'a>( ui: &'a Ui, metrics: FrameMetrics, detail: &'a Detail, + form: &'a LaunchForm, forced: Option, texts: Texts, ) -> Element<'a, Message> { @@ -74,6 +79,7 @@ pub(super) fn narrow_detail<'a>( .width(Length::Fill); body = push_text(ui, metrics, body, detail); + body = body.push(super::launch_args::section(ui, &detail.launch, form, texts)); body = body.push(frame::text_role( ui, diff --git a/crates/app-iced/src/pages/instances/launch_args.rs b/crates/app-iced/src/pages/instances/launch_args.rs new file mode 100644 index 0000000..51daf33 --- /dev/null +++ b/crates/app-iced/src/pages/instances/launch_args.rs @@ -0,0 +1,218 @@ +//! 实例详情页的「启动参数」区(M157)。 +//! +//! | 控件 | 语义 | 消息 | +//! |---|---|---| +//! | JVM 参数(多行) | 实例层,与全局 `java.extra_args` **叠加**(实例段最高优先级) | [`Message::Jvm`] | +//! | 游戏参数(多行) | 实例层,追加在命令行最后 | [`Message::Game`] | +//! | 最大内存(单行数字) | 实例层;留空 = 跟随全局默认 | [`Message::Memory`] | +//! | Java 可执行文件(单行) | 实例层;留空 = 跟随全局 → 自动挑 | [`Message::Java`] | +//! | 图形后端(三态 chips) | `default` / `opengl` / `vulkan`;**版本不够时整体灰化 + 提示** | [`Message::Backend`] | +//! +//! 两条判据刻意不在这里重算: +//! 1. **能不能用 `--graphicsBackend`** 来自 [`LaunchFacts::allowed`](core 的 `graphics_backend_allowed`, +//! presenter 的 host 已经算好)——UI 不复制版本号比较; +//! 2. **生效值**(内存/Java/后端)同样来自 facts,表单里画的是实例层原文。 +//! +//! 文案全走 [`Texts`](守门见 `instances/tests/i18n.rs` 的白名单与死 key 扫描)。 + +use iced::widget::{column, container, row}; +use iced::{Background, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::inputs::{text_field, Field}; +use crate::app::components::selection::{chip, Chip}; + +use crate::theme::Ui; + +use super::super::model::LaunchFacts; +use super::super::Message as PageMessage; +use super::state::{LaunchForm, Message}; +use super::Texts; + +/// 页内消息 → 页面消息(本文件的控件树统一画 `pages::Message`)。 +fn page(message: Message) -> PageMessage { + PageMessage::Instances(message) +} + +/// 「启动参数」区(在详情列的正文段之后、动作行之前)。 +pub(super) fn section<'a>( + ui: &'a Ui, + facts: &'a LaunchFacts, + form: &'a LaunchForm, + texts: Texts, +) -> Element<'a, PageMessage> { + let mut body = column![frame_title(ui, texts.launch_title())] + .spacing(ui.space(Space::X3)) + .width(Length::Fill); + + body = body.push(args_field( + ui, + texts.launch_jvm(), + texts.launch_jvm_hint(), + form.jvm.as_str(), + |value| page(Message::Jvm(value)), + )); + body = body.push(args_field( + ui, + texts.launch_game(), + texts.launch_game_hint(), + form.game.as_str(), + |value| page(Message::Game(value)), + )); + body = body.push(text_field_with_hint( + ui, + texts.launch_memory(), + form.memory.as_str(), + super::super::frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + texts.launch_memory_hint(facts.memory_effective_mb), + ui.color(Role::OnSurfaceVariant), + ), + |value| page(Message::Memory(value)), + )); + body = body.push(text_field_with_hint( + ui, + texts.launch_java(), + form.java.as_str(), + super::super::frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + texts.launch_java_hint().to_string(), + ui.color(Role::OnSurfaceVariant), + ), + |value| page(Message::Java(value)), + )); + body = body.push(backend_row(ui, facts, form, texts)); + body = body.push(actions_row(ui, texts)); + + container(body) + .width(Length::Fill) + .padding(ui.space(Space::X3)) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Card))), + border: ui.outlined(ShapeScale::Medium), + ..container::Style::default() + }) + .into() +} + +fn frame_title<'a>(ui: &'a Ui, title: &'a str) -> Element<'a, PageMessage> { + super::super::frame::text_role( + ui, + TypeRole::LabelMedium, + true, + title, + ui.color(Role::OnSurfaceVariant), + ) +} + +/// 一段参数输入 + 一行提示(单行受控输入;参数按空白切分,与 core 的 `tokenize` 同口径)。 +fn args_field<'a>( + ui: &'a Ui, + label: &'a str, + hint: &'a str, + value: &'a str, + on_input: impl Fn(String) -> PageMessage + 'a, +) -> Element<'a, PageMessage> { + column![ + text_field(ui, Field::new(value).label(label), on_input), + super::super::frame::text_role( + ui, + TypeRole::BodySmall, + false, + hint, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +/// 单行输入 + 一行提示(提示是**拥有的**字符串,所以不能走 `Field::supporting` 的借用口径)。 +fn text_field_with_hint<'a>( + ui: &'a Ui, + label: &'a str, + value: &'a str, + hint: Element<'a, PageMessage>, + on_input: impl Fn(String) -> PageMessage + 'a, +) -> Element<'a, PageMessage> { + column![ + text_field(ui, Field::new(value).label(label), on_input), + hint, + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +/// 三态后端:版本不够时整体禁用(chips 不带 `on_press`)+ 一行门槛提示。 +fn backend_row<'a>( + ui: &'a Ui, + facts: &'a LaunchFacts, + form: &'a LaunchForm, + texts: Texts, +) -> Element<'a, PageMessage> { + let allowed = facts.allowed; + let mut chips = row![].spacing(ui.space(Space::X2)); + for id in &facts.options { + let label = texts.backend_label(id).unwrap_or(id.as_str()); + let selected = form.backend == *id; + let spec = if allowed { + Chip::filter(label, selected, page(Message::Backend(id.clone()))) + } else { + Chip::filter(label, selected, page(Message::Backend(id.clone()))).disabled() + }; + chips = chips.push(chip(ui, spec)); + } + + let mut meta = column![ + super::super::frame::text_role( + ui, + TypeRole::BodySmall, + false, + texts.launch_backend(), + ui.color(Role::OnSurface), + ), + chips, + super::super::frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + texts.launch_effective(&facts.backend_effective), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + if !allowed { + // 灰化 + 明确提示:既不说「已生效」,也不静默吞掉用户的选择。 + meta = meta.push(super::super::frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + texts.launch_backend_gated(&facts.mc_version), + ui.color(Role::Error), + )); + } + meta.into() +} + +fn actions_row<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, PageMessage> { + row![ + buttons::filled(ui, Button::new(texts.launch_save(), page(Message::Save))), + buttons::text(ui, Button::new(texts.launch_reset(), page(Message::Reset))), + ] + .spacing(ui.space(Space::X2)) + .into() +} diff --git a/crates/app-iced/src/pages/instances/state.rs b/crates/app-iced/src/pages/instances/state.rs new file mode 100644 index 0000000..2b4ebbe --- /dev/null +++ b/crates/app-iced/src/pages/instances/state.rs @@ -0,0 +1,190 @@ +//! 实例页的**页面状态**(M157):启动参数区的表单。 +//! +//! ## 为什么状态在 app、页面只借一帧 +//! +//! iced 的 `view` 不能持有跨帧状态,而启动参数区是可编辑的(两个多行框 + 两行单行框 + +//! 三态 chips)。所以表单状态的所有者是 `App`(`app/state.rs` 的 `launch_form` 字段), +//! 页面层只在这一帧借用;这与设置页「组状态归 `App`」是同一条纪律(`pages/settings/state.rs`)。 +//! +//! ## 与快照的关系(唯一的重播种点) +//! +//! [`LaunchForm::sync`] 由 `App::refresh_view` 调用: +//! +//! | 情况 | 行为 | +//! |---|---| +//! | 换了一个实例 | **重播种**(用户没写完的草稿不能串到别的实例上) | +//! | 同一个实例、没改过 | 重播种(保存/外部改了配置后跟着刷新) | +//! | 同一个实例、改过(`dirty`) | **不覆盖**(每来一条事件就把输入抹掉是不可接受的) | +//! +//! 「留空 = 跟随下一层」由提交时把空串原样交给 host 表达(`Some("")` = 清空), +//! 表单自己不做任何回落计算——回落只发生在 core。 + +use unlucky_presenter::dto::{LaunchArgsPatch, LaunchArgsView}; + +/// 启动参数表单(五项 + 脏标记 + 绑定的实例 id)。 +#[derive(Debug)] +pub struct LaunchForm { + /// 这份草稿属于哪个实例(`None` = 还没播种)。 + pub bound: Option, + /// 实例级 JVM 参数(空白分隔的文本)。 + pub jvm: String, + /// 实例级游戏参数(空白分隔的文本)。 + pub game: String, + /// 最大堆(文本形态,空 = 跟随全局)。 + pub memory: String, + /// Java 可执行文件(空 = 跟随全局 / 自动挑)。 + pub java: String, + /// 图形后端(`default` / `opengl` / `vulkan`)。 + pub backend: String, + /// 用户改过、还没保存。 + pub dirty: bool, +} + +impl Default for LaunchForm { + fn default() -> Self { + Self::from_view(None, &LaunchArgsView::default()) + } +} + +impl LaunchForm { + /// 用一份快照播种(`bound` 为 `None` = 还没绑定实例)。 + #[must_use] + pub fn from_view(bound: Option<&str>, view: &LaunchArgsView) -> Self { + Self { + bound: bound.map(str::to_string), + jvm: view.jvm_args.clone(), + game: view.game_args.clone(), + memory: view.memory_mb.map(|mb| mb.to_string()).unwrap_or_default(), + java: view.java_path.clone(), + backend: view.graphics_backend.clone(), + dirty: false, + } + } + + /// 这份草稿是否正对着该实例。 + #[must_use] + pub fn bound_to(&self, id: &str) -> bool { + self.bound.as_deref() == Some(id) + } + + /// 重播种(见模块文档的三档)。 + pub fn sync(&mut self, view: &LaunchArgsView) { + let same_instance = self.bound_to(&view.instance_id); + if same_instance && self.dirty { + return; + } + *self = Self::from_view(Some(&view.instance_id), view); + } + + /// 提交补丁:**五项全给**(表单是整份可见的,不用增量语义会让「清空」无法表达)。 + #[must_use] + pub fn patch(&self) -> LaunchArgsPatch { + LaunchArgsPatch { + jvm_args: Some(self.jvm.clone()), + game_args: Some(self.game.clone()), + memory_mb: Some(self.memory.clone()), + java_path: Some(self.java.clone()), + graphics_backend: Some(self.backend.clone()), + } + } + + /// 「恢复跟随全局」:五项清空,脏标记置位(等用户点保存)。 + pub fn reset(&mut self) { + self.jvm.clear(); + self.game.clear(); + self.memory.clear(); + self.java.clear(); + self.backend = "default".to_string(); + self.dirty = true; + } +} + +/// 启动参数区的消息(`pages::Message::Instances` 的载荷)。 +/// +/// 只表达**页面意图**:编辑写进表单,保存/恢复才需要 app 层理解(前者 dispatch 一条 +/// `AppCommand::SetInstanceLaunchArgs`,后者只是清表单)。 +#[derive(Debug, Clone)] +pub enum Message { + /// 编辑 JVM 参数。 + Jvm(String), + /// 编辑游戏参数。 + Game(String), + /// 编辑最大内存(单行文本)。 + Memory(String), + /// 编辑 Java 路径(单行文本)。 + Java(String), + /// 选图形后端(三态 chips)。 + Backend(String), + /// 保存(派发命令)。 + Save, + /// 恢复跟随全局(清表单,仍需保存)。 + Reset, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn view(instance: &str, jvm: &str, memory: Option, backend: &str) -> LaunchArgsView { + LaunchArgsView { + instance_id: instance.to_string(), + jvm_args: jvm.to_string(), + game_args: String::new(), + memory_mb: memory, + memory_effective_mb: memory.unwrap_or(2048), + java_path: String::new(), + java_effective: String::new(), + graphics_backend: backend.to_string(), + graphics_backend_effective: backend.to_string(), + graphics_backend_allowed: true, + mc_version: "26.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(), + ], + } + } + + /// 换实例必重播种;同实例的脏草稿不被事件冲掉。 + #[test] + fn sync_reseeds_per_instance_but_keeps_dirty_edits() { + let mut form = LaunchForm::default(); + form.sync(&view("a", "-Xss4m", Some(4096), "vulkan")); + assert_eq!(form.jvm, "-Xss4m"); + assert_eq!(form.memory, "4096"); + assert!(!form.dirty); + + // 用户改动 → 脏 → 同实例的快照刷新不许覆盖。 + form.memory = "8192".to_string(); + form.dirty = true; + form.sync(&view("a", "-Xss4m", Some(4096), "vulkan")); + assert_eq!(form.memory, "8192", "脏草稿被事件冲掉了"); + + // 换实例 → 重播种(草稿不串台)。 + form.sync(&view("b", "", None, "default")); + assert_eq!(form.memory, ""); + assert!(!form.dirty); + assert!(form.bound_to("b")); + } + + /// 补丁五项全给:空串就是「清空」,不留 `None` 让 host 猜。 + #[test] + fn patch_always_covers_all_five_fields() { + let mut form = LaunchForm::from_view(Some("a"), &view("a", "", None, "default")); + form.memory = "6144".to_string(); + form.backend = "opengl".to_string(); + let patch = form.patch(); + assert_eq!(patch.jvm_args.as_deref(), Some("")); + assert_eq!(patch.memory_mb.as_deref(), Some("6144")); + assert_eq!(patch.graphics_backend.as_deref(), Some("opengl")); + + form.reset(); + assert!(form.dirty); + let patch = form.patch(); + assert_eq!(patch.jvm_args.as_deref(), Some("")); + assert_eq!(patch.memory_mb.as_deref(), Some("")); + assert_eq!(patch.graphics_backend.as_deref(), Some("default")); + } +} diff --git a/crates/app-iced/src/pages/instances/tests/i18n.rs b/crates/app-iced/src/pages/instances/tests/i18n.rs index 4e96787..61fdfc5 100644 --- a/crates/app-iced/src/pages/instances/tests/i18n.rs +++ b/crates/app-iced/src/pages/instances/tests/i18n.rs @@ -28,14 +28,18 @@ use super::super::Texts; /// /// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的; /// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。 -const ALLOWED: &[&str] = &["Java", "pid"]; +const ALLOWED: &[&str] = &["Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB"]; -/// 页面取词的五个源码文件(死 key 扫描与旧串扫描共用)。 -const PAGE_SOURCES: [(&str, &str); 5] = [ +/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。 +const PAGE_SOURCES: [(&str, &str); 6] = [ ("instances.rs", include_str!("../../instances.rs")), ("instances/detail.rs", include_str!("../detail.rs")), ("instances/demo.rs", include_str!("../demo.rs")), ("instances/text.rs", include_str!("../text.rs")), + ( + "instances/launch_args.rs", + include_str!("../launch_args.rs"), + ), ( "app/view/instances.rs", include_str!("../../../app/view/instances.rs"), @@ -57,13 +61,29 @@ const PAGE_SOURCES: [(&str, &str); 5] = [ /// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 | /// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) | /// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) | -const ALLOWED_LITERALS: [&str; 6] = [ +const ALLOWED_LITERALS: [&str; 17] = [ "{date} {time} UTC", "isolated", "shared", "pid", "reason", "Instances", + // M157:图形后端三态的**稳定 id**(与 core 的 `GraphicsBackend::IDS` 同一份词表), + // `backend_label` 只认这三档,其余原样上屏。 + "default", + "opengl", + "vulkan", + // M157:`Catalog::render` 的占位符名(`{version}` / `{value}` / `{default}`),不是文案。 + "version", + "value", + // M157:`instances/demo.rs` 的**出图夹具**(用户数据的形状,不是界面文案): + // 一个示例 JVM 参数、两个示例 MC 版本、一个示例 Java 路径,以及夹具开关的环境变量名与取值。 + "-XX:+UseG1GC", + "26.2-snapshot-2", + "1.20.4", + "C:/Java/temurin-21/bin/java.exe", + "ULL_LAUNCH_ARGS_FIXTURE", + "gated", ]; /// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。 @@ -248,9 +268,13 @@ fn every_english_literal_in_the_page_sources_is_whitelisted() { "text.rs 没有引用本族的 key" ); assert!( - PAGE_SOURCES[4].1.contains("Key::InstancesStage"), + PAGE_SOURCES[5].1.contains("Key::InstancesStage"), "阶段标签没有走目录" ); + assert!( + PAGE_SOURCES[4].1.contains("texts.launch_title()"), + "启动参数区没有走目录" + ); } /// **死 key 守门**:目录里每一条 `Instances*` 都必须被页面(或产品快照转换)真的引用。 diff --git a/crates/app-iced/src/pages/instances/text.rs b/crates/app-iced/src/pages/instances/text.rs index b792445..c49f935 100644 --- a/crates/app-iced/src/pages/instances/text.rs +++ b/crates/app-iced/src/pages/instances/text.rs @@ -85,6 +85,67 @@ impl Texts { self.text(Key::InstancesNarrowNote) } + // ------------------------------------------------------------- 启动参数(M157) + pub(super) fn launch_title(self) -> &'static str { + self.text(Key::InstancesLaunchTitle) + } + pub(super) fn launch_jvm(self) -> &'static str { + self.text(Key::InstancesLaunchJvm) + } + pub(super) fn launch_jvm_hint(self) -> &'static str { + self.text(Key::InstancesLaunchJvmHint) + } + pub(super) fn launch_game(self) -> &'static str { + self.text(Key::InstancesLaunchGame) + } + pub(super) fn launch_game_hint(self) -> &'static str { + self.text(Key::InstancesLaunchGameHint) + } + pub(super) fn launch_memory(self) -> &'static str { + self.text(Key::InstancesLaunchMemory) + } + /// 「留空 = 跟随全局默认({default} MiB)」,`default` 是生效值(三级回落之后)。 + pub(super) fn launch_memory_hint(self, default_mb: u32) -> String { + self.0.render( + Key::InstancesLaunchMemoryHint, + &[("default", &default_mb.to_string())], + ) + } + pub(super) fn launch_java(self) -> &'static str { + self.text(Key::InstancesLaunchJava) + } + pub(super) fn launch_java_hint(self) -> &'static str { + self.text(Key::InstancesLaunchJavaHint) + } + pub(super) fn launch_backend(self) -> &'static str { + self.text(Key::InstancesLaunchBackend) + } + /// 「需游戏版本 26.2 及以上(本实例 1.20.4)」。 + pub(super) fn launch_backend_gated(self, version: &str) -> String { + self.0 + .render(Key::InstancesLaunchBackendGated, &[("version", version)]) + } + /// 「生效:vulkan」。 + pub(super) fn launch_effective(self, value: &str) -> String { + self.0 + .render(Key::InstancesLaunchEffective, &[("value", value)]) + } + /// 三态后端的显示名(稳定 id → 文案);**未知 id 返回 `None`**(调用方原样上屏,不猜)。 + pub(super) fn backend_label(self, id: &str) -> Option<&'static str> { + Some(self.text(match id { + "default" => Key::InstancesBackendDefault, + "opengl" => Key::InstancesBackendOpengl, + "vulkan" => Key::InstancesBackendVulkan, + _ => return None, + })) + } + pub(super) fn launch_save(self) -> &'static str { + self.text(Key::InstancesLaunchSave) + } + pub(super) fn launch_reset(self) -> &'static str { + self.text(Key::InstancesLaunchReset) + } + fn text(self, key: Key) -> &'static str { self.0.text(key) } diff --git a/crates/app-iced/src/pages/message.rs b/crates/app-iced/src/pages/message.rs index baa530d..b4966bb 100644 --- a/crates/app-iced/src/pages/message.rs +++ b/crates/app-iced/src/pages/message.rs @@ -91,6 +91,8 @@ pub enum Message { /// 加一条内容库消息只动 `pages/content/message.rs`,不动本枚举的正文,也不动 /// `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。 Content(content::Message), + /// 实例页「启动参数」区的消息(**M157**):编辑写进页面状态,保存才 dispatch。 + Instances(super::instances::InstanceMessage), /// 账户页消息(**M114**:三轨登录 / 列表动作 / 表单输入;app 层翻成 `AppCommand`)。 /// /// 与 `Settings` 同一种做法:账户页自己的消息枚举在 `pages/account.rs`,这里只加一支入口。 diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 18fb0f8..b508ac1 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -126,10 +126,12 @@ pub(crate) mod settings; #[cfg(test)] mod tests; pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader}; +pub(crate) use instances::demo_form as demo_launch_form; +pub use instances::{InstanceMessage, LaunchForm}; pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome}; pub use message::{Action, Message}; -pub use model::{Detail, Instances, Meta, Model, Row}; +pub use model::{Detail, Instances, LaunchFacts, Meta, Model, Row}; pub use screen::Screen; pub use settings::Language; @@ -150,7 +152,11 @@ use frame::filled; /// /// 视图模型由**调用方持有**(`App` 在状态变化时重建一次 `Model`),这里只借用: /// `view(&self)` 不能返回一棵借用局部变量的树。 -pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> { +pub fn page<'a>( + ui: &'a Ui, + model: &'a Model, + form: &'a instances::LaunchForm, +) -> Element<'a, Message> { render( ui, model, @@ -159,6 +165,7 @@ pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> { groups: settings::demo_groups(), }, Language::default(), + form, ) } @@ -174,6 +181,7 @@ pub fn page_with_appearance<'a>( ui: &'a Ui, model: &'a Model, appearance: &'a appearance::State, + form: &'a instances::LaunchForm, ) -> Element<'a, Message> { render( ui, @@ -183,6 +191,7 @@ pub fn page_with_appearance<'a>( groups: settings::demo_groups(), }, Language::default(), + form, ) } @@ -199,8 +208,9 @@ pub fn page_with_settings<'a>( model: &'a Model, states: settings::States<'a>, language: Language, + launch_form: &'a instances::LaunchForm, ) -> Element<'a, Message> { - render(ui, model, states, language) + render(ui, model, states, language, launch_form) } fn render<'a>( @@ -208,11 +218,12 @@ fn render<'a>( model: &'a Model, states: settings::States<'a>, language: Language, + launch_form: &'a instances::LaunchForm, ) -> Element<'a, Message> { let metrics = layout::frame_metrics(model.screen.kit(), model.window_width); match model.screen { - Screen::Instances => instances::view(ui, metrics, model), + Screen::Instances => instances::view(ui, metrics, model, launch_form), // M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。 Screen::Download => download::view(ui, metrics, &model.download, model.lang), // M109:内容库页同样跟 `model.lang` @@ -227,8 +238,12 @@ fn render<'a>( /// 产品路径**不走这里**(它走 [`page`] + `shell::view_with_content`,用真标题栏)。这个入口留给 /// `pages_demo` example 与单测:它们要一张「只有页面、不接 app 消息」的独立图,而 `shell` 的 /// 回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。 -pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> { - app_with_appearance(ui, model, None) +pub fn app<'a>( + ui: &'a Ui, + model: &'a Model, + form: &'a instances::LaunchForm, +) -> Element<'a, Message> { + app_with_appearance(ui, model, None, form) } /// 同 [`app`],但可以带上**外观运行期状态**。 @@ -241,6 +256,7 @@ pub fn app_with_appearance<'a>( ui: &'a Ui, model: &'a Model, appearance: Option<&'a appearance::State>, + form: &'a instances::LaunchForm, ) -> Element<'a, Message> { // harness 路径没有真组状态:用演示状态([`settings::demo_groups`]),外观状态照传。 let body = render( @@ -251,6 +267,7 @@ pub fn app_with_appearance<'a>( groups: settings::demo_groups(), }, Language::default(), + form, ); let shell = row![nav::rail(ui, model.active), body] diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index fdde68d..c66addf 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -64,6 +64,30 @@ pub struct Detail { pub can_launch: bool, /// 「Stop」可用 pub can_stop: bool, + /// 启动参数区的只读事实(M157) + pub launch: LaunchFacts, +} + +/// 启动参数区(M157)的**只读事实**:门槛、生效值、三态词表。 +/// +/// 可编辑的值不在这里——它们在页面状态 [`super::instances::LaunchForm`](由 `App` 持有)。 +/// 这样「每来一条事件就重建视图模型」不会把用户正在输入的内容冲掉。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct LaunchFacts { + /// 这个版本能不能下发 `--graphicsBackend`(core 的版本门槛判定)。 + pub allowed: bool, + /// 判定依据的游戏版本(提示文案里给出)。 + pub mc_version: String, + /// 门槛的最低版本(`26.2-snapshot-2`;提示与测试共用一份来源)。 + pub min_version: String, + /// 生效最大堆(三级回落之后)。 + pub memory_effective_mb: u32, + /// 生效 Java(空 = 自动挑)。 + pub java_effective: String, + /// 生效图形后端。 + pub backend_effective: String, + /// 三态词表(`default` / `opengl` / `vulkan`)。 + pub options: Vec, } /// 实例页要画的东西。 diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs index 469b772..7869dd3 100644 --- a/crates/app-iced/src/pages/tests.rs +++ b/crates/app-iced/src/pages/tests.rs @@ -54,10 +54,11 @@ fn screens_declare_the_layout_their_page_needs() { #[test] fn every_screen_builds_at_the_three_comparison_widths() { let ui = ui(); + let form = demo_launch_form(); for width in [1180.0, 900.0, 700.0] { for screen in Screen::ALL { let model = demo_model(screen, width); - let _element: Element<'_, Message> = app(&ui, &model); + let _element: Element<'_, Message> = app(&ui, &model, &form); } } } @@ -66,9 +67,10 @@ fn every_screen_builds_at_the_three_comparison_widths() { #[test] fn an_empty_snapshot_still_builds() { let ui = ui(); + let form = demo_launch_form(); for screen in Screen::ALL { let model = Model::new(screen, 1180.0, Some(screen.nav())); - let _element: Element<'_, Message> = app(&ui, &model); + let _element: Element<'_, Message> = app(&ui, &model, &form); } } diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index eaadf7f..620957b 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -14,6 +14,9 @@ pub const fn general() -> &'static str { 命令: instances list 列出实例与已安装版本 + instances launch-args [--jvm T] [--game T] [--memory MB] [--java P] + [--backend default|opengl|vulkan] [--clear] + 读/写实例级启动参数(不带选项 = 只读;--clear = 全部回跟随全局) install [--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 [--jvm T] [--game T] [--memory MB] [--java P] \ +[--backend default|opengl|vulkan] [--clear] + +读/写实例配置页「启动参数」区(M157)。不带取写选项时只读: + --jvm 实例级 JVM 参数(空白分隔、支持引号);与全局 java.extra_args **叠加**, + 实例这层在最高优先级段(同名选项实例赢,`-Xmx` 也由它赢过内存字段) + --game 实例级游戏参数(追加在命令行最后,可覆盖前面一切游戏参数) + --memory 实例级最大堆(256~65536);留空/不写 = 跟随全局 java.default_memory_mb + --java 实例级 Java 可执行文件;留空 = 跟随全局 java.selected_path 再退到自动挑 + --backend default(不下发)/ opengl / vulkan;只对 MC 26.2-snapshot-2+ 下发, + 老版本会被版本门槛拦下(启动时给诊断告警,不静默) + --clear 先把五项清空再应用其它选项(= 全部回「跟随全局」) + 每一项都是「实例 → 全局 → 默认」的独立回落,只填一项不会顶掉别的项。 + 值以 `--` 开头时(多数游戏参数都是)写成 `--game=--fullscreen`:单独一个 `--fullscreen` + 会被当成下一个选项(缺少取值),这是全项目 CLI 的既有解析口径。 + --json 输出 {\"data\":{\"launch\":{…实例层…},\"effective\":{…回落后的生效值…}, …}}", ), "pack" => Some( "\ diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 8f04c14..0ddc3af 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -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; diff --git a/crates/cli/src/cli/model.rs b/crates/cli/src/cli/model.rs index 19f5fd0..ad6c92a 100644 --- a/crates/cli/src/cli/model.rs +++ b/crates/cli/src/cli/model.rs @@ -26,6 +26,8 @@ pub enum Command { Help(Option), /// `instances list`。 InstancesList, + /// `instances launch-args [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。 + InstancesLaunchArgs(InstanceLaunchArgs), /// `install [--loader X] [--name N]`。 Install(InstallArgs), /// `pack import [--target ] [--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, + /// 实例级游戏参数。 + pub game_args: Option, + /// 实例级最大堆(MiB 文本;空串由 `--memory ""` 表达「跟随全局」)。 + pub memory_mb: Option, + /// 实例级 Java 可执行文件。 + pub java: Option, + /// 图形后端(`default` / `opengl` / `vulkan`)。 + pub backend: Option, + /// 先清空五项再应用给出的项(`--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 { diff --git a/crates/cli/src/cli/parse.rs b/crates/cli/src/cli/parse.rs index 7280b77..1b7c639 100644 --- a/crates/cli/src/cli/parse.rs +++ b/crates/cli/src/cli/parse.rs @@ -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 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 Result { - 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 { let parsed = parse_flags(tail, &["loader", "loader-version", "name"], &["dry-run"])?; let version = require_single_positional(&parsed, "install ")?; diff --git a/crates/cli/src/cli/parse/instances.rs b/crates/cli/src/cli/parse/instances.rs new file mode 100644 index 0000000..9b4b9e9 --- /dev/null +++ b/crates/cli/src/cli/parse/instances.rs @@ -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 { + 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 [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。 +/// +/// 五个取值选项都接受空串(`--jvm ""`)= 该清空,与 `--clear` 的「先清后改」互补。 +fn parse_instances_launch_args(tail: &[String]) -> Result { + let parsed = parse_flags( + tail, + &["jvm", "game", "memory", "java", "backend"], + &["clear"], + )?; + let instance = require_single_positional(&parsed, "instances launch-args ")?; + 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"), + })) +} diff --git a/crates/cli/src/commands/instance_launch.rs b/crates/cli/src/commands/instance_launch.rs new file mode 100644 index 0000000..425c1a8 --- /dev/null +++ b/crates/cli/src/commands/instance_launch.rs @@ -0,0 +1,262 @@ +//! `instances launch-args `(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 { + 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 { + 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 { + 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; diff --git a/crates/cli/src/commands/instance_launch/tests.rs b/crates/cli/src/commands/instance_launch/tests.rs new file mode 100644 index 0000000..a166b34 --- /dev/null +++ b/crates/cli/src/commands/instance_launch/tests.rs @@ -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 +} + +/// 写 `/.minecraft/versions//.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 = args.iter().map(|arg| (*arg).to_string()).collect(); + let mut out: Vec = Vec::new(); + let mut err: Vec = 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}"); +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 9a41fa2..b3b2b9c 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -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 { 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)) diff --git a/crates/cli/src/commands/snapshot.rs b/crates/cli/src/commands/snapshot.rs index efc01a8..ac3857e 100644 --- a/crates/cli/src/commands/snapshot.rs +++ b/crates/cli/src/commands/snapshot.rs @@ -151,11 +151,21 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result { 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 { 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, diff --git a/crates/core/src/game/install/instance.rs b/crates/core/src/game/install/instance.rs index 8fa0aa3..3c86c2a 100644 --- a/crates/core/src/game/install/instance.rs +++ b/crates/core/src/game/install/instance.rs @@ -25,6 +25,7 @@ use serde::{Deserialize, Serialize}; use super::super::error::GameError; use super::super::isolation::{IsolationMode, ModeChange, ModeChangeWarning}; +use super::super::launch_overrides::LaunchOverrides; use super::super::profiles::{InstanceProfile, ProfileList}; use crate::platform::atomic::write_atomic; use crate::platform::layout::LauncherLayout; @@ -122,6 +123,12 @@ pub struct InstanceConfig { /// 该实例覆盖了哪些设置键。 #[serde(default, skip_serializing_if = "Vec::is_empty")] pub overrides: Vec, + /// 实例级启动参数覆盖(M157):JVM/游戏参数、内存、Java、图形后端五项,逐项回落。 + /// + /// 空段(全 `None`/空白)**不落盘**——与 `isolation` 同一套稀疏语义,清空输入框即「跟随全局」。 + /// 回落规则与生效值见 [`crate::game::launch_overrides`]。 + #[serde(default, skip_serializing_if = "LaunchOverrides::is_empty")] + pub launch: LaunchOverrides, /// 上次隔离档位切换的记录(`lastModeChange`;没切过就不落盘)。 /// /// 由 [`crate::game::isolation::observe_mode`] 维护,只记事实(含双侧探测),**不迁移数据**。 @@ -166,6 +173,7 @@ impl InstanceConfig { isolation: None, game_dir: None, overrides: Vec::new(), + launch: LaunchOverrides::default(), last_mode_change: None, extra: BTreeMap::new(), } diff --git a/crates/core/src/game/launch_overrides.rs b/crates/core/src/game/launch_overrides.rs new file mode 100644 index 0000000..d6708ae --- /dev/null +++ b/crates/core/src/game/launch_overrides.rs @@ -0,0 +1,360 @@ +//! 实例级启动参数覆盖(五项)+ **三级回落**(实例 → 全局 → 默认)。 +//! +//! 依据:M157 的定案(叠加/冲突规则见该 mission 的 notes;用户 2026-11-07 拍板实例配置页 +//! 支持启动参数,含 MC 26.2+ 的 `--graphicsBackend vulkan`)。 +//! +//! # 为什么是「五项全 Option」 +//! +//! 每个字段独立回落:只填了内存的实例不能把全局 JVM 参数一起顶掉。`None`(或空白字符串) +//! = 这一项跟随下一层,与 D20 的 `isolation` 同一套稀疏语义([`super::install::instance`])。 +//! +//! # 键名为什么叫 `launch` 而不是 `overrides` +//! +//! `overrides` 已经被 D20 占用(`["maxMemory", "javaType", "runningDirectory"]` 这份 +//! 「该实例覆盖了哪些全局键」的清单),抢占会让存量实例文件的语义漂移。五项**取值**段落 +//! 落在 `launch` 对象里,互不干扰。 +//! +//! # 三级回落逐项(**不是整段覆盖**) +//! +//! | 字段 | 实例层 | 全局层 | 出厂默认 | +//! |---|---|---|---| +//! | `jvmArgs` | `launch.jvmArgs` | `java.extra_args` | 空 | +//! | `gameArgs` | `launch.gameArgs` | (无全局键,留 `None`) | 空 | +//! | `memoryMb` | `launch.memoryMb` | `java.default_memory_mb` | [`DEFAULT_MAX_MEMORY_MB`] | +//! | `javaPath` | `launch.javaPath` | `java.selected_path` | 自动挑(托管 > JAVA_HOME > PATH) | +//! | `graphicsBackend` | `launch.graphicsBackend` | (无全局键,留 `None`) | `default` | +//! +//! `jvmArgs` 是**追加**而不是替换:全局是基线(进组装表第 7 项 `user.jvm`),实例是精调 +//! (进第 4 项 `user.override_jvm`),两者都下发,同名选项 JVM 取后出现者 ⇒ 实例赢。 +//! 落位实现在 [`crate::launch::overrides`],本文件只给**生效值**(纯计算、零 IO)。 +//! +//! # 越界的内存值不算「覆盖」 +//! +//! 手改文件能绕过 schema 校验,所以 `memoryMb` 在这个模块里也过一遍范围: +//! 越界 ⇒ 当作没填(落到下一层),而不是拿 `-Xmx0m` 去把 JVM 启动弄崩。判据与 +//! `setting::instance_keys` 登记的区间同源([`MIN_MAX_MEMORY_MB`] / [`MAX_MAX_MEMORY_MB`])。 + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// 出厂默认最大堆(MiB):与 `setting::keys` 的 `java.default_memory_mb` 默认值一致。 +pub const DEFAULT_MAX_MEMORY_MB: u32 = 2048; +/// `memoryMb` 允许的下限(MiB)。 +pub const MIN_MAX_MEMORY_MB: u32 = 256; +/// `memoryMb` 允许的上限(MiB)。 +pub const MAX_MAX_MEMORY_MB: u32 = 65536; + +/// 图形后端三态(任务书:默认 / OpenGL / Vulkan)。 +/// +/// `Default` **不是**「交给 JVM 决定」的另一个参数,而是「不下发 `--graphicsBackend`」—— +/// 26.2 以前根本没有这个参数,下发即 `UnrecognizedOptionException`。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GraphicsBackend { + /// 不下发(游戏按自己的默认后端启动)。 + #[default] + Default, + /// `--graphicsBackend opengl`。 + Opengl, + /// `--graphicsBackend vulkan`。 + Vulkan, +} + +impl GraphicsBackend { + /// 全部三态(顺序即 UI 下拉的展示顺序)。 + pub const ALL: [Self; 3] = [Self::Default, Self::Opengl, Self::Vulkan]; + /// 中文档位说明里的稳定 id 之一。 + pub const IDS: [&'static str; 3] = ["default", "opengl", "vulkan"]; + + /// 稳定 id(`default` / `opengl` / `vulkan`;GUI 与 `--json` 同词表)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Opengl => "opengl", + Self::Vulkan => "vulkan", + } + } + + /// 从稳定 id 解析;未知值返回 `None`(**调用方必须报错**,不许静默退化成 `default`)。 + #[must_use] + pub fn parse(raw: &str) -> Option { + let wanted = raw.trim(); + Self::ALL + .into_iter() + .find(|backend| backend.as_str().eq_ignore_ascii_case(wanted)) + } + + /// `--graphicsBackend` 的取值;`Default` = 不下发(`None`)。 + #[must_use] + pub const fn flag_value(self) -> Option<&'static str> { + match self { + Self::Default => None, + Self::Opengl => Some("opengl"), + Self::Vulkan => Some("vulkan"), + } + } + + /// 是否需要下发参数(`default` 不需要)。 + #[must_use] + pub const fn is_default(self) -> bool { + matches!(self, Self::Default) + } +} + +/// 实例级启动参数覆盖(`config/instances/.json` 的 `launch` 段)。 +/// +/// 稀疏:只写用户显式改过的项,键不存在即「跟随下一层」。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct LaunchOverrides { + /// 实例级 JVM 参数(空白分隔的文本,支持引号);空 = 只用全局 `java.extra_args`。 + #[serde(skip_serializing_if = "Option::is_none")] + pub jvm_args: Option, + /// 实例级游戏参数(同上);空 = 不追加。 + #[serde(skip_serializing_if = "Option::is_none")] + pub game_args: Option, + /// 最大堆(MiB);空或越界 = 跟随全局 `java.default_memory_mb`。 + #[serde(skip_serializing_if = "Option::is_none")] + pub memory_mb: Option, + /// 实例级 Java 可执行文件;空 = 跟随全局 `java.selected_path` 再退到自动挑。 + #[serde(skip_serializing_if = "Option::is_none")] + pub java_path: Option, + /// 图形后端;空 = `default`(不下发 `--graphicsBackend`)。 + /// + /// 反序列化对未知值**报错**(`unknown variant`),不静默落成 `default`。 + #[serde(skip_serializing_if = "Option::is_none")] + pub graphics_backend: Option, +} + +impl LaunchOverrides { + /// 整段是否为空(序列化时用来决定 `launch` 键出不出现)。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.normalized() == Self::default() + } + + /// 归一:空白字符串与空路径当作没填(用户清空输入框 = 回到跟随)。 + #[must_use] + pub fn normalized(&self) -> Self { + Self { + jvm_args: non_blank(self.jvm_args.as_deref()).map(str::to_string), + game_args: non_blank(self.game_args.as_deref()).map(str::to_string), + memory_mb: self.memory_mb.filter(|value| valid_memory(*value)), + java_path: self + .java_path + .as_ref() + .map(|path| path.display().to_string()) + .filter(|text| !text.trim().is_empty()) + .map(PathBuf::from), + graphics_backend: self.graphics_backend, + } + } + + /// 生效的最大堆(已过范围校验;`None` = 这一层没给)。 + #[must_use] + pub fn effective_memory_mb(&self) -> Option { + self.memory_mb.filter(|value| valid_memory(*value)) + } +} + +/// 全局层(`config/launcher.json` 里与启动参数相关的键)。 +/// +/// 读配置归调用方(CLI / presenter 的 host),本结构只承载**值**——`game` 不 import +/// `setting`,回落规则才能纯函数化、可纯单测。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GlobalLaunchDefaults { + /// `java.extra_args`(基线 JVM 参数)。 + pub jvm_args: Option, + /// 全局游戏参数(本轮没有对应设置键,留 `None` 占位,回落链照样逐项成立)。 + pub game_args: Option, + /// `java.default_memory_mb`。 + pub memory_mb: Option, + /// `java.selected_path`。 + pub java_path: Option, + /// 全局图形后端(本轮没有对应设置键)。 + pub graphics_backend: Option, +} + +impl GlobalLaunchDefaults { + /// 只带 `java.extra_args` 的最小构造(测试/CLI 常用)。 + #[must_use] + pub fn with_jvm_args(text: impl Into) -> Self { + Self { + jvm_args: Some(text.into()), + ..Self::default() + } + } +} + +/// 三级回落之后的**生效值**(拼装链的输入,见 [`crate::launch::overrides::apply`])。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedLaunch { + /// 全局层 JVM 参数(组装表第 7 项,`None` = 没有)。 + pub global_jvm_args: Option, + /// 实例层 JVM 参数(组装表第 4 项,最高优先级,`None` = 没有)。 + pub instance_jvm_args: Option, + /// 游戏参数(第 32 项,最后追加)。 + pub game_args: Option, + /// 生效最大堆(三级回落 + 范围校验 + 出厂默认,**恒有值**)。 + pub memory_mb: u32, + /// 生效 Java 可执行文件;`None` = 调用方走自动挑。 + pub java_path: Option, + /// 生效图形后端(`Default` = 不下发)。 + pub graphics_backend: GraphicsBackend, +} + +impl ResolvedLaunch { + /// 是否与「全默认」等价(测试与日志用)。 + #[must_use] + pub fn is_defaults(&self) -> bool { + self.global_jvm_args.is_none() + && self.instance_jvm_args.is_none() + && self.game_args.is_none() + && self.memory_mb == DEFAULT_MAX_MEMORY_MB + && self.java_path.is_none() + && self.graphics_backend.is_default() + } +} + +/// 三级回落:`instance` 为 `None`/空项一律落到 `global`,再落到出厂默认。 +#[must_use] +pub fn resolve( + instance: Option<&LaunchOverrides>, + global: &GlobalLaunchDefaults, +) -> ResolvedLaunch { + let instance = instance + .map(LaunchOverrides::normalized) + .unwrap_or_default(); + let global = GlobalLaunchDefaults { + jvm_args: non_blank(global.jvm_args.as_deref()).map(str::to_string), + game_args: non_blank(global.game_args.as_deref()).map(str::to_string), + memory_mb: global.memory_mb.filter(|value| valid_memory(*value)), + java_path: global.java_path.clone(), + graphics_backend: global.graphics_backend, + }; + + ResolvedLaunch { + global_jvm_args: global.jvm_args, + instance_jvm_args: instance.jvm_args, + // gameArgs 是**替换**语义:实例给了就用实例的(全局层本轮恒空)。 + game_args: instance.game_args.or(global.game_args), + memory_mb: instance + .memory_mb + .or(global.memory_mb) + .unwrap_or(DEFAULT_MAX_MEMORY_MB), + java_path: instance.java_path.or(global.java_path), + graphics_backend: instance + .graphics_backend + .or(global.graphics_backend) + .unwrap_or_default(), + } +} + +/// 内存范围判据(与 `setting::instance_keys` 登记的区间同源)。 +#[must_use] +pub fn valid_memory(mb: u32) -> bool { + (MIN_MAX_MEMORY_MB..=MAX_MAX_MEMORY_MB).contains(&mb) +} + +/// 五项**文本**补丁(GUI 表单与 CLI 共用):`None` = 这一项不改;`Some("")` = 清空回「跟随」。 +/// +/// 校验只在这里做一次(内存整数/范围、图形后端词表),两个调用端都拿同一条错误文案—— +/// 否则「GUI 说合法、CLI 说非法」这类两套真相迟早出现。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LaunchOverridesPatch { + /// 实例层 JVM 参数原文。 + pub jvm_args: Option, + /// 实例层游戏参数原文。 + pub game_args: Option, + /// 实例层最大堆(文本形态,见 [`MIN_MAX_MEMORY_MB`] / [`MAX_MAX_MEMORY_MB`])。 + pub memory_mb: Option, + /// 实例层 Java 可执行文件。 + pub java_path: Option, + /// 实例层图形后端(`default` / `opengl` / `vulkan`)。 + pub graphics_backend: Option, +} + +impl LaunchOverridesPatch { + /// 只改图形后端(下拉切换的最小补丁)。 + #[must_use] + pub fn backend(id: &str) -> Self { + Self { + graphics_backend: Some(id.to_string()), + ..Self::default() + } + } + + /// 清空全部五项(`--clear` 的语义;随后可按需再给几项)。 + #[must_use] + pub fn clear_all() -> Self { + Self { + jvm_args: Some(String::new()), + game_args: Some(String::new()), + memory_mb: Some(String::new()), + java_path: Some(String::new()), + graphics_backend: Some("default".to_string()), + } + } +} + +impl LaunchOverrides { + /// 应用文本补丁:`None` 不动、空串清空;非法值**报错**(不静默丢弃)。 + /// + /// # Errors + /// + /// 内存不是整数、越界,或图形后端不在 [`GraphicsBackend::IDS`] 里时返回用户可读文案。 + pub fn patched(&self, patch: &LaunchOverridesPatch) -> Result { + let mut next = self.normalized(); + if let Some(text) = &patch.jvm_args { + next.jvm_args = non_blank(Some(text)).map(str::to_string); + } + if let Some(text) = &patch.game_args { + next.game_args = non_blank(Some(text)).map(str::to_string); + } + if let Some(text) = &patch.memory_mb { + next.memory_mb = parse_memory(text)?; + } + if let Some(text) = &patch.java_path { + next.java_path = non_blank(Some(text)).map(PathBuf::from); + } + if let Some(text) = &patch.graphics_backend { + next.graphics_backend = match GraphicsBackend::parse(text) { + // `default` 是「不下发」而不是要落盘的覆盖值:清成 None(稀疏语义)。 + Some(GraphicsBackend::Default) => None, + Some(backend) => Some(backend), + None => { + return Err(format!( + "无法识别的图形后端 `{text}`(只接受 {})", + GraphicsBackend::IDS.join(" / ") + )) + } + }; + } + Ok(next) + } +} + +/// 内存文本 → `Option`(空 = 清空;非整数/越界 = 错)。 +fn parse_memory(text: &str) -> Result, String> { + let Some(text) = non_blank(Some(text)) else { + return Ok(None); + }; + let value: u32 = text + .parse() + .map_err(|_| format!("内存需要是整数(MiB),收到 `{text}`"))?; + if !valid_memory(value) { + return Err(format!( + "内存超出范围({MIN_MAX_MEMORY_MB}~{MAX_MAX_MEMORY_MB} MiB),收到 {value}" + )); + } + Ok(Some(value)) +} + +fn non_blank(text: Option<&str>) -> Option<&str> { + text.map(str::trim).filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/core/src/game/launch_overrides/tests.rs b/crates/core/src/game/launch_overrides/tests.rs new file mode 100644 index 0000000..8268376 --- /dev/null +++ b/crates/core/src/game/launch_overrides/tests.rs @@ -0,0 +1,295 @@ +use super::*; + +fn instance_with(field: &str) -> LaunchOverrides { + match field { + "jvm" => LaunchOverrides { + jvm_args: Some("-XX:+UseG1GC".to_string()), + ..LaunchOverrides::default() + }, + "game" => LaunchOverrides { + game_args: Some("--fullscreen".to_string()), + ..LaunchOverrides::default() + }, + "memory" => LaunchOverrides { + memory_mb: Some(6144), + ..LaunchOverrides::default() + }, + "java" => LaunchOverrides { + java_path: Some(PathBuf::from("D:/jdk21/bin/java.exe")), + ..LaunchOverrides::default() + }, + "backend" => LaunchOverrides { + graphics_backend: Some(GraphicsBackend::Vulkan), + ..LaunchOverrides::default() + }, + other => panic!("未知字段 {other}"), + } +} + +fn global_with(field: &str) -> GlobalLaunchDefaults { + match field { + "jvm" => GlobalLaunchDefaults { + jvm_args: Some("-Xss4m".to_string()), + ..GlobalLaunchDefaults::default() + }, + "game" => GlobalLaunchDefaults { + game_args: Some("--foo".to_string()), + ..GlobalLaunchDefaults::default() + }, + "memory" => GlobalLaunchDefaults { + memory_mb: Some(3072), + ..GlobalLaunchDefaults::default() + }, + "java" => GlobalLaunchDefaults { + java_path: Some(PathBuf::from("C:/jdk17/bin/java.exe")), + ..GlobalLaunchDefaults::default() + }, + "backend" => GlobalLaunchDefaults { + graphics_backend: Some(GraphicsBackend::Opengl), + ..GlobalLaunchDefaults::default() + }, + other => panic!("未知字段 {other}"), + } +} + +/// **三级回落逐项**:五项各自走「实例 → 全局 → 默认」,一项不得连坐另一项。 +#[test] +fn every_field_falls_back_on_its_own() { + for field in ["jvm", "game", "memory", "java", "backend"] { + // ① 实例有值 → 实例赢(jvm 例外:两层叠加,见下一条测试)。 + let from_instance = resolve( + Some(&instance_with(field)), + &GlobalLaunchDefaults::default(), + ); + let from_global = resolve(None, &global_with(field)); + let neither = resolve(None, &GlobalLaunchDefaults::default()); + + match field { + "jvm" => { + assert_eq!( + from_instance.instance_jvm_args.as_deref(), + Some("-XX:+UseG1GC") + ); + assert_eq!(from_global.global_jvm_args.as_deref(), Some("-Xss4m")); + assert!(neither.global_jvm_args.is_none()); + assert!(neither.instance_jvm_args.is_none()); + } + "game" => { + assert_eq!(from_instance.game_args.as_deref(), Some("--fullscreen")); + assert_eq!(from_global.game_args.as_deref(), Some("--foo")); + assert!(neither.game_args.is_none()); + } + "memory" => { + assert_eq!(from_instance.memory_mb, 6144); + assert_eq!(from_global.memory_mb, 3072); + assert_eq!(neither.memory_mb, DEFAULT_MAX_MEMORY_MB); + } + "java" => { + assert_eq!( + from_instance.java_path.as_deref(), + Some(std::path::Path::new("D:/jdk21/bin/java.exe")) + ); + assert_eq!( + from_global.java_path.as_deref(), + Some(std::path::Path::new("C:/jdk17/bin/java.exe")) + ); + assert!(neither.java_path.is_none()); + } + "backend" => { + assert_eq!(from_instance.graphics_backend, GraphicsBackend::Vulkan); + assert_eq!(from_global.graphics_backend, GraphicsBackend::Opengl); + assert_eq!(neither.graphics_backend, GraphicsBackend::Default); + } + _ => unreachable!(), + } + } +} + +/// 只填了一项时,其余各项**不受牵连**(稀疏语义的真判据)。 +#[test] +fn one_field_does_not_reset_the_others() { + let instance = LaunchOverrides { + memory_mb: Some(8192), + ..LaunchOverrides::default() + }; + let global = GlobalLaunchDefaults { + jvm_args: Some("-XX:+UseZGC".to_string()), + game_args: Some("--width 1280".to_string()), + memory_mb: Some(2048), + java_path: Some(PathBuf::from("C:/java.exe")), + graphics_backend: Some(GraphicsBackend::Opengl), + }; + let resolved = resolve(Some(&instance), &global); + assert_eq!(resolved.memory_mb, 8192, "实例内存赢"); + assert_eq!( + resolved.global_jvm_args.as_deref(), + Some("-XX:+UseZGC"), + "全局 JVM 参数照旧下发(追加语义)" + ); + assert_eq!(resolved.game_args.as_deref(), Some("--width 1280")); + assert_eq!( + resolved.java_path.as_deref(), + Some(std::path::Path::new("C:/java.exe")) + ); + assert_eq!( + resolved.graphics_backend, + GraphicsBackend::Opengl, + "实例没给后端 → 落到全局层" + ); +} + +/// 空白字符串 = 没填:用户清空输入框必须回到跟随全局,而不是下发一个空参数。 +#[test] +fn blank_text_falls_back_to_the_next_layer() { + let instance = LaunchOverrides { + jvm_args: Some(" ".to_string()), + game_args: Some(String::new()), + java_path: Some(PathBuf::from(" ")), + ..LaunchOverrides::default() + }; + assert!(instance.is_empty(), "全空白视为空段"); + let resolved = resolve( + Some(&instance), + &GlobalLaunchDefaults::with_jvm_args("-Xmx1g"), + ); + assert_eq!(resolved.global_jvm_args.as_deref(), Some("-Xmx1g")); + assert!(resolved.instance_jvm_args.is_none()); + assert!(resolved.game_args.is_none()); + assert!(resolved.java_path.is_none()); +} + +/// 越界的内存值不算覆盖(手改文件绕得过 schema,绕不过这里)。 +#[test] +fn out_of_range_memory_falls_back() { + for bad in [0, 1, MIN_MAX_MEMORY_MB - 1, MAX_MAX_MEMORY_MB + 1] { + let instance = LaunchOverrides { + memory_mb: Some(bad), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + assert_eq!( + resolved.memory_mb, DEFAULT_MAX_MEMORY_MB, + "{bad} MiB 不该生效" + ); + } + let ok = resolve( + Some(&instance_with("memory")), + &GlobalLaunchDefaults::default(), + ); + assert_eq!(ok.memory_mb, 6144); +} + +/// 无效 `graphicsBackend` 值**拒绝**(不是静默落成 default)。 +#[test] +fn unknown_graphics_backend_is_rejected() { + let error = serde_json::from_str::(r#"{"graphicsBackend":"vulkan2"}"#) + .expect_err("未知取值必须报错"); + assert!( + error.to_string().contains("unknown variant"), + "错误文案要指出是变体不明:{error}" + ); + // 大小写与空白容错只发生在 `parse` 这条显式路径上。 + assert_eq!( + GraphicsBackend::parse(" VULKAN "), + Some(GraphicsBackend::Vulkan) + ); + assert_eq!(GraphicsBackend::parse("metal"), None); + assert_eq!(GraphicsBackend::parse(""), None); +} + +/// 三态词表与 `--graphicsBackend` 取值:`default` 不下发。 +#[test] +fn backend_flag_values_skip_default() { + assert_eq!(GraphicsBackend::Default.flag_value(), None); + assert_eq!(GraphicsBackend::Opengl.flag_value(), Some("opengl")); + assert_eq!(GraphicsBackend::Vulkan.flag_value(), Some("vulkan")); + for (backend, id) in GraphicsBackend::ALL.into_iter().zip(GraphicsBackend::IDS) { + assert_eq!(backend.as_str(), id); + assert_eq!(GraphicsBackend::parse(id), Some(backend)); + } +} + +/// 落盘形状:空段不写键;有值才出现(稀疏),且能原样读回来。 +#[test] +fn empty_section_is_not_serialized() { + let empty = serde_json::to_string(&LaunchOverrides::default()).expect("序列化"); + assert_eq!(empty, "{}"); + let filled = LaunchOverrides { + memory_mb: Some(4096), + graphics_backend: Some(GraphicsBackend::Vulkan), + ..LaunchOverrides::default() + }; + let json = serde_json::to_string(&filled).expect("序列化"); + assert_eq!(json, r#"{"memoryMb":4096,"graphicsBackend":"vulkan"}"#); + let back: LaunchOverrides = serde_json::from_str(&json).expect("反序列化"); + assert_eq!(back, filled); +} + +/// **补丁语义**:`None` 不动、`Some("")` 清空、非法值报错(GUI 与 CLI 共用这一条实现)。 +#[test] +fn patch_touches_only_the_fields_it_is_given() { + let current = LaunchOverrides { + jvm_args: Some("-Xss4m".to_string()), + game_args: Some("--fullscreen".to_string()), + memory_mb: Some(4096), + java_path: Some(PathBuf::from("C:/jdk21/bin/java.exe")), + graphics_backend: Some(GraphicsBackend::Opengl), + }; + // 只给内存:其余四项原样保留。 + let patched = current + .patched(&LaunchOverridesPatch { + memory_mb: Some("8192".to_string()), + ..LaunchOverridesPatch::default() + }) + .expect("合法补丁"); + assert_eq!(patched.memory_mb, Some(8192)); + assert_eq!(patched.jvm_args.as_deref(), Some("-Xss4m")); + assert_eq!(patched.graphics_backend, Some(GraphicsBackend::Opengl)); + + // 空串清空;空白也算清空。 + let cleared = current + .patched(&LaunchOverridesPatch { + jvm_args: Some(" ".to_string()), + game_args: Some(String::new()), + ..LaunchOverridesPatch::default() + }) + .expect("清空"); + assert!(cleared.jvm_args.is_none()); + assert!(cleared.game_args.is_none()); + assert_eq!(cleared.memory_mb, Some(4096), "没给的项不许被连带清掉"); + + // `clear_all` 把五项一起清掉(`default` 后端同样落成「没有覆盖」)。 + let cleared = current + .patched(&LaunchOverridesPatch::clear_all()) + .expect("全清"); + assert!(cleared.is_empty(), "{cleared:?}"); +} + +/// 非法补丁值必须报错:内存非整数/越界、后端词表外。 +#[test] +fn patch_rejects_invalid_values() { + let current = LaunchOverrides::default(); + for bad in ["4g", "0", "1", "999999"] { + let patch = LaunchOverridesPatch { + memory_mb: Some(bad.to_string()), + ..LaunchOverridesPatch::default() + }; + assert!(current.patched(&patch).is_err(), "`{bad}` 不该通过内存校验"); + } + for bad in ["vulkan2", "metal", "gl"] { + let error = current + .patched(&LaunchOverridesPatch::backend(bad)) + .expect_err("未知后端要报错"); + assert!(error.contains(bad), "{error}"); + assert!(error.contains("vulkan"), "错误文案要给出可选值:{error}"); + } + // 大小写容错走 `parse`(显式路径),落盘仍是小写稳定 id。 + let patched = current + .patched(&LaunchOverridesPatch::backend(" VULKAN ")) + .expect("大小写容错"); + assert_eq!(patched.graphics_backend, Some(GraphicsBackend::Vulkan)); + assert_eq!( + serde_json::to_string(&patched).expect("序列化"), + r#"{"graphicsBackend":"vulkan"}"# + ); +} diff --git a/crates/core/src/game/mod.rs b/crates/core/src/game/mod.rs index 8cb4d38..7ada9b5 100644 --- a/crates/core/src/game/mod.rs +++ b/crates/core/src/game/mod.rs @@ -34,6 +34,7 @@ pub mod inherit; pub mod install; pub mod isolation; pub mod java; +pub mod launch_overrides; pub mod layout; pub mod library; pub mod loader; @@ -55,6 +56,11 @@ pub use isolation::{ IsolationSource, ModeChange, ModeChangeProbe, ModeChangeWarning, SideContent, PROBED_CONTENT_DIRS, SHARED_LAYERS, }; +pub use launch_overrides::{ + resolve as resolve_launch_overrides, valid_memory, GlobalLaunchDefaults, GraphicsBackend, + LaunchOverrides, LaunchOverridesPatch, ResolvedLaunch, DEFAULT_MAX_MEMORY_MB, + MAX_MAX_MEMORY_MB, MIN_MAX_MEMORY_MB, +}; pub use layout::GameLayout; pub use library::{Library, MavenCoordinate}; pub use loader::{InstanceLoader, LoaderKind}; diff --git a/crates/core/src/launch/mod.rs b/crates/core/src/launch/mod.rs index 02db942..b26cc18 100644 --- a/crates/core/src/launch/mod.rs +++ b/crates/core/src/launch/mod.rs @@ -40,6 +40,7 @@ pub mod launch_session; pub mod launcher; pub mod log_level; pub mod natives; +pub mod overrides; pub mod placeholders; pub mod platform; pub mod process; diff --git a/crates/core/src/launch/overrides.rs b/crates/core/src/launch/overrides.rs new file mode 100644 index 0000000..a6950d6 --- /dev/null +++ b/crates/core/src/launch/overrides.rs @@ -0,0 +1,372 @@ +//! 实例级启动参数的**落位**:`ResolvedLaunch` → [`LaunchRequest`]。 +//! +//! 三级回落本身在 [`crate::game::launch_overrides`](纯计算);本模块只回答「落到哪一段」, +//! 规则与理由见 M157 的定案(叠加/冲突),这里给出可执行的对照表: +//! +//! | 生效值 | 落点 | 组装表 | 为什么在这儿 | +//! |---|---|---|---| +//! | 全局 `java.extra_args` | `user.jvm` | 第 7 项 | 基线:按“默认值”处理,可被清单参数抑制 | +//! | 实例 `launch.jvmArgs` | `user.override_jvm` | 第 4 项 | 精调:最高优先级,`-Xmx`/编码/代理默认值全被它抑制 | +//! | 实例 `launch.gameArgs` | `user.game` | 第 32 项 | 最后追加,可覆盖前面一切游戏参数 | +//! | `memoryMb`(已回落) | `memory.max_mb` | 第 5 项 | `-Xmx` 只从这里长出来 | +//! | `graphicsBackend` | `graphics_backend` | 第 31 项 | 仍过 `graphics_backend_allowed` 门槛,不放行给诊断告警 | +//! | `javaPath` | **不进来** | — | Java 选择在调用方(`JavaSelection`),见标量注释 | +//! +//! # 两条必须一起读的结论 +//! +//! 1. **追加,不替换**:全局与实例的 JVM 参数都下发。实例段在前、全局段在后,同名选项由 JVM +//! 取后出现者——但注意组装表第 4 项在 `-Xmx` **之前**:实例文本里的 `-Xmx` 会抑制内存字段 +//! 产出的 `-Xmx`(这是「文本比字段更精确」的表达,不是 bug); +//! 2. **全局文本里的 `-Xmx` 不生效**:第 7 项晚于第 5 项,`-Xmx` 已被内存字段占住 +//! (`add_default` 语义)。要改堆请用内存字段或实例 JVM 文本——写进注释与测试,别当特例。 +//! +//! `javaPath` 不进 `LaunchRequest`:那份结构只收选好的 [`crate::launch::request::JavaSelection`], +//! 调用方用 `resolved.java_path` 决定「显式路径优先 / 自动挑」。 + +use std::path::Path; + +use crate::game::launch_overrides::ResolvedLaunch; +use crate::launch::command_builder::tokenize; +use crate::launch::request::{LaunchRequest, MemoryOptions}; + +/// 把三级回落后的生效值写进请求(覆盖 `user.*` / `memory` / `graphics_backend` 三处)。 +/// +/// 调用方在 [`crate::launch::adapter::build_request`] 之后调用一次即可;它**接管**这三处, +/// 所以别在此之前再往 `user` 里塞东西(目前 CLI 与 GUI 都没有别的来源,契约就是这一段)。 +pub fn apply(request: &mut LaunchRequest, resolved: &ResolvedLaunch) { + request.user.jvm = tokens(resolved.global_jvm_args.as_deref()); + request.user.override_jvm = tokens(resolved.instance_jvm_args.as_deref()); + request.user.game = tokens(resolved.game_args.as_deref()); + request.memory = MemoryOptions { + min_mb: request.memory.min_mb, + max_mb: Some(resolved.memory_mb), + }; + request.graphics_backend = resolved.graphics_backend.flag_value().map(str::to_string); +} + +/// 实例/全局给的显式 Java 路径(`None` = 调用方走自动挑)。 +#[must_use] +pub fn java_binary(resolved: &ResolvedLaunch) -> Option<&Path> { + resolved.java_path.as_deref() +} + +/// 版本能不能下发 `--graphicsBackend`:**解析过继承链的**版本 JSON + 游戏版本号。 +/// +/// `resolved_json` 必须是继承解析之后的那份(26.2 的 `lwjgl-vulkan` 在父层, +/// 只看 loader 层会漏)。判据本身仍是 [`graphics_backend_allowed`] 一处实现—— +/// UI 与拼装链共用它,别在调用方再抄一遍能力位判断。 +#[must_use] +pub fn backend_allowed( + version: &crate::launch::game_version::GameVersion, + resolved_json: &crate::game::VersionJson, +) -> bool { + crate::launch::game_args::graphics_backend_allowed( + version, + crate::game::VersionCapabilities::from_json(resolved_json).has_vulkan_backend, + ) +} + +fn tokens(text: Option<&str>) -> Vec { + text.map(tokenize).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::game::launch_overrides::{ + resolve, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides, + }; + use crate::launch::args_table::{assemble, AssemblyWarning}; + use crate::launch::game_version::GameVersion; + use crate::launch::platform::{OsFamily, PlatformCtx}; + use crate::launch::request::{ + AuthPlaceholders, InstanceInfo, JavaSelection, LaunchDirs, LauncherInfo, Log4jOptions, + ManifestArgs, OptimizationOptions, + }; + + fn request(version: &str) -> LaunchRequest { + LaunchRequest { + instance: InstanceInfo { + id: "inst-1".to_string(), + name: version.to_string(), + version: GameVersion::parse(version), + version_type: "release".to_string(), + }, + launcher: LauncherInfo { + name: "UnluckyLauncher".to_string(), + version: "0.2.2".to_string(), + }, + java: JavaSelection::new("C:/java/bin/java.exe", 21, 64), + memory: MemoryOptions { + min_mb: None, + max_mb: None, + }, + manifest: ManifestArgs { + main_class: Some("net.minecraft.client.main.Main".to_string()), + ..ManifestArgs::default() + }, + dirs: LaunchDirs { + game_dir: PathBuf::from("C:/mc"), + primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"), + ..LaunchDirs::default() + }, + classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")], + auth: AuthPlaceholders::default(), + log4j: Log4jOptions { + enabled: false, + ..Log4jOptions::default() + }, + optimization: OptimizationOptions { + enabled: false, + physical_memory_mb: None, + }, + language: "zh-CN".to_string(), + ..LaunchRequest::default() + } + } + + fn ctx() -> PlatformCtx { + PlatformCtx::new(OsFamily::Windows, "x86_64") + } + + fn argvs(req: &LaunchRequest) -> Vec { + assemble(req, &ctx()).expect("组装应成功").args + } + + fn position(args: &[String], needle: &str) -> Option { + args.iter().position(|arg| arg == needle) + } + + /// **叠加规则**:实例段在 `-Xmx` 之前、全局段在之后;两段都下发(不替换)。 + #[test] + fn instance_and_global_jvm_args_stack_in_order() { + let instance = LaunchOverrides { + jvm_args: Some("-XX:+UseG1GC".to_string()), + ..LaunchOverrides::default() + }; + let global = GlobalLaunchDefaults { + jvm_args: Some("-Xss4m".to_string()), + ..GlobalLaunchDefaults::default() + }; + let resolved = resolve(Some(&instance), &global); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + let args = argvs(&req); + + let instance_at = position(&args, "-XX:+UseG1GC").expect("实例参数要下发"); + let memory_at = position(&args, "-Xmx2048m").expect("内存字段要产 -Xmx"); + let global_at = position(&args, "-Xss4m").expect("全局参数要下发"); + assert!( + instance_at < memory_at, + "实例段(override_jvm)必须在 -Xmx 之前:{args:?}" + ); + assert!( + global_at > memory_at, + "全局段(user.jvm)在 -Xmx 之后(基线不抢内存字段):{args:?}" + ); + } + + /// **-Xmx 冲突**:实例文本写 `-Xmx` ⇒ 内存字段不再产出(文本赢);全局文本写 `-Xmx` ⇒ 被内存字段抑制。 + #[test] + fn xmx_conflict_follows_the_assembly_order() { + let instance = LaunchOverrides { + jvm_args: Some("-Xmx3g".to_string()), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + let args = argvs(&req); + assert!( + args.iter().any(|arg| arg == "-Xmx3g"), + "实例文本的 -Xmx 必须生效:{args:?}" + ); + assert!( + !args.iter().any(|arg| arg == "-Xmx2048m"), + "内存字段的 -Xmx 必须被抑制:{args:?}" + ); + + let resolved = resolve(None, &GlobalLaunchDefaults::with_jvm_args("-Xmx3g")); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + let args = argvs(&req); + assert!( + args.iter().any(|arg| arg == "-Xmx2048m"), + "全局文本的 -Xmx 不生效(第 7 项晚于第 5 项):{args:?}" + ); + assert!(!args.iter().any(|arg| arg == "-Xmx3g"), "{args:?}"); + } + + /// 内存三级回落落进 `-Xmx`:实例 → 全局 → 默认。 + #[test] + fn memory_falls_back_into_xmx() { + let global = GlobalLaunchDefaults { + memory_mb: Some(6144), + ..GlobalLaunchDefaults::default() + }; + let instance = LaunchOverrides { + memory_mb: Some(8192), + ..LaunchOverrides::default() + }; + for (overrides, expected) in [(Some(instance), "-Xmx8192m"), (None, "-Xmx6144m")] { + let resolved = resolve(overrides.as_ref(), &global); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + assert!( + argvs(&req).iter().any(|arg| arg == expected), + "期望 {expected}" + ); + } + let resolved = resolve(None, &GlobalLaunchDefaults::default()); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + assert!(argvs(&req).iter().any(|arg| arg == "-Xmx2048m")); + } + + /// 游戏参数最后追加(可覆盖前面一切游戏参数)。 + #[test] + fn game_args_land_at_the_tail() { + let instance = LaunchOverrides { + game_args: Some("--fullscreen --width 1920".to_string()), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + let args = argvs(&req); + let tail = &args[args.len() - 3..]; + assert_eq!(tail, ["--fullscreen", "--width", "1920"], "{args:?}"); + } + + /// **版本门槛**:26.2-snapshot-2+ 才下发;老版本不下发但必须给诊断告警(不静默)。 + #[test] + fn graphics_backend_is_version_gated_with_a_warning() { + let instance = LaunchOverrides { + graphics_backend: Some(GraphicsBackend::Vulkan), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + + let mut supported = request("26.2-snapshot-2"); + apply(&mut supported, &resolved); + let command = assemble(&supported, &ctx()).expect("组装"); + let at = position(&command.args, "--graphicsBackend").expect("支持版本要下发"); + assert_eq!(command.args[at + 1], "vulkan"); + assert!( + !command + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })), + "支持版本不该有跳过告警:{:?}", + command.warnings + ); + + let mut old = request("1.20.4"); + apply(&mut old, &resolved); + let command = assemble(&old, &ctx()).expect("组装"); + assert!( + !command.args.iter().any(|arg| arg == "--graphicsBackend"), + "老版本不许下发:{:?}", + command.args + ); + assert!( + command + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })), + "跳过必须留诊断:{:?}", + command.warnings + ); + } + + /// `default` = 不下发、不告警(三态里的「跟随游戏默认」)。 + #[test] + fn default_backend_emits_nothing() { + for instance in [None, Some(LaunchOverrides::default())] { + let resolved = resolve(instance.as_ref(), &GlobalLaunchDefaults::default()); + let mut req = request("26.2"); + apply(&mut req, &resolved); + let command = assemble(&req, &ctx()).expect("组装"); + assert!(!command.args.iter().any(|arg| arg == "--graphicsBackend")); + assert!(command.warnings.is_empty(), "{:?}", command.warnings); + } + } + + /// 启动器自带的 `--fullscreen`(第 29 项)与实例游戏参数共存:实例在最后,可覆盖。 + #[test] + fn instance_game_args_win_over_launcher_defaults() { + let instance = LaunchOverrides { + game_args: Some("--fullscreen".to_string()), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + let mut req = request("1.20.4"); + req.fullscreen = true; + apply(&mut req, &resolved); + let args = argvs(&req); + assert_eq!( + args.iter() + .filter(|arg| arg.as_str() == "--fullscreen") + .count(), + 2, + "两处都给 --fullscreen,由 MC 取后者(实例段在最后):{args:?}" + ); + assert_eq!(args.last().map(String::as_str), Some("--fullscreen")); + assert!( + position(&args, "net.minecraft.client.main.Main").unwrap() < args.len() - 1, + "实例游戏参数必须在主类之后" + ); + } + + /// Java 路径不进请求、只作为调用方的选择依据。 + #[test] + fn java_path_is_a_scalar_for_the_caller() { + let instance = LaunchOverrides { + java_path: Some(PathBuf::from("D:/jdk21/bin/java.exe")), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + assert_eq!( + java_binary(&resolved), + Some(Path::new("D:/jdk21/bin/java.exe")) + ); + let mut req = request("1.20.4"); + let before = req.java.binary.clone(); + apply(&mut req, &resolved); + assert_eq!(req.java.binary, before, "apply 不改 Java 选择"); + } + + /// `backend_allowed`:继承解析后的库表 + 版本门限,两条判据的**唯一组合入口**。 + /// + /// 它是 UI 灰化与拼装链共用的那一格:调用方只负责把「继承解析后的版本 JSON」和 + /// 「游戏版本号」递进来,不许自己拼 `has_vulkan_backend && is_at_least(..)`。 + #[test] + fn backend_allowed_combines_capability_and_threshold() { + use crate::game::VersionJson; + + let with_vulkan = VersionJson::from_json_str( + r#"{"id":"x","libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#, + ) + .expect("解析"); + let without = VersionJson::from_json_str(r#"{"id":"x"}"#).expect("解析"); + + // 可解析版本:精确门限说了算(库表只作周快照兜底)。 + assert!(backend_allowed( + &GameVersion::parse("26.2-snapshot-2"), + &without + )); + assert!(!backend_allowed( + &GameVersion::parse("26.2-snapshot-1"), + &with_vulkan + )); + assert!(!backend_allowed( + &GameVersion::parse("1.20.4"), + &with_vulkan + )); + // 周快照:看库表。 + assert!(backend_allowed(&GameVersion::parse("26w14a"), &with_vulkan)); + assert!(!backend_allowed(&GameVersion::parse("26w14a"), &without)); + } +} diff --git a/crates/core/src/setting/instance_keys.rs b/crates/core/src/setting/instance_keys.rs index aaa7948..54c563b 100644 --- a/crates/core/src/setting/instance_keys.rs +++ b/crates/core/src/setting/instance_keys.rs @@ -41,6 +41,11 @@ pub const ISOLATION_OVERRIDES: &[&str] = &["inherit", "isolated", "shared", "cus pub const ISOLATION_RESOLVED: &[&str] = &["isolated", "shared", "custom"]; /// 实例可覆盖的设置键(enum 白名单,拼错即校验失败而不是静默不生效)。 pub const OVERRIDE_KEYS: &[&str] = &["maxMemory", "javaType", "runningDirectory"]; +/// `launch.graphicsBackend` 的允许值(`default` = 不下发 `--graphicsBackend`)。 +/// +/// 与 `crate::game::launch_overrides::GraphicsBackend::IDS` 必须是同一份集合——setting 层 +/// 不依赖 game 层(分层纪律),一致性由本文件的 `graphics_backends_match_the_game_layer` 固定。 +pub const GRAPHICS_BACKENDS: &[&str] = &["default", "opengl", "vulkan"]; /// profiles.json 当前 schema 版本。 #[must_use] @@ -111,6 +116,42 @@ pub fn instance_schema() -> SettingSchema { AiPolicy::locked(), "该实例覆盖了全局哪些设置键(enum 白名单,拼错即校验失败)", )) + // M157:实例级启动参数(`launch` 段,五项全可选、逐项回落到全局)。 + // 作者是 `game::install::instance::InstanceConfig`(serde 形状与这些路径逐键对应), + // 回落规则与生效值在 `game::launch_overrides`,拼装消费在 `launch::overrides`。 + .with(SettingSpec::text( + "launch.jvmArgs", + "", + 1024, + AiPolicy::sensitive(), + "实例级 JVM 参数(空白分隔、支持引号);留空 = 只用全局 java.extra_args(两者叠加,实例优先)", + )) + .with(SettingSpec::text( + "launch.gameArgs", + "", + 1024, + AiPolicy::audited(), + "实例级游戏参数(追加在命令行末段,可覆盖前面一切游戏参数);留空 = 不追加", + )) + .with(SettingSpec::int( + "launch.memoryMb", + 0, + (0, 65536), + AiPolicy::sensitive(), + "实例级最大堆(MiB);0/缺键 = 跟随全局 java.default_memory_mb(有效区间 256~65536)", + )) + .with(SettingSpec::path( + "launch.javaPath", + AiPolicy::sensitive(), + "实例级 Java 可执行文件;留空 = 跟随全局 java.selected_path 再退到自动挑(托管 > JAVA_HOME > PATH)", + )) + .with(SettingSpec::enumeration( + "launch.graphicsBackend", + "default", + GRAPHICS_BACKENDS, + AiPolicy::audited(), + "图形后端:default(不下发)/ opengl / vulkan。只对 MC 26.2-snapshot-2+ 下发,老版本会被门槛拦下并给出诊断告警", + )) .with(SettingSpec::text( "lastModeChange.at", "", @@ -153,3 +194,58 @@ pub fn instance_schema() -> SettingSchema { "该次切换的提示是否已展示过(保证切换后首次启动只提示一次)", )) } + +#[cfg(test)] +mod tests { + use super::*; + + /// M157:图形后端词表**只有一份真相源**(`game::launch_overrides::GraphicsBackend`), + /// setting 层是它的白名单视图——两边脱节就是「写得进、读不出」或「UI 三态少一档」。 + #[test] + fn graphics_backends_match_the_game_layer() { + use crate::game::launch_overrides::GraphicsBackend; + let mut setting: Vec<&str> = GRAPHICS_BACKENDS.to_vec(); + let mut game: Vec<&str> = GraphicsBackend::IDS.to_vec(); + setting.sort_unstable(); + game.sort_unstable(); + assert_eq!(setting, game, "setting 白名单与 game 层词表脱节了"); + for id in GRAPHICS_BACKENDS { + assert!( + GraphicsBackend::parse(id).is_some(), + "`{id}` 在 game 层解析不出来" + ); + } + } + + /// 五项键真的进了实例 schema 白名单(且 `default` 之外的后端值被拒)。 + #[test] + fn launch_section_keys_are_registered() { + let schema = instance_schema(); + assert!(schema + .validate("launch.jvmArgs", &serde_json::json!("-XX:+UseG1GC")) + .is_ok()); + assert!(schema + .validate("launch.gameArgs", &serde_json::json!("--fullscreen")) + .is_ok()); + assert!(schema + .validate("launch.memoryMb", &serde_json::json!(4096)) + .is_ok()); + assert!(schema + .validate( + "launch.javaPath", + &serde_json::json!("D:/jdk21/bin/java.exe") + ) + .is_ok()); + assert!(schema + .validate("launch.graphicsBackend", &serde_json::json!("vulkan")) + .is_ok()); + // 非法后端值:白名单外必须报错(手改文件绕不过校验)。 + assert!(schema + .validate("launch.graphicsBackend", &serde_json::json!("vulkan2")) + .is_err()); + // 越界内存:schema 层也拦。 + assert!(schema + .validate("launch.memoryMb", &serde_json::json!(1_000_000)) + .is_err()); + } +} diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 3bd438c..15bf155 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -40,360 +40,36 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("InstancesStageVerify", "Verifying", "校验中"), ("InstancesStageLaunch", "Starting", "启动中"), ("InstancesStageWindow", "Waiting for window", "等待游戏窗口"), - ("LoaderVanilla", "Vanilla", "原版"), - ("LaunchOutcomeNormal", "Finished normally", "正常结束"), - ("LaunchOutcomeCrash", "The game crashed", "游戏崩溃"), - ("LaunchOutcomeSilent", "Suspected silent failure: exit code 0, but the game never really started", "疑似静默失败:退出码 0,但游戏没真正跑起来"), - ("LaunchFailedWithCode", "Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"), - ("LaunchMsaNotConfigured", "Microsoft sign-in is not configured: it needs an Azure public client id (the login flow itself is implemented). Build with ULL_MSA_CLIENT_ID=, or set that environment variable before launching.", "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID= cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=。"), - ("FontCjkNeedsDownload", "No Chinese font found on this system. Download an open-source (OFL) Chinese font?", "系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"), - ("FontCjkDownloading", "Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"), - ("FontCjkDownloadFailed", "Chinese font download failed; the interface stays in English.", "中文字体下载失败,界面暂时保持英文。"), - ("FontCjkReady", "Chinese font ready: {family}", "中文字体已就绪:{family}"), - ("SettingsLanguage", "Language", "界面语言"), - ("SettingsLanguageHint", "Switches the interface language. Chinese text needs a Chinese font.", "切换界面语言。显示中文需要中文字体。"), - ("SettingsAppearanceCorner", "Window corners", "窗口圆角"), - ("SettingsAppearanceCornerSystem", "Follow system", "跟随系统"), - ("SettingsAppearanceCornerSquare", "Square", "直角"), - ("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"), - ("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"), - ("CoreTextUntranslated", "[system text not translated]", "[未翻译的系统文案]"), - ("DownloadTitle", "Version management", "版本管理"), - ("DownloadSubtitle", "Manage versions and loaders: install, upgrade, repair", "管理版本与加载器:安装、升级、修复"), - ("DownloadRefresh", "Refresh", "刷新"), - ("DownloadInstall", "Install", "安装"), - ("DownloadCancel", "Cancel install", "取消安装"), - ("DownloadInstalled", "Installed", "已安装"), - ("DownloadCount", "{count} versions", "{count} 个版本"), - ("DownloadRowSupporting", "{kind} · released {date}", "{kind} · 发布于 {date}"), - ("DownloadSelectHint", "Pick a version, then install", "选一个版本再安装"), - ("DownloadLoading", "Loading versions…", "正在加载版本列表…"), - ("DownloadLoadingHint", "If this stays here, press Refresh in the top right.", "如果一直停在这里,点右上角的刷新。"), - ("DownloadEmptyTitle", "No versions to show", "没有可显示的版本"), - ("DownloadEmptyBody", "Pick another category, or refresh the list.", "换一个分类,或刷新一次列表。"), - ("DownloadCategoryAll", "All", "全部"), - ("DownloadCategoryRelease", "Release", "正式版"), - ("DownloadCategorySnapshot", "Snapshot", "预览版"), - ("DownloadCategoryAncient", "Ancient", "远古版"), - ("DownloadStagePlanning", "Planning", "解析版本"), - ("DownloadStageDownloading", "Downloading files", "下载文件"), - ("DownloadStageInstalling", "Building the version", "生成版本"), - ("DownloadStageRegistering", "Registering the instance", "登记实例"), - ("DownloadStageDone", "Done", "已完成"), - ("DownloadStageFailed", "Install failed", "安装失败"), - ("DownloadStageCancelled", "Install cancelled", "安装已取消"), - ("DownloadProgress", "{done}/{total} files", "{done}/{total} 文件"), - ("DownloadProgressUnknown", "Waiting for the first file…", "等第一个文件…"), - ("DownloadRate", "{speed} · {eta}", "{speed},{eta}"), - ("DownloadEtaSecs", "{secs}s left", "剩 {secs}s"), - ("DownloadEtaMins", "{mins}m{secs}s left", "剩 {mins}m{secs}s"), - ("DownloadFailedHint", "The Logs page has the full reason.", "完整原因在日志页。"), - ("DownloadOpenLogs", "Open logs", "打开日志"), - ("DownloadDismiss", "Dismiss", "关闭"), - ("DownloadOptionsTitle", "Install options", "安装选项"), - ("DownloadOptionsLoaderVersion", "Loader version", "加载器版本"), - ("DownloadOptionsLoaderVersionsLoading", "Fetching loader versions…", "正在获取加载器版本…"), - ("DownloadOptionsLoaderVersionsFailed", "Could not fetch loader versions: {reason}", "取不到加载器版本:{reason}"), - ("DownloadOptionsLoaderVersionsEmpty", "No loader builds published for {version}", "官方没有为 {version} 发布加载器版本"), - ("DownloadOptionsInstanceName", "Instance name", "实例名"), - ("DownloadOptionsInstanceNameHint", "Empty = use the version id", "留空就用版本号"), - ("DownloadOptionsIncompatible", "{loader} has no builds for {version}", "{loader} 没有适配 {version} 的构建"), - ("DownloadOptionsNeedsAtLeast", "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新"), - ("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"), - ("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"), - ("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"), - ("LogsOpenFolder", "Open folder", "打开目录"), - ("LogsClear", "Clear", "清空"), - ("LogsOutputTitle", "OUTPUT", "输出"), - ("LogsDiagnosisTitle", "DIAGNOSIS", "诊断"), - ("LogsConfidenceHigh", "HIGH CONFIDENCE", "高置信度"), - ("LogsConfidenceMedium", "MEDIUM CONFIDENCE", "中置信度"), - ("LogsConfidenceLow", "LOW CONFIDENCE", "低置信度"), - ("LogsSubtitleLive", "Running now — logs stream as they arrive", "正在运行 — 日志实时滚动"), - ("LogsConfidence", "Confidence {percent}%", "置信度 {percent}%"), - ("LogsSuggestedFix", "Suggested fix", "建议动作"), - ("LogsSummaryLabel", "One-line summary", "一行摘要"), - ("LogsCopySummary", "Copy summary", "复制摘要"), - ("LogsEmptyTitle", "No output yet", "还没有输出"), - ("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"), - ("LogsEmptyAction", "Choose an instance", "选择实例"), - ("AgentTitle", "Assistant", "助手"), - ("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"), - ("AgentCollapse", "Collapse", "收起"), - ("AgentDetach", "Pop out", "拆出"), - ("AgentDock", "Dock back", "收回"), - ("AgentOpen", "Open assistant", "打开助手"), - ("AgentStripLabel", "AI", "助手"), - ("AgentSend", "Send", "发送"), - ("AgentStop", "Stop", "中断"), - ("AgentPlaceholder", "Message the simulated assistant…", "给模拟助手发消息…"), - ("AgentEmptyTitle", "Nothing in this session yet", "这个会话还没有内容"), - ("AgentEmptyBody", "Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"), - ("AgentNotePending", "Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"), - ("AgentNoteStopped", "Stopped by you.", "你中断了这次回复。"), - ("AgentUnread", "{count} unread", "{count} 条未读"), - ("AgentResize", "Drag to resize the assistant rail", "拖动调整助手栏宽度"), - ("AgentWidthLabel", "Width {width}", "宽度 {width}"), - ("AgentRoleYou", "You", "你"), - ("AgentRoleAssistant", "Assistant", "助手"), - ("AgentRoleNotice", "Notice", "提示"), - ("AgentErrorTitle", "Assistant error", "助手出错"), - ("AgentErrorUnavailable", "Couldn't reach the assistant backend. Check the endpoint, then try again.", "连不上助手后端:检查端点后重试。"), - ("AgentErrorNotImplemented", "This assistant request isn't wired up yet.", "这个助手请求还没接线。"), - ("AgentConfirmRequest", "The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"), - ("AgentConfirmAllow", "Allow", "允许"), - ("AgentConfirmDeny", "Deny", "拒绝"), - ("AgentSimulated", "Simulated", "模拟"), - ("AgentSubtitleReal", "Conversation", "会话"), - ("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"), - ("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"), - // ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**, - // 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。 - ("SettingsLanguageFollowSystem", "Follow system", "跟随系统"), - ("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"), - ("SettingsLanguageEn", "English", "英文"), - ("SettingsLanguageEffective", "Interface right now: {language}", "界面当前使用:{language}"), - ("SettingsTheme", "Theme", "主题"), - ("SettingsThemeFollowSystem", "Follow system", "跟随系统"), - ("SettingsStartupPage", "Startup page", "启动页"), - ("SettingsStartupInstances", "Instances", "实例"), - ("SettingsCloseButton", "Close button", "关闭按钮"), - ("SettingsCloseToTray", "Exit to tray", "退出到托盘"), - ("SettingsSubtitle", "Launcher behaviour, Java runtimes and download sources", "启动器行为、Java 运行时与下载源"), - ("SettingsResetSection", "Reset section", "重置本组"), - ("SettingsSave", "Save", "保存"), - ("NavAi", "Assistant", "助手"), - ("SettingsGroupGeneral", "General", "通用"), - ("SettingsGroupAppearance", "Appearance", "外观"), - ("SettingsGroupJava", "Java", "Java 运行时"), - ("SettingsGroupDownloads", "Downloads", "下载"), - ("SettingsOverridesEmptyTitle", "No per-instance overrides", "没有逐实例覆盖"), - ("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"), - ("SettingsOverridesAdd", "Add override", "新增覆盖"), - ("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"), - // --- M107 版本管理页(追加在链尾:`Key::ALL` 顺序 = 金表顺序) --- - ("VersionRailTitle", "Version type", "版本类型"), - ("VersionMajorTitle", "Major version", "主版本"), - ("VersionMajorAll", "All versions", "全部版本"), - ("VersionSearchPlaceholder", "Search version numbers", "搜版本号"), - ("VersionSearchClear", "Clear search", "清空搜索"), - ("VersionSearchHits", "{count} matches", "{count} 个匹配"), - ("VersionSearchEmptyTitle", "No matching versions", "没有匹配的版本"), - ("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"), - ("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"), - ("VersionSearchElsewhereAction", "Show all types", "看全部类型"), - // --- M107 r2(追加在末尾:`Key::ALL` 顺序 = 金表顺序) --- - ("VersionSearchHitsOne", "1 match", "1 个匹配"), - ("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"), - ("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"), - // --- M109 内容库页(同样追加在链尾:`Key::ALL` 顺序 = 金表顺序) --- - ("ContentTitle", "Content", "内容库"), - ("ContentSubtitle", "Modpacks, mods, resource packs and shaders", "整合包、Mod、资源包与光影"), - ("ContentSectionVersions", "Version management", "版本管理"), - ("ContentSectionContent", "Content library", "内容库"), - ("ContentKindModpack", "Modpacks", "整合包"), - ("ContentKindMod", "Mods", "Mod"), - ("ContentKindResourcepack", "Resource packs", "资源包"), - ("ContentKindShader", "Shaders", "光影"), - ("ContentSourceModrinth", "Modrinth", "Modrinth 源"), - ("ContentSourceCurseforge", "CurseForge", "CurseForge 源"), - ("ContentSortRelevance", "Relevance", "相关度"), - ("ContentSortDownloads", "Downloads", "下载量"), - ("ContentSortNewest", "Newest", "最新发布"), - ("ContentSortUpdated", "Recently updated", "最近更新"), - ("ContentSearchPlaceholder", "Search content…", "搜索内容…"), - ("ContentSearch", "Search", "搜索"), - ("ContentFilterVersion", "Game version", "游戏版本"), - ("ContentFilterLoader", "Loader", "加载器"), - ("ContentFilterAny", "Any", "不限"), - ("ContentCount", "{count} results", "{count} 个结果"), - ("ContentLoading", "Loading content…", "正在加载内容…"), - ("ContentLoadingHint", "Results come from the network; nothing is cached offline yet.", "结果来自网络,暂时没有离线缓存。"), - ("ContentEmptyTitle", "Nothing matched", "没有匹配的内容"), - ("ContentEmptyBody", "Try another keyword, or clear a filter.", "换个关键词,或清掉一个筛选条件。"), - ("ContentFailedTitle", "Could not load content", "内容加载失败"), - ("ContentFailedBody", "Check the network, then retry.", "检查网络后重试。"), - ("ContentRetry", "Retry", "重试"), - ("ContentLoadMore", "Load more", "加载更多"), - ("ContentNoMore", "No more results", "没有更多了"), - ("ContentDetailEmpty", "Pick an item to see what it supports.", "选一个项目看它支持什么。"), - ("ContentDetailDownloads", "{count} downloads", "下载量 {count}"), - ("ContentDetailUpdated", "Updated {date}", "更新于 {date}"), - ("ContentDetailLoaders", "Loaders: {list}", "加载器:{list}"), - ("ContentDetailVersions", "Game versions: {list}", "游戏版本:{list}"), - ("ContentInstall", "Install into instance", "装入实例"), - ("ContentTargetInstance", "Target instance", "目标实例"), - ("ContentNoInstances", "No instance to install into", "没有可装入的实例"), - ("ContentIncompatibleVersion", "Needs {version}; this instance runs {actual}", "需要 {version},该实例运行的是 {actual}"), - ("ContentIncompatibleLoader", "Not built for {loader}", "不支持 {loader}"), - ("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"), - ("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"), - ("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"), - ("PackTitle", "Modpack import & export", "整合包导入 / 导出"), - ("PackImportTitle", "Import a modpack", "导入整合包"), - ("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"), - ("PackDropActive", "Release to probe the pack", "松手开始探测"), - ("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below.", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"), - ("PackPathLabel", "Pack path", "整合包路径"), - ("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的MC.mrpack"), - ("PackImportAction", "Import", "导入"), - ("PackCancel", "Cancel import", "取消导入"), - ("PackDismiss", "Dismiss", "关闭"), - ("PackTargetLabel", "Install into", "装到实例"), - ("PackTargetNew", "New instance", "新建实例"), - ("PackInstanceName", "Instance name", "实例名"), - ("PackStageLine", "Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"), - ("PackStageProbe", "Reading the manifest", "读取清单"), - ("PackStageExtract", "Unpacking overrides", "解包覆盖内容"), - ("PackStageFetch", "Fetching missing files", "补下载文件"), - ("PackStageCommit", "Writing files", "落地写盘"), - ("PackStageRegister", "Registering the instance", "登记实例"), - ("PackProbeTitle", "This pack is almost a known format", "这个包差一点就是已知格式"), - ("PackProbeLooksLike", "Looks like {format}, but missing: {missing}", "看起来像 {format},但缺:{missing}"), - ("PackProbeUnknown", "No known format claims this pack", "没有任何已知格式认领这个包"), - ("PackProbeSaw", "Found in the pack: {saw}", "包里看到:{saw}"), - ("PackProbeNothing", "nothing recognisable", "没有可识别的东西"), - ("PackStatusSuccess", "Imported", "已导入"), - ("PackStatusPartial", "Imported with gaps", "导入完成,但有缺口"), - ("PackStatusFailed", "Import failed — rolled back", "导入失败 —— 已整包回滚"), - ("PackReportWritten", "Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"), - ("PackReportMissing", "Could not fetch (tolerated): {count}", "有 {count} 个文件没搞到(可容忍)"), - ("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"), - ("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"), - ("PackReportRejectedNote", "not supported by the client", "客户端不支持"), - ("PackCancelled", "Import keeps running in the background — this result is discarded. Files already written stay.", "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"), - ("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"), - ("PackOverwriteNote", "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"), - ("PackMissingOffline", "offline import", "离线导入"), - ("PackMissingNoSource", "no download source available yet", "暂时拿不到下载地址"), - ("PackMissingFetchFailed", "download failed: {detail}", "下载失败:{detail}"), - ("PackFatalTitle", "Rolled back — nothing was left behind", "已回滚 —— 目标目录零残留"), - ("PackFatalHint", "What to try next", "下一步可以试"), - ("PackConflictUserModified", "you changed it", "你改过"), - ("PackConflictUserDeleted", "you deleted it", "你删过"), - ("PackConflictPreExisting", "was already there", "本来就在"), - ("PackChoiceKeepUser", "kept yours", "保留我的"), - ("PackChoiceOverwrite", "pack version used", "用包里的"), - ("PackConflictHashes", "pack {pack} · base {base} · disk {disk}", "包内 {pack} · 基线 {base} · 磁盘 {disk}"), - ("PackConflictDetail", "Hashes", "三方哈希"), - ("PackFormatModrinth", "Modrinth .mrpack", "Modrinth 的 .mrpack"), - ("PackFormatCurseforge", "CurseForge manifest", "CurseForge 清单"), - ("PackFormatMultimc", "MultiMC / Prism instance", "MultiMC·Prism 实例"), - ("PackExportTitle", "Export this instance", "导出这个实例"), - ("PackExportOpen", "Export…", "导出…"), - ("PackExportFormat", "Format", "格式"), - ("PackExportName", "Pack name", "包名"), - ("PackExportVersion", "Version", "版本"), - ("PackExportOutput", "Save to", "保存到"), - ("PackExportOutputPlaceholder", "empty = suggested path", "留空 = 自动给一个路径"), - ("PackExportPrecheck", "Dry run", "预检"), - ("PackExportConfirm", "Write the pack", "确认写盘"), - ("PackExportAbort", "Back", "返回"), - ("PackExportMultimcNote", "MultiMC/Prism can only be imported, not exported (by design).", "MultiMC·Prism 只能导入、不能导出(有意省略)。"), - ("PackExportDone", "Wrote {path} · {bytes}", "已写出 {path} · {bytes}"), - ("PackExportFailed", "Export failed: {reason}", "导出失败:{reason}"), - ("PackExportPending", "Scanning the instance…", "正在扫描实例…"), - ("PackPreviewHead", "Will inline {inline} files · cannot export: {skipped}", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"), - ("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"), - ("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"), - ("PackPreviewInlineTitle", "Copied into overrides/ (first {shown}):", "原样拷进 overrides/(只列前 {shown} 个):"), - ("PackPreviewSkippedTitle", "Not in the pack: {count}", "没进包:{count} 项"), - ("PackPreviewNotes", "Known losses", "已知损失"), - ("PackPreviewTarget", "Will be written to {path}", "将写到 {path}"), - ("PackPreviewMore", "…and {count} more files (not shown here)", "…还有 {count} 个文件(这里不展开)"), - ("PackPreviewComplete", "Nothing is left out", "没有遗漏任何文件"), - ("PackPreviewIncomplete", "Some files will not be in the pack", "有文件不会进包"), - ("PackReasonModrinthSha1", "Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"), - ("PackReasonCurseFingerprint", "CurseForge match (fingerprint)", "CurseForge 命中(文件指纹)"), - ("PackReasonNoRemoteMatch", "unknown to both catalogs, inlined", "两个目录都不认识,已内联"), - ("PackReasonCurseOnly", "only CurseForge knows it, this format cannot use it, inlined", "只有 CF 认识,目标格式用不上,已内联"), - ("PackReasonModrinthOnly", "only Modrinth knows it, the CF manifest cannot use it, inlined", "只有 Modrinth 认识,CF 清单用不上,已内联"), - ("PackReasonMissingSha512", "the catalogue entry has no sha512 hash, inlined", "命中缺 sha512,已内联"), - ("PackReasonMissingCurseIds", "the catalogue entry has no CurseForge ids, inlined", "命中缺 projectID/fileID,已内联"), - ("PackReasonDisabled", "disabled file, this format cannot express it (.disabled kept)", "禁用文件,目标格式表达不了(保留 .disabled)"), - ("PackReasonSymlink", "symbolic link, skipped", "符号链接,已跳过"), - ("PackReasonUnsafePath", "path fails the pack path rules, skipped", "路径过不了包内规整,已跳过"), - ("PackReasonUnreadable", "cannot read it, skipped", "读不出来,已跳过"), - // ── M114 账户页:追加在 main 的全部域之后(金表只追加不重排) - ("AccountTitle", "Accounts", "账户"), - ("AccountSubtitle", "Microsoft, third-party skin servers and local profiles", "微软正版、第三方皮肤站与本地档案"), - ("AccountListTitle", "All accounts", "全部账户"), - ("AccountAddedOn", "Added {date}", "添加于 {date}"), - ("AccountActiveChip", "Active", "当前"), - ("AccountSwitch", "Use for launch", "切换为启动账户"), - ("AccountRelogin", "Sign in again", "重新登录"), - ("AccountRemove", "Remove", "删除"), - ("AccountKindMicrosoft", "Microsoft", "微软正版"), - ("AccountKindAuthlib", "Third-party skin server", "第三方皮肤站"), - ("AccountKindOffline", "Local profile", "本地档案"), - ("AccountServer", "Server {server}", "服务器 {server}"), - ("AccountEmptyTitle", "No accounts yet", "还没有账户"), - ("AccountEmptyBody", "Add a Microsoft account, a third-party skin server account, or a local profile.", "添加微软正版账户、第三方皮肤站账户,或建一个本地档案。"), - ("AccountEmptyAction", "Add an account", "添加账户"), - ("AccountTrackMicrosoftTitle", "Microsoft account", "微软正版"), - ("AccountTrackMicrosoftBody", "Sign in with a device code or in your browser. Online servers require this kind of account.", "用设备码或浏览器登录。联机服务器必须用这种账户。"), - ("AccountTrackAuthlibTitle", "Third-party skin server", "第三方皮肤站"), - ("AccountTrackAuthlibBody", "An authlib-injector compatible server (LittleSkin and friends) brings its own skins and capes.", "兼容 authlib-injector 的皮肤站(如 LittleSkin)自带皮肤与披风。"), - ("AccountTrackOfflineTitle", "Local profile", "本地档案"), - ("AccountTrackOfflineBody", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"), - ("AccountTrackOpen", "Sign in", "登录"), - ("AccountTrackCreate", "Create profile", "创建档案"), - ("AccountDialogClose", "Close", "关闭"), - ("AccountOfflineNameLabel", "Profile name", "档案名"), - ("AccountOfflineNameHint", "3–16 letters, digits or underscore", "3–16 位字母、数字或下划线"), - ("AccountOfflineSubmit", "Create profile", "创建档案"), - ("AccountOfflineWarning", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"), - ("AccountDeviceCodeLabel", "Enter this code", "输入这个用户码"), - ("AccountDeviceCodeCopy", "Copy code", "复制用户码"), - ("AccountDeviceLinkCopy", "Copy link", "复制链接"), - ("AccountDeviceCountdown", "{time} left", "剩 {time}"), - ("AccountDeviceExpired", "This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"), - ("AccountDeviceRetry", "Try again", "重试"), - ("AccountDeviceCancel", "Cancel", "取消"), - ("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"), - ("AccountDeviceUseCode", "Use a device code instead", "改用设备码"), - ("AccountServerLabel", "Server address", "服务器地址"), - ("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"), - ("AccountServerProbe", "Check server", "校验服务器"), - ("AccountLoginNameLabel", "Email or username", "邮箱或用户名"), - ("AccountPasswordLabel", "Password", "密码"), - ("AccountPasswordHint", "Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"), - ("AccountAuthlibSubmit", "Sign in", "登录"), - ("AccountAuthlibIdleHint", "The server is checked automatically when you sign in; Check server only does it up front.", "登录时会自动校验服务器;「校验服务器」只是提前跑一遍。"), - ("AccountSkinTitle", "Skin", "皮肤"), - ("AccountSkinNone", "No skin available", "没有可用皮肤"), - ("AccountSkinHint", "Skins come from the account's own server; nothing is generated locally.", "皮肤来自账户自己的服务器,本地不生成任何假皮肤。"), - ("AccountRailSignedOut", "Not signed in", "未登录"), - ("AccountDeviceHint", "Open the link in your browser and enter the code above.", "在浏览器里打开链接,输入上面的用户码。"), - ("AccountDeviceBrowserHint", "Finish the sign-in in the browser window we just opened; the link below also works.", "在我们刚打开的浏览器窗口里完成登录;下面的链接也能手动打开。"), - ("AccountServerChecking", "Checking the server…", "正在校验服务器…"), - ("AccountServerOk", "Server {server} is ready", "服务器 {server} 可用"), - ("AccountLoginFailed", "Sign-in failed", "登录失败"), - ("AccountCurrentTitle", "Current account", "当前账户"), - ("AccountFailureCode", "Code: {code}", "代码:{code}"), - ("AccountFailureDenied", "The server refused this sign-in (access denied).", "服务器拒绝了这次登录(拒绝访问)。"), - ("AccountFailureWhitelist", "Microsoft has not approved this launcher's app registration yet. An administrator has to accept the app, then try again.", "微软还没批准本启动器的应用注册:需要管理员同意该应用后再试。"), - ("AccountFailureUnreachable", "Could not reach the server. Check the network connection or the address, then try again.", "连不上服务器:检查网络或地址后重试。"), - ("AccountFailureNotAuthlib", "This address does not look like an authlib-injector server (the API root usually ends with /api/yggdrasil/).", "这个地址不像 authlib-injector 服务器(API 根通常以 /api/yggdrasil/ 结尾)。"), - ("AccountFailureBadCredentials", "The server rejected the username or password.", "服务器不认这个用户名或密码。"), - ("AccountFailureNoProfile", "The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"), - ("AccountFailureStore", "Could not save the account to disk.", "账户没能写到磁盘上。"), - ("AccountFailureOther", "The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"), - ("AccountLoginNotConfigured", "Microsoft sign-in is not configured", "微软登录未配置"), - ("AccountFailureMsaNotConfigured", "This build has no Azure public client id, so Microsoft sign-in cannot start (the sign-in flow itself is implemented). Set the environment variable ULL_MSA_CLIENT_ID= before starting the launcher, or build with ULL_MSA_CLIENT_ID=. Then restart the launcher: a running process does not pick up a new environment variable.", "本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=,或构建时注入:ULL_MSA_CLIENT_ID= cargo build。改完请重启启动器——已在运行的进程读不到新设的环境变量。"), - ("AccountDeviceRequesting", "Contacting Microsoft…", "正在联系微软…"), - ("AccountLoginSucceeded", "Signed in as {name}", "已登录 {name}"), - ("AccountNoticeAdded", "Added {name}", "已添加 {name}"), - ("AccountNoticeRemoved", "Removed {name}", "已删除 {name}"), - ("AccountNoticeSwitched", "{name} will be used for the next launch", "{name} 将作为下次启动的账户"), - ("AccountNoticeOfflineNameInvalid", "Profile names must be 3–16 letters, digits or underscores.", "档案名必须是 3–16 位字母、数字或下划线。"), - ("AccountNoticeAccountExists", "An account with that name already exists.", "同名账户已经存在。"), - ("AccountNoticeStoreFailed", "Could not save the account store. Check the disk and try again.", "账户库写不进去:检查磁盘后重试。"), - ("AccountNoticeNotFound", "That account no longer exists.", "这个账户已经不在了。"), - ("AccountNoticeOther", "The account action failed. The full reason is in the launcher log.", "账户操作失败,完整原因在启动器日志里。"), - ("AccountNoticeDismiss", "Dismiss", "关闭"), - ("AccountNoticeTitleFailed", "Account action failed", "账户操作失败"), - ("AccountNoticeTitleDone", "Account updated", "账户已更新"), - ("AccountHintAuthlibReauth", "Enter the password again for {server}.", "请输入 {server} 的密码。"), - ("AccountHintOfflineNoRelogin", "Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"), + ("InstancesLaunchTitle", "LAUNCH OPTIONS", "启动参数"), + ("InstancesLaunchJvm", "JVM arguments", "JVM 参数"), + ("InstancesLaunchJvmHint", "Space separated, quotes allowed. Empty = follow the global extra JVM arguments (both are sent; on duplicates the instance wins).", "空格分隔、支持引号。留空 = 跟随全局附加 JVM 参数(两者叠加下发,同名选项实例赢)。"), + ("InstancesLaunchGame", "Game arguments", "游戏参数"), + ("InstancesLaunchGameHint", "Appended last, so they override earlier game arguments. Empty = nothing appended.", "追加在命令行最后,可覆盖前面的游戏参数。留空 = 不追加。"), + ("InstancesLaunchMemory", "Max memory (MiB)", "最大内存(MiB)"), + ("InstancesLaunchMemoryHint", "Empty = follow the global default ({default} MiB).", "留空 = 跟随全局默认({default} MiB)。"), + ("InstancesLaunchJava", "Java executable", "Java 可执行文件"), + ("InstancesLaunchJavaHint", "Empty = follow the global setting, then auto-pick (managed > JAVA_HOME > PATH).", "留空 = 跟随全局设置,再退到自动挑选(托管 > 环境变量 > 系统路径)。"), + ("InstancesLaunchBackend", "Graphics backend", "图形后端"), + ("InstancesLaunchBackendGated", "Requires MC 26.2+ (this instance: {version}). The flag is not sent on this version.", "需游戏版本 26.2 及以上(本实例 {version}),这个版本不会下发该参数。"), + ("InstancesLaunchEffective", "Effective: {value}", "生效:{value}"), + ("InstancesBackendDefault", "Default", "默认"), + ("InstancesBackendOpengl", "OpenGL", "OpenGL 后端"), + ("InstancesBackendVulkan", "Vulkan", "Vulkan 后端"), + ("InstancesLaunchSave", "Save", "保存"), + ("InstancesLaunchReset", "Reset to global", "恢复跟随全局"), ]; + +/// 金表的**全量**行(前半段在本文件、后半段在 [`tail`],顺序即 `Key::ALL`)。 +/// +/// 拆成两段只为守 400 行硬线(M157 加了 19 行启动参数文案后越线);对调用方是**一条**序列, +/// `catalog/tests.rs` 的逐字节断言不用关心分段。 +pub(super) fn rows() -> impl Iterator { + GOLDEN.iter().chain(tail::ROWS.iter()) +} + +/// 金表总行数(`Key::ALL` 的条数)。 +pub(super) fn len() -> usize { + GOLDEN.len() + tail::ROWS.len() +} + +mod tail; diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs new file mode 100644 index 0000000..761ca11 --- /dev/null +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -0,0 +1,364 @@ +//! 金表的**后半段**(`InstancesLaunchReset` 之后的全部行)。 +//! +//! 为什么拆:`golden.rs` 在 M157 加了 19 行启动参数文案后到 416 行,越过 400 行硬线; +//! 拆分口径与仓库其它 `-p1/-p2` 一致——**纯搬移、一行不改**,顺序即 `Key::ALL` 的后半段。 +//! 拼装与断言在 `golden.rs` 的 [`rows`] 与 `catalog/tests.rs`。 + +pub(super) const ROWS: &[(&str, &str, &str)] = &[ + ("LoaderVanilla", "Vanilla", "原版"), + ("LaunchOutcomeNormal", "Finished normally", "正常结束"), + ("LaunchOutcomeCrash", "The game crashed", "游戏崩溃"), + ("LaunchOutcomeSilent", "Suspected silent failure: exit code 0, but the game never really started", "疑似静默失败:退出码 0,但游戏没真正跑起来"), + ("LaunchFailedWithCode", "Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"), + ("LaunchMsaNotConfigured", "Microsoft sign-in is not configured: it needs an Azure public client id (the login flow itself is implemented). Build with ULL_MSA_CLIENT_ID=, or set that environment variable before launching.", "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID= cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=。"), + ("FontCjkNeedsDownload", "No Chinese font found on this system. Download an open-source (OFL) Chinese font?", "系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"), + ("FontCjkDownloading", "Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"), + ("FontCjkDownloadFailed", "Chinese font download failed; the interface stays in English.", "中文字体下载失败,界面暂时保持英文。"), + ("FontCjkReady", "Chinese font ready: {family}", "中文字体已就绪:{family}"), + ("SettingsLanguage", "Language", "界面语言"), + ("SettingsLanguageHint", "Switches the interface language. Chinese text needs a Chinese font.", "切换界面语言。显示中文需要中文字体。"), + ("SettingsAppearanceCorner", "Window corners", "窗口圆角"), + ("SettingsAppearanceCornerSystem", "Follow system", "跟随系统"), + ("SettingsAppearanceCornerSquare", "Square", "直角"), + ("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"), + ("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"), + ("CoreTextUntranslated", "[system text not translated]", "[未翻译的系统文案]"), + ("DownloadTitle", "Version management", "版本管理"), + ("DownloadSubtitle", "Manage versions and loaders: install, upgrade, repair", "管理版本与加载器:安装、升级、修复"), + ("DownloadRefresh", "Refresh", "刷新"), + ("DownloadInstall", "Install", "安装"), + ("DownloadCancel", "Cancel install", "取消安装"), + ("DownloadInstalled", "Installed", "已安装"), + ("DownloadCount", "{count} versions", "{count} 个版本"), + ("DownloadRowSupporting", "{kind} · released {date}", "{kind} · 发布于 {date}"), + ("DownloadSelectHint", "Pick a version, then install", "选一个版本再安装"), + ("DownloadLoading", "Loading versions…", "正在加载版本列表…"), + ("DownloadLoadingHint", "If this stays here, press Refresh in the top right.", "如果一直停在这里,点右上角的刷新。"), + ("DownloadEmptyTitle", "No versions to show", "没有可显示的版本"), + ("DownloadEmptyBody", "Pick another category, or refresh the list.", "换一个分类,或刷新一次列表。"), + ("DownloadCategoryAll", "All", "全部"), + ("DownloadCategoryRelease", "Release", "正式版"), + ("DownloadCategorySnapshot", "Snapshot", "预览版"), + ("DownloadCategoryAncient", "Ancient", "远古版"), + ("DownloadStagePlanning", "Planning", "解析版本"), + ("DownloadStageDownloading", "Downloading files", "下载文件"), + ("DownloadStageInstalling", "Building the version", "生成版本"), + ("DownloadStageRegistering", "Registering the instance", "登记实例"), + ("DownloadStageDone", "Done", "已完成"), + ("DownloadStageFailed", "Install failed", "安装失败"), + ("DownloadStageCancelled", "Install cancelled", "安装已取消"), + ("DownloadProgress", "{done}/{total} files", "{done}/{total} 文件"), + ("DownloadProgressUnknown", "Waiting for the first file…", "等第一个文件…"), + ("DownloadRate", "{speed} · {eta}", "{speed},{eta}"), + ("DownloadEtaSecs", "{secs}s left", "剩 {secs}s"), + ("DownloadEtaMins", "{mins}m{secs}s left", "剩 {mins}m{secs}s"), + ("DownloadFailedHint", "The Logs page has the full reason.", "完整原因在日志页。"), + ("DownloadOpenLogs", "Open logs", "打开日志"), + ("DownloadDismiss", "Dismiss", "关闭"), + ("DownloadOptionsTitle", "Install options", "安装选项"), + ("DownloadOptionsLoaderVersion", "Loader version", "加载器版本"), + ("DownloadOptionsLoaderVersionsLoading", "Fetching loader versions…", "正在获取加载器版本…"), + ("DownloadOptionsLoaderVersionsFailed", "Could not fetch loader versions: {reason}", "取不到加载器版本:{reason}"), + ("DownloadOptionsLoaderVersionsEmpty", "No loader builds published for {version}", "官方没有为 {version} 发布加载器版本"), + ("DownloadOptionsInstanceName", "Instance name", "实例名"), + ("DownloadOptionsInstanceNameHint", "Empty = use the version id", "留空就用版本号"), + ("DownloadOptionsIncompatible", "{loader} has no builds for {version}", "{loader} 没有适配 {version} 的构建"), + ("DownloadOptionsNeedsAtLeast", "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新"), + ("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"), + ("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"), + ("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"), + ("LogsOpenFolder", "Open folder", "打开目录"), + ("LogsClear", "Clear", "清空"), + ("LogsOutputTitle", "OUTPUT", "输出"), + ("LogsDiagnosisTitle", "DIAGNOSIS", "诊断"), + ("LogsConfidenceHigh", "HIGH CONFIDENCE", "高置信度"), + ("LogsConfidenceMedium", "MEDIUM CONFIDENCE", "中置信度"), + ("LogsConfidenceLow", "LOW CONFIDENCE", "低置信度"), + ("LogsSubtitleLive", "Running now — logs stream as they arrive", "正在运行 — 日志实时滚动"), + ("LogsConfidence", "Confidence {percent}%", "置信度 {percent}%"), + ("LogsSuggestedFix", "Suggested fix", "建议动作"), + ("LogsSummaryLabel", "One-line summary", "一行摘要"), + ("LogsCopySummary", "Copy summary", "复制摘要"), + ("LogsEmptyTitle", "No output yet", "还没有输出"), + ("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"), + ("LogsEmptyAction", "Choose an instance", "选择实例"), + ("AgentTitle", "Assistant", "助手"), + ("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"), + ("AgentCollapse", "Collapse", "收起"), + ("AgentDetach", "Pop out", "拆出"), + ("AgentDock", "Dock back", "收回"), + ("AgentOpen", "Open assistant", "打开助手"), + ("AgentStripLabel", "AI", "助手"), + ("AgentSend", "Send", "发送"), + ("AgentStop", "Stop", "中断"), + ("AgentPlaceholder", "Message the simulated assistant…", "给模拟助手发消息…"), + ("AgentEmptyTitle", "Nothing in this session yet", "这个会话还没有内容"), + ("AgentEmptyBody", "Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"), + ("AgentNotePending", "Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"), + ("AgentNoteStopped", "Stopped by you.", "你中断了这次回复。"), + ("AgentUnread", "{count} unread", "{count} 条未读"), + ("AgentResize", "Drag to resize the assistant rail", "拖动调整助手栏宽度"), + ("AgentWidthLabel", "Width {width}", "宽度 {width}"), + ("AgentRoleYou", "You", "你"), + ("AgentRoleAssistant", "Assistant", "助手"), + ("AgentRoleNotice", "Notice", "提示"), + ("AgentErrorTitle", "Assistant error", "助手出错"), + ("AgentErrorUnavailable", "Couldn't reach the assistant backend. Check the endpoint, then try again.", "连不上助手后端:检查端点后重试。"), + ("AgentErrorNotImplemented", "This assistant request isn't wired up yet.", "这个助手请求还没接线。"), + ("AgentConfirmRequest", "The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"), + ("AgentConfirmAllow", "Allow", "允许"), + ("AgentConfirmDeny", "Deny", "拒绝"), + ("AgentSimulated", "Simulated", "模拟"), + ("AgentSubtitleReal", "Conversation", "会话"), + ("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"), + ("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"), + // ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**, + // 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。 + ("SettingsLanguageFollowSystem", "Follow system", "跟随系统"), + ("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"), + ("SettingsLanguageEn", "English", "英文"), + ("SettingsLanguageEffective", "Interface right now: {language}", "界面当前使用:{language}"), + ("SettingsTheme", "Theme", "主题"), + ("SettingsThemeFollowSystem", "Follow system", "跟随系统"), + ("SettingsStartupPage", "Startup page", "启动页"), + ("SettingsStartupInstances", "Instances", "实例"), + ("SettingsCloseButton", "Close button", "关闭按钮"), + ("SettingsCloseToTray", "Exit to tray", "退出到托盘"), + ("SettingsSubtitle", "Launcher behaviour, Java runtimes and download sources", "启动器行为、Java 运行时与下载源"), + ("SettingsResetSection", "Reset section", "重置本组"), + ("SettingsSave", "Save", "保存"), + ("NavAi", "Assistant", "助手"), + ("SettingsGroupGeneral", "General", "通用"), + ("SettingsGroupAppearance", "Appearance", "外观"), + ("SettingsGroupJava", "Java", "Java 运行时"), + ("SettingsGroupDownloads", "Downloads", "下载"), + ("SettingsOverridesEmptyTitle", "No per-instance overrides", "没有逐实例覆盖"), + ("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"), + ("SettingsOverridesAdd", "Add override", "新增覆盖"), + ("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"), + // --- M107 版本管理页(追加在链尾:`Key::ALL` 顺序 = 金表顺序) --- + ("VersionRailTitle", "Version type", "版本类型"), + ("VersionMajorTitle", "Major version", "主版本"), + ("VersionMajorAll", "All versions", "全部版本"), + ("VersionSearchPlaceholder", "Search version numbers", "搜版本号"), + ("VersionSearchClear", "Clear search", "清空搜索"), + ("VersionSearchHits", "{count} matches", "{count} 个匹配"), + ("VersionSearchEmptyTitle", "No matching versions", "没有匹配的版本"), + ("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"), + ("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"), + ("VersionSearchElsewhereAction", "Show all types", "看全部类型"), + // --- M107 r2(追加在末尾:`Key::ALL` 顺序 = 金表顺序) --- + ("VersionSearchHitsOne", "1 match", "1 个匹配"), + ("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"), + ("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"), + // --- M109 内容库页(同样追加在链尾:`Key::ALL` 顺序 = 金表顺序) --- + ("ContentTitle", "Content", "内容库"), + ("ContentSubtitle", "Modpacks, mods, resource packs and shaders", "整合包、Mod、资源包与光影"), + ("ContentSectionVersions", "Version management", "版本管理"), + ("ContentSectionContent", "Content library", "内容库"), + ("ContentKindModpack", "Modpacks", "整合包"), + ("ContentKindMod", "Mods", "Mod"), + ("ContentKindResourcepack", "Resource packs", "资源包"), + ("ContentKindShader", "Shaders", "光影"), + ("ContentSourceModrinth", "Modrinth", "Modrinth 源"), + ("ContentSourceCurseforge", "CurseForge", "CurseForge 源"), + ("ContentSortRelevance", "Relevance", "相关度"), + ("ContentSortDownloads", "Downloads", "下载量"), + ("ContentSortNewest", "Newest", "最新发布"), + ("ContentSortUpdated", "Recently updated", "最近更新"), + ("ContentSearchPlaceholder", "Search content…", "搜索内容…"), + ("ContentSearch", "Search", "搜索"), + ("ContentFilterVersion", "Game version", "游戏版本"), + ("ContentFilterLoader", "Loader", "加载器"), + ("ContentFilterAny", "Any", "不限"), + ("ContentCount", "{count} results", "{count} 个结果"), + ("ContentLoading", "Loading content…", "正在加载内容…"), + ("ContentLoadingHint", "Results come from the network; nothing is cached offline yet.", "结果来自网络,暂时没有离线缓存。"), + ("ContentEmptyTitle", "Nothing matched", "没有匹配的内容"), + ("ContentEmptyBody", "Try another keyword, or clear a filter.", "换个关键词,或清掉一个筛选条件。"), + ("ContentFailedTitle", "Could not load content", "内容加载失败"), + ("ContentFailedBody", "Check the network, then retry.", "检查网络后重试。"), + ("ContentRetry", "Retry", "重试"), + ("ContentLoadMore", "Load more", "加载更多"), + ("ContentNoMore", "No more results", "没有更多了"), + ("ContentDetailEmpty", "Pick an item to see what it supports.", "选一个项目看它支持什么。"), + ("ContentDetailDownloads", "{count} downloads", "下载量 {count}"), + ("ContentDetailUpdated", "Updated {date}", "更新于 {date}"), + ("ContentDetailLoaders", "Loaders: {list}", "加载器:{list}"), + ("ContentDetailVersions", "Game versions: {list}", "游戏版本:{list}"), + ("ContentInstall", "Install into instance", "装入实例"), + ("ContentTargetInstance", "Target instance", "目标实例"), + ("ContentNoInstances", "No instance to install into", "没有可装入的实例"), + ("ContentIncompatibleVersion", "Needs {version}; this instance runs {actual}", "需要 {version},该实例运行的是 {actual}"), + ("ContentIncompatibleLoader", "Not built for {loader}", "不支持 {loader}"), + ("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"), + ("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"), + ("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"), + ("PackTitle", "Modpack import & export", "整合包导入 / 导出"), + ("PackImportTitle", "Import a modpack", "导入整合包"), + ("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"), + ("PackDropActive", "Release to probe the pack", "松手开始探测"), + ("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below.", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"), + ("PackPathLabel", "Pack path", "整合包路径"), + ("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的MC.mrpack"), + ("PackImportAction", "Import", "导入"), + ("PackCancel", "Cancel import", "取消导入"), + ("PackDismiss", "Dismiss", "关闭"), + ("PackTargetLabel", "Install into", "装到实例"), + ("PackTargetNew", "New instance", "新建实例"), + ("PackInstanceName", "Instance name", "实例名"), + ("PackStageLine", "Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"), + ("PackStageProbe", "Reading the manifest", "读取清单"), + ("PackStageExtract", "Unpacking overrides", "解包覆盖内容"), + ("PackStageFetch", "Fetching missing files", "补下载文件"), + ("PackStageCommit", "Writing files", "落地写盘"), + ("PackStageRegister", "Registering the instance", "登记实例"), + ("PackProbeTitle", "This pack is almost a known format", "这个包差一点就是已知格式"), + ("PackProbeLooksLike", "Looks like {format}, but missing: {missing}", "看起来像 {format},但缺:{missing}"), + ("PackProbeUnknown", "No known format claims this pack", "没有任何已知格式认领这个包"), + ("PackProbeSaw", "Found in the pack: {saw}", "包里看到:{saw}"), + ("PackProbeNothing", "nothing recognisable", "没有可识别的东西"), + ("PackStatusSuccess", "Imported", "已导入"), + ("PackStatusPartial", "Imported with gaps", "导入完成,但有缺口"), + ("PackStatusFailed", "Import failed — rolled back", "导入失败 —— 已整包回滚"), + ("PackReportWritten", "Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"), + ("PackReportMissing", "Could not fetch (tolerated): {count}", "有 {count} 个文件没搞到(可容忍)"), + ("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"), + ("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"), + ("PackReportRejectedNote", "not supported by the client", "客户端不支持"), + ("PackCancelled", "Import keeps running in the background — this result is discarded. Files already written stay.", "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"), + ("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"), + ("PackOverwriteNote", "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"), + ("PackMissingOffline", "offline import", "离线导入"), + ("PackMissingNoSource", "no download source available yet", "暂时拿不到下载地址"), + ("PackMissingFetchFailed", "download failed: {detail}", "下载失败:{detail}"), + ("PackFatalTitle", "Rolled back — nothing was left behind", "已回滚 —— 目标目录零残留"), + ("PackFatalHint", "What to try next", "下一步可以试"), + ("PackConflictUserModified", "you changed it", "你改过"), + ("PackConflictUserDeleted", "you deleted it", "你删过"), + ("PackConflictPreExisting", "was already there", "本来就在"), + ("PackChoiceKeepUser", "kept yours", "保留我的"), + ("PackChoiceOverwrite", "pack version used", "用包里的"), + ("PackConflictHashes", "pack {pack} · base {base} · disk {disk}", "包内 {pack} · 基线 {base} · 磁盘 {disk}"), + ("PackConflictDetail", "Hashes", "三方哈希"), + ("PackFormatModrinth", "Modrinth .mrpack", "Modrinth 的 .mrpack"), + ("PackFormatCurseforge", "CurseForge manifest", "CurseForge 清单"), + ("PackFormatMultimc", "MultiMC / Prism instance", "MultiMC·Prism 实例"), + ("PackExportTitle", "Export this instance", "导出这个实例"), + ("PackExportOpen", "Export…", "导出…"), + ("PackExportFormat", "Format", "格式"), + ("PackExportName", "Pack name", "包名"), + ("PackExportVersion", "Version", "版本"), + ("PackExportOutput", "Save to", "保存到"), + ("PackExportOutputPlaceholder", "empty = suggested path", "留空 = 自动给一个路径"), + ("PackExportPrecheck", "Dry run", "预检"), + ("PackExportConfirm", "Write the pack", "确认写盘"), + ("PackExportAbort", "Back", "返回"), + ("PackExportMultimcNote", "MultiMC/Prism can only be imported, not exported (by design).", "MultiMC·Prism 只能导入、不能导出(有意省略)。"), + ("PackExportDone", "Wrote {path} · {bytes}", "已写出 {path} · {bytes}"), + ("PackExportFailed", "Export failed: {reason}", "导出失败:{reason}"), + ("PackExportPending", "Scanning the instance…", "正在扫描实例…"), + ("PackPreviewHead", "Will inline {inline} files · cannot export: {skipped}", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"), + ("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"), + ("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"), + ("PackPreviewInlineTitle", "Copied into overrides/ (first {shown}):", "原样拷进 overrides/(只列前 {shown} 个):"), + ("PackPreviewSkippedTitle", "Not in the pack: {count}", "没进包:{count} 项"), + ("PackPreviewNotes", "Known losses", "已知损失"), + ("PackPreviewTarget", "Will be written to {path}", "将写到 {path}"), + ("PackPreviewMore", "…and {count} more files (not shown here)", "…还有 {count} 个文件(这里不展开)"), + ("PackPreviewComplete", "Nothing is left out", "没有遗漏任何文件"), + ("PackPreviewIncomplete", "Some files will not be in the pack", "有文件不会进包"), + ("PackReasonModrinthSha1", "Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"), + ("PackReasonCurseFingerprint", "CurseForge match (fingerprint)", "CurseForge 命中(文件指纹)"), + ("PackReasonNoRemoteMatch", "unknown to both catalogs, inlined", "两个目录都不认识,已内联"), + ("PackReasonCurseOnly", "only CurseForge knows it, this format cannot use it, inlined", "只有 CF 认识,目标格式用不上,已内联"), + ("PackReasonModrinthOnly", "only Modrinth knows it, the CF manifest cannot use it, inlined", "只有 Modrinth 认识,CF 清单用不上,已内联"), + ("PackReasonMissingSha512", "the catalogue entry has no sha512 hash, inlined", "命中缺 sha512,已内联"), + ("PackReasonMissingCurseIds", "the catalogue entry has no CurseForge ids, inlined", "命中缺 projectID/fileID,已内联"), + ("PackReasonDisabled", "disabled file, this format cannot express it (.disabled kept)", "禁用文件,目标格式表达不了(保留 .disabled)"), + ("PackReasonSymlink", "symbolic link, skipped", "符号链接,已跳过"), + ("PackReasonUnsafePath", "path fails the pack path rules, skipped", "路径过不了包内规整,已跳过"), + ("PackReasonUnreadable", "cannot read it, skipped", "读不出来,已跳过"), + // ── M114 账户页:追加在 main 的全部域之后(金表只追加不重排) + ("AccountTitle", "Accounts", "账户"), + ("AccountSubtitle", "Microsoft, third-party skin servers and local profiles", "微软正版、第三方皮肤站与本地档案"), + ("AccountListTitle", "All accounts", "全部账户"), + ("AccountAddedOn", "Added {date}", "添加于 {date}"), + ("AccountActiveChip", "Active", "当前"), + ("AccountSwitch", "Use for launch", "切换为启动账户"), + ("AccountRelogin", "Sign in again", "重新登录"), + ("AccountRemove", "Remove", "删除"), + ("AccountKindMicrosoft", "Microsoft", "微软正版"), + ("AccountKindAuthlib", "Third-party skin server", "第三方皮肤站"), + ("AccountKindOffline", "Local profile", "本地档案"), + ("AccountServer", "Server {server}", "服务器 {server}"), + ("AccountEmptyTitle", "No accounts yet", "还没有账户"), + ("AccountEmptyBody", "Add a Microsoft account, a third-party skin server account, or a local profile.", "添加微软正版账户、第三方皮肤站账户,或建一个本地档案。"), + ("AccountEmptyAction", "Add an account", "添加账户"), + ("AccountTrackMicrosoftTitle", "Microsoft account", "微软正版"), + ("AccountTrackMicrosoftBody", "Sign in with a device code or in your browser. Online servers require this kind of account.", "用设备码或浏览器登录。联机服务器必须用这种账户。"), + ("AccountTrackAuthlibTitle", "Third-party skin server", "第三方皮肤站"), + ("AccountTrackAuthlibBody", "An authlib-injector compatible server (LittleSkin and friends) brings its own skins and capes.", "兼容 authlib-injector 的皮肤站(如 LittleSkin)自带皮肤与披风。"), + ("AccountTrackOfflineTitle", "Local profile", "本地档案"), + ("AccountTrackOfflineBody", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"), + ("AccountTrackOpen", "Sign in", "登录"), + ("AccountTrackCreate", "Create profile", "创建档案"), + ("AccountDialogClose", "Close", "关闭"), + ("AccountOfflineNameLabel", "Profile name", "档案名"), + ("AccountOfflineNameHint", "3–16 letters, digits or underscore", "3–16 位字母、数字或下划线"), + ("AccountOfflineSubmit", "Create profile", "创建档案"), + ("AccountOfflineWarning", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"), + ("AccountDeviceCodeLabel", "Enter this code", "输入这个用户码"), + ("AccountDeviceCodeCopy", "Copy code", "复制用户码"), + ("AccountDeviceLinkCopy", "Copy link", "复制链接"), + ("AccountDeviceCountdown", "{time} left", "剩 {time}"), + ("AccountDeviceExpired", "This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"), + ("AccountDeviceRetry", "Try again", "重试"), + ("AccountDeviceCancel", "Cancel", "取消"), + ("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"), + ("AccountDeviceUseCode", "Use a device code instead", "改用设备码"), + ("AccountServerLabel", "Server address", "服务器地址"), + ("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"), + ("AccountServerProbe", "Check server", "校验服务器"), + ("AccountLoginNameLabel", "Email or username", "邮箱或用户名"), + ("AccountPasswordLabel", "Password", "密码"), + ("AccountPasswordHint", "Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"), + ("AccountAuthlibSubmit", "Sign in", "登录"), + ("AccountAuthlibIdleHint", "The server is checked automatically when you sign in; Check server only does it up front.", "登录时会自动校验服务器;「校验服务器」只是提前跑一遍。"), + ("AccountSkinTitle", "Skin", "皮肤"), + ("AccountSkinNone", "No skin available", "没有可用皮肤"), + ("AccountSkinHint", "Skins come from the account's own server; nothing is generated locally.", "皮肤来自账户自己的服务器,本地不生成任何假皮肤。"), + ("AccountRailSignedOut", "Not signed in", "未登录"), + ("AccountDeviceHint", "Open the link in your browser and enter the code above.", "在浏览器里打开链接,输入上面的用户码。"), + ("AccountDeviceBrowserHint", "Finish the sign-in in the browser window we just opened; the link below also works.", "在我们刚打开的浏览器窗口里完成登录;下面的链接也能手动打开。"), + ("AccountServerChecking", "Checking the server…", "正在校验服务器…"), + ("AccountServerOk", "Server {server} is ready", "服务器 {server} 可用"), + ("AccountLoginFailed", "Sign-in failed", "登录失败"), + ("AccountCurrentTitle", "Current account", "当前账户"), + ("AccountFailureCode", "Code: {code}", "代码:{code}"), + ("AccountFailureDenied", "The server refused this sign-in (access denied).", "服务器拒绝了这次登录(拒绝访问)。"), + ("AccountFailureWhitelist", "Microsoft has not approved this launcher's app registration yet. An administrator has to accept the app, then try again.", "微软还没批准本启动器的应用注册:需要管理员同意该应用后再试。"), + ("AccountFailureUnreachable", "Could not reach the server. Check the network connection or the address, then try again.", "连不上服务器:检查网络或地址后重试。"), + ("AccountFailureNotAuthlib", "This address does not look like an authlib-injector server (the API root usually ends with /api/yggdrasil/).", "这个地址不像 authlib-injector 服务器(API 根通常以 /api/yggdrasil/ 结尾)。"), + ("AccountFailureBadCredentials", "The server rejected the username or password.", "服务器不认这个用户名或密码。"), + ("AccountFailureNoProfile", "The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"), + ("AccountFailureStore", "Could not save the account to disk.", "账户没能写到磁盘上。"), + ("AccountFailureOther", "The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"), + ("AccountLoginNotConfigured", "Microsoft sign-in is not configured", "微软登录未配置"), + ("AccountFailureMsaNotConfigured", "This build has no Azure public client id, so Microsoft sign-in cannot start (the sign-in flow itself is implemented). Set the environment variable ULL_MSA_CLIENT_ID= before starting the launcher, or build with ULL_MSA_CLIENT_ID=. Then restart the launcher: a running process does not pick up a new environment variable.", "本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=,或构建时注入:ULL_MSA_CLIENT_ID= cargo build。改完请重启启动器——已在运行的进程读不到新设的环境变量。"), + ("AccountDeviceRequesting", "Contacting Microsoft…", "正在联系微软…"), + ("AccountLoginSucceeded", "Signed in as {name}", "已登录 {name}"), + ("AccountNoticeAdded", "Added {name}", "已添加 {name}"), + ("AccountNoticeRemoved", "Removed {name}", "已删除 {name}"), + ("AccountNoticeSwitched", "{name} will be used for the next launch", "{name} 将作为下次启动的账户"), + ("AccountNoticeOfflineNameInvalid", "Profile names must be 3–16 letters, digits or underscores.", "档案名必须是 3–16 位字母、数字或下划线。"), + ("AccountNoticeAccountExists", "An account with that name already exists.", "同名账户已经存在。"), + ("AccountNoticeStoreFailed", "Could not save the account store. Check the disk and try again.", "账户库写不进去:检查磁盘后重试。"), + ("AccountNoticeNotFound", "That account no longer exists.", "这个账户已经不在了。"), + ("AccountNoticeOther", "The account action failed. The full reason is in the launcher log.", "账户操作失败,完整原因在启动器日志里。"), + ("AccountNoticeDismiss", "Dismiss", "关闭"), + ("AccountNoticeTitleFailed", "Account action failed", "账户操作失败"), + ("AccountNoticeTitleDone", "Account updated", "账户已更新"), + ("AccountHintAuthlibReauth", "Enter the password again for {server}.", "请输入 {server} 的密码。"), + ("AccountHintOfflineNoRelogin", "Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"), +]; diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index 9bbb84f..6ed82f9 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -78,6 +78,24 @@ macro_rules! instances_entries { InstancesStageVerify => ("Verifying", "校验中"), InstancesStageLaunch => ("Starting", "启动中"), InstancesStageWindow => ("Waiting for window", "等待游戏窗口"), + // ── 启动参数区(M157):实例配置页的五项 + 三态后端 + 门槛提示 + InstancesLaunchTitle => ("LAUNCH OPTIONS", "启动参数"), + InstancesLaunchJvm => ("JVM arguments", "JVM 参数"), + InstancesLaunchJvmHint => ("Space separated, quotes allowed. Empty = follow the global extra JVM arguments (both are sent; on duplicates the instance wins).", "空格分隔、支持引号。留空 = 跟随全局附加 JVM 参数(两者叠加下发,同名选项实例赢)。"), + InstancesLaunchGame => ("Game arguments", "游戏参数"), + InstancesLaunchGameHint => ("Appended last, so they override earlier game arguments. Empty = nothing appended.", "追加在命令行最后,可覆盖前面的游戏参数。留空 = 不追加。"), + InstancesLaunchMemory => ("Max memory (MiB)", "最大内存(MiB)"), + InstancesLaunchMemoryHint => ("Empty = follow the global default ({default} MiB).", "留空 = 跟随全局默认({default} MiB)。"), + InstancesLaunchJava => ("Java executable", "Java 可执行文件"), + InstancesLaunchJavaHint => ("Empty = follow the global setting, then auto-pick (managed > JAVA_HOME > PATH).", "留空 = 跟随全局设置,再退到自动挑选(托管 > 环境变量 > 系统路径)。"), + InstancesLaunchBackend => ("Graphics backend", "图形后端"), + InstancesLaunchBackendGated => ("Requires MC 26.2+ (this instance: {version}). The flag is not sent on this version.", "需游戏版本 26.2 及以上(本实例 {version}),这个版本不会下发该参数。"), + InstancesLaunchEffective => ("Effective: {value}", "生效:{value}"), + InstancesBackendDefault => ("Default", "默认"), + InstancesBackendOpengl => ("OpenGL", "OpenGL 后端"), + InstancesBackendVulkan => ("Vulkan", "Vulkan 后端"), + InstancesLaunchSave => ("Save", "保存"), + InstancesLaunchReset => ("Reset to global", "恢复跟随全局"), } } }; } diff --git a/crates/i18n/src/catalog/tests.rs b/crates/i18n/src/catalog/tests.rs index 1518e4c..5c137cc 100644 --- a/crates/i18n/src/catalog/tests.rs +++ b/crates/i18n/src/catalog/tests.rs @@ -187,11 +187,11 @@ fn split_keeps_every_translation() { assert_eq!( actual.len(), - super::golden::GOLDEN.len(), + super::golden::len(), "key 条数与拆分前不一致(某条被搬丢,或多出来一条)" ); - for (index, expected) in super::golden::GOLDEN.iter().enumerate() { + for (index, expected) in super::golden::rows().enumerate() { let got = actual[index]; assert_eq!( got.0, expected.0, diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index c4508a7..6181e25 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -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`] 验证通道语义。 diff --git a/crates/presenter/src/dto/instances.rs b/crates/presenter/src/dto/instances.rs index 9c5f2e5..b86ebb7 100644 --- a/crates/presenter/src/dto/instances.rs +++ b/crates/presenter/src/dto/instances.rs @@ -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("反序列化"); diff --git a/crates/presenter/src/dto/launch_args.rs b/crates/presenter/src/dto/launch_args.rs new file mode 100644 index 0000000..3ec1c08 --- /dev/null +++ b/crates/presenter/src/dto/launch_args.rs @@ -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, + /// 生效最大堆(三级回落 + 出厂默认,恒有值)。 + 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, +} + +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, + /// 实例层游戏参数(文本)。 + pub game_args: Option, + /// 实例层最大堆(文本形态,host 解析;空 = 跟随全局)。 + pub memory_mb: Option, + /// 实例层 Java 可执行文件。 + pub java_path: Option, + /// 实例层图形后端(`default` / `opengl` / `vulkan`)。 + pub graphics_backend: Option, +} + +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")); + } +} diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 7c6f7ed..233ad3f 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -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 diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index 533f15c..d310a8f 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -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))); } diff --git a/crates/presenter/src/host/game.rs b/crates/presenter/src/host/game.rs index 960ab82..c7df977 100644 --- a/crates/presenter/src/host/game.rs +++ b/crates/presenter/src/host/game.rs @@ -126,16 +126,18 @@ pub fn instances(env: &HostEnv) -> Vec { .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`):`--` - ("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//.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; diff --git a/crates/presenter/src/host/game/tests.rs b/crates/presenter/src/host/game/tests.rs new file mode 100644 index 0000000..a451237 --- /dev/null +++ b/crates/presenter/src/host/game/tests.rs @@ -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`):`--` + ("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//.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(); +} diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 8d65527..1ea2e43 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -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)) } diff --git a/crates/presenter/src/host/launch_args.rs b/crates/presenter/src/host/launch_args.rs new file mode 100644 index 0000000..edbb68d --- /dev/null +++ b/crates/presenter/src/host/launch_args.rs @@ -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 { + 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 { + 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 { + 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/.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); + } +} diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index ed55b51..0576a60 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -26,6 +26,7 @@ mod install_processors; mod java; mod job; mod launch; +mod launch_args; mod loader_versions; mod logs; mod meter; diff --git a/crates/presenter/src/mock_data.rs b/crates/presenter/src/mock_data.rs index edc1fd9..db91faf 100644 --- a/crates/presenter/src/mock_data.rs +++ b/crates/presenter/src/mock_data.rs @@ -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 { 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 { 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 { 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 { 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(), + ], + } +} diff --git a/crates/presenter/src/state_tests.rs b/crates/presenter/src/state_tests.rs index e1a4358..b7a508b 100644 --- a/crates/presenter/src/state_tests.rs +++ b/crates/presenter/src/state_tests.rs @@ -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(), } } From c76a1437e677f1929c95ebfdfda433d6305b8ebf Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 7 Nov 2026 10:12:19 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(app-iced):=20M157=20r2=20=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E5=88=97=E5=8A=A0=E6=BB=9A=E5=8A=A8=E5=8C=BA=20+=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=8F=82=E6=95=B0=E5=8D=A1=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=8D=A1=EF=BC=88D30=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 详情列(含卡片与动作行)套 scrollable:1180×1000 下被裁的内容可达、滚动条与左列同一套几何;右侧 X2 内缩,卡片描边不再被滚动条压住 - 启动参数卡改用 frame::card(card_padding 21 + OutlinedCard 描边),标题 LabelMedium 不加粗——与相邻信息卡同款 - 段间距 X3→X2,卡片不再比内容长 - 演示夹具补 Game arguments(--fullscreen) - presenter host 单测临时目录加序号(同一进程并行用例互删导致 flake) --- crates/app-iced/src/pages/instances/demo.rs | 2 +- crates/app-iced/src/pages/instances/detail.rs | 38 ++++++++++++++++--- .../src/pages/instances/launch_args.rs | 25 +++++------- .../src/pages/instances/tests/i18n.rs | 4 +- crates/presenter/src/host/launch_args.rs | 10 ++++- 5 files changed, 56 insertions(+), 23 deletions(-) diff --git a/crates/app-iced/src/pages/instances/demo.rs b/crates/app-iced/src/pages/instances/demo.rs index 7d3c2d6..4d10619 100644 --- a/crates/app-iced/src/pages/instances/demo.rs +++ b/crates/app-iced/src/pages/instances/demo.rs @@ -103,7 +103,7 @@ fn demo_view() -> LaunchArgsView { LaunchArgsView { instance_id: DEMO[SELECTED].name.to_string(), jvm_args: "-XX:+UseG1GC".to_string(), - game_args: String::new(), + game_args: "--fullscreen".to_string(), memory_mb: (!gated).then_some(4096), memory_effective_mb: if gated { 2048 } else { 4096 }, java_path: String::new(), diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs index 4c291b5..cd5599b 100644 --- a/crates/app-iced/src/pages/instances/detail.rs +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -4,8 +4,8 @@ //! → 一条分隔线 → 正文段 / 运行状态行 → 动作行(Duplicate / Open folder / Delete)。 //! 窄窗(<840)时列表退成上一屏,本文件只画详情 + 一个「返回」按钮。 -use iced::widget::{column, container, row}; -use iced::{Alignment, Element, Length}; +use iced::widget::{column, container, row, scrollable}; +use iced::{Alignment, Element, Length, Padding}; use unlucky_design::color::Role; use unlucky_design::spacing::Space; @@ -48,8 +48,13 @@ pub(super) fn detail_column<'a>( // M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。 body = body.push(super::launch_args::section(ui, &detail.launch, form, texts)); - // 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留) - frame::left_aligned(metrics.column, body.push(action_row(ui, forced, texts))) + // 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)。 + // + // M157 r2(D30 复审):详情列的内容(正文段 + 启动参数卡 + 动作行)在 1180×1000 下**超出一屏**, + // 而这里原来没有滚动区 ⇒ Max memory 以下的控件被静默裁掉、够不着。现在整列套一个滚动区 + // (与左列列表同一套滚动条样式与宽度),被裁内容可达;滚动条是唯一入口,没有第二份状态。 + let content = body.push(action_row(ui, forced, texts)); + frame::left_aligned(metrics.column, scroller(ui, content)) } pub(super) fn narrow_detail<'a>( @@ -89,7 +94,30 @@ pub(super) fn narrow_detail<'a>( ui.color(Role::OnSurfaceVariant), )); - frame::body(metrics, body.push(action_row(ui, forced, texts))) + frame::body( + metrics, + scroller(ui, body.push(action_row(ui, forced, texts))), + ) +} + +/// 详情列的滚动区(M157 r2):与左列列表同一套滚动条几何(宽 6 / 内缩 2 / 同一 `style`)。 +/// +/// 右侧留 `Space::X2` 内缩:滚动条是**覆盖**在内容上的,不留这条缝时卡片的 1dp 描边会被 +/// 滚动条压住(D30 复审里「卡片右边框缺一截」就是它)。 +fn scroller<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, Message> { + let content = container(content) + .width(Length::Fill) + .padding(Padding::default().right(ui.space(Space::X2))); + scrollable(content) + .direction(scrollable::Direction::Vertical( + scrollable::Scrollbar::new() + .width(frame::SCROLLBAR_WIDTH) + .scroller_width(frame::SCROLLBAR_WIDTH) + .margin(frame::SCROLLBAR_MARGIN), + )) + .height(Length::Fill) + .style(frame::scroll_style(ui)) + .into() } /// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。 diff --git a/crates/app-iced/src/pages/instances/launch_args.rs b/crates/app-iced/src/pages/instances/launch_args.rs index 51daf33..6774f60 100644 --- a/crates/app-iced/src/pages/instances/launch_args.rs +++ b/crates/app-iced/src/pages/instances/launch_args.rs @@ -15,12 +15,10 @@ //! //! 文案全走 [`Texts`](守门见 `instances/tests/i18n.rs` 的白名单与死 key 扫描)。 -use iced::widget::{column, container, row}; -use iced::{Background, Element, Length}; +use iced::widget::{column, row}; +use iced::{Element, Length}; use unlucky_design::color::Role; -use unlucky_design::elevation::MaterialLayer; -use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; @@ -47,8 +45,10 @@ pub(super) fn section<'a>( form: &'a LaunchForm, texts: Texts, ) -> Element<'a, PageMessage> { + // 段间距 X2(8dp):卡内 5 个字段 + 三态 + 动作行在 424 宽下已经很长, + // 用 X3 会让详情列更长一屏;行内间距仍是 X1(4dp),读起来不挤。 let mut body = column![frame_title(ui, texts.launch_title())] - .spacing(ui.space(Space::X3)) + .spacing(ui.space(Space::X2)) .width(Length::Fill); body = body.push(args_field( @@ -94,22 +94,17 @@ pub(super) fn section<'a>( body = body.push(backend_row(ui, facts, form, texts)); body = body.push(actions_row(ui, texts)); - container(body) - .width(Length::Fill) - .padding(ui.space(Space::X3)) - .style(move |_theme| container::Style { - background: Some(Background::Color(ui.layer(MaterialLayer::Card))), - border: ui.outlined(ShapeScale::Medium), - ..container::Style::default() - }) - .into() + // 与相邻信息卡**同一套**卡壳(`frame::card`:`card_padding` 21dp + OutlinedCard 的圆角/描边)。 + super::super::frame::card(ui, body) } +/// 卡片标题:**与信息卡的标签同款**(`LabelMedium` 不加粗、`OnSurfaceVariant`), +/// 复审 ③⑤ 抓的就是这里(原来用了 `emphasized = true`,比同屏其它标签重一档)。 fn frame_title<'a>(ui: &'a Ui, title: &'a str) -> Element<'a, PageMessage> { super::super::frame::text_role( ui, TypeRole::LabelMedium, - true, + false, title, ui.color(Role::OnSurfaceVariant), ) diff --git a/crates/app-iced/src/pages/instances/tests/i18n.rs b/crates/app-iced/src/pages/instances/tests/i18n.rs index 61fdfc5..8973176 100644 --- a/crates/app-iced/src/pages/instances/tests/i18n.rs +++ b/crates/app-iced/src/pages/instances/tests/i18n.rs @@ -61,7 +61,7 @@ const PAGE_SOURCES: [(&str, &str); 6] = [ /// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 | /// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) | /// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) | -const ALLOWED_LITERALS: [&str; 17] = [ +const ALLOWED_LITERALS: [&str; 18] = [ "{date} {time} UTC", "isolated", "shared", @@ -79,6 +79,8 @@ const ALLOWED_LITERALS: [&str; 17] = [ // M157:`instances/demo.rs` 的**出图夹具**(用户数据的形状,不是界面文案): // 一个示例 JVM 参数、两个示例 MC 版本、一个示例 Java 路径,以及夹具开关的环境变量名与取值。 "-XX:+UseG1GC", + // M157 r2:示例游戏参数(同上,是夹具不是文案)。 + "--fullscreen", "26.2-snapshot-2", "1.20.4", "C:/Java/temurin-21/bin/java.exe", diff --git a/crates/presenter/src/host/launch_args.rs b/crates/presenter/src/host/launch_args.rs index edbb68d..404be34 100644 --- a/crates/presenter/src/host/launch_args.rs +++ b/crates/presenter/src/host/launch_args.rs @@ -203,6 +203,7 @@ mod tests { /// 最小的 tempdir(与本 crate 其它 host 测试同款:不引第三方 tempfile)。 mod tempdir { use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; pub struct Dir { pub path: PathBuf, @@ -210,7 +211,14 @@ mod tests { impl Dir { pub fn new(tag: &str) -> Self { - let path = std::env::temp_dir().join(format!("{tag}-{}", std::process::id())); + // 每个用例一个目录:同一进程里跑多个用例时,只用 PID 命名会互相 `remove_dir_all` + // (并行跑就是「系统找不到指定的路径」——M157 r2 实测)。 + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "{tag}-{}-{seq}", + std::process::id() + )); let _ = std::fs::remove_dir_all(&path); std::fs::create_dir_all(&path).expect("建临时目录"); Self { path } From 211e07b470fa7152ceb230e8d94b3c8fddff24b1 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 7 Nov 2026 10:27:16 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(launch):=20M157=20r3=20=E5=91=A8?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E9=97=A8=E9=99=90=E6=8C=89=E7=BB=A7=E6=89=BF?= =?UTF-8?q?=E6=A0=B9=E7=89=88=E6=9C=AC=20+=20=E4=BF=9D=E5=AD=98=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=BF=9D=E7=95=99=E8=8D=89=E7=A8=BF=EF=BC=88=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=20P2-1/P2-2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1 周快照 + 加载器的 --graphicsBackend 静默丢失:presenter host::launch::build_request 与 cli snapshot::prepare 给 SnapshotInput.game_version 传 None,装配按带加载器后缀的 version_id(26w14a-fabric-0.16.5)判门限,GameVersion::is_weekly_snapshot 的 6 字符形态认不出来。两个调用点改为传继承链的 MC 根版本(25 行注释说明「门槛与拼装必须同一口径」)。回归:core launch::overrides 的周快照实际装配用例(正/负两档)、presenter host::launch::tests、cli snapshot::tests 各一条根版本断言。 P2-2 保存失败丢草稿:app/update/instances.rs 的 Save 分支派发前就把 launch_form.dirty 清掉,失败事件后 pump 用旧快照重建视图并覆盖表单。改为派发不清脏;LaunchForm::sync 只在「回程快照已等于表单」(= 落盘确认)时清脏并重播种,否则保留草稿。回归:failed_save_keeps_the_draft / confirmed_save_clears_dirty_and_reseeds / save_dispatch_keeps_the_dirty_flag。 顺带:launch::overrides 测试拆到 overrides/tests.rs(守 400 行)。 --- crates/app-iced/src/app/update/instances.rs | 22 +- crates/app-iced/src/pages/instances/state.rs | 60 ++++ crates/cli/src/commands/instance_launch.rs | 9 +- crates/cli/src/commands/snapshot.rs | 96 ++++- crates/core/src/launch/overrides.rs | 300 +--------------- crates/core/src/launch/overrides/tests.rs | 353 +++++++++++++++++++ crates/presenter/src/host/launch.rs | 10 +- crates/presenter/src/host/launch/tests.rs | 96 +++++ crates/presenter/src/host/launch_args.rs | 5 +- 9 files changed, 640 insertions(+), 311 deletions(-) create mode 100644 crates/core/src/launch/overrides/tests.rs create mode 100644 crates/presenter/src/host/launch/tests.rs diff --git a/crates/app-iced/src/app/update/instances.rs b/crates/app-iced/src/app/update/instances.rs index f3cd807..0abfbe2 100644 --- a/crates/app-iced/src/app/update/instances.rs +++ b/crates/app-iced/src/app/update/instances.rs @@ -53,8 +53,11 @@ impl App { self.launch_form.reset(); } InstanceMessage::Save => { + // **不在这里清脏**:`dispatch` 只是把命令送出去,落盘成败要等回程事件。 + // 失败时(内存 `4g` / 写盘失败)pump 会带**旧快照**重建视图,脏标记若已清掉, + // `LaunchForm::sync` 就会用旧值覆盖用户刚敲的草稿(M157 r3 复审 P2-2)。 + // 清脏的唯一时机是「回程快照已经等于表单」——见 `LaunchForm::sync`。 let patch: LaunchArgsPatch = self.launch_form.patch(); - self.launch_form.dirty = false; self.dispatch(AppCommand::set_launch_args(&instance, patch)); } } @@ -149,13 +152,22 @@ mod tests { assert!(!app.launch_form.dirty); } - /// 保存之后脏标记清掉(否则下一次快照刷新不敢重播种,界面会一直显示旧值)。 + /// **保存只派发、不清脏**(M157 r3 复审 P2-2):落盘成败要等回程事件, + /// 派发前清掉脏标记会让失败后的重播覆盖用户草稿。 + /// + /// 抓得住:Save 分支里又写回 `dirty = false`(那条路径在失败时丢草稿)。 + /// 抓不住:回程事件的真实时序——清脏判据在 `LaunchForm::sync` 的 + /// `confirmed_save_clears_dirty_and_reseeds` / `failed_save_keeps_the_draft` 里。 #[test] - fn save_clears_the_dirty_flag() { + fn save_dispatch_keeps_the_dirty_flag() { let mut app = app_with_form(); - let _ = app.on_instance_message(InstanceMessage::Java("D:/jdk21/bin/java.exe".to_string())); + let _ = app.on_instance_message(InstanceMessage::Memory("4g".to_string())); assert!(app.launch_form.dirty); let _ = app.on_instance_message(InstanceMessage::Save); - assert!(!app.launch_form.dirty); + assert!( + app.launch_form.dirty, + "派发后仍要保留脏标记,等回程快照确认" + ); + assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容"); } } diff --git a/crates/app-iced/src/pages/instances/state.rs b/crates/app-iced/src/pages/instances/state.rs index 2b4ebbe..0c0951e 100644 --- a/crates/app-iced/src/pages/instances/state.rs +++ b/crates/app-iced/src/pages/instances/state.rs @@ -68,14 +68,36 @@ impl LaunchForm { } /// 重播种(见模块文档的三档)。 + /// + /// **脏草稿只在「落盘已确认」时被替换**:保存把命令派发出去之后,回程可能是一条错误事件 + /// (内存写了 `4g`、实例文件写不进去),那时 pump 会带着**旧快照**重建视图——如果这里无条件 + /// 覆盖,用户刚敲的东西就没了(M157 r3 复审 P2-2)。判据是「快照已经等于表单」: + /// 相等 ⇒ 这次保存落了盘,清脏并重播种;不相等 ⇒ 保留草稿等用户改。 pub fn sync(&mut self, view: &LaunchArgsView) { let same_instance = self.bound_to(&view.instance_id); if same_instance && self.dirty { + if self.matches(view) { + self.dirty = false; + *self = Self::from_view(Some(&view.instance_id), view); + } return; } *self = Self::from_view(Some(&view.instance_id), view); } + /// 表单当前内容是否与一份快照(= 盘上已持久化的值)完全一致。 + /// + /// 比较的是**文本口径**:内存按「快照有值 → 十进制串,无值 → 空串」对齐,其余直接比原文。 + #[must_use] + pub fn matches(&self, view: &LaunchArgsView) -> bool { + let memory = view.memory_mb.map(|mb| mb.to_string()).unwrap_or_default(); + self.jvm.trim() == view.jvm_args.trim() + && self.game.trim() == view.game_args.trim() + && self.memory.trim() == memory + && self.java.trim() == view.java_path.trim() + && self.backend == view.graphics_backend + } + /// 提交补丁:**五项全给**(表单是整份可见的,不用增量语义会让「清空」无法表达)。 #[must_use] pub fn patch(&self) -> LaunchArgsPatch { @@ -169,6 +191,44 @@ mod tests { assert!(form.bound_to("b")); } + /// **保存失败不许吃掉草稿**(M157 r3 复审 P2-2):脏表单 + 旧快照回来 ⇒ 原样保留。 + /// + /// 抓得住:`sync` 在「同实例 + 脏」时无条件重播种(用户敲的 `4g` 被磁盘上的 4096 覆盖)。 + /// 抓不住:真实事件时序(那在 `app/update/instances.rs` 的用例里按消息顺序模拟)。 + #[test] + fn failed_save_keeps_the_draft() { + let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default")); + // 用户把内存改成非法值(host 会拒),此时表单是脏的。 + form.memory = "4g".to_string(); + form.jvm = "-Xmx3g".to_string(); + form.dirty = true; + + // 回程事件带来的仍是**旧**快照(保存失败 ⇒ 盘上没变)。 + form.sync(&view("a", "", Some(4096), "default")); + + assert_eq!(form.memory, "4g", "失败后的重播把用户正在改的内存吃掉了"); + assert_eq!(form.jvm, "-Xmx3g"); + assert!(form.dirty, "草稿还在,脏标记也不能掉"); + } + + /// **保存成功要清脏**:回程快照等于表单 ⇒ 这一步算落盘确认,之后可以跟着外部变化重播种。 + #[test] + fn confirmed_save_clears_dirty_and_reseeds() { + let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default")); + form.memory = "8192".to_string(); + form.backend = "vulkan".to_string(); + form.dirty = true; + + form.sync(&view("a", "", Some(8192), "vulkan")); + assert!(!form.dirty, "盘上已经是这份值了,脏标记该清"); + assert_eq!(form.memory, "8192"); + + // 之后外部改了配置(比如 CLI 写入)⇒ 干净的表格跟着更新。 + form.sync(&view("a", "-Xss4m", Some(2048), "opengl")); + assert_eq!(form.memory, "2048"); + assert_eq!(form.jvm, "-Xss4m"); + } + /// 补丁五项全给:空串就是「清空」,不留 `None` 让 host 猜。 #[test] fn patch_always_covers_all_five_fields() { diff --git a/crates/cli/src/commands/instance_launch.rs b/crates/cli/src/commands/instance_launch.rs index 425c1a8..19d88d4 100644 --- a/crates/cli/src/commands/instance_launch.rs +++ b/crates/cli/src/commands/instance_launch.rs @@ -241,7 +241,14 @@ fn backend_gate(ctx: &Ctx, version_id: &str) -> (bool, String) { } /// 继承链根(原版层)的 id——能力位与版本门限都以它为准(loader 层的 id 带加载器后缀)。 -fn root_version_id(layout: &unlucky_core::game::GameLayout, manifest: &VersionJson) -> String { +/// +/// `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 { diff --git a/crates/cli/src/commands/snapshot.rs b/crates/cli/src/commands/snapshot.rs index ac3857e..004a5bd 100644 --- a/crates/cli/src/commands/snapshot.rs +++ b/crates/cli/src/commands/snapshot.rs @@ -131,11 +131,14 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result { .clone() .or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone())); + // 同 presenter:装配门限按**继承链的 MC 根版本**判(loader 实例的 version_id 带加载器后缀, + // 周快照的 6 字符形态判据在那儿失效,`--graphicsBackend` 会被静默跳过)。 + let root_version = super::instance_launch::root_version_id(&game, &manifest); let snapshot = SnapshotInput { version_id: &version_id, version_name: Some(&input.instance_name), version_type: None, - game_version: None, + game_version: Some(root_version.as_str()), main_class: resolved.main_class.as_deref(), jvm_arguments: &expanded.jvm, game_arguments: &expanded.game, @@ -235,3 +238,94 @@ fn describe_warning(warning: &AdapterWarning) -> String { } } } + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + + /// 一次性临时数据根(`--data-dir` 的等价物:custom 模式 → `/.ULL` + `/.minecraft`)。 + struct TempDir(PathBuf); + + impl TempDir { + fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-cli-snapshot-{tag}-{}-{seq}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn ctx_at(dir: &TempDir) -> Ctx { + let globals = crate::cli::Globals { + data_dir: Some(dir.path().to_path_buf()), + ..crate::cli::Globals::default() + }; + Ctx::resolve(&globals).expect("解析数据根") + } + + fn write_version(ctx: &Ctx, id: &str, json: &str) { + let path = ctx.game().version_json(id); + std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录"); + std::fs::write(&path, json).expect("写版本 JSON"); + } + + /// **周快照 + 加载器实例**(M157 r3 复审 P2-1):`prepare` 交给装配的游戏版本是 + /// 继承链根的 `26w14a`,不是 `26w14a-fabric-0.16.5`。 + /// + /// 抓得住:`SnapshotInput.game_version` 回退成 `None`(装配用带后缀的 id 判门限, + /// `--graphicsBackend` 会被静默跳过,而 CLI 自己的门槛显示「可下发」)。 + /// 抓不住:完整 argv(`crates/core/src/launch/overrides/tests.rs` 的周快照用例)。 + #[test] + fn weekly_loader_instance_is_assembled_against_the_mc_root_version() { + let dir = TempDir::new("weekly"); + let ctx = ctx_at(&dir); + write_version( + &ctx, + "26w14a", + r#"{"id":"26w14a","mainClass":"net.minecraft.client.main.Main", + "libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#, + ); + write_version( + &ctx, + "26w14a-fabric-0.16.5", + r#"{"id":"26w14a-fabric-0.16.5","inheritsFrom":"26w14a", + "mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient"}"#, + ); + + let plan = prepare( + &ctx, + &PlanInput { + version_id: "26w14a-fabric-0.16.5".to_string(), + instance_name: "周快照 Fabric".to_string(), + ..PlanInput::default() + }, + ) + .expect("装配应成功"); + + assert_eq!( + plan.request.instance.version.raw(), + "26w14a", + "装配的游戏版本必须是继承链的 MC 根版本(CLI 门槛与拼装必须同一口径)" + ); + assert!(plan.request.instance.version.is_weekly_snapshot()); + assert!(plan.request.manifest.has_vulkan_backend); + } +} diff --git a/crates/core/src/launch/overrides.rs b/crates/core/src/launch/overrides.rs index a6950d6..d6b7f4d 100644 --- a/crates/core/src/launch/overrides.rs +++ b/crates/core/src/launch/overrides.rs @@ -71,302 +71,4 @@ fn tokens(text: Option<&str>) -> Vec { } #[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::*; - use crate::game::launch_overrides::{ - resolve, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides, - }; - use crate::launch::args_table::{assemble, AssemblyWarning}; - use crate::launch::game_version::GameVersion; - use crate::launch::platform::{OsFamily, PlatformCtx}; - use crate::launch::request::{ - AuthPlaceholders, InstanceInfo, JavaSelection, LaunchDirs, LauncherInfo, Log4jOptions, - ManifestArgs, OptimizationOptions, - }; - - fn request(version: &str) -> LaunchRequest { - LaunchRequest { - instance: InstanceInfo { - id: "inst-1".to_string(), - name: version.to_string(), - version: GameVersion::parse(version), - version_type: "release".to_string(), - }, - launcher: LauncherInfo { - name: "UnluckyLauncher".to_string(), - version: "0.2.2".to_string(), - }, - java: JavaSelection::new("C:/java/bin/java.exe", 21, 64), - memory: MemoryOptions { - min_mb: None, - max_mb: None, - }, - manifest: ManifestArgs { - main_class: Some("net.minecraft.client.main.Main".to_string()), - ..ManifestArgs::default() - }, - dirs: LaunchDirs { - game_dir: PathBuf::from("C:/mc"), - primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"), - ..LaunchDirs::default() - }, - classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")], - auth: AuthPlaceholders::default(), - log4j: Log4jOptions { - enabled: false, - ..Log4jOptions::default() - }, - optimization: OptimizationOptions { - enabled: false, - physical_memory_mb: None, - }, - language: "zh-CN".to_string(), - ..LaunchRequest::default() - } - } - - fn ctx() -> PlatformCtx { - PlatformCtx::new(OsFamily::Windows, "x86_64") - } - - fn argvs(req: &LaunchRequest) -> Vec { - assemble(req, &ctx()).expect("组装应成功").args - } - - fn position(args: &[String], needle: &str) -> Option { - args.iter().position(|arg| arg == needle) - } - - /// **叠加规则**:实例段在 `-Xmx` 之前、全局段在之后;两段都下发(不替换)。 - #[test] - fn instance_and_global_jvm_args_stack_in_order() { - let instance = LaunchOverrides { - jvm_args: Some("-XX:+UseG1GC".to_string()), - ..LaunchOverrides::default() - }; - let global = GlobalLaunchDefaults { - jvm_args: Some("-Xss4m".to_string()), - ..GlobalLaunchDefaults::default() - }; - let resolved = resolve(Some(&instance), &global); - let mut req = request("1.20.4"); - apply(&mut req, &resolved); - let args = argvs(&req); - - let instance_at = position(&args, "-XX:+UseG1GC").expect("实例参数要下发"); - let memory_at = position(&args, "-Xmx2048m").expect("内存字段要产 -Xmx"); - let global_at = position(&args, "-Xss4m").expect("全局参数要下发"); - assert!( - instance_at < memory_at, - "实例段(override_jvm)必须在 -Xmx 之前:{args:?}" - ); - assert!( - global_at > memory_at, - "全局段(user.jvm)在 -Xmx 之后(基线不抢内存字段):{args:?}" - ); - } - - /// **-Xmx 冲突**:实例文本写 `-Xmx` ⇒ 内存字段不再产出(文本赢);全局文本写 `-Xmx` ⇒ 被内存字段抑制。 - #[test] - fn xmx_conflict_follows_the_assembly_order() { - let instance = LaunchOverrides { - jvm_args: Some("-Xmx3g".to_string()), - ..LaunchOverrides::default() - }; - let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); - let mut req = request("1.20.4"); - apply(&mut req, &resolved); - let args = argvs(&req); - assert!( - args.iter().any(|arg| arg == "-Xmx3g"), - "实例文本的 -Xmx 必须生效:{args:?}" - ); - assert!( - !args.iter().any(|arg| arg == "-Xmx2048m"), - "内存字段的 -Xmx 必须被抑制:{args:?}" - ); - - let resolved = resolve(None, &GlobalLaunchDefaults::with_jvm_args("-Xmx3g")); - let mut req = request("1.20.4"); - apply(&mut req, &resolved); - let args = argvs(&req); - assert!( - args.iter().any(|arg| arg == "-Xmx2048m"), - "全局文本的 -Xmx 不生效(第 7 项晚于第 5 项):{args:?}" - ); - assert!(!args.iter().any(|arg| arg == "-Xmx3g"), "{args:?}"); - } - - /// 内存三级回落落进 `-Xmx`:实例 → 全局 → 默认。 - #[test] - fn memory_falls_back_into_xmx() { - let global = GlobalLaunchDefaults { - memory_mb: Some(6144), - ..GlobalLaunchDefaults::default() - }; - let instance = LaunchOverrides { - memory_mb: Some(8192), - ..LaunchOverrides::default() - }; - for (overrides, expected) in [(Some(instance), "-Xmx8192m"), (None, "-Xmx6144m")] { - let resolved = resolve(overrides.as_ref(), &global); - let mut req = request("1.20.4"); - apply(&mut req, &resolved); - assert!( - argvs(&req).iter().any(|arg| arg == expected), - "期望 {expected}" - ); - } - let resolved = resolve(None, &GlobalLaunchDefaults::default()); - let mut req = request("1.20.4"); - apply(&mut req, &resolved); - assert!(argvs(&req).iter().any(|arg| arg == "-Xmx2048m")); - } - - /// 游戏参数最后追加(可覆盖前面一切游戏参数)。 - #[test] - fn game_args_land_at_the_tail() { - let instance = LaunchOverrides { - game_args: Some("--fullscreen --width 1920".to_string()), - ..LaunchOverrides::default() - }; - let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); - let mut req = request("1.20.4"); - apply(&mut req, &resolved); - let args = argvs(&req); - let tail = &args[args.len() - 3..]; - assert_eq!(tail, ["--fullscreen", "--width", "1920"], "{args:?}"); - } - - /// **版本门槛**:26.2-snapshot-2+ 才下发;老版本不下发但必须给诊断告警(不静默)。 - #[test] - fn graphics_backend_is_version_gated_with_a_warning() { - let instance = LaunchOverrides { - graphics_backend: Some(GraphicsBackend::Vulkan), - ..LaunchOverrides::default() - }; - let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); - - let mut supported = request("26.2-snapshot-2"); - apply(&mut supported, &resolved); - let command = assemble(&supported, &ctx()).expect("组装"); - let at = position(&command.args, "--graphicsBackend").expect("支持版本要下发"); - assert_eq!(command.args[at + 1], "vulkan"); - assert!( - !command - .warnings - .iter() - .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })), - "支持版本不该有跳过告警:{:?}", - command.warnings - ); - - let mut old = request("1.20.4"); - apply(&mut old, &resolved); - let command = assemble(&old, &ctx()).expect("组装"); - assert!( - !command.args.iter().any(|arg| arg == "--graphicsBackend"), - "老版本不许下发:{:?}", - command.args - ); - assert!( - command - .warnings - .iter() - .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })), - "跳过必须留诊断:{:?}", - command.warnings - ); - } - - /// `default` = 不下发、不告警(三态里的「跟随游戏默认」)。 - #[test] - fn default_backend_emits_nothing() { - for instance in [None, Some(LaunchOverrides::default())] { - let resolved = resolve(instance.as_ref(), &GlobalLaunchDefaults::default()); - let mut req = request("26.2"); - apply(&mut req, &resolved); - let command = assemble(&req, &ctx()).expect("组装"); - assert!(!command.args.iter().any(|arg| arg == "--graphicsBackend")); - assert!(command.warnings.is_empty(), "{:?}", command.warnings); - } - } - - /// 启动器自带的 `--fullscreen`(第 29 项)与实例游戏参数共存:实例在最后,可覆盖。 - #[test] - fn instance_game_args_win_over_launcher_defaults() { - let instance = LaunchOverrides { - game_args: Some("--fullscreen".to_string()), - ..LaunchOverrides::default() - }; - let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); - let mut req = request("1.20.4"); - req.fullscreen = true; - apply(&mut req, &resolved); - let args = argvs(&req); - assert_eq!( - args.iter() - .filter(|arg| arg.as_str() == "--fullscreen") - .count(), - 2, - "两处都给 --fullscreen,由 MC 取后者(实例段在最后):{args:?}" - ); - assert_eq!(args.last().map(String::as_str), Some("--fullscreen")); - assert!( - position(&args, "net.minecraft.client.main.Main").unwrap() < args.len() - 1, - "实例游戏参数必须在主类之后" - ); - } - - /// Java 路径不进请求、只作为调用方的选择依据。 - #[test] - fn java_path_is_a_scalar_for_the_caller() { - let instance = LaunchOverrides { - java_path: Some(PathBuf::from("D:/jdk21/bin/java.exe")), - ..LaunchOverrides::default() - }; - let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); - assert_eq!( - java_binary(&resolved), - Some(Path::new("D:/jdk21/bin/java.exe")) - ); - let mut req = request("1.20.4"); - let before = req.java.binary.clone(); - apply(&mut req, &resolved); - assert_eq!(req.java.binary, before, "apply 不改 Java 选择"); - } - - /// `backend_allowed`:继承解析后的库表 + 版本门限,两条判据的**唯一组合入口**。 - /// - /// 它是 UI 灰化与拼装链共用的那一格:调用方只负责把「继承解析后的版本 JSON」和 - /// 「游戏版本号」递进来,不许自己拼 `has_vulkan_backend && is_at_least(..)`。 - #[test] - fn backend_allowed_combines_capability_and_threshold() { - use crate::game::VersionJson; - - let with_vulkan = VersionJson::from_json_str( - r#"{"id":"x","libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#, - ) - .expect("解析"); - let without = VersionJson::from_json_str(r#"{"id":"x"}"#).expect("解析"); - - // 可解析版本:精确门限说了算(库表只作周快照兜底)。 - assert!(backend_allowed( - &GameVersion::parse("26.2-snapshot-2"), - &without - )); - assert!(!backend_allowed( - &GameVersion::parse("26.2-snapshot-1"), - &with_vulkan - )); - assert!(!backend_allowed( - &GameVersion::parse("1.20.4"), - &with_vulkan - )); - // 周快照:看库表。 - assert!(backend_allowed(&GameVersion::parse("26w14a"), &with_vulkan)); - assert!(!backend_allowed(&GameVersion::parse("26w14a"), &without)); - } -} +mod tests; diff --git a/crates/core/src/launch/overrides/tests.rs b/crates/core/src/launch/overrides/tests.rs new file mode 100644 index 0000000..590f50f --- /dev/null +++ b/crates/core/src/launch/overrides/tests.rs @@ -0,0 +1,353 @@ +use std::path::PathBuf; + +use super::*; +use crate::game::launch_overrides::{ + resolve, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides, +}; +use crate::launch::adapter::{build_request, AdapterOptions, LibraryInput, SnapshotInput}; +use crate::launch::args_table::{assemble, AssemblyWarning}; +use crate::launch::game_version::GameVersion; +use crate::launch::platform::{OsFamily, PlatformCtx}; +use crate::launch::request::{ + AuthPlaceholders, InstanceInfo, JavaSelection, LaunchDirs, LauncherInfo, Log4jOptions, + ManifestArgs, OptimizationOptions, +}; + +fn request(version: &str) -> LaunchRequest { + LaunchRequest { + instance: InstanceInfo { + id: "inst-1".to_string(), + name: version.to_string(), + version: GameVersion::parse(version), + version_type: "release".to_string(), + }, + launcher: LauncherInfo { + name: "UnluckyLauncher".to_string(), + version: "0.2.2".to_string(), + }, + java: JavaSelection::new("C:/java/bin/java.exe", 21, 64), + memory: MemoryOptions { + min_mb: None, + max_mb: None, + }, + manifest: ManifestArgs { + main_class: Some("net.minecraft.client.main.Main".to_string()), + ..ManifestArgs::default() + }, + dirs: LaunchDirs { + game_dir: PathBuf::from("C:/mc"), + primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"), + ..LaunchDirs::default() + }, + classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")], + auth: AuthPlaceholders::default(), + log4j: Log4jOptions { + enabled: false, + ..Log4jOptions::default() + }, + optimization: OptimizationOptions { + enabled: false, + physical_memory_mb: None, + }, + language: "zh-CN".to_string(), + ..LaunchRequest::default() + } +} + +fn ctx() -> PlatformCtx { + PlatformCtx::new(OsFamily::Windows, "x86_64") +} + +fn argvs(req: &LaunchRequest) -> Vec { + assemble(req, &ctx()).expect("组装应成功").args +} + +fn position(args: &[String], needle: &str) -> Option { + args.iter().position(|arg| arg == needle) +} + +/// **叠加规则**:实例段在 `-Xmx` 之前、全局段在之后;两段都下发(不替换)。 +#[test] +fn instance_and_global_jvm_args_stack_in_order() { + let instance = LaunchOverrides { + jvm_args: Some("-XX:+UseG1GC".to_string()), + ..LaunchOverrides::default() + }; + let global = GlobalLaunchDefaults { + jvm_args: Some("-Xss4m".to_string()), + ..GlobalLaunchDefaults::default() + }; + let resolved = resolve(Some(&instance), &global); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + let args = argvs(&req); + + let instance_at = position(&args, "-XX:+UseG1GC").expect("实例参数要下发"); + let memory_at = position(&args, "-Xmx2048m").expect("内存字段要产 -Xmx"); + let global_at = position(&args, "-Xss4m").expect("全局参数要下发"); + assert!( + instance_at < memory_at, + "实例段(override_jvm)必须在 -Xmx 之前:{args:?}" + ); + assert!( + global_at > memory_at, + "全局段(user.jvm)在 -Xmx 之后(基线不抢内存字段):{args:?}" + ); +} + +/// **-Xmx 冲突**:实例文本写 `-Xmx` ⇒ 内存字段不再产出(文本赢);全局文本写 `-Xmx` ⇒ 被内存字段抑制。 +#[test] +fn xmx_conflict_follows_the_assembly_order() { + let instance = LaunchOverrides { + jvm_args: Some("-Xmx3g".to_string()), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + let args = argvs(&req); + assert!( + args.iter().any(|arg| arg == "-Xmx3g"), + "实例文本的 -Xmx 必须生效:{args:?}" + ); + assert!( + !args.iter().any(|arg| arg == "-Xmx2048m"), + "内存字段的 -Xmx 必须被抑制:{args:?}" + ); + + let resolved = resolve(None, &GlobalLaunchDefaults::with_jvm_args("-Xmx3g")); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + let args = argvs(&req); + assert!( + args.iter().any(|arg| arg == "-Xmx2048m"), + "全局文本的 -Xmx 不生效(第 7 项晚于第 5 项):{args:?}" + ); + assert!(!args.iter().any(|arg| arg == "-Xmx3g"), "{args:?}"); +} + +/// 内存三级回落落进 `-Xmx`:实例 → 全局 → 默认。 +#[test] +fn memory_falls_back_into_xmx() { + let global = GlobalLaunchDefaults { + memory_mb: Some(6144), + ..GlobalLaunchDefaults::default() + }; + let instance = LaunchOverrides { + memory_mb: Some(8192), + ..LaunchOverrides::default() + }; + for (overrides, expected) in [(Some(instance), "-Xmx8192m"), (None, "-Xmx6144m")] { + let resolved = resolve(overrides.as_ref(), &global); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + assert!( + argvs(&req).iter().any(|arg| arg == expected), + "期望 {expected}" + ); + } + let resolved = resolve(None, &GlobalLaunchDefaults::default()); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + assert!(argvs(&req).iter().any(|arg| arg == "-Xmx2048m")); +} + +/// 游戏参数最后追加(可覆盖前面一切游戏参数)。 +#[test] +fn game_args_land_at_the_tail() { + let instance = LaunchOverrides { + game_args: Some("--fullscreen --width 1920".to_string()), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + let mut req = request("1.20.4"); + apply(&mut req, &resolved); + let args = argvs(&req); + let tail = &args[args.len() - 3..]; + assert_eq!(tail, ["--fullscreen", "--width", "1920"], "{args:?}"); +} + +/// **版本门槛**:26.2-snapshot-2+ 才下发;老版本不下发但必须给诊断告警(不静默)。 +#[test] +fn graphics_backend_is_version_gated_with_a_warning() { + let instance = LaunchOverrides { + graphics_backend: Some(GraphicsBackend::Vulkan), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + + let mut supported = request("26.2-snapshot-2"); + apply(&mut supported, &resolved); + let command = assemble(&supported, &ctx()).expect("组装"); + let at = position(&command.args, "--graphicsBackend").expect("支持版本要下发"); + assert_eq!(command.args[at + 1], "vulkan"); + assert!( + !command + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })), + "支持版本不该有跳过告警:{:?}", + command.warnings + ); + + let mut old = request("1.20.4"); + apply(&mut old, &resolved); + let command = assemble(&old, &ctx()).expect("组装"); + assert!( + !command.args.iter().any(|arg| arg == "--graphicsBackend"), + "老版本不许下发:{:?}", + command.args + ); + assert!( + command + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })), + "跳过必须留诊断:{:?}", + command.warnings + ); +} + +/// `default` = 不下发、不告警(三态里的「跟随游戏默认」)。 +#[test] +fn default_backend_emits_nothing() { + for instance in [None, Some(LaunchOverrides::default())] { + let resolved = resolve(instance.as_ref(), &GlobalLaunchDefaults::default()); + let mut req = request("26.2"); + apply(&mut req, &resolved); + let command = assemble(&req, &ctx()).expect("组装"); + assert!(!command.args.iter().any(|arg| arg == "--graphicsBackend")); + assert!(command.warnings.is_empty(), "{:?}", command.warnings); + } +} + +/// 启动器自带的 `--fullscreen`(第 29 项)与实例游戏参数共存:实例在最后,可覆盖。 +#[test] +fn instance_game_args_win_over_launcher_defaults() { + let instance = LaunchOverrides { + game_args: Some("--fullscreen".to_string()), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + let mut req = request("1.20.4"); + req.fullscreen = true; + apply(&mut req, &resolved); + let args = argvs(&req); + assert_eq!( + args.iter() + .filter(|arg| arg.as_str() == "--fullscreen") + .count(), + 2, + "两处都给 --fullscreen,由 MC 取后者(实例段在最后):{args:?}" + ); + assert_eq!(args.last().map(String::as_str), Some("--fullscreen")); + assert!( + position(&args, "net.minecraft.client.main.Main").unwrap() < args.len() - 1, + "实例游戏参数必须在主类之后" + ); +} + +/// Java 路径不进请求、只作为调用方的选择依据。 +#[test] +fn java_path_is_a_scalar_for_the_caller() { + let instance = LaunchOverrides { + java_path: Some(PathBuf::from("D:/jdk21/bin/java.exe")), + ..LaunchOverrides::default() + }; + let resolved = resolve(Some(&instance), &GlobalLaunchDefaults::default()); + assert_eq!( + java_binary(&resolved), + Some(Path::new("D:/jdk21/bin/java.exe")) + ); + let mut req = request("1.20.4"); + let before = req.java.binary.clone(); + apply(&mut req, &resolved); + assert_eq!(req.java.binary, before, "apply 不改 Java 选择"); +} + +/// `backend_allowed`:继承解析后的库表 + 版本门限,两条判据的**唯一组合入口**。 +/// +/// 它是 UI 灰化与拼装链共用的那一格:调用方只负责把「继承解析后的版本 JSON」和 +/// 「游戏版本号」递进来,不许自己拼 `has_vulkan_backend && is_at_least(..)`。 +#[test] +fn backend_allowed_combines_capability_and_threshold() { + use crate::game::VersionJson; + + let with_vulkan = VersionJson::from_json_str( + r#"{"id":"x","libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#, + ) + .expect("解析"); + let without = VersionJson::from_json_str(r#"{"id":"x"}"#).expect("解析"); + + // 可解析版本:精确门限说了算(库表只作周快照兜底)。 + assert!(backend_allowed( + &GameVersion::parse("26.2-snapshot-2"), + &without + )); + assert!(!backend_allowed( + &GameVersion::parse("26.2-snapshot-1"), + &with_vulkan + )); + assert!(!backend_allowed( + &GameVersion::parse("1.20.4"), + &with_vulkan + )); + // 周快照:看库表。 + assert!(backend_allowed(&GameVersion::parse("26w14a"), &with_vulkan)); + assert!(!backend_allowed(&GameVersion::parse("26w14a"), &without)); + + /// **周快照 + 加载器**(M157 r3 复审 P2-1):装配门限必须按**继承链的 MC 根版本**判。 + /// + /// 造一个 Fabric 实例(id `26w14a-fabric-0.16.5`,继承周快照 `26w14a`),库表里有 + /// `org.lwjgl:lwjgl-vulkan`:`game_version = Some("26w14a")` ⇒ 命令行带 `--graphicsBackend vulkan`; + /// 退回到「拿带加载器后缀的 version_id 当游戏版本」⇒ `GameVersion::is_weekly_snapshot` + /// 的 6 字符形态认不出来,参数被跳过(**负向锚点**:这正是复审复现出来的静默丢失)。 + /// + /// 抓得住:adapter 的 `game_version` 传错(调用方又回退成 `None`)——周快照 loader 实例 + /// 「UI 说可用、命令行里没有」。 + /// 抓不住:两个调用点自己有没有传(那由 presenter `host::launch::tests` 与 + /// CLI `snapshot::tests` 各自的根版本断言守)。 + #[test] + fn modded_weekly_snapshot_needs_the_root_version_for_the_gate() { + let version_id = "26w14a-fabric-0.16.5"; + let libraries = [LibraryInput::new( + "C:/mc/libraries/org/lwjgl/lwjgl-vulkan/3.4.3/lwjgl-vulkan-3.4.3.jar", + "org.lwjgl:lwjgl-vulkan:3.4.3", + )]; + + for (game_version, expected) in [(Some("26w14a"), true), (None, false)] { + let snapshot = SnapshotInput { + game_version, + libraries: &libraries, + primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"), + ..SnapshotInput::minimal(version_id, "net.minecraft.client.main.Main") + }; + let options = AdapterOptions { + check_classpath: false, + language: "zh-CN".to_string(), + ..AdapterOptions::default() + }; + let mut out = build_request(&snapshot, options).request; + let resolved = resolve( + Some(&LaunchOverrides { + graphics_backend: Some(GraphicsBackend::Vulkan), + ..LaunchOverrides::default() + }), + &GlobalLaunchDefaults::default(), + ); + apply(&mut out, &resolved); + let command = assemble(&out, &ctx()).expect("组装应成功"); + let sent = command.args.iter().any(|arg| arg == "--graphicsBackend"); + assert_eq!( + sent, expected, + "game_version={game_version:?} 时是否下发不对:{:?}", + command.args + ); + let skipped = command + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })); + assert_eq!(skipped, !expected, "跳过与否必须与下发相反(不静默)"); + } + } +} diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 1ea2e43..8f30240 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -199,11 +199,16 @@ pub fn build_request( .assets .clone() .or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone())); + // 装配的门限 / 分支按**继承链的 MC 根版本**判,不是带加载器后缀的 version_id: + // `26w14a-fabric-0.16.5` 这种 id 在 `GameVersion::is_weekly_snapshot`(6 字符形态)上判不出来, + // 周快照的 `--graphicsBackend` 会在拼装链里被静默跳过——而详情页的门槛 + // (`host::launch_args::backend_gate`)与 CLI 用的都是根版本,两边必须同一口径。 + let (mc_version, _) = super::game::describe_version(env, &version_id); let snapshot = SnapshotInput { version_id: &version_id, version_name: Some(&target.name), version_type: None, - game_version: None, + game_version: Some(mc_version.as_str()), main_class: resolved.main_class.as_deref(), jvm_arguments: &expanded.jvm, game_arguments: &expanded.game, @@ -372,3 +377,6 @@ fn describe_warning(warning: &AdapterWarning) -> String { pub fn isolation_label(mode: IsolationMode) -> &'static str { mode.as_str() } + +#[cfg(test)] +mod tests; diff --git a/crates/presenter/src/host/launch/tests.rs b/crates/presenter/src/host/launch/tests.rs new file mode 100644 index 0000000..93207b2 --- /dev/null +++ b/crates/presenter/src/host/launch/tests.rs @@ -0,0 +1,96 @@ +//! `host::launch` 的回归用例(M157 r3 复审 P2-1):装配用的游戏版本必须是**继承链的 MC 根版本**。 + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::*; + +/// 一次性临时目录(同 `host::launch_args` 测试:不引第三方 tempfile)。 +struct TempDir(PathBuf); + +impl TempDir { + fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-host-launch-{tag}-{}-{seq}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +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"); +} + +/// **周快照 + 加载器实例**的装配:`SnapshotInput.game_version` 要拿继承链根的 `26w14a`, +/// 不是带加载器后缀的 `26w14a-fabric-0.16.5`。 +/// +/// 抓得住:`build_request` 回退成 `game_version: None`(装配链用 `version_id` 判门限)—— +/// 那时 `GameVersion::is_weekly_snapshot` 的 6 字符形态认不出这个 id,`--graphicsBackend` +/// 会在拼装链里被静默跳过,而详情页的门槛(`host::launch_args::backend_gate`,用根版本) +/// 显示「可用」——正是复审复现的「UI 说能开、命令行里没有」。 +/// 抓不住:拼装本身(`crates/core/src/launch/overrides/tests.rs` 的 +/// `modded_weekly_snapshot_needs_the_root_version_for_the_gate` 出完整 argv)。 +#[test] +fn weekly_loader_instance_assembles_against_the_mc_root_version() { + let dir = TempDir::new("weekly"); + let env = HostEnv::at_root(dir.path()); + std::fs::create_dir_all(env.layout.config()).expect("config 目录"); + + // 周快照本体:库表带 lwjgl-vulkan(能力位),主类齐全。 + write_version( + &env, + "26w14a", + r#"{"id":"26w14a","type":"snapshot","mainClass":"net.minecraft.client.main.Main", + "libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#, + ); + // Fabric 层:只写自己的主类 + inheritsFrom(真实落盘形状)。 + write_version( + &env, + "26w14a-fabric-0.16.5", + r#"{"id":"26w14a-fabric-0.16.5","inheritsFrom":"26w14a", + "mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient"}"#, + ); + + let target = LaunchTarget { + id: "inst-1".to_string(), + name: "周快照 Fabric".to_string(), + version_id: "26w14a-fabric-0.16.5".to_string(), + account: None, + }; + let (request, _warnings) = build_request(&env, &target).expect("装配应成功"); + + assert_eq!( + request.instance.version.raw(), + "26w14a", + "装配用的游戏版本必须是继承链的 MC 根版本(带加载器后缀的 id 会让周快照门限失效)" + ); + assert!( + request.instance.version.is_weekly_snapshot(), + "根版本要能被识别成周快照形态" + ); + assert!( + request.manifest.has_vulkan_backend, + "能力位来自继承链解析后的库表(lwjgl-vulkan 在父层)" + ); + assert!( + !request.instance.version.is_known(), + "周快照刻意不可排序(is_known=false),门限只能靠库表兜底" + ); +} diff --git a/crates/presenter/src/host/launch_args.rs b/crates/presenter/src/host/launch_args.rs index 404be34..2ab059c 100644 --- a/crates/presenter/src/host/launch_args.rs +++ b/crates/presenter/src/host/launch_args.rs @@ -215,10 +215,7 @@ mod tests { // (并行跑就是「系统找不到指定的路径」——M157 r2 实测)。 static COUNTER: AtomicU64 = AtomicU64::new(0); let seq = COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "{tag}-{}-{seq}", - std::process::id() - )); + let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id())); let _ = std::fs::remove_dir_all(&path); std::fs::create_dir_all(&path).expect("建临时目录"); Self { path } From ee67965b9b352b937b2cf6505b30eedb56115a5d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 7 Nov 2026 11:06:18 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(m157):=20r4=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=9C=AA=E6=94=B6=E9=9B=86=E7=9A=84=E5=91=A8=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=20+=20=E4=B8=A4=E5=A4=84=20clippy=20?= =?UTF-8?q?=E7=BA=A2=EF=BC=8C=E5=B9=B6=E8=A1=A5=E6=95=B4=E7=8E=AF=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=EF=BC=88=E5=A4=8D=E5=AE=A1=20p1-2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)代入;金表同步;灰化态截图重拍。 --- crates/app-iced/src/app/tests.rs | 2 + .../src/app/tests/instances_save_loop.rs | 145 ++++++++++++++++++ .../src/pages/instances/launch_args.rs | 2 +- crates/app-iced/src/pages/instances/state.rs | 47 +++++- .../src/pages/instances/tests/i18n.rs | 4 +- crates/app-iced/src/pages/instances/text.rs | 13 +- crates/cli/src/commands/instance_launch.rs | 2 +- crates/core/src/launch/overrides/tests.rs | 107 ++++++------- crates/i18n/src/catalog/golden.rs | 2 +- crates/i18n/src/catalog/instances.rs | 2 +- crates/presenter/src/mock_data.rs | 46 +++--- 11 files changed, 285 insertions(+), 87 deletions(-) create mode 100644 crates/app-iced/src/app/tests/instances_save_loop.rs diff --git a/crates/app-iced/src/app/tests.rs b/crates/app-iced/src/app/tests.rs index ce40508..2f10ec5 100644 --- a/crates/app-iced/src/app/tests.rs +++ b/crates/app-iced/src/app/tests.rs @@ -12,6 +12,8 @@ use unlucky_presenter::settings_bridge::SettingsBridge; #[cfg(test)] mod appearance; #[cfg(test)] +mod instances_save_loop; +#[cfg(test)] mod presenter; use super::message::Message; diff --git a/crates/app-iced/src/app/tests/instances_save_loop.rs b/crates/app-iced/src/app/tests/instances_save_loop.rs new file mode 100644 index 0000000..1a350af --- /dev/null +++ b/crates/app-iced/src/app/tests/instances_save_loop.rs @@ -0,0 +1,145 @@ +//! 「启动参数保存失败」的**整环**回归(M157 r4 复审 P3-a)。 +//! +//! 仓里原来只有这条环的两半:`LaunchForm::sync` 的单测(本 crate 的 `pages/instances/state.rs`) +//! 与「Save 只派发不清脏」(`app/update/instances.rs`)。两半各自绿,但**接线**(消息 → +//! `dispatch` → 后端回事件 → `pump` → `refresh_view` → `sync_launch_form`)没有任何活着的断言。 +//! 把 `dirty = false` 提前到派发前,只有这条整环测试会红(单测两半都不会)。 +//! +//! 造的后端与真 host 的失败路径同形:收到 `SetInstanceLaunchArgs` 只回一条 Toast(错误的用户 +//! 可见反馈),**不回** `InstancesChanged`——盘上没改,快照还是旧的。 + +use std::cell::RefCell; +use std::rc::Rc; + +use unlucky_design::color::SchemeFlavor; +use unlucky_presenter::dto::{ + AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime, InstanceSummary, LaunchArgsView, + LoaderInfo, LoaderKind, +}; +use unlucky_presenter::router::Route; +use unlucky_presenter::settings_bridge::SettingsBridge; +use unlucky_presenter::toast::{Toast, ToastKind}; + +use crate::app::message::Message; +use crate::app::state::{App, TICK}; +use crate::cli::Options; +use crate::pages; +use crate::theme::Families; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +/// 失败路径的后端:命令进台账;`SetInstanceLaunchArgs` 只回一条错误 Toast。 +#[derive(Debug, Default)] +struct FailingSaveBackend { + dispatched: Rc>>, + queued: Vec, +} + +impl Backend for FailingSaveBackend { + fn drain_events(&mut self) -> Vec { + std::mem::take(&mut self.queued) + } + + fn dispatch(&mut self, command: AppCommand) { + self.dispatched.borrow_mut().push(command.clone()); + if matches!(command, AppCommand::SetInstanceLaunchArgs { .. }) { + // 真 host 在 `dispatch_inner` 的 Err 分支只发 toast(不发 InstancesChanged)。 + self.queued.push(AppEvent::Toast(Toast { + id: 0, + kind: ToastKind::Error, + text: "内存超出范围".to_string(), + remaining_ms: 0, + })); + } + } +} + +/// 盘上的实例快照:内存 4096、其余留空(= 保存失败时 pump 会带回来的那份)。 +fn snapshot() -> Vec { + vec![InstanceSummary { + id: "survival".to_string(), + name: "生存".to_string(), + mc_version: "1.20.4".to_string(), + loader: LoaderInfo { + kind: LoaderKind::Vanilla, + version: String::new(), + }, + last_played: None, + runtime: InstanceRuntime::Idle, + icon: None, + isolation: "isolated".to_string(), + version_id: "1.20.4".to_string(), + version_present: true, + launch: LaunchArgsView { + instance_id: "survival".to_string(), + memory_mb: Some(4096), + memory_effective_mb: 4096, + ..LaunchArgsView::default() + }, + }] +} + +fn app_with_snapshot() -> (App, Rc>>) { + let dispatched = Rc::new(RefCell::new(Vec::new())); + let backend = FailingSaveBackend { + dispatched: Rc::clone(&dispatched), + queued: vec![AppEvent::Core(CoreEvent::InstancesChanged(snapshot()))], + }; + let app = App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(backend), + SettingsBridge::memory_only(), + ); + (app, dispatched) +} + +/// **保存失败不许吃掉草稿**(整环):输入 `4g` → 点保存 → 后端回错误事件 → pump 重建视图。 +/// +/// 抓得住:①`Save` 在派发前清脏(草稿被旧快照覆盖成 4096);②`LaunchForm::sync` 去掉 +/// `matches` 判据(同样覆盖);③`pump` 没把事件喂进 `apply`/`refresh_view`(表单不会被播种, +/// 下面的前置断言会红)。 +/// 抓不住:真 host 的落盘失败(那是 `host::launch_args` 的单测射程)。 +#[test] +fn failed_save_keeps_the_draft_through_the_whole_loop() { + let (mut app, dispatched) = app_with_snapshot(); + + // 首屏快照到位 → 自动选中 → 表单按盘上的值播种。 + let _ = app.update(Message::Boot); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + app.advance(TICK); + assert_eq!(app.launch_form.memory, "4096", "首帧要按快照播种"); + assert!(!app.launch_form.dirty); + + // 用户把内存改成非法值(host 会拒),然后点保存。 + let _ = app.update(Message::Page(pages::Message::Instances( + pages::InstanceMessage::Memory("4g".to_string()), + ))); + assert_eq!(app.launch_form.memory, "4g"); + let _ = app.update(Message::Page(pages::Message::Instances( + pages::InstanceMessage::Save, + ))); + assert!( + dispatched + .borrow() + .iter() + .any(|command| matches!(command, AppCommand::SetInstanceLaunchArgs { .. })), + "保存要把命令派发出去" + ); + + // 失败回程:一条错误 Toast → pump → refresh_view → sync_launch_form(旧快照)。 + app.advance(TICK); + + assert_eq!( + app.launch_form.memory, "4g", + "保存失败后,pump 用旧快照把用户刚敲的内容冲掉了" + ); + assert!(app.launch_form.dirty, "草稿还在,脏标记也不能掉"); +} diff --git a/crates/app-iced/src/pages/instances/launch_args.rs b/crates/app-iced/src/pages/instances/launch_args.rs index 6774f60..8f64e3d 100644 --- a/crates/app-iced/src/pages/instances/launch_args.rs +++ b/crates/app-iced/src/pages/instances/launch_args.rs @@ -196,7 +196,7 @@ fn backend_row<'a>( ui, TypeRole::BodySmall, false, - texts.launch_backend_gated(&facts.mc_version), + texts.launch_backend_gated(&facts.mc_version, &facts.min_version), ui.color(Role::Error), )); } diff --git a/crates/app-iced/src/pages/instances/state.rs b/crates/app-iced/src/pages/instances/state.rs index 0c0951e..1343e31 100644 --- a/crates/app-iced/src/pages/instances/state.rs +++ b/crates/app-iced/src/pages/instances/state.rs @@ -87,17 +87,30 @@ impl LaunchForm { /// 表单当前内容是否与一份快照(= 盘上已持久化的值)完全一致。 /// - /// 比较的是**文本口径**:内存按「快照有值 → 十进制串,无值 → 空串」对齐,其余直接比原文。 + /// **内存按数值比**:host 会把 `"04096"` / `"+4096"` 归一成 `4096` 落盘,回程快照给的是 + /// `Some(4096)`——按文本比会永远不等,于是表单一直脏、也不再跟随外部改动(r4 复审 P3-b)。 + /// 解析失败(用户正在敲半截)才退回文本口径。 + /// 其余字段直接比原文(JVM/游戏参数由 core 的 `tokenize` 消费,不做归一)。 #[must_use] pub fn matches(&self, view: &LaunchArgsView) -> bool { - let memory = view.memory_mb.map(|mb| mb.to_string()).unwrap_or_default(); self.jvm.trim() == view.jvm_args.trim() && self.game.trim() == view.game_args.trim() - && self.memory.trim() == memory + && self.memory_matches(view.memory_mb) && self.java.trim() == view.java_path.trim() && self.backend == view.graphics_backend } + /// 内存字段是否等于快照里的值(数值优先,解析失败退文本)。 + fn memory_matches(&self, persisted: Option) -> bool { + let typed = self.memory.trim(); + match (typed.parse::(), persisted) { + (Ok(typed), Some(persisted)) => typed == persisted, + // 空输入 = 跟随全局:快照那边也应当是「没有实例覆盖」。 + (Err(_), None) => typed.is_empty(), + _ => false, + } + } + /// 提交补丁:**五项全给**(表单是整份可见的,不用增量语义会让「清空」无法表达)。 #[must_use] pub fn patch(&self) -> LaunchArgsPatch { @@ -229,6 +242,34 @@ mod tests { assert_eq!(form.jvm, "-Xss4m"); } + /// **内存按数值比**(r4 复审 P3-b):`04096` / `+4096` 落盘后回程是 `4096`,必须判等。 + /// + /// 抓得住:`matches` 退回文本比较——那样这两类输入会永远「没保存成功」,表单一直脏、 + /// 之后外部改配置也不再重播种。 + #[test] + fn memory_is_compared_by_value_not_by_text() { + for typed in ["04096", "+4096", " 4096 "] { + let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default")); + form.memory = typed.to_string(); + form.dirty = true; + assert!( + form.matches(&view("a", "", Some(4096), "default")), + "`{typed}` 应判等" + ); + form.sync(&view("a", "", Some(4096), "default")); + assert!(!form.dirty, "`{typed}` 保存确认后要清脏"); + } + // 数值不等就是不等于;半截输入(解析失败)也不许判等。 + let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default")); + form.memory = "8".to_string(); + assert!(!form.matches(&view("a", "", Some(4096), "default"))); + form.memory = "4g".to_string(); + assert!(!form.matches(&view("a", "", Some(4096), "default"))); + // 清空 = 跟随全局:快照也得是「没有实例覆盖」。 + form.memory.clear(); + assert!(form.matches(&view("a", "", None, "default"))); + } + /// 补丁五项全给:空串就是「清空」,不留 `None` 让 host 猜。 #[test] fn patch_always_covers_all_five_fields() { diff --git a/crates/app-iced/src/pages/instances/tests/i18n.rs b/crates/app-iced/src/pages/instances/tests/i18n.rs index 8973176..1d257c0 100644 --- a/crates/app-iced/src/pages/instances/tests/i18n.rs +++ b/crates/app-iced/src/pages/instances/tests/i18n.rs @@ -61,7 +61,7 @@ const PAGE_SOURCES: [(&str, &str); 6] = [ /// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 | /// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) | /// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) | -const ALLOWED_LITERALS: [&str; 18] = [ +const ALLOWED_LITERALS: [&str; 19] = [ "{date} {time} UTC", "isolated", "shared", @@ -76,6 +76,8 @@ const ALLOWED_LITERALS: [&str; 18] = [ // M157:`Catalog::render` 的占位符名(`{version}` / `{value}` / `{default}`),不是文案。 "version", "value", + // M157 r4:门槛文案的占位符名(版本号由 DTO 给,目录里不写死)。 + "min", // M157:`instances/demo.rs` 的**出图夹具**(用户数据的形状,不是界面文案): // 一个示例 JVM 参数、两个示例 MC 版本、一个示例 Java 路径,以及夹具开关的环境变量名与取值。 "-XX:+UseG1GC", diff --git a/crates/app-iced/src/pages/instances/text.rs b/crates/app-iced/src/pages/instances/text.rs index c49f935..69874cb 100644 --- a/crates/app-iced/src/pages/instances/text.rs +++ b/crates/app-iced/src/pages/instances/text.rs @@ -120,10 +120,15 @@ impl Texts { pub(super) fn launch_backend(self) -> &'static str { self.text(Key::InstancesLaunchBackend) } - /// 「需游戏版本 26.2 及以上(本实例 1.20.4)」。 - pub(super) fn launch_backend_gated(self, version: &str) -> String { - self.0 - .render(Key::InstancesLaunchBackendGated, &[("version", version)]) + /// 「需游戏版本 {min} 及以上(本实例 {version})」。 + /// + /// `min` 来自 DTO(`LaunchFacts::min_version` ← core 的 `GRAPHICS_BACKEND_MIN_VERSION`), + /// **不在目录里硬编码版本号**——否则 core 一改门槛,文案会继续骗人(r4 复审 P3-c)。 + pub(super) fn launch_backend_gated(self, version: &str, min: &str) -> String { + self.0.render( + Key::InstancesLaunchBackendGated, + &[("version", version), ("min", min)], + ) } /// 「生效:vulkan」。 pub(super) fn launch_effective(self, value: &str) -> String { diff --git a/crates/cli/src/commands/instance_launch.rs b/crates/cli/src/commands/instance_launch.rs index 19d88d4..22e5502 100644 --- a/crates/cli/src/commands/instance_launch.rs +++ b/crates/cli/src/commands/instance_launch.rs @@ -183,7 +183,7 @@ fn patch_from(args: &InstanceLaunchArgs) -> LaunchOverridesPatch { /// 全局层(三个设置键)。 pub(crate) fn global_defaults(ctx: &Ctx) -> GlobalLaunchDefaults { - let store = SettingStore::load(&ctx.launcher_config(), launcher_schema()); + let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); let text = |key: &str| -> Option { store .doc() diff --git a/crates/core/src/launch/overrides/tests.rs b/crates/core/src/launch/overrides/tests.rs index 590f50f..7dc1f58 100644 --- a/crates/core/src/launch/overrides/tests.rs +++ b/crates/core/src/launch/overrides/tests.rs @@ -295,59 +295,62 @@ fn backend_allowed_combines_capability_and_threshold() { // 周快照:看库表。 assert!(backend_allowed(&GameVersion::parse("26w14a"), &with_vulkan)); assert!(!backend_allowed(&GameVersion::parse("26w14a"), &without)); +} - /// **周快照 + 加载器**(M157 r3 复审 P2-1):装配门限必须按**继承链的 MC 根版本**判。 - /// - /// 造一个 Fabric 实例(id `26w14a-fabric-0.16.5`,继承周快照 `26w14a`),库表里有 - /// `org.lwjgl:lwjgl-vulkan`:`game_version = Some("26w14a")` ⇒ 命令行带 `--graphicsBackend vulkan`; - /// 退回到「拿带加载器后缀的 version_id 当游戏版本」⇒ `GameVersion::is_weekly_snapshot` - /// 的 6 字符形态认不出来,参数被跳过(**负向锚点**:这正是复审复现出来的静默丢失)。 - /// - /// 抓得住:adapter 的 `game_version` 传错(调用方又回退成 `None`)——周快照 loader 实例 - /// 「UI 说可用、命令行里没有」。 - /// 抓不住:两个调用点自己有没有传(那由 presenter `host::launch::tests` 与 - /// CLI `snapshot::tests` 各自的根版本断言守)。 - #[test] - fn modded_weekly_snapshot_needs_the_root_version_for_the_gate() { - let version_id = "26w14a-fabric-0.16.5"; - let libraries = [LibraryInput::new( - "C:/mc/libraries/org/lwjgl/lwjgl-vulkan/3.4.3/lwjgl-vulkan-3.4.3.jar", - "org.lwjgl:lwjgl-vulkan:3.4.3", - )]; +/// **周快照 + 加载器**(M157 r3 复审 P2-1):装配门限必须按**继承链的 MC 根版本**判。 +/// +/// 造一个 Fabric 实例(id `26w14a-fabric-0.16.5`,继承周快照 `26w14a`),库表里有 +/// `org.lwjgl:lwjgl-vulkan`:`game_version = Some("26w14a")` ⇒ 命令行带 `--graphicsBackend vulkan`; +/// 退回到「拿带加载器后缀的 version_id 当游戏版本」⇒ `GameVersion::is_weekly_snapshot` +/// 的 6 字符形态认不出来,参数被跳过(**负向锚点**:这正是复审复现出来的静默丢失)。 +/// +/// 抓得住:adapter 的 `game_version` 传错(调用方又回退成 `None`)——周快照 loader 实例 +/// 「UI 说可用、命令行里没有」。 +/// 抓不住:两个调用点自己有没有传(那由 presenter `host::launch::tests` 与 +/// CLI `snapshot::tests` 各自的根版本断言守)。 +#[test] +fn modded_weekly_snapshot_needs_the_root_version_for_the_gate() { + let version_id = "26w14a-fabric-0.16.5"; + let libraries = [LibraryInput::new( + "C:/mc/libraries/org/lwjgl/lwjgl-vulkan/3.4.3/lwjgl-vulkan-3.4.3.jar", + "org.lwjgl:lwjgl-vulkan:3.4.3", + )]; - for (game_version, expected) in [(Some("26w14a"), true), (None, false)] { - let snapshot = SnapshotInput { - game_version, - libraries: &libraries, - primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"), - ..SnapshotInput::minimal(version_id, "net.minecraft.client.main.Main") - }; - let options = AdapterOptions { - check_classpath: false, - language: "zh-CN".to_string(), - ..AdapterOptions::default() - }; - let mut out = build_request(&snapshot, options).request; - let resolved = resolve( - Some(&LaunchOverrides { - graphics_backend: Some(GraphicsBackend::Vulkan), - ..LaunchOverrides::default() - }), - &GlobalLaunchDefaults::default(), - ); - apply(&mut out, &resolved); - let command = assemble(&out, &ctx()).expect("组装应成功"); - let sent = command.args.iter().any(|arg| arg == "--graphicsBackend"); - assert_eq!( - sent, expected, - "game_version={game_version:?} 时是否下发不对:{:?}", - command.args - ); - let skipped = command - .warnings - .iter() - .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })); - assert_eq!(skipped, !expected, "跳过与否必须与下发相反(不静默)"); - } + for (game_version, expected) in [(Some("26w14a"), true), (None, false)] { + let snapshot = SnapshotInput { + game_version, + libraries: &libraries, + primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"), + ..SnapshotInput::minimal(version_id, "net.minecraft.client.main.Main") + }; + let options = AdapterOptions { + // `assemble` 要求 Java 可执行文件非空(`EmptyJavaPath`);adapter 默认是空路径, + // 所以这里显式给一个(原来这条用例从没被执行过,r4 把它提到模块层才暴露出来)。 + java: JavaSelection::new("C:/java/bin/java.exe", 21, 64), + check_classpath: false, + language: "zh-CN".to_string(), + ..AdapterOptions::default() + }; + let mut out = build_request(&snapshot, options).request; + let resolved = resolve( + Some(&LaunchOverrides { + graphics_backend: Some(GraphicsBackend::Vulkan), + ..LaunchOverrides::default() + }), + &GlobalLaunchDefaults::default(), + ); + apply(&mut out, &resolved); + let command = assemble(&out, &ctx()).expect("组装应成功"); + let sent = command.args.iter().any(|arg| arg == "--graphicsBackend"); + assert_eq!( + sent, expected, + "game_version={game_version:?} 时是否下发不对:{:?}", + command.args + ); + let skipped = command + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })); + assert_eq!(skipped, !expected, "跳过与否必须与下发相反(不静默)"); } } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 15bf155..a1cb711 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -50,7 +50,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("InstancesLaunchJava", "Java executable", "Java 可执行文件"), ("InstancesLaunchJavaHint", "Empty = follow the global setting, then auto-pick (managed > JAVA_HOME > PATH).", "留空 = 跟随全局设置,再退到自动挑选(托管 > 环境变量 > 系统路径)。"), ("InstancesLaunchBackend", "Graphics backend", "图形后端"), - ("InstancesLaunchBackendGated", "Requires MC 26.2+ (this instance: {version}). The flag is not sent on this version.", "需游戏版本 26.2 及以上(本实例 {version}),这个版本不会下发该参数。"), + ("InstancesLaunchBackendGated", "Requires MC {min}+ (this instance: {version}). The flag is not sent on this version.", "需游戏版本 {min} 及以上(本实例 {version}),这个版本不会下发该参数。"), ("InstancesLaunchEffective", "Effective: {value}", "生效:{value}"), ("InstancesBackendDefault", "Default", "默认"), ("InstancesBackendOpengl", "OpenGL", "OpenGL 后端"), diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index 6ed82f9..41abc7a 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -89,7 +89,7 @@ macro_rules! instances_entries { InstancesLaunchJava => ("Java executable", "Java 可执行文件"), InstancesLaunchJavaHint => ("Empty = follow the global setting, then auto-pick (managed > JAVA_HOME > PATH).", "留空 = 跟随全局设置,再退到自动挑选(托管 > 环境变量 > 系统路径)。"), InstancesLaunchBackend => ("Graphics backend", "图形后端"), - InstancesLaunchBackendGated => ("Requires MC 26.2+ (this instance: {version}). The flag is not sent on this version.", "需游戏版本 26.2 及以上(本实例 {version}),这个版本不会下发该参数。"), + InstancesLaunchBackendGated => ("Requires MC {min}+ (this instance: {version}). The flag is not sent on this version.", "需游戏版本 {min} 及以上(本实例 {version}),这个版本不会下发该参数。"), InstancesLaunchEffective => ("Effective: {value}", "生效:{value}"), InstancesBackendDefault => ("Default", "默认"), InstancesBackendOpengl => ("OpenGL", "OpenGL 后端"), diff --git a/crates/presenter/src/mock_data.rs b/crates/presenter/src/mock_data.rs index db91faf..5591b8c 100644 --- a/crates/presenter/src/mock_data.rs +++ b/crates/presenter/src/mock_data.rs @@ -210,6 +210,29 @@ pub fn mock_versions_for(category: VersionCategory) -> Vec { .collect() } +/// 演示用的启动参数视图(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(), + ], + } +} + #[cfg(test)] mod tests { use crate::dto::{AncientKind, VersionCategory, VersionKind}; @@ -240,26 +263,3 @@ 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(), - ], - } -}