Merge branch 'feat/m157-instargs-per-instance-launch-args-a'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-07 11:28:53 +08:00
commit a80362915a
60 files changed
+4343 -614

No files matched your search

+6
View File
@@ -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:判定没变就不重下、不重记日志)。
@@ -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(),
+27
View File
@@ -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,
}
}
}
+2
View File
@@ -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;
+2 -2
View File
@@ -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)**:面板 α 只作用到允许半透明的层。
@@ -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<RefCell<Vec<AppCommand>>>,
queued: Vec<AppEvent>,
}
impl Backend for FailingSaveBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
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<InstanceSummary> {
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<RefCell<Vec<AppCommand>>>) {
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, "草稿还在,脏标记也不能掉");
}
+1
View File
@@ -48,6 +48,7 @@ mod boot_env;
mod clock;
mod content;
mod download;
mod instances;
mod page_messages;
mod settings;
+173
View File
@@ -0,0 +1,173 @@
//! 实例页「启动参数」区的消息落点(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<Message> {
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 => {
// **不在这里清脏**:`dispatch` 只是把命令送出去,落盘成败要等回程事件。
// 失败时(内存 `4g` / 写盘失败)pump 会带**旧快照**重建视图,脏标记若已清掉,
// `LaunchForm::sync` 就会用旧值覆盖用户刚敲的草稿(M157 r3 复审 P2-2)。
// 清脏的唯一时机是「回程快照已经等于表单」——见 `LaunchForm::sync`。
let patch: LaunchArgsPatch = self.launch_form.patch();
self.dispatch(AppCommand::set_launch_args(&instance, patch));
}
}
// 纯本地编辑:不重建视图模型(页面直接从表单画),只要一帧重绘即可——iced 在
// 消息处理完之后本来就会画一帧,所以这里什么都不排。
Task::none()
}
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
fn selected_instance_id(&self) -> Option<String> {
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);
}
/// **保存只派发、不清脏**(M157 r3 复审 P2-2):落盘成败要等回程事件,
/// 派发前清掉脏标记会让失败后的重播覆盖用户草稿。
///
/// 抓得住:Save 分支里又写回 `dirty = false`(那条路径在失败时丢草稿)。
/// 抓不住:回程事件的真实时序——清脏判据在 `LaunchForm::sync` 的
/// `confirmed_save_clears_dirty_and_reseeds` / `failed_save_keeps_the_draft` 里。
#[test]
fn save_dispatch_keeps_the_dirty_flag() {
let mut app = app_with_form();
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_eq!(app.launch_form.memory, "4g", "派发不改表单内容");
}
}
@@ -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 模块文档的偏离登记)
+2
View File
@@ -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)
+12 -1
View File
@@ -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(),
},
}
}
+1
View File
@@ -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(),
}
}
@@ -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 {
+5 -1
View File
@@ -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<appearance::State>,
/// 实例页「启动参数」区的表单(M157):harness 没有 `App`,自己持有演示表单。
form: instances::LaunchForm,
window: Option<window::Id>,
/// 窗口原生句柄(`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)
}
}
+1
View File
@@ -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,
+12 -4
View File
@@ -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)),
},
};
+61 -1
View File
@@ -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: "--fullscreen".to_string(),
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)
}
+39 -5
View File
@@ -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;
@@ -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<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
@@ -43,15 +45,23 @@ 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)))
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(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>(
ui: &'a Ui,
metrics: FrameMetrics,
detail: &'a Detail,
form: &'a LaunchForm,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
@@ -74,6 +84,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,
@@ -83,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>>) -> 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()
}
/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。
@@ -0,0 +1,213 @@
//! 实例详情页的「启动参数」区(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, row};
use iced::{Element, Length};
use unlucky_design::color::Role;
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> {
// 段间距 X2(8dp):卡内 5 个字段 + 三态 + 动作行在 424 宽下已经很长,
// 用 X3 会让详情列更长一屏;行内间距仍是 X1(4dp),读起来不挤。
let mut body = column![frame_title(ui, texts.launch_title())]
.spacing(ui.space(Space::X2))
.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));
// 与相邻信息卡**同一套**卡壳(`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,
false,
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, &facts.min_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()
}
@@ -0,0 +1,291 @@
//! 实例页的**页面状态**(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<String>,
/// 实例级 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)
}
/// 重播种(见模块文档的三档)。
///
/// **脏草稿只在「落盘已确认」时被替换**:保存把命令派发出去之后,回程可能是一条错误事件
/// (内存写了 `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);
}
/// 表单当前内容是否与一份快照(= 盘上已持久化的值)完全一致。
///
/// **内存按数值比**:host 会把 `"04096"` / `"+4096"` 归一成 `4096` 落盘,回程快照给的是
/// `Some(4096)`——按文本比会永远不等,于是表单一直脏、也不再跟随外部改动(r4 复审 P3-b)。
/// 解析失败(用户正在敲半截)才退回文本口径。
/// 其余字段直接比原文(JVM/游戏参数由 core 的 `tokenize` 消费,不做归一)。
#[must_use]
pub fn matches(&self, view: &LaunchArgsView) -> bool {
self.jvm.trim() == view.jvm_args.trim()
&& self.game.trim() == view.game_args.trim()
&& self.memory_matches(view.memory_mb)
&& self.java.trim() == view.java_path.trim()
&& self.backend == view.graphics_backend
}
/// 内存字段是否等于快照里的值(数值优先,解析失败退文本)。
fn memory_matches(&self, persisted: Option<u32>) -> bool {
let typed = self.memory.trim();
match (typed.parse::<u32>(), persisted) {
(Ok(typed), Some(persisted)) => typed == persisted,
// 空输入 = 跟随全局:快照那边也应当是「没有实例覆盖」。
(Err(_), None) => typed.is_empty(),
_ => false,
}
}
/// 提交补丁:**五项全给**(表单是整份可见的,不用增量语义会让「清空」无法表达)。
#[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<u32>, 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"));
}
/// **保存失败不许吃掉草稿**(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");
}
/// **内存按数值比**(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() {
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"));
}
}
@@ -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,33 @@ 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; 19] = [
"{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 r4:门槛文案的占位符名(版本号由 DTO 给,目录里不写死)。
"min",
// 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",
"ULL_LAUNCH_ARGS_FIXTURE",
"gated",
];
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
@@ -248,9 +272,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*` 都必须被页面(或产品快照转换)真的引用。
@@ -85,6 +85,72 @@ 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)
}
/// 「需游戏版本 {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 {
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)
}
+2
View File
@@ -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`,这里只加一支入口。
+23 -6
View File
@@ -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]
+24
View File
@@ -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<String>,
}
/// 实例页要画的东西。
+4 -2
View File
@@ -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);
}
}
+21 -1
View File
@@ -14,6 +14,9 @@ pub const fn general() -> &'static str {
命令:
instances list 列出实例与已安装版本
instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P]
[--backend default|opengl|vulkan] [--clear]
读/写实例级启动参数(不带选项 = 只读;--clear = 全部回跟随全局)
install <version> [--loader X] [--loader-version V] [--name N] [--dry-run]
下载并安装版本本体(--loader fabric|quilt|neoforge|forge
时再叠一层加载器)→ 登记实例;--dry-run 只出计划
@@ -69,7 +72,24 @@ pub fn topic(name: &str) -> Option<&'static str> {
version id 里的加载器版本段;原版、以及 id 里没写版本的(1.12.2-LiteLoader)为空串
注意:kind 是「认得出」的口径,不是「装得了」的口径 —— optifine / liteloader 只能由别的
启动器装好后继承进来,install --loader 不接受这两个值;kind=vanilla 只表示 id 里没有加载器
痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。",
痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。
%prog% instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P] \
[--backend default|opengl|vulkan] [--clear]
读/写实例配置页「启动参数」区(M157)。不带取写选项时只读:
--jvm <text> 实例级 JVM 参数(空白分隔、支持引号);与全局 java.extra_args **叠加**,
实例这层在最高优先级段(同名选项实例赢,`-Xmx` 也由它赢过内存字段)
--game <text> 实例级游戏参数(追加在命令行最后,可覆盖前面一切游戏参数)
--memory <MB> 实例级最大堆(256~65536);留空/不写 = 跟随全局 java.default_memory_mb
--java <path> 实例级 Java 可执行文件;留空 = 跟随全局 java.selected_path 再退到自动挑
--backend <id> default(不下发)/ opengl / vulkan;只对 MC 26.2-snapshot-2+ 下发,
老版本会被版本门槛拦下(启动时给诊断告警,不静默)
--clear 先把五项清空再应用其它选项(= 全部回「跟随全局」)
每一项都是「实例 → 全局 → 默认」的独立回落,只填一项不会顶掉别的项。
值以 `--` 开头时(多数游戏参数都是)写成 `--game=--fullscreen`:单独一个 `--fullscreen`
会被当成下一个选项(缺少取值),这是全项目 CLI 的既有解析口径。
--json 输出 {\"data\":{\"launch\":{…实例层…},\"effective\":{…回落后的生效值…}, …}}",
),
"pack" => Some(
"\
+2 -1
View File
@@ -13,7 +13,8 @@ pub mod program;
pub use model::{
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs, PackImportArgs,
DiagArgs, Globals, InstallArgs, InstanceLaunchArgs, Invocation, LaunchArgs, LogsArgs,
PackImportArgs,
};
pub use parse::parse;
+34
View File
@@ -26,6 +26,8 @@ pub enum Command {
Help(Option<String>),
/// `instances list`。
InstancesList,
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
InstancesLaunchArgs(InstanceLaunchArgs),
/// `install <version> [--loader X] [--name N]`。
Install(InstallArgs),
/// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。
@@ -185,6 +187,38 @@ pub struct DiagArgs {
pub tail: usize,
}
/// `instances launch-args` 的参数(M157:实例级启动参数)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InstanceLaunchArgs {
/// 实例 id 或实例名。
pub instance: String,
/// 实例级 JVM 参数(空白分隔,支持引号)。
pub jvm_args: Option<String>,
/// 实例级游戏参数。
pub game_args: Option<String>,
/// 实例级最大堆(MiB 文本;空串由 `--memory ""` 表达「跟随全局」)。
pub memory_mb: Option<String>,
/// 实例级 Java 可执行文件。
pub java: Option<String>,
/// 图形后端(`default` / `opengl` / `vulkan`)。
pub backend: Option<String>,
/// 先清空五项再应用给出的项(`--clear`;不带其它选项时等于「全部跟随全局」)。
pub clear: bool,
}
impl InstanceLaunchArgs {
/// 这次调用是不是纯读取(没有任何写入口)。
#[must_use]
pub fn is_read_only(&self) -> bool {
!self.clear
&& self.jvm_args.is_none()
&& self.game_args.is_none()
&& self.memory_mb.is_none()
&& self.java.is_none()
&& self.backend.is_none()
}
}
/// `config get` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigGetArgs {
+2 -15
View File
@@ -9,6 +9,7 @@ use crate::error::CliError;
mod accounts;
mod agent;
mod instances;
use super::model::{
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
@@ -81,7 +82,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
match name.as_str() {
"version" => Ok(Command::Version),
"help" => Ok(Command::Help(tail.first().cloned())),
"instances" => parse_instances(tail),
"instances" => instances::parse_instances(tail),
"install" => parse_install(tail, globals),
"pack" => parse_pack(tail),
"launch" => parse_launch(tail),
@@ -99,20 +100,6 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
}
}
fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
let args = match tail.first().map(String::as_str) {
None | Some("list") => tail.get(1..).unwrap_or_default(),
Some(other) => {
return Err(CliError::usage(format!(
"instances 的子命令只有 list,收到 `{other}`"
)));
}
};
let parsed = parse_flags(args, &[], &[])?;
ensure_no_positionals(&parsed, "instances list")?;
Ok(Command::InstancesList)
}
fn parse_install(tail: &[String], globals: &Globals) -> Result<Command, CliError> {
let parsed = parse_flags(tail, &["loader", "loader-version", "name"], &["dry-run"])?;
let version = require_single_positional(&parsed, "install <version>")?;
+45
View File
@@ -0,0 +1,45 @@
//! `instances` 的两个子命令解析(`list` / `launch-args`)。
//!
//! 从 `parse.rs` 拆出来只为守 400 行硬线(M157 的 `launch-args` 让那边到 415 行);
//! 纯搬移,语义未变。
use super::super::model::InstanceLaunchArgs;
use super::*;
pub(super) fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
let args = match tail.first().map(String::as_str) {
None | Some("list") => tail.get(1..).unwrap_or_default(),
Some("launch-args") => {
return parse_instances_launch_args(tail.get(1..).unwrap_or_default())
}
Some(other) => {
return Err(CliError::usage(format!(
"instances 的子命令只有 list / launch-args,收到 `{other}`"
)));
}
};
let parsed = parse_flags(args, &[], &[])?;
ensure_no_positionals(&parsed, "instances list")?;
Ok(Command::InstancesList)
}
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
///
/// 五个取值选项都接受空串(`--jvm ""`)= 该清空,与 `--clear` 的「先清后改」互补。
fn parse_instances_launch_args(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(
tail,
&["jvm", "game", "memory", "java", "backend"],
&["clear"],
)?;
let instance = require_single_positional(&parsed, "instances launch-args <instance>")?;
Ok(Command::InstancesLaunchArgs(InstanceLaunchArgs {
instance,
jvm_args: parsed.value("jvm"),
game_args: parsed.value("game"),
memory_mb: parsed.value("memory"),
java: parsed.value("java"),
backend: parsed.value("backend"),
clear: parsed.has("clear"),
}))
}
+269
View File
@@ -0,0 +1,269 @@
//! `instances launch-args <instance>`(M157):实例级启动参数的读/写。
//!
//! 五项的**含义与回落规则**都在 core([`unlucky_core::game::launch_overrides`]),本命令只做
//! 三件事:找实例、把命令行选项拼成 [`LaunchOverridesPatch`]、把生效值印出来。
//!
//! ```text
//! ull instances launch-args 生存 # 读:实例层 + 生效值 + 后端门槛
//! ull instances launch-args 生存 --jvm "-XX:+UseG1GC" --backend vulkan
//! ull instances launch-args 生存 --memory 8192 --game "--fullscreen"
//! ull instances launch-args 生存 --clear # 五项全部回「跟随全局」
//! ```
//!
//! 判定「版本够不够 Vulkan」用 core 的组合判据
//! [`unlucky_core::launch::overrides::backend_allowed`](继承解析后的库表 + 版本门槛),
//! 与 GUI 灰化、拼装链的告警是同一个函数——CLI 不另写一条。
//!
//! 全局层(`java.extra_args` / `java.default_memory_mb` / `java.selected_path`)在这里读一次
//! 设置文档填 [`GlobalLaunchDefaults`];presenter 的 host 侧有一份同形的读法(两个 crate 各自
//! 依赖 core,没法共享那三行 IO),字段口径以 core 的回落表为准。
use serde_json::json;
use unlucky_core::game::install::instance::{
read_instance_config, write_instance_config, InstanceConfig,
};
use unlucky_core::game::launch_overrides::{
resolve, GlobalLaunchDefaults, GraphicsBackend, LaunchOverridesPatch, ResolvedLaunch,
};
use unlucky_core::game::{self, VersionJson};
use unlucky_core::launch::game_args::GRAPHICS_BACKEND_MIN_VERSION;
use unlucky_core::launch::game_version::GameVersion;
use unlucky_core::launch::overrides::backend_allowed;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::InstanceLaunchArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
use super::instances::read_profiles;
/// `ull instances launch-args`。
///
/// # Errors
///
/// 实例不存在、值非法(内存越界 / 未知后端)、实例文件读写失败时返回用户错误。
pub fn run(ctx: &Ctx, args: &InstanceLaunchArgs) -> Result<Report, CliError> {
let profiles = read_profiles(ctx)?;
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == args.instance || profile.name == args.instance)
.cloned()
.ok_or_else(|| {
CliError::user("not_found", format!("找不到实例 `{}`", args.instance))
.with_hint("用 `%prog% instances list` 看已登记的实例")
})?;
let id = profile.id.as_str().to_string();
let layout = ctx.layout();
let mut config = read_instance_config(&layout, &id)
.map_err(|error| CliError::user("instance_config", format!("读实例设置失败:{error}")))?
.unwrap_or_else(|| InstanceConfig::new(&id, &profile.version_id));
let mut updated = false;
if !args.is_read_only() {
config.launch = config
.launch
.patched(&patch_from(args))
.map_err(CliError::usage)?;
write_instance_config(&layout, &config).map_err(|error| {
CliError::user("instance_config", format!("写实例设置失败:{error}"))
})?;
updated = true;
}
let effective = resolve(Some(&config.launch), &global_defaults(ctx));
let (allowed, mc_version) = backend_gate(ctx, &profile.version_id);
let layer = &config.launch;
let data = json!({
"instance_id": id,
"name": profile.name,
"version_id": profile.version_id,
"updated": updated,
"launch": {
"jvm_args": layer.jvm_args.clone().unwrap_or_default(),
"game_args": layer.game_args.clone().unwrap_or_default(),
"memory_mb": layer.memory_mb,
"java_path": layer.java_path.as_ref().map(|path| path.display().to_string()),
"graphics_backend": layer.graphics_backend.unwrap_or_default().as_str(),
},
"effective": {
"jvm_args": effective.instance_jvm_args.clone().unwrap_or_default(),
"global_jvm_args": effective.global_jvm_args.clone().unwrap_or_default(),
"game_args": effective.game_args.clone().unwrap_or_default(),
"memory_mb": effective.memory_mb,
"java_path": effective.java_path.as_ref().map(|path| path.display().to_string()),
"graphics_backend": effective.graphics_backend.as_str(),
"graphics_backend_allowed": allowed,
"graphics_backend_min_version": GRAPHICS_BACKEND_MIN_VERSION,
"mc_version": mc_version,
},
"graphics_backend_options": GraphicsBackend::IDS,
});
let mut human = vec![format!(
"{}({} · {}){}",
profile.name,
profile.version_id,
id,
if updated { " ← 已保存" } else { "" }
)];
human.push(format!(
" JVM 参数(实例): {}",
layer
.jvm_args
.clone()
.unwrap_or_else(|| "(跟随全局)".to_string())
));
human.push(format!(
" 游戏参数(实例): {}",
layer
.game_args
.clone()
.unwrap_or_else(|| "(无)".to_string())
));
human.push(format!(
" 最大内存: {} MiB{}",
effective.memory_mb,
match layer.memory_mb {
Some(_) => "(实例覆盖)",
None => "(跟随全局 / 出厂默认)",
}
));
human.push(format!(
" Java: {}",
effective
.java_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "(自动挑:托管 > JAVA_HOME > PATH)".to_string())
));
human.push(format!(
" 图形后端: {}{}",
effective.graphics_backend.as_str(),
if effective.graphics_backend.is_default() {
"(不下发 --graphicsBackend)".to_string()
} else if allowed {
format!("({} 可下发)", mc_version)
} else {
format!("(版本 {mc_version} 不支持,不会下发;需 MC {GRAPHICS_BACKEND_MIN_VERSION}+)")
}
));
Ok(Report::new(data, human))
}
/// 命令行选项 → core 的文本补丁(`--clear` 先清五项,随后给出的项覆盖)。
fn patch_from(args: &InstanceLaunchArgs) -> LaunchOverridesPatch {
let mut patch = if args.clear {
LaunchOverridesPatch::clear_all()
} else {
LaunchOverridesPatch::default()
};
if let Some(value) = &args.jvm_args {
patch.jvm_args = Some(value.clone());
}
if let Some(value) = &args.game_args {
patch.game_args = Some(value.clone());
}
if let Some(value) = &args.memory_mb {
patch.memory_mb = Some(value.clone());
}
if let Some(value) = &args.java {
patch.java_path = Some(value.clone());
}
if let Some(value) = &args.backend {
patch.graphics_backend = Some(value.clone());
}
patch
}
/// 全局层(三个设置键)。
pub(crate) fn global_defaults(ctx: &Ctx) -> GlobalLaunchDefaults {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
let text = |key: &str| -> Option<String> {
store
.doc()
.effective_str(key)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
GlobalLaunchDefaults {
jvm_args: text("java.extra_args"),
game_args: None,
memory_mb: store
.effective("java.default_memory_mb")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok()),
java_path: text("java.selected_path").map(std::path::PathBuf::from),
graphics_backend: None,
}
}
/// 三级回落后的生效值(启动装配用;`instance_id` 为空 = 没有实例档案,只用全局层)。
///
/// 与 presenter 的 `host::launch_args::resolved` 同义:那边给 GUI 用,这边给 `ull launch` 用,
/// 规则都在 core 的 [`resolve`],两处只剩「读哪几份文件」的差别。
pub(crate) fn resolved(ctx: &Ctx, instance_id: &str) -> ResolvedLaunch {
let overrides = if instance_id.is_empty() {
None
} else {
read_instance_config(&ctx.layout(), instance_id)
.ok()
.flatten()
.map(|config| config.launch)
};
resolve(overrides.as_ref(), &global_defaults(ctx))
}
/// 版本能不能用 `--graphicsBackend`,以及判定依据的游戏版本(继承链根)。
fn backend_gate(ctx: &Ctx, version_id: &str) -> (bool, String) {
let game_layout = ctx.game();
let Ok(text) = std::fs::read_to_string(game_layout.version_json(version_id)) else {
return (false, version_id.to_string());
};
let Ok(manifest) = VersionJson::from_json_str(&text) else {
return (false, version_id.to_string());
};
let mc_version = root_version_id(&game_layout, &manifest);
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(game_layout.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
let resolved = game::resolve(&source, &manifest).unwrap_or(manifest);
let allowed = backend_allowed(&GameVersion::parse(&mc_version), &resolved);
(allowed, mc_version)
}
/// 继承链根(原版层)的 id——能力位与版本门限都以它为准(loader 层的 id 带加载器后缀)。
///
/// `pub(crate)`:`snapshot::prepare` 的装配(`SnapshotInput.game_version`)也要这一份口径,
/// 门槛判定(本模块的 `backend_gate`)与拼装必须用**同一个**根版本,否则会出现
/// 「UI 说可用、命令行里没有这个参数」的静默偏差。
pub(crate) fn root_version_id(
layout: &unlucky_core::game::GameLayout,
manifest: &VersionJson,
) -> String {
let mut current = manifest.id.clone().unwrap_or_default();
let mut parent = manifest.inherits_from.clone().filter(|id| !id.is_empty());
for _ in 0..16 {
let Some(next) = parent else { break };
let Ok(text) = std::fs::read_to_string(layout.version_json(&next)) else {
return next;
};
let Ok(json) = VersionJson::from_json_str(&text) else {
return next;
};
current = json.id.clone().unwrap_or(next);
parent = json.inherits_from.clone().filter(|id| !id.is_empty());
}
current
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,212 @@
//! [`super`] 的单测:走真入口 [`crate::run`],断言用户/脚本拿到的那份输出。
//!
//! 口径与 `instances/tests.rs` 一致:临时 `--data-dir` + 真 `profiles.json`,只看 stdout 的
//! JSON 信封(`--json`)与退出码。回落规则的真值表在 core 的
//! `game::launch_overrides` / `launch::overrides` 测试里,这里只验 CLI 有没有如实搬运。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use unlucky_core::platform::layout::{LauncherLayout, LAUNCHER_HOME_DIR};
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-instance-launch-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn arg(&self) -> String {
self.0.display().to_string()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 写 `.ULL/config/profiles.json` 并返回第一个实例的 id。
fn write_profiles(data_root: &Path, name: &str, version_id: &str) -> String {
let dir = data_root.join(LAUNCHER_HOME_DIR).join("config");
std::fs::create_dir_all(&dir).expect("建配置目录");
let id = "11111111-2222-4333-8444-555555555555".to_string();
let document = json!({
"schema_version": 1,
"profiles": [{ "id": id, "name": name, "version_id": version_id }],
});
std::fs::write(
dir.join("profiles.json"),
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
)
.expect("写 profiles.json");
id
}
/// 写 `<data>/.minecraft/versions/<id>/<id>.json`(库表可带 vulkan)。
fn write_version(data_root: &Path, id: &str, vulkan: bool) {
let path = data_root
.join(".minecraft")
.join("versions")
.join(id)
.join(format!("{id}.json"));
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
let libs = if vulkan {
r#"[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]"#
} else {
"[]"
};
std::fs::write(&path, format!(r#"{{"id":"{id}","libraries":{libs}}}"#)).expect("写版本 JSON");
}
/// 跑一次 CLI,返回(退出码, stdout)。
fn run_cli(args: &[&str]) -> (i32, String) {
let owned: Vec<String> = args.iter().map(|arg| (*arg).to_string()).collect();
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
let code = crate::run(&owned, &mut out, &mut err);
(code, String::from_utf8_lossy(&out).into_owned())
}
/// 跑 `instances launch-args … --json`,返回 `data` 载荷。
fn launch_args(dir: &TempDir, rest: &[&str]) -> Value {
let root = dir.arg();
let mut args = vec!["instances", "launch-args", "--json", "--data-dir", &root];
args.extend_from_slice(rest);
let (code, stdout) = run_cli(&args);
assert_eq!(code, 0, "{stdout}");
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["ok"], true, "{stdout}");
value["data"].clone()
}
#[test]
fn read_reports_instance_layer_and_effective_values() {
let dir = TempDir::new("read");
let id = write_profiles(dir.path(), "生存", "1.20.4");
let data = launch_args(&dir, &[&id]);
assert_eq!(data["instance_id"], id.as_str());
assert_eq!(data["updated"], false);
assert_eq!(data["launch"]["graphics_backend"], "default");
// 没配内存 ⇒ 生效值是出厂默认 2048(三级回落的最后一级)。
assert_eq!(data["effective"]["memory_mb"], 2048);
assert_eq!(data["effective"]["graphics_backend_allowed"], false);
assert_eq!(
data["graphics_backend_options"],
json!(["default", "opengl", "vulkan"])
);
}
#[test]
fn write_from_flags_then_read_back() {
let dir = TempDir::new("write");
let id = write_profiles(dir.path(), "生存", "26.2-snapshot-2");
write_version(dir.path(), "26.2-snapshot-2", true);
let data = launch_args(
&dir,
&[
&id,
"--jvm=-XX:+UseG1GC",
// 值以 `--` 开头时必须用 `--key=value` 形式(`--game --fullscreen` 会被解析器
// 当成「缺少取值」——那是全项目 CLI 的既有口径,见 help 里的提示)。
"--game=--fullscreen",
"--memory",
"6144",
"--backend",
"vulkan",
],
);
assert_eq!(data["updated"], true);
assert_eq!(data["launch"]["jvm_args"], "-XX:+UseG1GC");
assert_eq!(data["launch"]["memory_mb"], 6144);
assert_eq!(data["launch"]["graphics_backend"], "vulkan");
// 26.2-snapshot-2 过了门槛(判定来自 core 的能力位 + 版本门限)。
assert_eq!(data["effective"]["graphics_backend_allowed"], true);
// 再读一次:落盘之后依然在(`--json` 是事实上的对外契约)。
let again = launch_args(&dir, &[&id]);
assert_eq!(again["updated"], false);
assert_eq!(again["launch"]["jvm_args"], "-XX:+UseG1GC");
assert_eq!(again["launch"]["game_args"], "--fullscreen");
}
#[test]
fn clear_restores_follow_the_global() {
let dir = TempDir::new("clear");
let id = write_profiles(dir.path(), "生存", "1.20.4");
launch_args(&dir, &[&id, "--memory", "4096", "--backend", "opengl"]);
let cleared = launch_args(&dir, &[&id, "--clear"]);
assert_eq!(cleared["launch"]["memory_mb"], Value::Null);
assert_eq!(cleared["launch"]["graphics_backend"], "default");
assert_eq!(cleared["effective"]["memory_mb"], 2048);
}
#[test]
fn invalid_values_fail_with_a_usage_error() {
let dir = TempDir::new("invalid");
let id = write_profiles(dir.path(), "生存", "1.20.4");
let root = dir.arg();
for (flag, value) in [
("--backend", "vulkan2"),
("--memory", "4g"),
("--memory", "1"),
] {
let (code, stdout) = run_cli(&[
"instances",
"launch-args",
"--json",
"--data-dir",
&root,
&id,
flag,
value,
]);
assert_ne!(code, 0, "{flag} {value} 应当失败:{stdout}");
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["error"]["code"], "usage", "{stdout}");
}
// 失败的写入不许留下半截设置文件。
let layout = LauncherLayout::new(dir.path().join(LAUNCHER_HOME_DIR));
assert!(
unlucky_core::game::install::instance::read_instance_config(&layout, &id)
.expect("读")
.is_none(),
"非法补丁不该落盘"
);
}
#[test]
fn unknown_instance_is_a_user_error() {
let dir = TempDir::new("missing");
let root = dir.arg();
let (code, stdout) = run_cli(&[
"instances",
"launch-args",
"--json",
"--data-dir",
&root,
"nope",
]);
assert_ne!(code, 0);
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["error"]["code"], "not_found", "{stdout}");
}
+4
View File
@@ -5,6 +5,7 @@ pub mod agent;
pub mod config;
pub mod identity;
pub mod install;
pub mod instance_launch;
pub mod instances;
pub mod java;
pub mod java_launch;
@@ -32,6 +33,9 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
Command::Version => Ok(Outcome::new(meta::version())),
Command::Help(topic) => Ok(Outcome::new(meta::help(topic.as_deref()))),
Command::InstancesList => with_ctx(&invocation.globals, instances::list),
Command::InstancesLaunchArgs(args) => {
with_ctx(&invocation.globals, |ctx| instance_launch::run(ctx, args))
}
Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)),
Command::PackImport(args) => {
with_ctx_outcome(&invocation.globals, |ctx| pack::run(ctx, args))
+109 -3
View File
@@ -131,11 +131,14 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
.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,
@@ -151,11 +154,21 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
game_assets: assets_root,
};
// 实例级启动参数的三级回落(实例 → 全局 → 默认)。临时启动参数(`--max-memory` /
// `--java`)在 D20 的覆盖链里位于实例之上,所以这里先把它们叠上去再落位。
let mut effective = super::instance_launch::resolved(ctx, &input.instance_id);
if let Some(mb) = input.max_memory {
effective.memory_mb = mb;
}
if let Some(path) = &input.java {
effective.java_path = Some(path.clone());
}
let options = AdapterOptions {
java: super::java::selection(input.java.as_deref()),
java: super::java::selection(effective.java_path.as_deref()),
memory: MemoryOptions {
min_mb: None,
max_mb: input.max_memory,
max_mb: Some(effective.memory_mb),
},
auth: input.auth.clone().unwrap_or_else(|| AuthPlaceholders {
player_name: input
@@ -181,6 +194,8 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
request.instance.id = input.instance_id.clone();
}
request.instance.name = input.instance_name.clone();
// 实例级启动参数(三级回落后的生效值)最后落位:user.* / memory / graphics_backend。
unlucky_core::launch::overrides::apply(&mut request, &effective);
Ok(Plan {
version_id,
@@ -223,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 模式 → `<root>/.ULL` + `<root>/.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);
}
}
+8
View File
@@ -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<OverrideKey>,
/// 实例级启动参数覆盖(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(),
}
+360
View File
@@ -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<Self> {
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/<id>.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<String>,
/// 实例级游戏参数(同上);空 = 不追加。
#[serde(skip_serializing_if = "Option::is_none")]
pub game_args: Option<String>,
/// 最大堆(MiB);空或越界 = 跟随全局 `java.default_memory_mb`。
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_mb: Option<u32>,
/// 实例级 Java 可执行文件;空 = 跟随全局 `java.selected_path` 再退到自动挑。
#[serde(skip_serializing_if = "Option::is_none")]
pub java_path: Option<PathBuf>,
/// 图形后端;空 = `default`(不下发 `--graphicsBackend`)。
///
/// 反序列化对未知值**报错**(`unknown variant`),不静默落成 `default`。
#[serde(skip_serializing_if = "Option::is_none")]
pub graphics_backend: Option<GraphicsBackend>,
}
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<u32> {
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<String>,
/// 全局游戏参数(本轮没有对应设置键,留 `None` 占位,回落链照样逐项成立)。
pub game_args: Option<String>,
/// `java.default_memory_mb`。
pub memory_mb: Option<u32>,
/// `java.selected_path`。
pub java_path: Option<PathBuf>,
/// 全局图形后端(本轮没有对应设置键)。
pub graphics_backend: Option<GraphicsBackend>,
}
impl GlobalLaunchDefaults {
/// 只带 `java.extra_args` 的最小构造(测试/CLI 常用)。
#[must_use]
pub fn with_jvm_args(text: impl Into<String>) -> 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<String>,
/// 实例层 JVM 参数(组装表第 4 项,最高优先级,`None` = 没有)。
pub instance_jvm_args: Option<String>,
/// 游戏参数(第 32 项,最后追加)。
pub game_args: Option<String>,
/// 生效最大堆(三级回落 + 范围校验 + 出厂默认,**恒有值**)。
pub memory_mb: u32,
/// 生效 Java 可执行文件;`None` = 调用方走自动挑。
pub java_path: Option<PathBuf>,
/// 生效图形后端(`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<String>,
/// 实例层游戏参数原文。
pub game_args: Option<String>,
/// 实例层最大堆(文本形态,见 [`MIN_MAX_MEMORY_MB`] / [`MAX_MAX_MEMORY_MB`])。
pub memory_mb: Option<String>,
/// 实例层 Java 可执行文件。
pub java_path: Option<String>,
/// 实例层图形后端(`default` / `opengl` / `vulkan`)。
pub graphics_backend: Option<String>,
}
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<Self, String> {
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<u32>`(空 = 清空;非整数/越界 = 错)。
fn parse_memory(text: &str) -> Result<Option<u32>, 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;
@@ -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::<LaunchOverrides>(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"}"#
);
}
+6
View File
@@ -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};
+1
View File
@@ -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;
+74
View File
@@ -0,0 +1,74 @@
//! 实例级启动参数的**落位**:`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<String> {
text.map(tokenize).unwrap_or_default()
}
#[cfg(test)]
mod tests;
+356
View File
@@ -0,0 +1,356 @@
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<String> {
assemble(req, &ctx()).expect("组装应成功").args
}
fn position(args: &[String], needle: &str) -> Option<usize> {
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 {
// `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, "跳过与否必须与下发相反(不静默)");
}
}
+96
View File
@@ -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());
}
}
+32 -356
View File
@@ -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=<id>, or set that environment variable before launching.", "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=<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=<id> before starting the launcher, or build with ULL_MSA_CLIENT_ID=<id>. Then restart the launcher: a running process does not pick up a new environment variable.", "本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=<id>,或构建时注入:ULL_MSA_CLIENT_ID=<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 {min}+ (this instance: {version}). The flag is not sent on this version.", "需游戏版本 {min} 及以上(本实例 {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<Item = &'static (&'static str, &'static str, &'static str)> {
GOLDEN.iter().chain(tail::ROWS.iter())
}
/// 金表总行数(`Key::ALL` 的条数)。
pub(super) fn len() -> usize {
GOLDEN.len() + tail::ROWS.len()
}
mod tail;
+364
View File
@@ -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=<id>, or set that environment variable before launching.", "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=<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=<id> before starting the launcher, or build with ULL_MSA_CLIENT_ID=<id>. Then restart the launcher: a running process does not pick up a new environment variable.", "本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=<id>,或构建时注入:ULL_MSA_CLIENT_ID=<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.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
];
+18
View File
@@ -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 {min}+ (this instance: {version}). The flag is not sent on this version.", "需游戏版本 {min} 及以上(本实例 {version}),这个版本不会下发该参数。"),
InstancesLaunchEffective => ("Effective: {value}", "生效:{value}"),
InstancesBackendDefault => ("Default", "默认"),
InstancesBackendOpengl => ("OpenGL", "OpenGL 后端"),
InstancesBackendVulkan => ("Vulkan", "Vulkan 后端"),
InstancesLaunchSave => ("Save", "保存"),
InstancesLaunchReset => ("Reset to global", "恢复跟随全局"),
} }
};
}
+2 -2
View File
@@ -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,
+18
View File
@@ -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`] 验证通道语义。
+5
View File
@@ -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("反序列化");
+136
View File
@@ -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<u32>,
/// 生效最大堆(三级回落 + 出厂默认,恒有值)。
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<String>,
}
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<String>,
/// 实例层游戏参数(文本)。
pub game_args: Option<String>,
/// 实例层最大堆(文本形态,host 解析;空 = 跟随全局)。
pub memory_mb: Option<String>,
/// 实例层 Java 可执行文件。
pub java_path: Option<String>,
/// 实例层图形后端(`default` / `opengl` / `vulkan`)。
pub graphics_backend: Option<String>,
}
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"));
}
}
+3
View File
@@ -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
+9
View File
@@ -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)));
}
+6 -196
View File
@@ -126,16 +126,18 @@ pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
.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`):`<mc>-<kind>-<loader>`
("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/<id>/<id>.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;
+189
View File
@@ -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`):`<mc>-<kind>-<loader>`
("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/<id>/<id>.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();
}
+35 -11
View File
@@ -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) => {
@@ -181,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,
@@ -201,18 +224,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 +246,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))
}
@@ -356,3 +377,6 @@ fn describe_warning(warning: &AdapterWarning) -> String {
pub fn isolation_label(mode: IsolationMode) -> &'static str {
mode.as_str()
}
#[cfg(test)]
mod tests;
+96
View File
@@ -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),门限只能靠库表兜底"
);
}
+356
View File
@@ -0,0 +1,356 @@
//! 实例启动参数:读(三级回落 + 版本门槛)与写(`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<String> {
store
.doc()
.effective_str(key)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
GlobalLaunchDefaults {
jvm_args: text("java.extra_args"),
game_args: None,
memory_mb: store
.effective("java.default_memory_mb")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok()),
java_path: text("java.selected_path").map(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<VersionJson> {
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<VersionJson> {
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/<id>.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};
use std::sync::atomic::{AtomicU64, Ordering};
pub struct Dir {
pub path: PathBuf,
}
impl Dir {
pub fn new(tag: &str) -> Self {
// 每个用例一个目录:同一进程里跑多个用例时,只用 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 }
}
}
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", &not_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);
}
}
+1
View File
@@ -26,6 +26,7 @@ mod install_processors;
mod java;
mod job;
mod launch;
mod launch_args;
mod loader_versions;
mod logs;
mod meter;
+29 -2
View File
@@ -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<InstanceSummary> {
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<InstanceSummary> {
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<InstanceSummary> {
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<InstanceSummary> {
isolation: "isolated".to_string(),
version_id: "1.21.1-neoforge-21.1.72".to_string(),
version_present: true,
launch: mock_launch(),
},
]
}
@@ -206,6 +210,29 @@ pub fn mock_versions_for(category: VersionCategory) -> Vec<VersionSummary> {
.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};
+1
View File
@@ -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(),
}
}