feat(launch): M157 实例级启动参数(五项回落 + graphicsBackend 三态)
core: 新增 game/launch_overrides(LaunchOverrides 五项 All-Option + 三级回落 + 文本补丁校验)与 launch/overrides(拼装落位:全局 java.extra_args 进 user.jvm、实例 jvmArgs 进 override_jvm、gameArgs 进 user.game、memory 进 -Xmx、backend 进 --graphicsBackend 并复用既有版本门槛告警);实例 schema 登记 launch.* 五键。 presenter: dto/LaunchArgsView+LaunchArgsPatch,主机侧读/写与门槛判定(backend_allowed),AppCommand::SetInstanceLaunchArgs。 cli: 新命令 instances launch-args(读/写/--clear),launch 装配链消费回落值。 app-iced: 实例详情页「启动参数」区(JVM/游戏参数、内存、Java、三态后端 chips + 灰化提示 + 保存/恢复),表单状态归 App、i18n 中英文案同步。
This commit is contained in:
1 parent
d68523b02a
commit
e2021645d7
56 files changed
+3776
-607
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)**:面板 α 只作用到允许半透明的层。
|
||||
|
||||
@@ -48,6 +48,7 @@ mod boot_env;
|
||||
mod clock;
|
||||
mod content;
|
||||
mod download;
|
||||
mod instances;
|
||||
mod page_messages;
|
||||
mod settings;
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! 实例页「启动参数」区的消息落点(M157)。
|
||||
//!
|
||||
//! 三件事:
|
||||
//! 1. **编辑**只改页面状态(`App::launch_form`)并置脏——不 dispatch,不打网络;
|
||||
//! 2. **保存**派一条 [`AppCommand::SetInstanceLaunchArgs`](整份五项补丁),回程由
|
||||
//! `InstancesChanged` 快照刷新页面(不做乐观回显,与 M90 的启动/停止同一条纪律);
|
||||
//! 3. **恢复跟随全局**只清表单(仍需用户点保存)——「点一下就写盘」会让误触直接生效。
|
||||
//!
|
||||
//! 脏标记的清理点在保存之后:`refresh_view` 的 `LaunchForm::sync` 看到「同实例 + 脏」不会覆盖,
|
||||
//! 所以保存后要显式清脏,让下一次快照(host 写完文件回来的那份)重播种。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch};
|
||||
|
||||
use crate::pages::InstanceMessage;
|
||||
|
||||
use super::super::message::Message;
|
||||
use super::super::state::App;
|
||||
|
||||
impl App {
|
||||
/// 启动参数区的一条消息。
|
||||
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<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 => {
|
||||
let patch: LaunchArgsPatch = self.launch_form.patch();
|
||||
self.launch_form.dirty = false;
|
||||
self.dispatch(AppCommand::set_launch_args(&instance, patch));
|
||||
}
|
||||
}
|
||||
// 纯本地编辑:不重建视图模型(页面直接从表单画),只要一帧重绘即可——iced 在
|
||||
// 消息处理完之后本来就会画一帧,所以这里什么都不排。
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
|
||||
fn selected_instance_id(&self) -> Option<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);
|
||||
}
|
||||
|
||||
/// 保存之后脏标记清掉(否则下一次快照刷新不敢重播种,界面会一直显示旧值)。
|
||||
#[test]
|
||||
fn save_clears_the_dirty_flag() {
|
||||
let mut app = app_with_form();
|
||||
let _ = app.on_instance_message(InstanceMessage::Java("D:/jdk21/bin/java.exe".to_string()));
|
||||
assert!(app.launch_form.dirty);
|
||||
let _ = app.on_instance_message(InstanceMessage::Save);
|
||||
assert!(!app.launch_form.dirty);
|
||||
}
|
||||
}
|
||||
@@ -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 模块文档的偏离登记)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
//! 两份数据源喂同一个渲染函数,这是 [`crate::pages::model`] 存在的理由。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::LaunchArgsView;
|
||||
|
||||
use super::super::model::{Detail, Instances, Meta, Row};
|
||||
use super::super::model::{Detail, Instances, LaunchFacts, Meta, Row};
|
||||
use super::data::{DEMO, NOTE, SELECTED};
|
||||
use super::state::LaunchForm;
|
||||
|
||||
/// 演示路径的目录:**恒英文**。
|
||||
///
|
||||
@@ -51,6 +53,7 @@ pub(crate) fn demo() -> Instances {
|
||||
status: None,
|
||||
can_launch: true,
|
||||
can_stop: false,
|
||||
launch: launch_facts(),
|
||||
};
|
||||
|
||||
Instances {
|
||||
@@ -65,3 +68,60 @@ fn meta(label: &str, value: &str) -> Meta {
|
||||
value: value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动参数区的演示事实(M157 的 D30 出图夹具)。
|
||||
///
|
||||
/// `ULL_LAUNCH_ARGS_FIXTURE=gated` 出「版本不够、三态灰化」那一屏;缺省(`open`)是
|
||||
/// 「26.2 实例 + Vulkan 可选」的正常态。**这是出图夹具,不是产品逻辑**:产品路径的门槛
|
||||
/// 来自 presenter 快照(core 的 `graphics_backend_allowed`),与这个环境变量无关。
|
||||
pub(crate) fn launch_facts() -> LaunchFacts {
|
||||
let gated = gated_fixture();
|
||||
LaunchFacts {
|
||||
allowed: !gated,
|
||||
mc_version: if gated { "1.20.4" } else { "26.2-snapshot-2" }.to_string(),
|
||||
min_version: "26.2-snapshot-2".to_string(),
|
||||
memory_effective_mb: if gated { 2048 } else { 4096 },
|
||||
java_effective: "C:/Java/temurin-21/bin/java.exe".to_string(),
|
||||
backend_effective: if gated { "default" } else { "vulkan" }.to_string(),
|
||||
options: ["default", "opengl", "vulkan"]
|
||||
.iter()
|
||||
.map(|id| (*id).to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 演示用的表单初值(页面状态的所有者是 `App`;harness 没有 `App`,自己持有一份)。
|
||||
///
|
||||
/// 返回**拥有**的表单而不是 `&'static`:`text_editor::Content` 内含 `RefCell`,不满足
|
||||
/// `Sync`,做不成 `static`;harness/测试各自持有即可(一帧借用一次)。
|
||||
pub(crate) fn demo_form() -> LaunchForm {
|
||||
LaunchForm::from_view(Some(DEMO[SELECTED].name), &demo_view())
|
||||
}
|
||||
|
||||
fn demo_view() -> LaunchArgsView {
|
||||
let gated = gated_fixture();
|
||||
LaunchArgsView {
|
||||
instance_id: DEMO[SELECTED].name.to_string(),
|
||||
jvm_args: "-XX:+UseG1GC".to_string(),
|
||||
game_args: String::new(),
|
||||
memory_mb: (!gated).then_some(4096),
|
||||
memory_effective_mb: if gated { 2048 } else { 4096 },
|
||||
java_path: String::new(),
|
||||
java_effective: "C:/Java/temurin-21/bin/java.exe".to_string(),
|
||||
graphics_backend: if gated { "default" } else { "vulkan" }.to_string(),
|
||||
graphics_backend_effective: if gated { "default" } else { "vulkan" }.to_string(),
|
||||
graphics_backend_allowed: !gated,
|
||||
mc_version: if gated { "1.20.4" } else { "26.2-snapshot-2" }.to_string(),
|
||||
graphics_backend_min_version: "26.2-snapshot-2".to_string(),
|
||||
graphics_backend_options: ["default", "opengl", "vulkan"]
|
||||
.iter()
|
||||
.map(|id| (*id).to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn gated_fixture() -> bool {
|
||||
std::env::var("ULL_LAUNCH_ARGS_FIXTURE")
|
||||
.map(|value| value.trim().eq_ignore_ascii_case("gated"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -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,6 +45,8 @@ pub(super) fn detail_column<'a>(
|
||||
.width(Length::Fill);
|
||||
|
||||
body = push_text(ui, metrics, body, detail);
|
||||
// M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
|
||||
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
|
||||
|
||||
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)
|
||||
frame::left_aligned(metrics.column, body.push(action_row(ui, forced, texts)))
|
||||
@@ -52,6 +56,7 @@ pub(super) fn narrow_detail<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
detail: &'a Detail,
|
||||
form: &'a LaunchForm,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
@@ -74,6 +79,7 @@ pub(super) fn narrow_detail<'a>(
|
||||
.width(Length::Fill);
|
||||
|
||||
body = push_text(ui, metrics, body, detail);
|
||||
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
|
||||
|
||||
body = body.push(frame::text_role(
|
||||
ui,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! 实例详情页的「启动参数」区(M157)。
|
||||
//!
|
||||
//! | 控件 | 语义 | 消息 |
|
||||
//! |---|---|---|
|
||||
//! | JVM 参数(多行) | 实例层,与全局 `java.extra_args` **叠加**(实例段最高优先级) | [`Message::Jvm`] |
|
||||
//! | 游戏参数(多行) | 实例层,追加在命令行最后 | [`Message::Game`] |
|
||||
//! | 最大内存(单行数字) | 实例层;留空 = 跟随全局默认 | [`Message::Memory`] |
|
||||
//! | Java 可执行文件(单行) | 实例层;留空 = 跟随全局 → 自动挑 | [`Message::Java`] |
|
||||
//! | 图形后端(三态 chips) | `default` / `opengl` / `vulkan`;**版本不够时整体灰化 + 提示** | [`Message::Backend`] |
|
||||
//!
|
||||
//! 两条判据刻意不在这里重算:
|
||||
//! 1. **能不能用 `--graphicsBackend`** 来自 [`LaunchFacts::allowed`](core 的 `graphics_backend_allowed`,
|
||||
//! presenter 的 host 已经算好)——UI 不复制版本号比较;
|
||||
//! 2. **生效值**(内存/Java/后端)同样来自 facts,表单里画的是实例层原文。
|
||||
//!
|
||||
//! 文案全走 [`Texts`](守门见 `instances/tests/i18n.rs` 的白名单与死 key 扫描)。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Background, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::inputs::{text_field, Field};
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::model::LaunchFacts;
|
||||
use super::super::Message as PageMessage;
|
||||
use super::state::{LaunchForm, Message};
|
||||
use super::Texts;
|
||||
|
||||
/// 页内消息 → 页面消息(本文件的控件树统一画 `pages::Message`)。
|
||||
fn page(message: Message) -> PageMessage {
|
||||
PageMessage::Instances(message)
|
||||
}
|
||||
|
||||
/// 「启动参数」区(在详情列的正文段之后、动作行之前)。
|
||||
pub(super) fn section<'a>(
|
||||
ui: &'a Ui,
|
||||
facts: &'a LaunchFacts,
|
||||
form: &'a LaunchForm,
|
||||
texts: Texts,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let mut body = column![frame_title(ui, texts.launch_title())]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill);
|
||||
|
||||
body = body.push(args_field(
|
||||
ui,
|
||||
texts.launch_jvm(),
|
||||
texts.launch_jvm_hint(),
|
||||
form.jvm.as_str(),
|
||||
|value| page(Message::Jvm(value)),
|
||||
));
|
||||
body = body.push(args_field(
|
||||
ui,
|
||||
texts.launch_game(),
|
||||
texts.launch_game_hint(),
|
||||
form.game.as_str(),
|
||||
|value| page(Message::Game(value)),
|
||||
));
|
||||
body = body.push(text_field_with_hint(
|
||||
ui,
|
||||
texts.launch_memory(),
|
||||
form.memory.as_str(),
|
||||
super::super::frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
texts.launch_memory_hint(facts.memory_effective_mb),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
|value| page(Message::Memory(value)),
|
||||
));
|
||||
body = body.push(text_field_with_hint(
|
||||
ui,
|
||||
texts.launch_java(),
|
||||
form.java.as_str(),
|
||||
super::super::frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
texts.launch_java_hint().to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
|value| page(Message::Java(value)),
|
||||
));
|
||||
body = body.push(backend_row(ui, facts, form, texts));
|
||||
body = body.push(actions_row(ui, texts));
|
||||
|
||||
container(body)
|
||||
.width(Length::Fill)
|
||||
.padding(ui.space(Space::X3))
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
|
||||
border: ui.outlined(ShapeScale::Medium),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn frame_title<'a>(ui: &'a Ui, title: &'a str) -> Element<'a, PageMessage> {
|
||||
super::super::frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
true,
|
||||
title,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/// 一段参数输入 + 一行提示(单行受控输入;参数按空白切分,与 core 的 `tokenize` 同口径)。
|
||||
fn args_field<'a>(
|
||||
ui: &'a Ui,
|
||||
label: &'a str,
|
||||
hint: &'a str,
|
||||
value: &'a str,
|
||||
on_input: impl Fn(String) -> PageMessage + 'a,
|
||||
) -> Element<'a, PageMessage> {
|
||||
column![
|
||||
text_field(ui, Field::new(value).label(label), on_input),
|
||||
super::super::frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hint,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 单行输入 + 一行提示(提示是**拥有的**字符串,所以不能走 `Field::supporting` 的借用口径)。
|
||||
fn text_field_with_hint<'a>(
|
||||
ui: &'a Ui,
|
||||
label: &'a str,
|
||||
value: &'a str,
|
||||
hint: Element<'a, PageMessage>,
|
||||
on_input: impl Fn(String) -> PageMessage + 'a,
|
||||
) -> Element<'a, PageMessage> {
|
||||
column![
|
||||
text_field(ui, Field::new(value).label(label), on_input),
|
||||
hint,
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 三态后端:版本不够时整体禁用(chips 不带 `on_press`)+ 一行门槛提示。
|
||||
fn backend_row<'a>(
|
||||
ui: &'a Ui,
|
||||
facts: &'a LaunchFacts,
|
||||
form: &'a LaunchForm,
|
||||
texts: Texts,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let allowed = facts.allowed;
|
||||
let mut chips = row![].spacing(ui.space(Space::X2));
|
||||
for id in &facts.options {
|
||||
let label = texts.backend_label(id).unwrap_or(id.as_str());
|
||||
let selected = form.backend == *id;
|
||||
let spec = if allowed {
|
||||
Chip::filter(label, selected, page(Message::Backend(id.clone())))
|
||||
} else {
|
||||
Chip::filter(label, selected, page(Message::Backend(id.clone()))).disabled()
|
||||
};
|
||||
chips = chips.push(chip(ui, spec));
|
||||
}
|
||||
|
||||
let mut meta = column![
|
||||
super::super::frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
texts.launch_backend(),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
chips,
|
||||
super::super::frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
texts.launch_effective(&facts.backend_effective),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
if !allowed {
|
||||
// 灰化 + 明确提示:既不说「已生效」,也不静默吞掉用户的选择。
|
||||
meta = meta.push(super::super::frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
texts.launch_backend_gated(&facts.mc_version),
|
||||
ui.color(Role::Error),
|
||||
));
|
||||
}
|
||||
meta.into()
|
||||
}
|
||||
|
||||
fn actions_row<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, PageMessage> {
|
||||
row![
|
||||
buttons::filled(ui, Button::new(texts.launch_save(), page(Message::Save))),
|
||||
buttons::text(ui, Button::new(texts.launch_reset(), page(Message::Reset))),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! 实例页的**页面状态**(M157):启动参数区的表单。
|
||||
//!
|
||||
//! ## 为什么状态在 app、页面只借一帧
|
||||
//!
|
||||
//! iced 的 `view` 不能持有跨帧状态,而启动参数区是可编辑的(两个多行框 + 两行单行框 +
|
||||
//! 三态 chips)。所以表单状态的所有者是 `App`(`app/state.rs` 的 `launch_form` 字段),
|
||||
//! 页面层只在这一帧借用;这与设置页「组状态归 `App`」是同一条纪律(`pages/settings/state.rs`)。
|
||||
//!
|
||||
//! ## 与快照的关系(唯一的重播种点)
|
||||
//!
|
||||
//! [`LaunchForm::sync`] 由 `App::refresh_view` 调用:
|
||||
//!
|
||||
//! | 情况 | 行为 |
|
||||
//! |---|---|
|
||||
//! | 换了一个实例 | **重播种**(用户没写完的草稿不能串到别的实例上) |
|
||||
//! | 同一个实例、没改过 | 重播种(保存/外部改了配置后跟着刷新) |
|
||||
//! | 同一个实例、改过(`dirty`) | **不覆盖**(每来一条事件就把输入抹掉是不可接受的) |
|
||||
//!
|
||||
//! 「留空 = 跟随下一层」由提交时把空串原样交给 host 表达(`Some("")` = 清空),
|
||||
//! 表单自己不做任何回落计算——回落只发生在 core。
|
||||
|
||||
use unlucky_presenter::dto::{LaunchArgsPatch, LaunchArgsView};
|
||||
|
||||
/// 启动参数表单(五项 + 脏标记 + 绑定的实例 id)。
|
||||
#[derive(Debug)]
|
||||
pub struct LaunchForm {
|
||||
/// 这份草稿属于哪个实例(`None` = 还没播种)。
|
||||
pub bound: Option<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)
|
||||
}
|
||||
|
||||
/// 重播种(见模块文档的三档)。
|
||||
pub fn sync(&mut self, view: &LaunchArgsView) {
|
||||
let same_instance = self.bound_to(&view.instance_id);
|
||||
if same_instance && self.dirty {
|
||||
return;
|
||||
}
|
||||
*self = Self::from_view(Some(&view.instance_id), view);
|
||||
}
|
||||
|
||||
/// 提交补丁:**五项全给**(表单是整份可见的,不用增量语义会让「清空」无法表达)。
|
||||
#[must_use]
|
||||
pub fn patch(&self) -> LaunchArgsPatch {
|
||||
LaunchArgsPatch {
|
||||
jvm_args: Some(self.jvm.clone()),
|
||||
game_args: Some(self.game.clone()),
|
||||
memory_mb: Some(self.memory.clone()),
|
||||
java_path: Some(self.java.clone()),
|
||||
graphics_backend: Some(self.backend.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 「恢复跟随全局」:五项清空,脏标记置位(等用户点保存)。
|
||||
pub fn reset(&mut self) {
|
||||
self.jvm.clear();
|
||||
self.game.clear();
|
||||
self.memory.clear();
|
||||
self.java.clear();
|
||||
self.backend = "default".to_string();
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动参数区的消息(`pages::Message::Instances` 的载荷)。
|
||||
///
|
||||
/// 只表达**页面意图**:编辑写进表单,保存/恢复才需要 app 层理解(前者 dispatch 一条
|
||||
/// `AppCommand::SetInstanceLaunchArgs`,后者只是清表单)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
/// 编辑 JVM 参数。
|
||||
Jvm(String),
|
||||
/// 编辑游戏参数。
|
||||
Game(String),
|
||||
/// 编辑最大内存(单行文本)。
|
||||
Memory(String),
|
||||
/// 编辑 Java 路径(单行文本)。
|
||||
Java(String),
|
||||
/// 选图形后端(三态 chips)。
|
||||
Backend(String),
|
||||
/// 保存(派发命令)。
|
||||
Save,
|
||||
/// 恢复跟随全局(清表单,仍需保存)。
|
||||
Reset,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn view(instance: &str, jvm: &str, memory: Option<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"));
|
||||
}
|
||||
|
||||
/// 补丁五项全给:空串就是「清空」,不留 `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,29 @@ const PAGE_SOURCES: [(&str, &str); 5] = [
|
||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||
const ALLOWED_LITERALS: [&str; 6] = [
|
||||
const ALLOWED_LITERALS: [&str; 17] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
"pid",
|
||||
"reason",
|
||||
"Instances",
|
||||
// M157:图形后端三态的**稳定 id**(与 core 的 `GraphicsBackend::IDS` 同一份词表),
|
||||
// `backend_label` 只认这三档,其余原样上屏。
|
||||
"default",
|
||||
"opengl",
|
||||
"vulkan",
|
||||
// M157:`Catalog::render` 的占位符名(`{version}` / `{value}` / `{default}`),不是文案。
|
||||
"version",
|
||||
"value",
|
||||
// M157:`instances/demo.rs` 的**出图夹具**(用户数据的形状,不是界面文案):
|
||||
// 一个示例 JVM 参数、两个示例 MC 版本、一个示例 Java 路径,以及夹具开关的环境变量名与取值。
|
||||
"-XX:+UseG1GC",
|
||||
"26.2-snapshot-2",
|
||||
"1.20.4",
|
||||
"C:/Java/temurin-21/bin/java.exe",
|
||||
"ULL_LAUNCH_ARGS_FIXTURE",
|
||||
"gated",
|
||||
];
|
||||
|
||||
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
||||
@@ -248,9 +268,13 @@ fn every_english_literal_in_the_page_sources_is_whitelisted() {
|
||||
"text.rs 没有引用本族的 key"
|
||||
);
|
||||
assert!(
|
||||
PAGE_SOURCES[4].1.contains("Key::InstancesStage"),
|
||||
PAGE_SOURCES[5].1.contains("Key::InstancesStage"),
|
||||
"阶段标签没有走目录"
|
||||
);
|
||||
assert!(
|
||||
PAGE_SOURCES[4].1.contains("texts.launch_title()"),
|
||||
"启动参数区没有走目录"
|
||||
);
|
||||
}
|
||||
|
||||
/// **死 key 守门**:目录里每一条 `Instances*` 都必须被页面(或产品快照转换)真的引用。
|
||||
|
||||
@@ -85,6 +85,67 @@ impl Texts {
|
||||
self.text(Key::InstancesNarrowNote)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- 启动参数(M157)
|
||||
pub(super) fn launch_title(self) -> &'static str {
|
||||
self.text(Key::InstancesLaunchTitle)
|
||||
}
|
||||
pub(super) fn launch_jvm(self) -> &'static str {
|
||||
self.text(Key::InstancesLaunchJvm)
|
||||
}
|
||||
pub(super) fn launch_jvm_hint(self) -> &'static str {
|
||||
self.text(Key::InstancesLaunchJvmHint)
|
||||
}
|
||||
pub(super) fn launch_game(self) -> &'static str {
|
||||
self.text(Key::InstancesLaunchGame)
|
||||
}
|
||||
pub(super) fn launch_game_hint(self) -> &'static str {
|
||||
self.text(Key::InstancesLaunchGameHint)
|
||||
}
|
||||
pub(super) fn launch_memory(self) -> &'static str {
|
||||
self.text(Key::InstancesLaunchMemory)
|
||||
}
|
||||
/// 「留空 = 跟随全局默认({default} MiB)」,`default` 是生效值(三级回落之后)。
|
||||
pub(super) fn launch_memory_hint(self, default_mb: u32) -> String {
|
||||
self.0.render(
|
||||
Key::InstancesLaunchMemoryHint,
|
||||
&[("default", &default_mb.to_string())],
|
||||
)
|
||||
}
|
||||
pub(super) fn launch_java(self) -> &'static str {
|
||||
self.text(Key::InstancesLaunchJava)
|
||||
}
|
||||
pub(super) fn launch_java_hint(self) -> &'static str {
|
||||
self.text(Key::InstancesLaunchJavaHint)
|
||||
}
|
||||
pub(super) fn launch_backend(self) -> &'static str {
|
||||
self.text(Key::InstancesLaunchBackend)
|
||||
}
|
||||
/// 「需游戏版本 26.2 及以上(本实例 1.20.4)」。
|
||||
pub(super) fn launch_backend_gated(self, version: &str) -> String {
|
||||
self.0
|
||||
.render(Key::InstancesLaunchBackendGated, &[("version", version)])
|
||||
}
|
||||
/// 「生效:vulkan」。
|
||||
pub(super) fn launch_effective(self, value: &str) -> String {
|
||||
self.0
|
||||
.render(Key::InstancesLaunchEffective, &[("value", value)])
|
||||
}
|
||||
/// 三态后端的显示名(稳定 id → 文案);**未知 id 返回 `None`**(调用方原样上屏,不猜)。
|
||||
pub(super) fn backend_label(self, id: &str) -> Option<&'static str> {
|
||||
Some(self.text(match id {
|
||||
"default" => Key::InstancesBackendDefault,
|
||||
"opengl" => Key::InstancesBackendOpengl,
|
||||
"vulkan" => Key::InstancesBackendVulkan,
|
||||
_ => return None,
|
||||
}))
|
||||
}
|
||||
pub(super) fn launch_save(self) -> &'static str {
|
||||
self.text(Key::InstancesLaunchSave)
|
||||
}
|
||||
pub(super) fn launch_reset(self) -> &'static str {
|
||||
self.text(Key::InstancesLaunchReset)
|
||||
}
|
||||
|
||||
fn text(self, key: Key) -> &'static str {
|
||||
self.0.text(key)
|
||||
}
|
||||
|
||||
@@ -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`,这里只加一支入口。
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
/// 实例页要画的东西。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ pub const fn general() -> &'static str {
|
||||
|
||||
命令:
|
||||
instances list 列出实例与已安装版本
|
||||
instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P]
|
||||
[--backend default|opengl|vulkan] [--clear]
|
||||
读/写实例级启动参数(不带选项 = 只读;--clear = 全部回跟随全局)
|
||||
install <version> [--loader X] [--loader-version V] [--name N] [--dry-run]
|
||||
下载并安装版本本体(--loader fabric|quilt|neoforge|forge
|
||||
时再叠一层加载器)→ 登记实例;--dry-run 只出计划
|
||||
@@ -69,7 +72,24 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
||||
version id 里的加载器版本段;原版、以及 id 里没写版本的(1.12.2-LiteLoader)为空串
|
||||
注意:kind 是「认得出」的口径,不是「装得了」的口径 —— optifine / liteloader 只能由别的
|
||||
启动器装好后继承进来,install --loader 不接受这两个值;kind=vanilla 只表示 id 里没有加载器
|
||||
痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。",
|
||||
痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。
|
||||
|
||||
%prog% instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P] \
|
||||
[--backend default|opengl|vulkan] [--clear]
|
||||
|
||||
读/写实例配置页「启动参数」区(M157)。不带取写选项时只读:
|
||||
--jvm <text> 实例级 JVM 参数(空白分隔、支持引号);与全局 java.extra_args **叠加**,
|
||||
实例这层在最高优先级段(同名选项实例赢,`-Xmx` 也由它赢过内存字段)
|
||||
--game <text> 实例级游戏参数(追加在命令行最后,可覆盖前面一切游戏参数)
|
||||
--memory <MB> 实例级最大堆(256~65536);留空/不写 = 跟随全局 java.default_memory_mb
|
||||
--java <path> 实例级 Java 可执行文件;留空 = 跟随全局 java.selected_path 再退到自动挑
|
||||
--backend <id> default(不下发)/ opengl / vulkan;只对 MC 26.2-snapshot-2+ 下发,
|
||||
老版本会被版本门槛拦下(启动时给诊断告警,不静默)
|
||||
--clear 先把五项清空再应用其它选项(= 全部回「跟随全局」)
|
||||
每一项都是「实例 → 全局 → 默认」的独立回落,只填一项不会顶掉别的项。
|
||||
值以 `--` 开头时(多数游戏参数都是)写成 `--game=--fullscreen`:单独一个 `--fullscreen`
|
||||
会被当成下一个选项(缺少取值),这是全项目 CLI 的既有解析口径。
|
||||
--json 输出 {\"data\":{\"launch\":{…实例层…},\"effective\":{…回落后的生效值…}, …}}",
|
||||
),
|
||||
"pack" => Some(
|
||||
"\
|
||||
|
||||
@@ -13,7 +13,8 @@ pub mod program;
|
||||
|
||||
pub use model::{
|
||||
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
|
||||
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs, PackImportArgs,
|
||||
DiagArgs, Globals, InstallArgs, InstanceLaunchArgs, Invocation, LaunchArgs, LogsArgs,
|
||||
PackImportArgs,
|
||||
};
|
||||
pub use parse::parse;
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ pub enum Command {
|
||||
Help(Option<String>),
|
||||
/// `instances list`。
|
||||
InstancesList,
|
||||
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
|
||||
InstancesLaunchArgs(InstanceLaunchArgs),
|
||||
/// `install <version> [--loader X] [--name N]`。
|
||||
Install(InstallArgs),
|
||||
/// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。
|
||||
@@ -185,6 +187,38 @@ pub struct DiagArgs {
|
||||
pub tail: usize,
|
||||
}
|
||||
|
||||
/// `instances launch-args` 的参数(M157:实例级启动参数)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct InstanceLaunchArgs {
|
||||
/// 实例 id 或实例名。
|
||||
pub instance: String,
|
||||
/// 实例级 JVM 参数(空白分隔,支持引号)。
|
||||
pub jvm_args: Option<String>,
|
||||
/// 实例级游戏参数。
|
||||
pub game_args: Option<String>,
|
||||
/// 实例级最大堆(MiB 文本;空串由 `--memory ""` 表达「跟随全局」)。
|
||||
pub memory_mb: Option<String>,
|
||||
/// 实例级 Java 可执行文件。
|
||||
pub java: Option<String>,
|
||||
/// 图形后端(`default` / `opengl` / `vulkan`)。
|
||||
pub backend: Option<String>,
|
||||
/// 先清空五项再应用给出的项(`--clear`;不带其它选项时等于「全部跟随全局」)。
|
||||
pub clear: bool,
|
||||
}
|
||||
|
||||
impl InstanceLaunchArgs {
|
||||
/// 这次调用是不是纯读取(没有任何写入口)。
|
||||
#[must_use]
|
||||
pub fn is_read_only(&self) -> bool {
|
||||
!self.clear
|
||||
&& self.jvm_args.is_none()
|
||||
&& self.game_args.is_none()
|
||||
&& self.memory_mb.is_none()
|
||||
&& self.java.is_none()
|
||||
&& self.backend.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// `config get` 的参数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigGetArgs {
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::error::CliError;
|
||||
|
||||
mod accounts;
|
||||
mod agent;
|
||||
mod instances;
|
||||
|
||||
use super::model::{
|
||||
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
|
||||
@@ -81,7 +82,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
|
||||
match name.as_str() {
|
||||
"version" => Ok(Command::Version),
|
||||
"help" => Ok(Command::Help(tail.first().cloned())),
|
||||
"instances" => parse_instances(tail),
|
||||
"instances" => instances::parse_instances(tail),
|
||||
"install" => parse_install(tail, globals),
|
||||
"pack" => parse_pack(tail),
|
||||
"launch" => parse_launch(tail),
|
||||
@@ -99,20 +100,6 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
|
||||
let args = match tail.first().map(String::as_str) {
|
||||
None | Some("list") => tail.get(1..).unwrap_or_default(),
|
||||
Some(other) => {
|
||||
return Err(CliError::usage(format!(
|
||||
"instances 的子命令只有 list,收到 `{other}`"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let parsed = parse_flags(args, &[], &[])?;
|
||||
ensure_no_positionals(&parsed, "instances list")?;
|
||||
Ok(Command::InstancesList)
|
||||
}
|
||||
|
||||
fn parse_install(tail: &[String], globals: &Globals) -> Result<Command, CliError> {
|
||||
let parsed = parse_flags(tail, &["loader", "loader-version", "name"], &["dry-run"])?;
|
||||
let version = require_single_positional(&parsed, "install <version>")?;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! `instances` 的两个子命令解析(`list` / `launch-args`)。
|
||||
//!
|
||||
//! 从 `parse.rs` 拆出来只为守 400 行硬线(M157 的 `launch-args` 让那边到 415 行);
|
||||
//! 纯搬移,语义未变。
|
||||
|
||||
use super::super::model::InstanceLaunchArgs;
|
||||
use super::*;
|
||||
|
||||
pub(super) fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
|
||||
let args = match tail.first().map(String::as_str) {
|
||||
None | Some("list") => tail.get(1..).unwrap_or_default(),
|
||||
Some("launch-args") => {
|
||||
return parse_instances_launch_args(tail.get(1..).unwrap_or_default())
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(CliError::usage(format!(
|
||||
"instances 的子命令只有 list / launch-args,收到 `{other}`"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let parsed = parse_flags(args, &[], &[])?;
|
||||
ensure_no_positionals(&parsed, "instances list")?;
|
||||
Ok(Command::InstancesList)
|
||||
}
|
||||
|
||||
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
|
||||
///
|
||||
/// 五个取值选项都接受空串(`--jvm ""`)= 该清空,与 `--clear` 的「先清后改」互补。
|
||||
fn parse_instances_launch_args(tail: &[String]) -> Result<Command, CliError> {
|
||||
let parsed = parse_flags(
|
||||
tail,
|
||||
&["jvm", "game", "memory", "java", "backend"],
|
||||
&["clear"],
|
||||
)?;
|
||||
let instance = require_single_positional(&parsed, "instances launch-args <instance>")?;
|
||||
Ok(Command::InstancesLaunchArgs(InstanceLaunchArgs {
|
||||
instance,
|
||||
jvm_args: parsed.value("jvm"),
|
||||
game_args: parsed.value("game"),
|
||||
memory_mb: parsed.value("memory"),
|
||||
java: parsed.value("java"),
|
||||
backend: parsed.value("backend"),
|
||||
clear: parsed.has("clear"),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! `instances launch-args <instance>`(M157):实例级启动参数的读/写。
|
||||
//!
|
||||
//! 五项的**含义与回落规则**都在 core([`unlucky_core::game::launch_overrides`]),本命令只做
|
||||
//! 三件事:找实例、把命令行选项拼成 [`LaunchOverridesPatch`]、把生效值印出来。
|
||||
//!
|
||||
//! ```text
|
||||
//! ull instances launch-args 生存 # 读:实例层 + 生效值 + 后端门槛
|
||||
//! ull instances launch-args 生存 --jvm "-XX:+UseG1GC" --backend vulkan
|
||||
//! ull instances launch-args 生存 --memory 8192 --game "--fullscreen"
|
||||
//! ull instances launch-args 生存 --clear # 五项全部回「跟随全局」
|
||||
//! ```
|
||||
//!
|
||||
//! 判定「版本够不够 Vulkan」用 core 的组合判据
|
||||
//! [`unlucky_core::launch::overrides::backend_allowed`](继承解析后的库表 + 版本门槛),
|
||||
//! 与 GUI 灰化、拼装链的告警是同一个函数——CLI 不另写一条。
|
||||
//!
|
||||
//! 全局层(`java.extra_args` / `java.default_memory_mb` / `java.selected_path`)在这里读一次
|
||||
//! 设置文档填 [`GlobalLaunchDefaults`];presenter 的 host 侧有一份同形的读法(两个 crate 各自
|
||||
//! 依赖 core,没法共享那三行 IO),字段口径以 core 的回落表为准。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::game::install::instance::{
|
||||
read_instance_config, write_instance_config, InstanceConfig,
|
||||
};
|
||||
use unlucky_core::game::launch_overrides::{
|
||||
resolve, GlobalLaunchDefaults, GraphicsBackend, LaunchOverridesPatch, ResolvedLaunch,
|
||||
};
|
||||
use unlucky_core::game::{self, VersionJson};
|
||||
use unlucky_core::launch::game_args::GRAPHICS_BACKEND_MIN_VERSION;
|
||||
use unlucky_core::launch::game_version::GameVersion;
|
||||
use unlucky_core::launch::overrides::backend_allowed;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::cli::InstanceLaunchArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
use super::instances::read_profiles;
|
||||
|
||||
/// `ull instances launch-args`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 实例不存在、值非法(内存越界 / 未知后端)、实例文件读写失败时返回用户错误。
|
||||
pub fn run(ctx: &Ctx, args: &InstanceLaunchArgs) -> Result<Report, CliError> {
|
||||
let profiles = read_profiles(ctx)?;
|
||||
let profile = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.id.as_str() == args.instance || profile.name == args.instance)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
CliError::user("not_found", format!("找不到实例 `{}`", args.instance))
|
||||
.with_hint("用 `%prog% instances list` 看已登记的实例")
|
||||
})?;
|
||||
let id = profile.id.as_str().to_string();
|
||||
let layout = ctx.layout();
|
||||
|
||||
let mut config = read_instance_config(&layout, &id)
|
||||
.map_err(|error| CliError::user("instance_config", format!("读实例设置失败:{error}")))?
|
||||
.unwrap_or_else(|| InstanceConfig::new(&id, &profile.version_id));
|
||||
|
||||
let mut updated = false;
|
||||
if !args.is_read_only() {
|
||||
config.launch = config
|
||||
.launch
|
||||
.patched(&patch_from(args))
|
||||
.map_err(CliError::usage)?;
|
||||
write_instance_config(&layout, &config).map_err(|error| {
|
||||
CliError::user("instance_config", format!("写实例设置失败:{error}"))
|
||||
})?;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
let effective = resolve(Some(&config.launch), &global_defaults(ctx));
|
||||
let (allowed, mc_version) = backend_gate(ctx, &profile.version_id);
|
||||
let layer = &config.launch;
|
||||
|
||||
let data = json!({
|
||||
"instance_id": id,
|
||||
"name": profile.name,
|
||||
"version_id": profile.version_id,
|
||||
"updated": updated,
|
||||
"launch": {
|
||||
"jvm_args": layer.jvm_args.clone().unwrap_or_default(),
|
||||
"game_args": layer.game_args.clone().unwrap_or_default(),
|
||||
"memory_mb": layer.memory_mb,
|
||||
"java_path": layer.java_path.as_ref().map(|path| path.display().to_string()),
|
||||
"graphics_backend": layer.graphics_backend.unwrap_or_default().as_str(),
|
||||
},
|
||||
"effective": {
|
||||
"jvm_args": effective.instance_jvm_args.clone().unwrap_or_default(),
|
||||
"global_jvm_args": effective.global_jvm_args.clone().unwrap_or_default(),
|
||||
"game_args": effective.game_args.clone().unwrap_or_default(),
|
||||
"memory_mb": effective.memory_mb,
|
||||
"java_path": effective.java_path.as_ref().map(|path| path.display().to_string()),
|
||||
"graphics_backend": effective.graphics_backend.as_str(),
|
||||
"graphics_backend_allowed": allowed,
|
||||
"graphics_backend_min_version": GRAPHICS_BACKEND_MIN_VERSION,
|
||||
"mc_version": mc_version,
|
||||
},
|
||||
"graphics_backend_options": GraphicsBackend::IDS,
|
||||
});
|
||||
|
||||
let mut human = vec![format!(
|
||||
"{}({} · {}){}",
|
||||
profile.name,
|
||||
profile.version_id,
|
||||
id,
|
||||
if updated { " ← 已保存" } else { "" }
|
||||
)];
|
||||
human.push(format!(
|
||||
" JVM 参数(实例): {}",
|
||||
layer
|
||||
.jvm_args
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(跟随全局)".to_string())
|
||||
));
|
||||
human.push(format!(
|
||||
" 游戏参数(实例): {}",
|
||||
layer
|
||||
.game_args
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(无)".to_string())
|
||||
));
|
||||
human.push(format!(
|
||||
" 最大内存: {} MiB{}",
|
||||
effective.memory_mb,
|
||||
match layer.memory_mb {
|
||||
Some(_) => "(实例覆盖)",
|
||||
None => "(跟随全局 / 出厂默认)",
|
||||
}
|
||||
));
|
||||
human.push(format!(
|
||||
" Java: {}",
|
||||
effective
|
||||
.java_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "(自动挑:托管 > JAVA_HOME > PATH)".to_string())
|
||||
));
|
||||
human.push(format!(
|
||||
" 图形后端: {}{}",
|
||||
effective.graphics_backend.as_str(),
|
||||
if effective.graphics_backend.is_default() {
|
||||
"(不下发 --graphicsBackend)".to_string()
|
||||
} else if allowed {
|
||||
format!("({} 可下发)", mc_version)
|
||||
} else {
|
||||
format!("(版本 {mc_version} 不支持,不会下发;需 MC {GRAPHICS_BACKEND_MIN_VERSION}+)")
|
||||
}
|
||||
));
|
||||
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
|
||||
/// 命令行选项 → core 的文本补丁(`--clear` 先清五项,随后给出的项覆盖)。
|
||||
fn patch_from(args: &InstanceLaunchArgs) -> LaunchOverridesPatch {
|
||||
let mut patch = if args.clear {
|
||||
LaunchOverridesPatch::clear_all()
|
||||
} else {
|
||||
LaunchOverridesPatch::default()
|
||||
};
|
||||
if let Some(value) = &args.jvm_args {
|
||||
patch.jvm_args = Some(value.clone());
|
||||
}
|
||||
if let Some(value) = &args.game_args {
|
||||
patch.game_args = Some(value.clone());
|
||||
}
|
||||
if let Some(value) = &args.memory_mb {
|
||||
patch.memory_mb = Some(value.clone());
|
||||
}
|
||||
if let Some(value) = &args.java {
|
||||
patch.java_path = Some(value.clone());
|
||||
}
|
||||
if let Some(value) = &args.backend {
|
||||
patch.graphics_backend = Some(value.clone());
|
||||
}
|
||||
patch
|
||||
}
|
||||
|
||||
/// 全局层(三个设置键)。
|
||||
pub(crate) fn global_defaults(ctx: &Ctx) -> GlobalLaunchDefaults {
|
||||
let store = SettingStore::load(&ctx.launcher_config(), launcher_schema());
|
||||
let text = |key: &str| -> Option<String> {
|
||||
store
|
||||
.doc()
|
||||
.effective_str(key)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
};
|
||||
GlobalLaunchDefaults {
|
||||
jvm_args: text("java.extra_args"),
|
||||
game_args: None,
|
||||
memory_mb: store
|
||||
.effective("java.default_memory_mb")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok()),
|
||||
java_path: text("java.selected_path").map(std::path::PathBuf::from),
|
||||
graphics_backend: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 三级回落后的生效值(启动装配用;`instance_id` 为空 = 没有实例档案,只用全局层)。
|
||||
///
|
||||
/// 与 presenter 的 `host::launch_args::resolved` 同义:那边给 GUI 用,这边给 `ull launch` 用,
|
||||
/// 规则都在 core 的 [`resolve`],两处只剩「读哪几份文件」的差别。
|
||||
pub(crate) fn resolved(ctx: &Ctx, instance_id: &str) -> ResolvedLaunch {
|
||||
let overrides = if instance_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
read_instance_config(&ctx.layout(), instance_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|config| config.launch)
|
||||
};
|
||||
resolve(overrides.as_ref(), &global_defaults(ctx))
|
||||
}
|
||||
|
||||
/// 版本能不能用 `--graphicsBackend`,以及判定依据的游戏版本(继承链根)。
|
||||
fn backend_gate(ctx: &Ctx, version_id: &str) -> (bool, String) {
|
||||
let game_layout = ctx.game();
|
||||
let Ok(text) = std::fs::read_to_string(game_layout.version_json(version_id)) else {
|
||||
return (false, version_id.to_string());
|
||||
};
|
||||
let Ok(manifest) = VersionJson::from_json_str(&text) else {
|
||||
return (false, version_id.to_string());
|
||||
};
|
||||
let mc_version = root_version_id(&game_layout, &manifest);
|
||||
let source = |id: &str| -> Option<VersionJson> {
|
||||
std::fs::read_to_string(game_layout.version_json(id))
|
||||
.ok()
|
||||
.and_then(|text| VersionJson::from_json_str(&text).ok())
|
||||
};
|
||||
let resolved = game::resolve(&source, &manifest).unwrap_or(manifest);
|
||||
let allowed = backend_allowed(&GameVersion::parse(&mc_version), &resolved);
|
||||
(allowed, mc_version)
|
||||
}
|
||||
|
||||
/// 继承链根(原版层)的 id——能力位与版本门限都以它为准(loader 层的 id 带加载器后缀)。
|
||||
fn root_version_id(layout: &unlucky_core::game::GameLayout, manifest: &VersionJson) -> String {
|
||||
let mut current = manifest.id.clone().unwrap_or_default();
|
||||
let mut parent = manifest.inherits_from.clone().filter(|id| !id.is_empty());
|
||||
for _ in 0..16 {
|
||||
let Some(next) = parent else { break };
|
||||
let Ok(text) = std::fs::read_to_string(layout.version_json(&next)) else {
|
||||
return next;
|
||||
};
|
||||
let Ok(json) = VersionJson::from_json_str(&text) else {
|
||||
return next;
|
||||
};
|
||||
current = json.id.clone().unwrap_or(next);
|
||||
parent = json.inherits_from.clone().filter(|id| !id.is_empty());
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,212 @@
|
||||
//! [`super`] 的单测:走真入口 [`crate::run`],断言用户/脚本拿到的那份输出。
|
||||
//!
|
||||
//! 口径与 `instances/tests.rs` 一致:临时 `--data-dir` + 真 `profiles.json`,只看 stdout 的
|
||||
//! JSON 信封(`--json`)与退出码。回落规则的真值表在 core 的
|
||||
//! `game::launch_overrides` / `launch::overrides` 测试里,这里只验 CLI 有没有如实搬运。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::platform::layout::{LauncherLayout, LAUNCHER_HOME_DIR};
|
||||
|
||||
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_nanos());
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ull-instance-launch-{tag}-{}-{seq}-{nanos}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
|
||||
fn arg(&self) -> String {
|
||||
self.0.display().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 写 `.ULL/config/profiles.json` 并返回第一个实例的 id。
|
||||
fn write_profiles(data_root: &Path, name: &str, version_id: &str) -> String {
|
||||
let dir = data_root.join(LAUNCHER_HOME_DIR).join("config");
|
||||
std::fs::create_dir_all(&dir).expect("建配置目录");
|
||||
let id = "11111111-2222-4333-8444-555555555555".to_string();
|
||||
let document = json!({
|
||||
"schema_version": 1,
|
||||
"profiles": [{ "id": id, "name": name, "version_id": version_id }],
|
||||
});
|
||||
std::fs::write(
|
||||
dir.join("profiles.json"),
|
||||
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
|
||||
)
|
||||
.expect("写 profiles.json");
|
||||
id
|
||||
}
|
||||
|
||||
/// 写 `<data>/.minecraft/versions/<id>/<id>.json`(库表可带 vulkan)。
|
||||
fn write_version(data_root: &Path, id: &str, vulkan: bool) {
|
||||
let path = data_root
|
||||
.join(".minecraft")
|
||||
.join("versions")
|
||||
.join(id)
|
||||
.join(format!("{id}.json"));
|
||||
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
|
||||
let libs = if vulkan {
|
||||
r#"[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]"#
|
||||
} else {
|
||||
"[]"
|
||||
};
|
||||
std::fs::write(&path, format!(r#"{{"id":"{id}","libraries":{libs}}}"#)).expect("写版本 JSON");
|
||||
}
|
||||
|
||||
/// 跑一次 CLI,返回(退出码, stdout)。
|
||||
fn run_cli(args: &[&str]) -> (i32, String) {
|
||||
let owned: Vec<String> = args.iter().map(|arg| (*arg).to_string()).collect();
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let mut err: Vec<u8> = Vec::new();
|
||||
let code = crate::run(&owned, &mut out, &mut err);
|
||||
(code, String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
/// 跑 `instances launch-args … --json`,返回 `data` 载荷。
|
||||
fn launch_args(dir: &TempDir, rest: &[&str]) -> Value {
|
||||
let root = dir.arg();
|
||||
let mut args = vec!["instances", "launch-args", "--json", "--data-dir", &root];
|
||||
args.extend_from_slice(rest);
|
||||
let (code, stdout) = run_cli(&args);
|
||||
assert_eq!(code, 0, "{stdout}");
|
||||
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
|
||||
assert_eq!(value["ok"], true, "{stdout}");
|
||||
value["data"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_reports_instance_layer_and_effective_values() {
|
||||
let dir = TempDir::new("read");
|
||||
let id = write_profiles(dir.path(), "生存", "1.20.4");
|
||||
let data = launch_args(&dir, &[&id]);
|
||||
assert_eq!(data["instance_id"], id.as_str());
|
||||
assert_eq!(data["updated"], false);
|
||||
assert_eq!(data["launch"]["graphics_backend"], "default");
|
||||
// 没配内存 ⇒ 生效值是出厂默认 2048(三级回落的最后一级)。
|
||||
assert_eq!(data["effective"]["memory_mb"], 2048);
|
||||
assert_eq!(data["effective"]["graphics_backend_allowed"], false);
|
||||
assert_eq!(
|
||||
data["graphics_backend_options"],
|
||||
json!(["default", "opengl", "vulkan"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_from_flags_then_read_back() {
|
||||
let dir = TempDir::new("write");
|
||||
let id = write_profiles(dir.path(), "生存", "26.2-snapshot-2");
|
||||
write_version(dir.path(), "26.2-snapshot-2", true);
|
||||
|
||||
let data = launch_args(
|
||||
&dir,
|
||||
&[
|
||||
&id,
|
||||
"--jvm=-XX:+UseG1GC",
|
||||
// 值以 `--` 开头时必须用 `--key=value` 形式(`--game --fullscreen` 会被解析器
|
||||
// 当成「缺少取值」——那是全项目 CLI 的既有口径,见 help 里的提示)。
|
||||
"--game=--fullscreen",
|
||||
"--memory",
|
||||
"6144",
|
||||
"--backend",
|
||||
"vulkan",
|
||||
],
|
||||
);
|
||||
assert_eq!(data["updated"], true);
|
||||
assert_eq!(data["launch"]["jvm_args"], "-XX:+UseG1GC");
|
||||
assert_eq!(data["launch"]["memory_mb"], 6144);
|
||||
assert_eq!(data["launch"]["graphics_backend"], "vulkan");
|
||||
// 26.2-snapshot-2 过了门槛(判定来自 core 的能力位 + 版本门限)。
|
||||
assert_eq!(data["effective"]["graphics_backend_allowed"], true);
|
||||
|
||||
// 再读一次:落盘之后依然在(`--json` 是事实上的对外契约)。
|
||||
let again = launch_args(&dir, &[&id]);
|
||||
assert_eq!(again["updated"], false);
|
||||
assert_eq!(again["launch"]["jvm_args"], "-XX:+UseG1GC");
|
||||
assert_eq!(again["launch"]["game_args"], "--fullscreen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_restores_follow_the_global() {
|
||||
let dir = TempDir::new("clear");
|
||||
let id = write_profiles(dir.path(), "生存", "1.20.4");
|
||||
launch_args(&dir, &[&id, "--memory", "4096", "--backend", "opengl"]);
|
||||
let cleared = launch_args(&dir, &[&id, "--clear"]);
|
||||
assert_eq!(cleared["launch"]["memory_mb"], Value::Null);
|
||||
assert_eq!(cleared["launch"]["graphics_backend"], "default");
|
||||
assert_eq!(cleared["effective"]["memory_mb"], 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_values_fail_with_a_usage_error() {
|
||||
let dir = TempDir::new("invalid");
|
||||
let id = write_profiles(dir.path(), "生存", "1.20.4");
|
||||
let root = dir.arg();
|
||||
for (flag, value) in [
|
||||
("--backend", "vulkan2"),
|
||||
("--memory", "4g"),
|
||||
("--memory", "1"),
|
||||
] {
|
||||
let (code, stdout) = run_cli(&[
|
||||
"instances",
|
||||
"launch-args",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&root,
|
||||
&id,
|
||||
flag,
|
||||
value,
|
||||
]);
|
||||
assert_ne!(code, 0, "{flag} {value} 应当失败:{stdout}");
|
||||
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
|
||||
assert_eq!(value["error"]["code"], "usage", "{stdout}");
|
||||
}
|
||||
// 失败的写入不许留下半截设置文件。
|
||||
let layout = LauncherLayout::new(dir.path().join(LAUNCHER_HOME_DIR));
|
||||
assert!(
|
||||
unlucky_core::game::install::instance::read_instance_config(&layout, &id)
|
||||
.expect("读")
|
||||
.is_none(),
|
||||
"非法补丁不该落盘"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_instance_is_a_user_error() {
|
||||
let dir = TempDir::new("missing");
|
||||
let root = dir.arg();
|
||||
let (code, stdout) = run_cli(&[
|
||||
"instances",
|
||||
"launch-args",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&root,
|
||||
"nope",
|
||||
]);
|
||||
assert_ne!(code, 0);
|
||||
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
|
||||
assert_eq!(value["error"]["code"], "not_found", "{stdout}");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod agent;
|
||||
pub mod config;
|
||||
pub mod identity;
|
||||
pub mod install;
|
||||
pub mod instance_launch;
|
||||
pub mod instances;
|
||||
pub mod java;
|
||||
pub mod java_launch;
|
||||
@@ -32,6 +33,9 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
|
||||
Command::Version => Ok(Outcome::new(meta::version())),
|
||||
Command::Help(topic) => Ok(Outcome::new(meta::help(topic.as_deref()))),
|
||||
Command::InstancesList => with_ctx(&invocation.globals, instances::list),
|
||||
Command::InstancesLaunchArgs(args) => {
|
||||
with_ctx(&invocation.globals, |ctx| instance_launch::run(ctx, args))
|
||||
}
|
||||
Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)),
|
||||
Command::PackImport(args) => {
|
||||
with_ctx_outcome(&invocation.globals, |ctx| pack::run(ctx, args))
|
||||
|
||||
@@ -151,11 +151,21 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
|
||||
game_assets: assets_root,
|
||||
};
|
||||
|
||||
// 实例级启动参数的三级回落(实例 → 全局 → 默认)。临时启动参数(`--max-memory` /
|
||||
// `--java`)在 D20 的覆盖链里位于实例之上,所以这里先把它们叠上去再落位。
|
||||
let mut effective = super::instance_launch::resolved(ctx, &input.instance_id);
|
||||
if let Some(mb) = input.max_memory {
|
||||
effective.memory_mb = mb;
|
||||
}
|
||||
if let Some(path) = &input.java {
|
||||
effective.java_path = Some(path.clone());
|
||||
}
|
||||
|
||||
let options = AdapterOptions {
|
||||
java: super::java::selection(input.java.as_deref()),
|
||||
java: super::java::selection(effective.java_path.as_deref()),
|
||||
memory: MemoryOptions {
|
||||
min_mb: None,
|
||||
max_mb: input.max_memory,
|
||||
max_mb: Some(effective.memory_mb),
|
||||
},
|
||||
auth: input.auth.clone().unwrap_or_else(|| AuthPlaceholders {
|
||||
player_name: input
|
||||
@@ -181,6 +191,8 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
|
||||
request.instance.id = input.instance_id.clone();
|
||||
}
|
||||
request.instance.name = input.instance_name.clone();
|
||||
// 实例级启动参数(三级回落后的生效值)最后落位:user.* / memory / graphics_backend。
|
||||
unlucky_core::launch::overrides::apply(&mut request, &effective);
|
||||
|
||||
Ok(Plan {
|
||||
version_id,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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"}"#
|
||||
);
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! 实例级启动参数的**落位**:`ResolvedLaunch` → [`LaunchRequest`]。
|
||||
//!
|
||||
//! 三级回落本身在 [`crate::game::launch_overrides`](纯计算);本模块只回答「落到哪一段」,
|
||||
//! 规则与理由见 M157 的定案(叠加/冲突),这里给出可执行的对照表:
|
||||
//!
|
||||
//! | 生效值 | 落点 | 组装表 | 为什么在这儿 |
|
||||
//! |---|---|---|---|
|
||||
//! | 全局 `java.extra_args` | `user.jvm` | 第 7 项 | 基线:按“默认值”处理,可被清单参数抑制 |
|
||||
//! | 实例 `launch.jvmArgs` | `user.override_jvm` | 第 4 项 | 精调:最高优先级,`-Xmx`/编码/代理默认值全被它抑制 |
|
||||
//! | 实例 `launch.gameArgs` | `user.game` | 第 32 项 | 最后追加,可覆盖前面一切游戏参数 |
|
||||
//! | `memoryMb`(已回落) | `memory.max_mb` | 第 5 项 | `-Xmx` 只从这里长出来 |
|
||||
//! | `graphicsBackend` | `graphics_backend` | 第 31 项 | 仍过 `graphics_backend_allowed` 门槛,不放行给诊断告警 |
|
||||
//! | `javaPath` | **不进来** | — | Java 选择在调用方(`JavaSelection`),见标量注释 |
|
||||
//!
|
||||
//! # 两条必须一起读的结论
|
||||
//!
|
||||
//! 1. **追加,不替换**:全局与实例的 JVM 参数都下发。实例段在前、全局段在后,同名选项由 JVM
|
||||
//! 取后出现者——但注意组装表第 4 项在 `-Xmx` **之前**:实例文本里的 `-Xmx` 会抑制内存字段
|
||||
//! 产出的 `-Xmx`(这是「文本比字段更精确」的表达,不是 bug);
|
||||
//! 2. **全局文本里的 `-Xmx` 不生效**:第 7 项晚于第 5 项,`-Xmx` 已被内存字段占住
|
||||
//! (`add_default` 语义)。要改堆请用内存字段或实例 JVM 文本——写进注释与测试,别当特例。
|
||||
//!
|
||||
//! `javaPath` 不进 `LaunchRequest`:那份结构只收选好的 [`crate::launch::request::JavaSelection`],
|
||||
//! 调用方用 `resolved.java_path` 决定「显式路径优先 / 自动挑」。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::game::launch_overrides::ResolvedLaunch;
|
||||
use crate::launch::command_builder::tokenize;
|
||||
use crate::launch::request::{LaunchRequest, MemoryOptions};
|
||||
|
||||
/// 把三级回落后的生效值写进请求(覆盖 `user.*` / `memory` / `graphics_backend` 三处)。
|
||||
///
|
||||
/// 调用方在 [`crate::launch::adapter::build_request`] 之后调用一次即可;它**接管**这三处,
|
||||
/// 所以别在此之前再往 `user` 里塞东西(目前 CLI 与 GUI 都没有别的来源,契约就是这一段)。
|
||||
pub fn apply(request: &mut LaunchRequest, resolved: &ResolvedLaunch) {
|
||||
request.user.jvm = tokens(resolved.global_jvm_args.as_deref());
|
||||
request.user.override_jvm = tokens(resolved.instance_jvm_args.as_deref());
|
||||
request.user.game = tokens(resolved.game_args.as_deref());
|
||||
request.memory = MemoryOptions {
|
||||
min_mb: request.memory.min_mb,
|
||||
max_mb: Some(resolved.memory_mb),
|
||||
};
|
||||
request.graphics_backend = resolved.graphics_backend.flag_value().map(str::to_string);
|
||||
}
|
||||
|
||||
/// 实例/全局给的显式 Java 路径(`None` = 调用方走自动挑)。
|
||||
#[must_use]
|
||||
pub fn java_binary(resolved: &ResolvedLaunch) -> Option<&Path> {
|
||||
resolved.java_path.as_deref()
|
||||
}
|
||||
|
||||
/// 版本能不能下发 `--graphicsBackend`:**解析过继承链的**版本 JSON + 游戏版本号。
|
||||
///
|
||||
/// `resolved_json` 必须是继承解析之后的那份(26.2 的 `lwjgl-vulkan` 在父层,
|
||||
/// 只看 loader 层会漏)。判据本身仍是 [`graphics_backend_allowed`] 一处实现——
|
||||
/// UI 与拼装链共用它,别在调用方再抄一遍能力位判断。
|
||||
#[must_use]
|
||||
pub fn backend_allowed(
|
||||
version: &crate::launch::game_version::GameVersion,
|
||||
resolved_json: &crate::game::VersionJson,
|
||||
) -> bool {
|
||||
crate::launch::game_args::graphics_backend_allowed(
|
||||
version,
|
||||
crate::game::VersionCapabilities::from_json(resolved_json).has_vulkan_backend,
|
||||
)
|
||||
}
|
||||
|
||||
fn tokens(text: Option<&str>) -> Vec<String> {
|
||||
text.map(tokenize).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
use crate::game::launch_overrides::{
|
||||
resolve, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides,
|
||||
};
|
||||
use crate::launch::args_table::{assemble, AssemblyWarning};
|
||||
use crate::launch::game_version::GameVersion;
|
||||
use crate::launch::platform::{OsFamily, PlatformCtx};
|
||||
use crate::launch::request::{
|
||||
AuthPlaceholders, InstanceInfo, JavaSelection, LaunchDirs, LauncherInfo, Log4jOptions,
|
||||
ManifestArgs, OptimizationOptions,
|
||||
};
|
||||
|
||||
fn request(version: &str) -> LaunchRequest {
|
||||
LaunchRequest {
|
||||
instance: InstanceInfo {
|
||||
id: "inst-1".to_string(),
|
||||
name: version.to_string(),
|
||||
version: GameVersion::parse(version),
|
||||
version_type: "release".to_string(),
|
||||
},
|
||||
launcher: LauncherInfo {
|
||||
name: "UnluckyLauncher".to_string(),
|
||||
version: "0.2.2".to_string(),
|
||||
},
|
||||
java: JavaSelection::new("C:/java/bin/java.exe", 21, 64),
|
||||
memory: MemoryOptions {
|
||||
min_mb: None,
|
||||
max_mb: None,
|
||||
},
|
||||
manifest: ManifestArgs {
|
||||
main_class: Some("net.minecraft.client.main.Main".to_string()),
|
||||
..ManifestArgs::default()
|
||||
},
|
||||
dirs: LaunchDirs {
|
||||
game_dir: PathBuf::from("C:/mc"),
|
||||
primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"),
|
||||
..LaunchDirs::default()
|
||||
},
|
||||
classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")],
|
||||
auth: AuthPlaceholders::default(),
|
||||
log4j: Log4jOptions {
|
||||
enabled: false,
|
||||
..Log4jOptions::default()
|
||||
},
|
||||
optimization: OptimizationOptions {
|
||||
enabled: false,
|
||||
physical_memory_mb: None,
|
||||
},
|
||||
language: "zh-CN".to_string(),
|
||||
..LaunchRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx() -> PlatformCtx {
|
||||
PlatformCtx::new(OsFamily::Windows, "x86_64")
|
||||
}
|
||||
|
||||
fn argvs(req: &LaunchRequest) -> Vec<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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 26.2+ (this instance: {version}). The flag is not sent on this version.", "需游戏版本 26.2 及以上(本实例 {version}),这个版本不会下发该参数。"),
|
||||
("InstancesLaunchEffective", "Effective: {value}", "生效:{value}"),
|
||||
("InstancesBackendDefault", "Default", "默认"),
|
||||
("InstancesBackendOpengl", "OpenGL", "OpenGL 后端"),
|
||||
("InstancesBackendVulkan", "Vulkan", "Vulkan 后端"),
|
||||
("InstancesLaunchSave", "Save", "保存"),
|
||||
("InstancesLaunchReset", "Reset to global", "恢复跟随全局"),
|
||||
];
|
||||
|
||||
/// 金表的**全量**行(前半段在本文件、后半段在 [`tail`],顺序即 `Key::ALL`)。
|
||||
///
|
||||
/// 拆成两段只为守 400 行硬线(M157 加了 19 行启动参数文案后越线);对调用方是**一条**序列,
|
||||
/// `catalog/tests.rs` 的逐字节断言不用关心分段。
|
||||
pub(super) fn rows() -> impl Iterator<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;
|
||||
@@ -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.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
|
||||
];
|
||||
@@ -78,6 +78,24 @@ macro_rules! instances_entries {
|
||||
InstancesStageVerify => ("Verifying", "校验中"),
|
||||
InstancesStageLaunch => ("Starting", "启动中"),
|
||||
InstancesStageWindow => ("Waiting for window", "等待游戏窗口"),
|
||||
// ── 启动参数区(M157):实例配置页的五项 + 三态后端 + 门槛提示
|
||||
InstancesLaunchTitle => ("LAUNCH OPTIONS", "启动参数"),
|
||||
InstancesLaunchJvm => ("JVM arguments", "JVM 参数"),
|
||||
InstancesLaunchJvmHint => ("Space separated, quotes allowed. Empty = follow the global extra JVM arguments (both are sent; on duplicates the instance wins).", "空格分隔、支持引号。留空 = 跟随全局附加 JVM 参数(两者叠加下发,同名选项实例赢)。"),
|
||||
InstancesLaunchGame => ("Game arguments", "游戏参数"),
|
||||
InstancesLaunchGameHint => ("Appended last, so they override earlier game arguments. Empty = nothing appended.", "追加在命令行最后,可覆盖前面的游戏参数。留空 = 不追加。"),
|
||||
InstancesLaunchMemory => ("Max memory (MiB)", "最大内存(MiB)"),
|
||||
InstancesLaunchMemoryHint => ("Empty = follow the global default ({default} MiB).", "留空 = 跟随全局默认({default} MiB)。"),
|
||||
InstancesLaunchJava => ("Java executable", "Java 可执行文件"),
|
||||
InstancesLaunchJavaHint => ("Empty = follow the global setting, then auto-pick (managed > JAVA_HOME > PATH).", "留空 = 跟随全局设置,再退到自动挑选(托管 > 环境变量 > 系统路径)。"),
|
||||
InstancesLaunchBackend => ("Graphics backend", "图形后端"),
|
||||
InstancesLaunchBackendGated => ("Requires MC 26.2+ (this instance: {version}). The flag is not sent on this version.", "需游戏版本 26.2 及以上(本实例 {version}),这个版本不会下发该参数。"),
|
||||
InstancesLaunchEffective => ("Effective: {value}", "生效:{value}"),
|
||||
InstancesBackendDefault => ("Default", "默认"),
|
||||
InstancesBackendOpengl => ("OpenGL", "OpenGL 后端"),
|
||||
InstancesBackendVulkan => ("Vulkan", "Vulkan 后端"),
|
||||
InstancesLaunchSave => ("Save", "保存"),
|
||||
InstancesLaunchReset => ("Reset to global", "恢复跟随全局"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`] 验证通道语义。
|
||||
|
||||
@@ -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("反序列化");
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -52,9 +52,28 @@ pub struct JavaPick {
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// 选一个能用的 Java:托管 > JAVA_HOME > PATH(`core::game::java` 的探测与优先级)。
|
||||
pub fn pick_java(env: &HostEnv, version_id: &str) -> JavaPick {
|
||||
/// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。
|
||||
///
|
||||
/// 显式路径**先探测**(`java -version`):探测失败不报错,按版本要求的基准主版本兜底——
|
||||
/// 与自动挑的退路同一口径(启动路径不该因为探测不到就拒绝启动)。
|
||||
pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick {
|
||||
let store = env.store();
|
||||
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
|
||||
if let Some(path) = explicit {
|
||||
let runtime = unlucky_core::game::java::probe::probe(
|
||||
path,
|
||||
unlucky_core::game::java::probe::JavaOrigin::Instance,
|
||||
);
|
||||
let major = runtime.major.unwrap_or(required.minimum);
|
||||
return JavaPick {
|
||||
selection: JavaSelection::new(runtime.binary.clone(), major, runtime.bits),
|
||||
summary: format!(
|
||||
"实例指定 Java {}({})",
|
||||
runtime.version.clone().unwrap_or_else(|| major.to_string()),
|
||||
path.display()
|
||||
),
|
||||
};
|
||||
}
|
||||
let install_root = store
|
||||
.doc()
|
||||
.effective_str("java.installRoot")
|
||||
@@ -69,7 +88,6 @@ pub fn pick_java(env: &HostEnv, version_id: &str) -> JavaPick {
|
||||
&ProcessEnv,
|
||||
);
|
||||
let candidates = unlucky_core::game::java::candidates_in_roots(&roots);
|
||||
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
|
||||
let picked = unlucky_core::game::java::pick(&candidates, &required).cloned();
|
||||
match picked {
|
||||
Some(runtime) => {
|
||||
@@ -201,18 +219,14 @@ pub fn build_request(
|
||||
game_assets: assets_root,
|
||||
};
|
||||
|
||||
let java = pick_java(env, &version_id);
|
||||
let overrides = super::launch_args::resolved(env, &target.id);
|
||||
let java = pick_java(env, &version_id, overrides.java_path.as_deref());
|
||||
let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
|
||||
let memory = env
|
||||
.store()
|
||||
.effective("java.default_memory_mb")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok());
|
||||
let options = AdapterOptions {
|
||||
java: java.selection,
|
||||
memory: MemoryOptions {
|
||||
min_mb: None,
|
||||
max_mb: memory,
|
||||
max_mb: Some(overrides.memory_mb),
|
||||
},
|
||||
auth,
|
||||
launcher_name: LAUNCHER_NAME.to_string(),
|
||||
@@ -227,6 +241,8 @@ pub fn build_request(
|
||||
let mut request = output.request;
|
||||
request.instance.id = target.id.clone();
|
||||
request.instance.name = target.name.clone();
|
||||
// 实例级启动参数(三级回落后的生效值)最后落位:user.* / memory / graphics_backend。
|
||||
unlucky_core::launch::overrides::apply(&mut request, &overrides);
|
||||
Ok((request, warnings))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! 实例启动参数:读(三级回落 + 版本门槛)与写(`launch` 段)。
|
||||
//!
|
||||
//! 本模块是 M157 的 host 侧唯一入口:
|
||||
//! - [`view`] → [`LaunchArgsView`](详情页「启动参数」区);
|
||||
//! - [`apply`] → 写回实例文件(`launch.*`),空输入清成「跟随」;
|
||||
//! - [`resolved`] → 启动装配用的生效值([`ResolvedLaunch`]),CLI 的 `snapshot::prepare`
|
||||
//! 与 GUI 的 `host::launch` 都从这里取,避免两条装配链各算一遍回落。
|
||||
//!
|
||||
//! 判据都在 core,本文件只做三件事:读设置文档填 [`GlobalLaunchDefaults`]、读实例文件、
|
||||
//! 把 core 的结论翻成 DTO。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::game::install::instance::{
|
||||
read_instance_config, write_instance_config, InstanceConfig,
|
||||
};
|
||||
use unlucky_core::game::launch_overrides::{
|
||||
resolve as resolve_overrides, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides,
|
||||
LaunchOverridesPatch, ResolvedLaunch,
|
||||
};
|
||||
use unlucky_core::game::{self, VersionCapabilities, VersionJson};
|
||||
use unlucky_core::launch::game_args::{graphics_backend_allowed, GRAPHICS_BACKEND_MIN_VERSION};
|
||||
use unlucky_core::launch::game_version::GameVersion;
|
||||
|
||||
use crate::dto::{LaunchArgsPatch, LaunchArgsView};
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
/// 全局层:`java.extra_args` / `java.default_memory_mb` / `java.selected_path`。
|
||||
///
|
||||
/// 另外两项(游戏参数、图形后端)本轮没有全局键——回落链照样成立(全局层为 `None`),
|
||||
/// 理由见 M157 的定案(不为它们单开全局键,免得与实例语义打架)。
|
||||
pub fn global_defaults(env: &HostEnv) -> GlobalLaunchDefaults {
|
||||
let store = env.store();
|
||||
let text = |key: &str| -> Option<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};
|
||||
|
||||
pub struct Dir {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl Dir {
|
||||
pub fn new(tag: &str) -> Self {
|
||||
let path = std::env::temp_dir().join(format!("{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Dir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(Path::new(&self.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_version(env: &HostEnv, id: &str, json: &str) {
|
||||
let path = env.game.version_json(id);
|
||||
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
|
||||
std::fs::write(&path, json).expect("写版本 JSON");
|
||||
}
|
||||
|
||||
/// 26.2-snapshot-2 的库表里带 `lwjgl-vulkan`(与 `research/05-mc-vulkan.md` 一致)。
|
||||
const VULKAN_VERSION: &str = r#"{
|
||||
"id": "26.2-snapshot-2",
|
||||
"libraries": [{"name": "org.lwjgl:lwjgl-vulkan:3.4.3"}]
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn gate_uses_core_capability_and_version_threshold() {
|
||||
let (env, _dir) = env();
|
||||
write_version(&env, "26.2-snapshot-2", VULKAN_VERSION);
|
||||
let (allowed, mc) = backend_gate(&env, "26.2-snapshot-2");
|
||||
assert!(allowed, "26.2-snapshot-2 应当放行");
|
||||
assert_eq!(mc, "26.2-snapshot-2");
|
||||
|
||||
// 26.1:版本不够(即使库表里硬塞了 vulkan 也不放行——精确门限优先)。
|
||||
write_version(&env, "26.1", VULKAN_VERSION);
|
||||
let (allowed, mc) = backend_gate(&env, "26.1");
|
||||
assert!(!allowed, "26.1 不该放行");
|
||||
assert_eq!(mc, "26.1");
|
||||
|
||||
// 没有版本 JSON:能力位全假 ⇒ 不放行(不 panic)。
|
||||
let (allowed, _) = backend_gate(&env, "1.20.4");
|
||||
assert!(!allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_read_round_trips_through_the_instance_file() {
|
||||
let (env, _dir) = env();
|
||||
let patch = LaunchArgsPatch {
|
||||
jvm_args: Some(" -XX:+UseG1GC ".to_string()),
|
||||
game_args: Some("--fullscreen".to_string()),
|
||||
memory_mb: Some("6144".to_string()),
|
||||
java_path: Some("D:/jdk21/bin/java.exe".to_string()),
|
||||
graphics_backend: Some("vulkan".to_string()),
|
||||
};
|
||||
apply(&env, "inst-1", "1.20.4", &patch).expect("写回");
|
||||
let config = read_instance_config(&env.layout, "inst-1")
|
||||
.expect("读回")
|
||||
.expect("文件应存在");
|
||||
assert_eq!(config.launch.jvm_args.as_deref(), Some("-XX:+UseG1GC"));
|
||||
assert_eq!(config.launch.memory_mb, Some(6144));
|
||||
assert_eq!(
|
||||
config.launch.graphics_backend,
|
||||
Some(GraphicsBackend::Vulkan)
|
||||
);
|
||||
assert_eq!(config.version_id, "1.20.4");
|
||||
|
||||
// 清空 = 跟随:`default` 与空串都把键抹掉。
|
||||
let clear = LaunchArgsPatch {
|
||||
jvm_args: Some(String::new()),
|
||||
game_args: Some(" ".to_string()),
|
||||
memory_mb: Some(String::new()),
|
||||
java_path: Some(String::new()),
|
||||
graphics_backend: Some("default".to_string()),
|
||||
};
|
||||
apply(&env, "inst-1", "1.20.4", &clear).expect("清空");
|
||||
let config = read_instance_config(&env.layout, "inst-1")
|
||||
.expect("读回")
|
||||
.expect("文件应存在");
|
||||
assert!(
|
||||
config.launch.is_empty(),
|
||||
"清空之后 launch 段应为空:{:?}",
|
||||
config.launch
|
||||
);
|
||||
let raw = config.to_json_string().expect("序列化");
|
||||
assert!(!raw.contains("\"launch\""), "空段不落盘:{raw}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_values_are_rejected_not_silently_dropped() {
|
||||
let (env, _dir) = env();
|
||||
let bad_backend = LaunchArgsPatch::backend("vulkan2");
|
||||
let error = apply(&env, "inst-1", "1.20.4", &bad_backend).expect_err("未知后端要报错");
|
||||
assert!(error.contains("vulkan2"), "{error}");
|
||||
|
||||
let bad_memory = LaunchArgsPatch {
|
||||
memory_mb: Some("999999".to_string()),
|
||||
..LaunchArgsPatch::default()
|
||||
};
|
||||
let error = apply(&env, "inst-1", "1.20.4", &bad_memory).expect_err("越界内存要报错");
|
||||
assert!(error.contains("256"), "{error}");
|
||||
|
||||
let not_a_number = LaunchArgsPatch {
|
||||
memory_mb: Some("4g".to_string()),
|
||||
..LaunchArgsPatch::default()
|
||||
};
|
||||
assert!(apply(&env, "inst-1", "1.20.4", ¬_a_number).is_err());
|
||||
|
||||
// 失败的写入不许留下半截文件。
|
||||
assert!(read_instance_config(&env.layout, "inst-1")
|
||||
.expect("读")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_reports_instance_layer_and_effective_values_apart() {
|
||||
let (env, _dir) = env();
|
||||
write_version(&env, "26.2-snapshot-2", VULKAN_VERSION);
|
||||
// 只填图形后端:其余各项都是「跟随」,生效值要给得出来。
|
||||
apply(
|
||||
&env,
|
||||
"inst-1",
|
||||
"26.2-snapshot-2",
|
||||
&LaunchArgsPatch::backend("vulkan"),
|
||||
)
|
||||
.expect("写回");
|
||||
let view = view(&env, "inst-1", "26.2-snapshot-2");
|
||||
assert_eq!(view.graphics_backend, "vulkan");
|
||||
assert_eq!(view.graphics_backend_effective, "vulkan");
|
||||
assert!(view.graphics_backend_allowed);
|
||||
assert_eq!(view.instance_id, "inst-1");
|
||||
assert_eq!(view.memory_mb, None);
|
||||
assert_eq!(
|
||||
view.memory_effective_mb,
|
||||
unlucky_core::game::launch_overrides::DEFAULT_MAX_MEMORY_MB
|
||||
);
|
||||
assert_eq!(view.graphics_backend_options.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ mod install_processors;
|
||||
mod java;
|
||||
mod job;
|
||||
mod launch;
|
||||
mod launch_args;
|
||||
mod loader_versions;
|
||||
mod logs;
|
||||
mod meter;
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -236,3 +240,26 @@ mod tests {
|
||||
assert!(mock_versions_for(VersionCategory::Mod).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// 演示用的启动参数视图(M157):一条真实形状的实例(有 JVM 参数、Vulkan、门槛放行)。
|
||||
fn mock_launch() -> LaunchArgsView {
|
||||
LaunchArgsView {
|
||||
instance_id: "survival".to_string(),
|
||||
jvm_args: "-XX:+UseG1GC".to_string(),
|
||||
game_args: String::new(),
|
||||
memory_mb: Some(4096),
|
||||
memory_effective_mb: 4096,
|
||||
java_path: String::new(),
|
||||
java_effective: "C:/Java/temurin-21/bin/java.exe".to_string(),
|
||||
graphics_backend: "vulkan".to_string(),
|
||||
graphics_backend_effective: "vulkan".to_string(),
|
||||
graphics_backend_allowed: true,
|
||||
mc_version: "26.2-snapshot-2".to_string(),
|
||||
graphics_backend_min_version: "26.2-snapshot-2".to_string(),
|
||||
graphics_backend_options: vec![
|
||||
"default".to_string(),
|
||||
"opengl".to_string(),
|
||||
"vulkan".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user