From dc8323531bfe8ea71972873916097bd336810d53 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 18:32:52 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(presenter):=20=E5=A4=9A=E6=A0=B9?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=EF=BC=88=E9=85=8D=E7=BD=AE=E6=A0=B9=20+=20?= =?UTF-8?q?=E5=AE=98=E6=96=B9=E5=90=AF=E5=8A=A8=E5=99=A8=E6=A0=87=E5=87=86?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=EF=BC=89=EF=BC=8C=E5=AE=9E=E4=BE=8B=E6=8C=89?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E7=9A=84=E6=A0=B9=E8=A7=A3=E6=9E=90=E5=AD=98?= =?UTF-8?q?=E6=A1=A3/=E6=A8=A1=E7=BB=84/=E5=90=AF=E5=8A=A8=EF=BC=88M274?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app-iced/src/pages/instances/saves.rs | 4 + .../src/pages/instances/saves/model.rs | 8 + .../src/pages/instances/tests/saves.rs | 34 ++ .../src/pages/instances/text/saves.rs | 4 + crates/i18n/src/catalog/golden.rs | 1 + crates/i18n/src/catalog/instances.rs | 5 + crates/presenter/src/host/addon/install.rs | 7 +- crates/presenter/src/host/addon/pack.rs | 10 +- crates/presenter/src/host/env.rs | 24 +- crates/presenter/src/host/game.rs | 110 +++-- crates/presenter/src/host/game/discovered.rs | 163 +++++-- crates/presenter/src/host/game/roots.rs | 190 +++++++++ crates/presenter/src/host/game/tests.rs | 4 +- .../src/host/game/tests_multiroot.rs | 397 ++++++++++++++++++ crates/presenter/src/host/game/tests_union.rs | 4 +- crates/presenter/src/host/java.rs | 4 +- crates/presenter/src/host/launch.rs | 44 +- crates/presenter/src/host/launch_args.rs | 33 +- crates/presenter/src/host/mods.rs | 9 +- crates/presenter/src/host/saves.rs | 9 +- 20 files changed, 943 insertions(+), 121 deletions(-) create mode 100644 crates/presenter/src/host/game/roots.rs create mode 100644 crates/presenter/src/host/game/tests_multiroot.rs diff --git a/crates/app-iced/src/pages/instances/saves.rs b/crates/app-iced/src/pages/instances/saves.rs index 81e3af1..844938e 100644 --- a/crates/app-iced/src/pages/instances/saves.rs +++ b/crates/app-iced/src/pages/instances/saves.rs @@ -116,6 +116,10 @@ pub(super) fn section<'a>( body = body.push(note(ui, texts.saves_loading())); return frame::card(ui, body); } + // 来源那一行(M274):多根之后「这个实例的存档到底在哪个目录」必须看得见。 + if saves.has_view && !saves.source.is_empty() { + body = body.push(note(ui, &saves.source)); + } if let Some(error) = saves.error.as_deref() { body = body.push(frame::text_role( ui, diff --git a/crates/app-iced/src/pages/instances/saves/model.rs b/crates/app-iced/src/pages/instances/saves/model.rs index 1f251fb..888a65c 100644 --- a/crates/app-iced/src/pages/instances/saves/model.rs +++ b/crates/app-iced/src/pages/instances/saves/model.rs @@ -86,6 +86,13 @@ pub struct Saves { pub retryable: bool, /// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。 pub has_view: bool, + /// 来源那一行(M274 多根):`存档目录:<绝对路径>`,**在这一层拼好**。 + /// + /// 便携目录与官方启动器是两个不同的根,两个根里都可能有一个叫 `A` 的实例、各有一批世界。 + /// 世界名分辨不出来源,所以把路径原样摆出来(也是排障时要问的第一件事)。 + /// 为什么不是页面里现拼:iced 的 `Element` 只借这一帧的数据,`view()` 返回的树借的是局部 + /// `String`,一出栈就 `E0515`(与 [`Saves::count`] 同一条理由)。 + pub source: String, /// 两个目录都不存在(新实例的常态:还没进过游戏)。 pub missing: bool, /// 世界行。 @@ -162,6 +169,7 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves { }; if let Some(view) = &state.view { out.has_view = true; + out.source = texts.saves_source(&view.dir); out.missing = !view.exists && !view.screenshots_exists; // 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍—— // M224 出图实测过"先算计数后装列表"画出 `0 启用 · 0 禁用` 的那种顺序错,这里从根上绕开。 diff --git a/crates/app-iced/src/pages/instances/tests/saves.rs b/crates/app-iced/src/pages/instances/tests/saves.rs index 15227fa..1d3afcf 100644 --- a/crates/app-iced/src/pages/instances/tests/saves.rs +++ b/crates/app-iced/src/pages/instances/tests/saves.rs @@ -253,5 +253,39 @@ fn millisecond_stamps_are_rendered_in_utc() { ); } +/// **来源那一行**(M274 多根):必须是这一份快照读的那个目录,两种语言都说出来。 +/// +/// 为什么非要有这一行:便携目录与官方启动器是两个不同的根,两个根里都可能有一个叫 `A` 的实例。 +/// 世界名分辨不出来源,只有路径能分辨(也是排障时要问的第一件事)。 +/// +/// 抓得住:来源行拿了别的实例 / 别的根的路径(界面上一模一样的错),或切了根却没跟着变; +/// 抓不住:页面真的把这一行画出来(`Element` 没有内省接口,见模块文档)。 +#[test] +fn the_source_line_names_the_folder_that_was_read() { + let mut state = bound_state("a"); + state.changed(view("a")); + let zh = from_state(&state, Lang::ZhCn.catalog()); + let en = from_state(&state, Lang::En.catalog()); + assert!( + zh.source.contains("C:/mc/saves"), + "来源行没带目录:{:?}", + zh.source + ); + assert!(en.source.contains("C:/mc/saves"), "{:?}", en.source); + assert_ne!(zh.source, en.source, "切换语言时这一行要变"); + + // 换一个根(另一个 `.minecraft`)⇒ 这一行跟着变(不许留在旧路径上)。 + let mut other = view("a"); + other.dir = "D:/official/.minecraft/saves".to_string(); + let mut state = bound_state("a"); + state.changed(other); + let moved = from_state(&state, Lang::ZhCn.catalog()); + assert!( + moved.source.contains("D:/official"), + "换了根之后来源行没跟着变:{:?}", + moved.source + ); +} + mod preview; mod write; diff --git a/crates/app-iced/src/pages/instances/text/saves.rs b/crates/app-iced/src/pages/instances/text/saves.rs index 8c01969..8dec27a 100644 --- a/crates/app-iced/src/pages/instances/text/saves.rs +++ b/crates/app-iced/src/pages/instances/text/saves.rs @@ -37,6 +37,10 @@ impl Texts { pub(in crate::pages::instances) fn saves_missing_dir(self) -> &'static str { self.text(Key::InstancesSavesMissingDir) } + /// 「这一份列表读自哪个目录」(M274 多根:世界名分辨不出来源,绝对路径可以)。 + pub(in crate::pages::instances) fn saves_source(self, dir: &str) -> String { + self.0.render(Key::InstancesSavesSource, &[("dir", dir)]) + } pub(in crate::pages::instances) fn saves_worlds(self) -> &'static str { self.text(Key::InstancesSavesWorlds) } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index ca18ae3..ba64137 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -160,6 +160,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("InstancesDiscoveredNote", "Discovered in versions/: where it runs is decided by the files that are already there.", "在版本目录里自动发现:它从哪里启动,由盘上已有的文件决定。"), ("InstancesDiscoveredRunShared", "Detected instance: it has no mods/ or saves/ of its own, so this launch uses the .minecraft folder (shared).", "自动发现的实例:它的版本目录里没有 mods/ 或 saves/,这次启动按共用目录运行(.minecraft 根目录)。"), ("InstancesDiscoveredRunIsolated", "Detected instance: it already has its own mods/ or saves/, so this launch uses its version folder (isolated).", "自动发现的实例:它的版本目录里已经有 mods/ 或 saves/,这次启动按独立目录运行(它自己的版本文件夹)。"), + ("InstancesSavesSource", "Saves folder: {dir}", "存档目录:{dir}"), ]; /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。 diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index 2b71551..145ebb8 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -231,6 +231,11 @@ macro_rules! instances_entries { InstancesDiscoveredNote => ("Discovered in versions/: where it runs is decided by the files that are already there.", "在版本目录里自动发现:它从哪里启动,由盘上已有的文件决定。"), InstancesDiscoveredRunShared => ("Detected instance: it has no mods/ or saves/ of its own, so this launch uses the .minecraft folder (shared).", "自动发现的实例:它的版本目录里没有 mods/ 或 saves/,这次启动按共用目录运行(.minecraft 根目录)。"), InstancesDiscoveredRunIsolated => ("Detected instance: it already has its own mods/ or saves/, so this launch uses its version folder (isolated).", "自动发现的实例:它的版本目录里已经有 mods/ 或 saves/,这次启动按独立目录运行(它自己的版本文件夹)。"), + // ── 多根发现(M274):存档区标明**这一份列表读自哪个目录**。 + // 一打开启动器就能看见两个 `.minecraft`(便携目录 + 官方启动器那个)各自的实例, + // 两个根都可能有一个叫 `A` 的实例、各有一批世界。世界名本身分辨不出来源, + // 所以这份列表把**绝对路径**摆出来:那是唯一不会说谎的来源标识(也是排障要的东西)。 + InstancesSavesSource => ("Saves folder: {dir}", "存档目录:{dir}"), } } }; } diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index baeb4b8..ae6f741 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -290,10 +290,13 @@ fn find_instance(env: &HostEnv, id: &str) -> Result 基底档,见 `game::base_mode`)。 fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result { - let base = super::super::game::base_mode(env, &instance.id, &instance.version_id); + // 装进**这个实例自己那个根**的运行目录(M274):官方根里的实例要把 mod 放进官方的 + // `.minecraft/mods`(或它自己的版本目录),不是 ULL 便携根。 + let game = super::super::game::game_for(env, &instance.id); + let base = super::super::game::base_mode(env, &game, &instance.id, &instance.version_id); let config = read_instance_config(&env.layout, &instance.id) .map_err(|error| local(format!("读实例设置失败:{error}")))?; - derive_game_dir(&env.game, &instance.version_id, base, config.as_ref(), None) + derive_game_dir(&game, &instance.version_id, base, config.as_ref(), None) .map(|derivation| derivation.game_dir) .map_err(|error| local(format!("隔离目录派生失败:{error}"))) } diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs index 738b8c7..b735a3d 100644 --- a/crates/presenter/src/host/addon/pack.rs +++ b/crates/presenter/src/host/addon/pack.rs @@ -286,10 +286,11 @@ pub(super) fn instance_root(env: &HostEnv, id: &InstanceId) -> Result Option, } impl HostEnv { @@ -56,12 +62,15 @@ impl HostEnv { let root = root.as_ref(); let request = request_from_portable(root); let absolute = |path: Option| path.filter(|path| path.is_absolute()); - Self::from_data(ResolvedData { + let mut env = Self::from_data(ResolvedData { mode: DataMode::Portable, launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")), game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")), redirected_from: None, - }) + }); + // 钉一个根的通道(截图 / 演示 / 用例)**不做多根发现**:见 `standard_game` 的文档。 + env.standard_game = None; + env } /// 由解析结果建环境。 @@ -74,10 +83,17 @@ impl HostEnv { pub fn from_data(data: ResolvedData) -> Self { let layout = data.layout(); unlucky_core::launch::launcher_log::set_dir(layout.logs()); + let platform = Platform::current(); Self { layout, game: GameLayout::new(data.game_home.clone()), - platform: Platform::current(), + platform, + // M274:多根发现的第二个候选永远是**官方启动器**的标准位置(HMCL / PCL 用的是 + // 同一个地方),与配置的 `game_home` 是不是同一个由 `roots::all` 的去重判据管。 + standard_game: unlucky_core::platform::paths::standard_game_home( + platform, + &unlucky_core::platform::paths::ProcessEnv, + ), data, } } diff --git a/crates/presenter/src/host/game.rs b/crates/presenter/src/host/game.rs index d41ccd8..fdadf10 100644 --- a/crates/presenter/src/host/game.rs +++ b/crates/presenter/src/host/game.rs @@ -6,6 +6,10 @@ //! 隔离档位取自实例设置(缺省跟全局 `instances.mode`)。 //! //! **列表是并集**(M273):档案 + 盘上有清单但没有档案绑定的版本目录,见 [`instances`] 的文档。 +//! +//! **根是多个**(M274):配置的 `game_home` + 官方启动器的标准位置(`%APPDATA%\.minecraft` 等), +//! 规范化真实路径去重([`roots`])。每个自动发现的实例因此带**自己的根**——读版本清单、派生运行 +//! 目录、列存档 / 模组都走 [`game_for`],不全是 `env.game`。 use std::collections::BTreeMap; use std::path::Path; @@ -14,7 +18,7 @@ use unlucky_core::game::install::instance::{ read_instance_config, read_profiles, write_instance_config, write_profiles, }; use unlucky_core::game::isolation::IsolationMode; -use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList, VersionJson}; +use unlucky_core::game::{GameLayout, InstanceProfile, ProfileId, ProfileList, VersionJson}; use crate::dto::{ InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView, @@ -23,10 +27,11 @@ use crate::dto::{ use super::env::HostEnv; mod discovered; +mod roots; -pub(super) use discovered::base_mode; +pub(super) use discovered::{base_mode, game_for}; -use discovered::{external_summary, external_version_of, external_versions}; +use discovered::{display_name, external_summary, external_version_of, external_versions}; /// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。 pub fn global_mode(env: &HostEnv) -> IsolationMode { @@ -59,13 +64,15 @@ pub fn last_played(env: &HostEnv) -> BTreeMap { } /// 版本 JSON 的继承链根 id + 加载器(读不到返回 `(version_id, 原版)`)。 -pub fn describe_version(env: &HostEnv, version_id: &str) -> (String, LoaderInfo) { +/// +/// **按传入的那个根读**(M274):外部实例的版本清单在自己那个根下,拿配置根去读会读空。 +pub fn describe_version(game: &GameLayout, version_id: &str) -> (String, LoaderInfo) { let loader = loader_from_id(version_id); let mut current = version_id.to_string(); let mut seen = 0; while seen < 16 { seen += 1; - let path = env.game.version_json(¤t); + let path = game.version_json(¤t); let Ok(text) = std::fs::read_to_string(&path) else { break; }; @@ -115,15 +122,17 @@ fn dto_kind(kind: unlucky_core::game::LoaderKind) -> LoaderKind { } /// 实例的隔离档位(实例文件优先,缺省 = [`base_mode`])。 -pub fn isolation_of(env: &HostEnv, id: &str, version_id: &str) -> String { - let base = base_mode(env, id, version_id); +/// +/// `game` 是这个实例**自己那个根**的布局(多根之后同一个版本 id 在两个根里可以有不同基底档)。 +pub fn isolation_of(env: &HostEnv, game: &GameLayout, id: &str, version_id: &str) -> String { + let base = base_mode(env, game, id, version_id); match read_instance_config(&env.layout, id) { Ok(Some(config)) => config.effective_isolation(base).as_str().to_string(), _ => base.as_str().to_string(), } } -/// 实例快照:**档案 ∪ 盘上的版本目录**。 +/// 实例快照:**档案 ∪ 盘上的版本目录**(M273),版本目录来自**多个游戏根**(M274)。 /// /// ## 为什么是并集(M273) /// @@ -132,20 +141,25 @@ pub fn isolation_of(env: &HostEnv, id: &str, version_id: &str) -> String { /// 存在 `versions//.json`,**没有**我们的档案记录。只读档案 ⇒ 已装好的实例在界面上 /// 不存在,用户以为「读不到这个 .minecraft」。 /// -/// 所以:档案照旧(名字 / 隔离档 / 启动参数都以档案为准),**盘上有清单但没有档案绑定的版本** -/// 就地合成一条视图挂上去。判据不在这里另写一套:扫描走 -/// [`unlucky_core::game::adopt::scan`](`versions//.json` 能解出 `id`、继承链走到底、 -/// 符号链接不越界、坏目录逐条报问题),本文件只做 DTO 翻译 + 「有没有档案绑定」这一步。 +/// ## 为什么是多个根(M274) +/// +/// 官方启动器用的是 `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `/.minecraft`——**两个 +/// 不同的目录**。只扫配置根等于「装了官方启动器也看不见它的实例」。所以这里遍历 +/// `roots::all(env)`:配置根 + 官方启动器的标准位置(规范化真实路径去重,见 `roots::key_of`)。 +/// +/// 那一批根里**只有配置根**有档案语义:`profiles.json` 的 `version_id` 指的是配置根下的版本, +/// 所以「已被档案绑定 ⇒ 不重复列」这条只对配置根成立;别的根里的同名版本是**另一个实例** +/// (id 里带着根,见 `discovered::external_instance_id`)。 /// /// ## 三条不变量 /// -/// 1. **只读**:合成视图不建目录、不写 `profiles.json`、不碰 `versions/` 里一个字节 +/// 1. **只读**:合成视图不建目录、不写 `profiles.json`、不碰任何 `versions/` 里一个字节 /// (`instances()` 会被每次刷新调用,写盘会变成「一打开启动器就改别人的文件夹」); -/// 2. **档案优先**:某个版本已经有档案绑定(不论档案 id 是什么)⇒ 只按档案出**一条**, +/// 2. **档案优先**:配置根里某个版本已经有档案绑定(不论档案 id 是什么)⇒ 只按档案出**一条**, /// 不重复挂合成视图(用户改过的名字/隔离档不会因为盘上有同名版本被顶掉); /// 3. **坏东西不当实例**:清单缺 / JSON 坏 / `id` 与目录名不一致 / 缺父版本 / 越界符号链接 -/// ⇒ 不进列表(`AdoptCandidate::adoptable()`);`versions/` 整个读不出来也只是退回 -/// 只有档案的旧行为,不让实例页因此报错。 +/// ⇒ 不进列表(`AdoptCandidate::adoptable()`);某个根整个读不出来也只是少列它那几条, +/// 不让实例页因此报错。 pub fn instances(env: &HostEnv) -> Vec { let profiles = profiles(env); let played = last_played(env); @@ -154,14 +168,50 @@ pub fn instances(env: &HostEnv) -> Vec { .iter() .map(|profile| profile_summary(env, profile, &played)) .collect(); - snapshot.extend( - external_versions(env, &profiles) - .iter() - .map(|version_id| external_summary(env, version_id, &played)), - ); + // 两件「每份档案只算一次」的事(反查要扫盘,放进版本循环里就是 O(档案×版本) 次扫描): + // ① 哪些版本被**属于配置根的**档案绑定了 ⇒ 配置根不再为它合成一条; + // ② 现有的档案 id ⇒ 同一个 id 不再合成第二条视图。 + let bound: Vec<&str> = profiles + .profiles + .iter() + .filter(|profile| profile_belongs_here(env, profile)) + .map(|profile| profile.version_id.as_str()) + .collect(); + let known: Vec<&str> = profiles + .profiles + .iter() + .map(|profile| profile.id.as_str()) + .collect(); + for root in roots::all(env) { + let configured = root.configured(); + for version_id in external_versions(env, root.game()) { + let id = discovered::external_instance_id(root.key(), &version_id); + if known.contains(&id.as_str()) { + continue; + } + if configured && bound.contains(&version_id.as_str()) { + continue; + } + snapshot.push(external_summary(env, &root, &version_id, &played)); + } + } snapshot } +/// 这条档案代表的是**配置根**里的版本吗(决定它是否「占住」配置根的同名版本)。 +/// +/// 有档案就按档案出——但多根之后要分清两种 `ext-…` 档案: +/// - 根 B 里的实例被懒创建成档案 ⇒ 它占的是**根 B** 的那个版本,不许连带把配置根里的同名版本 +/// 也一并抹掉(那是另一个实例,有自己的存档); +/// - 反查不到的 id(M273 之前合成的、或那一批根已经不在盘上)⇒ 按旧口径算配置根的, +/// 「档案优先」这条因此不会因为升级而失效。 +fn profile_belongs_here(env: &HostEnv, profile: &InstanceProfile) -> bool { + match discovered::locate(env, profile.id.as_str()) { + Some((root, _)) => root.configured(), + None => true, + } +} + /// 一条档案 → 磁贴(档案路径的唯一翻译点)。 fn profile_summary( env: &HostEnv, @@ -169,7 +219,9 @@ fn profile_summary( played: &BTreeMap, ) -> InstanceSummary { let version_id = profile.version_id.clone(); - let (mc_version, loader) = describe_version(env, &version_id); + // 档案实例属于**配置根**(`profiles.json` 里的 `version_id` 指的就是它)。 + let game = &env.game; + let (mc_version, loader) = describe_version(game, &version_id); let id = profile.id.as_str(); InstanceSummary { id: id.to_string(), @@ -179,9 +231,9 @@ fn profile_summary( last_played: played.get(id).cloned(), runtime: InstanceRuntime::Idle, icon: None, - isolation: isolation_of(env, id, &version_id), - version_present: env.game.is_version_installed(&version_id), - launch: super::launch_args::view(env, id, &version_id), + isolation: isolation_of(env, game, id, &version_id), + version_present: game.is_version_installed(&version_id), + launch: super::launch_args::view(env, game, id, &version_id), version_id, } } @@ -206,12 +258,13 @@ pub(super) fn ensure_profile(env: &HostEnv, id: &str) -> Result String { mod tests; #[cfg(test)] mod tests_union; + +#[cfg(test)] +mod tests_multiroot; diff --git a/crates/presenter/src/host/game/discovered.rs b/crates/presenter/src/host/game/discovered.rs index 730f3c1..1c52f1c 100644 --- a/crates/presenter/src/host/game/discovered.rs +++ b/crates/presenter/src/host/game/discovered.rs @@ -1,22 +1,34 @@ -//! 自动发现的实例(M273):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。 +//! 自动发现的实例(M273 · 多根 M274):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。 //! //! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件的两件事: //! -//! 1. **哪些盘上的版本算实例**:`versions//.json` 能解析、没有被任何档案绑定 -//! (判据来自 `core::game::adopt::scan`,本文件只做「有没有档案」这一步); +//! 1. **哪些盘上的版本算实例**:`versions//.json` 能解析(判据来自 `core::game::adopt::scan`, +//! 本文件只做「哪个根」与「有没有档案」这两步); //! 2. **它们从哪个目录跑**:[`base_mode`]——`versions//` 里有痕迹(`mods` / `saves` / -//! `config` / `options.txt`)就按独立目录,否则按共用目录(游戏根)。 +//! `config` / `options.txt`)就按独立目录,否则按共用目录(**它自己那个根**)。 //! -//! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节。 +//! # 多根(M274):身份 = (根, 版本) +//! +//! 两个根里都可能有 `1.20.4`,所以版本 id **不再唯一**。身份的落点只有一个: +//! [`external_instance_id`](把根的规范化路径喂进哈希)。于是「同一个根 + 同一个版本目录」跨重启 +//! 拿到同一个 id,而两个根里的同名版本拿到两个不同 id——列表自然出两条。 +//! +//! 反查(id → 根 + 版本)只能靠**重算**:哈希不可逆,所以 [`locate`] 枚举全部根、每个根扫一遍 +//! `versions/`,逐个比 id。因此它只在「用户动了一次某个实例」时被调用(存档 / 模组 / 启动 / 改设置), +//! 不在 `instances()` 的循环里。 +//! +//! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节, +//! 更不往官方启动器的根里写任何东西。 use std::collections::BTreeMap; use unlucky_core::game::isolation::IsolationMode; -use unlucky_core::game::ProfileList; +use unlucky_core::game::{GameLayout, ProfileList}; use crate::dto::{InstanceRuntime, InstanceSummary}; use super::super::env::HostEnv; +use super::roots::{self, GameRoot}; use super::{describe_version, global_mode, isolation_of}; /// `versions//` 里这些名字出现任何一个,就说明这个版本本来就被当成 @@ -27,8 +39,10 @@ use super::{describe_version, global_mode, isolation_of}; const RUN_DIR_TRACES: [&str; 4] = ["mods", "saves", "config", "options.txt"]; /// 版本目录里有没有「它自己用过」的痕迹(只读)。 -fn has_own_run_dir(env: &HostEnv, version_id: &str) -> bool { - let dir = env.game.version_dir(version_id); +/// +/// **必须传这个实例自己的根**:同一个版本 id 在两个根里的痕迹可以不同(官方根里跑过、ULL 根里没有)。 +fn has_own_run_dir(game: &GameLayout, version_id: &str) -> bool { + let dir = game.version_dir(version_id); RUN_DIR_TRACES.iter().any(|name| dir.join(name).exists()) } @@ -43,38 +57,42 @@ fn has_own_run_dir(env: &HostEnv, version_id: &str) -> bool { /// `versions//`。一个从没跑过、也没有 `mods`/`saves` 的外部版本若按 `isolated` 处理, /// 第一次启动就会在**别人的版本目录**里新长出 `mods/` `saves/` `config/`(D20 的 /// `AdoptTarget` 缺省值正是为了躲开这件事而选 `InstanceDir`)。反过来,盘上已经有这些痕迹的 -/// 版本本来就是独立目录实例,按 `shared` 处理会让它突然读到 `.minecraft` 根下别人的存档 —— +/// 版本本来就是独立目录实例,按 `shared` 处理会让它突然读到根下别人的存档 —— /// 两种错都真实存在,所以判据只能是**盘上的事实**。 /// +/// **M274**:痕迹按**这个实例自己的根**判(`game` 参数)。换根之后判定不变:官方根里那个 +/// 带 `mods/` 的版本仍是 `isolated`,ULL 根里那个干净的版本仍是 `shared`。 +/// /// 只读:这里不建目录、不写任何文件(写盘只发生在用户第一次改设置时,见 [`super::ensure_profile`])。 -pub(in crate::host) fn base_mode(env: &HostEnv, id: &str, version_id: &str) -> IsolationMode { +pub(in crate::host) fn base_mode( + env: &HostEnv, + game: &GameLayout, + id: &str, + version_id: &str, +) -> IsolationMode { if !crate::dto::is_discovered_instance(id) { return global_mode(env); } - if has_own_run_dir(env, version_id) { + if has_own_run_dir(game, version_id) { IsolationMode::Isolated } else { IsolationMode::Shared } } -/// 盘上有版本、但没有档案绑定的那些版本 id(字典序,来自 core 的扫描)。 +/// 一个根下「盘上有版本、可登记」的那些版本 id(字典序,来自 core 的扫描)。 /// /// 扫描失败(`versions/` 读不出来 / 断链联接)时返回空:列表退回「只有档案」的旧行为。 /// 这里**不**把错误往上抛——实例页打不开是比「少列几个外部实例」严重得多的故障。 -pub(super) fn external_versions(env: &HostEnv, profiles: &ProfileList) -> Vec { - let Ok(scan) = unlucky_core::game::adopt::scan(&env.game, &env.layout) else { +/// +/// 档案绑定(`profile.version_id`)**不在这一层过滤**:那只对配置根有意义,由 [`super::instances`] 做。 +pub(in crate::host) fn external_versions(env: &HostEnv, game: &GameLayout) -> Vec { + let Ok(scan) = unlucky_core::game::adopt::scan(game, &env.layout) else { return Vec::new(); }; scan.candidates .into_iter() .filter(|candidate| candidate.adoptable()) - .filter(|candidate| { - !profiles - .profiles - .iter() - .any(|profile| profile.version_id == candidate.version_id) - }) .map(|candidate| candidate.version_id) .collect() } @@ -86,34 +104,53 @@ pub(super) fn external_versions(env: &HostEnv, profiles: &ProfileList) -> Vec String { +/// **M274**:`root_key` 也进哈希(`fnv(root || 0 || version)`)——两个根里的 `1.20.4` 因此是两个 +/// 实例。分隔符用 `0`:路径里不可能有 NUL,所以 `("ab","c")` 与 `("a","bc")` 不会撞。 +pub(in crate::host) fn external_instance_id(root_key: &str, version_id: &str) -> String { let mut hash: u64 = 0xcbf2_9ce4_8422_2325; - for byte in version_id.as_bytes() { - hash ^= u64::from(*byte); + let bytes = root_key + .as_bytes() + .iter() + .copied() + .chain(std::iter::once(0)) + .chain(version_id.as_bytes().iter().copied()); + for byte in bytes { + hash ^= u64::from(byte); hash = hash.wrapping_mul(0x0000_0100_0000_01b3); } format!("{}{hash:016x}", crate::dto::DISCOVERED_ID_PREFIX) } -/// 一条外部版本 → 磁贴(显示名 = 版本目录名;**只读**,不落任何文件)。 -pub(super) fn external_summary( +/// 磁贴的显示名:配置根里的版本就是版本目录名;**别的根**里的版本带上根路径。 +/// +/// 为什么非带不可:两个根里都可能有一个叫 `A` 的版本,列表上两条同名磁贴,用户无从分辨点哪一条 +/// (而两条的存档在不同的目录里)。路径是**事实**,直接摆出来;只对配置根保持原样是为了不让 +/// 绝大多数用户看到一堆长路径。 +pub(in crate::host) fn display_name(root: &GameRoot, version_id: &str) -> String { + if root.configured() { + return version_id.to_string(); + } + format!("{version_id} · {}", root.path().display()) +} + +/// 一条外部版本 → 磁贴(**只读**,不落任何文件)。 +pub(in crate::host) fn external_summary( env: &HostEnv, + root: &GameRoot, version_id: &str, played: &BTreeMap, ) -> InstanceSummary { - let id = external_instance_id(version_id); - let (mc_version, loader) = describe_version(env, version_id); + let id = external_instance_id(root.key(), version_id); + let game = root.game(); + let (mc_version, loader) = describe_version(game, version_id); InstanceSummary { last_played: played.get(&id).cloned(), - isolation: isolation_of(env, &id, version_id), - launch: super::super::launch_args::view(env, &id, version_id), - version_present: env.game.is_version_installed(version_id), - name: version_id.to_string(), + isolation: isolation_of(env, game, &id, version_id), + launch: super::super::launch_args::view(env, game, &id, version_id), + version_present: game.is_version_installed(version_id), + name: display_name(root, version_id), version_id: version_id.to_string(), mc_version, loader, @@ -123,16 +160,56 @@ pub(super) fn external_summary( } } -/// 视图 id → 版本 id 的反查(只认「可登记、且还没有档案绑定」的版本)。 +/// 视图 id → (所属根, 版本 id):**唯一的反查点**(哈希不可逆,只能重算比对)。 /// -/// 与 [`external_versions`] 同一份判据:合成的视图 id 只在那个集合里有意义, -/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批。 -pub(super) fn external_version_of( +/// 与 [`external_versions`] 同一份判据(可登记 ⇒ 会出现在列表里),但**不**看档案绑定: +/// 一个自动发现的实例被懒创建成档案之后,id 仍然是 `ext-…`,而它的根必须还认得出来 +/// (否则存档 / 模组 / 启动会退回配置根,那正是 M274 要修的错)。 +/// +/// 不是 `ext-` 前缀的 id 直接返回 `None`(档案实例属于配置根,不需要反查)。 +#[must_use] +pub(in crate::host) fn locate(env: &HostEnv, id: &str) -> Option<(GameRoot, String)> { + if !crate::dto::is_discovered_instance(id) { + return None; + } + for root in roots::all(env) { + for version_id in external_versions(env, root.game()) { + if external_instance_id(root.key(), &version_id) == id { + return Some((root, version_id)); + } + } + } + None +} + +/// 实例所属的那个根的布局(读盘路径的唯一入口)。 +/// +/// 档案实例、以及「反查不到」的 id 一律退回配置根 `env.game`——**没有第二个答案**: +/// 宁可读配置根(旧行为),也不把实例指向一个猜出来的目录。 +pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout { + locate(env, id) + .map(|(root, _)| root.game().clone()) + .unwrap_or_else(|| env.game.clone()) +} + +/// 视图 id → 版本 id 的反查(只认「可登记、且**还没有档案绑定**」的版本)。 +/// +/// 与 `instances()` 同一份判据:合成的视图 id 只在那个集合里有意义,于是「界面上出现过的外部实例」 +/// 与「能懒创建的外部实例」永远是同一批(有了档案就走档案,不再重复列一条)。 +#[must_use] +pub(in crate::host) fn external_version_of( env: &HostEnv, profiles: &ProfileList, id: &str, -) -> Option { - external_versions(env, profiles) - .into_iter() - .find(|version_id| external_instance_id(version_id) == id) +) -> Option<(GameRoot, String)> { + let (root, version_id) = locate(env, id)?; + let bound = profiles + .profiles + .iter() + .any(|profile| profile.version_id == version_id); + // 档案绑定只对**配置根**有意义(别处的同名版本是另一个实例,同一个 id 也不会撞)。 + if root.configured() && bound { + return None; + } + Some((root, version_id)) } diff --git a/crates/presenter/src/host/game/roots.rs b/crates/presenter/src/host/game/roots.rs new file mode 100644 index 0000000..a1c66fa --- /dev/null +++ b/crates/presenter/src/host/game/roots.rs @@ -0,0 +1,190 @@ +//! 游戏根的发现与去重(M274):**当前配置的根** + **官方启动器的标准位置**。 +//! +//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是 +//! `%APPDATA%\.minecraft` / `~/Library/Application Support/minecraft` / `~/.minecraft`, +//! 而便携模式下的 ULL 用的是 `/.minecraft`——**两个根,不是一回事**。所以列表要能同时读两边。 +//! +//! # 只有一件事不是显然的:什么算「同一个根」 +//! +//! 同一个目录可以有四种写法:大小写不同、`\` 与 `/` 混用、8.3 短名(`PROGRA~1`)、 +//! 符号链接 / 目录联接(Windows 的 junction)。按字面量比较会让**同一批实例列两遍**, +//! 所以判据落在 [`key_of`] 的**规范化真实路径**上: +//! +//! 1. `canonicalize`(Windows 上是 `GetFinalPathNameByHandle`:短名展开成长名,联接解析到目标); +//! 2. `\` → `/`; +//! 3. **去掉 `\\?\` 前缀**——Windows 的 `canonicalize` 一律返回 verbatim 形式(`\\?\C:\…`), +//! 不去掉的话「同一路径的两种写法」永远不相等(这是个已知坑,用例 +//! `junction_to_the_same_folder_is_one_root` 钉住); +//! 4. Windows / macOS 折成小写(大小写不敏感的文件系统)。 +//! +//! `canonicalize` 失败(根还不存在)时退化成「字面量归一」:`\`→`/`、去尾斜杠、折小写。 +//! 尚不存在的目录没有短名/链接可解析,这一步够用。 +//! +//! # 只读 +//! +//! 本模块只 `is_dir()` / `canonicalize()`:不建目录、不写一个字节。官方启动器那个根上尤其要紧 +//! ——那是**别人的**目录。 + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use unlucky_core::game::GameLayout; + +use super::super::env::HostEnv; + +/// 一个被发现的游戏根。 +/// +/// `configured` 那一格区分「用户在设置里指定的根」与「官方启动器的标准位置」:档案绑定、 +/// 新建/安装版本都只对前者有意义(我们**从不往官方根写东西**,见 M274 的只读纪律)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::host) struct GameRoot { + key: String, + game: GameLayout, + configured: bool, +} + +impl GameRoot { + /// 这个根的布局(`versions/` / `libraries/` / `assets/` 都在它下面)。 + pub(in crate::host) fn game(&self) -> &GameLayout { + &self.game + } + + /// 规范化后的比较键(实例 id 的哈希输入之一,见 [`super::discovered::external_instance_id`])。 + pub(in crate::host) fn key(&self) -> &str { + &self.key + } + + /// 是不是**当前配置**的那个根(`env.game`)。 + pub(in crate::host) fn configured(&self) -> bool { + self.configured + } + + /// 根目录本身(显示用)。 + pub(in crate::host) fn path(&self) -> &Path { + self.game.root() + } +} + +/// 全部游戏根:**配置根永远第一位**,官方启动器的标准位置(存在、且不是同一个物理目录)第二位。 +/// +/// 官方位置不存在 ⇒ 不列。「没装官方启动器」不该在列表上留一个空根——那是「这里本来就没有」, +/// 与「有个空目录」是两件事。 +pub(in crate::host) fn all(env: &HostEnv) -> Vec { + let mut roots = vec![GameRoot { + key: key_of(env.game.root()), + game: env.game.clone(), + configured: true, + }]; + let mut seen: BTreeSet = roots.iter().map(|root| root.key.clone()).collect(); + for extra in env.standard_game.iter() { + if !extra.is_dir() { + continue; + } + let key = key_of(extra); + // 同一个物理目录(符号链接 / 目录联接 / 大小写 / 短名)⇒ 只留配置根那一条。 + if !seen.insert(key.clone()) { + continue; + } + roots.push(GameRoot { + key, + game: GameLayout::new(extra.clone()), + configured: false, + }); + } + roots +} + +/// 路径 → **比较键**(判据的完整理由见模块文档)。 +/// +/// 同一个物理目录的所有写法必须给同一个键;不同的目录必须给不同的键(大小写不敏感的平台除外, +/// 那里它们本来就是同一个目录)。 +pub(in crate::host) fn key_of(path: &Path) -> String { + let resolved: PathBuf = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let text = resolved.to_string_lossy().replace('\\', "/"); + // verbatim 前缀(`\\?\C:\…` 在上一步已经变成 `//?/C:/…`):不去掉就与普通写法永不相等。 + let text = text + .strip_prefix("//?/") + .or_else(|| text.strip_prefix("//./")) + .unwrap_or(text.as_str()); + let text = text.trim_end_matches('/'); + let text = if text.is_empty() { "/" } else { text }; + if cfg!(any(windows, target_os = "macos")) { + text.to_lowercase() + } else { + text.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_root(tag: &str) -> PathBuf { + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时目录"); + path + } + + /// 斜杠方向 / 尾斜杠 / 大小写不影响比较键(`canonicalize` 之后三者本来就该相等)。 + #[test] + fn key_ignores_separators_case_and_trailing_slash() { + let root = temp_root("ull-roots-key"); + let nested = root.join("Minecraft/.minecraft"); + std::fs::create_dir_all(&nested).expect("建目录"); + let plain = key_of(&nested); + assert_eq!(plain, key_of(&PathBuf::from(format!("{}/", plain)))); + let mixed = PathBuf::from(plain.replace('/', "\\")); + assert_eq!(plain, key_of(&mixed), "反斜杠写法必须是同一个根"); + assert!( + !plain.starts_with("//?/"), + "verbatim 前缀没去掉,两种写法永远不相等:{plain}" + ); + std::fs::remove_dir_all(&root).ok(); + } + + /// **去重判据**:`root_b` 是指向 `root_a` 的目录联接(junction)⇒ 两个写法是同一个根。 + /// + /// 这一条是整个多根发现最容易假绿的地方:只比字面量时它必然「通过」两条,而用户看到同一批 + /// 实例列了两遍。Windows 上用 `mklink /J`(不需要管理员),其它平台用符号链接。 + #[test] + fn junction_to_the_same_folder_is_one_root() { + let root = temp_root("ull-roots-junction"); + let target = root.join("official/.minecraft"); + std::fs::create_dir_all(target.join("versions")).expect("建目录"); + let link = root.join("linked"); + if !make_dir_link(&link, &target) { + // 造不出链接就**不假绿**:这一条判据在没链接的盘上等于没验,如实标出来。 + eprintln!("跳过:这个环境造不出目录链接({})", link.display()); + std::fs::remove_dir_all(&root).ok(); + return; + } + assert_eq!( + key_of(&target), + key_of(&link), + "联接与目标是同一个根:{} vs {}", + key_of(&target), + key_of(&link) + ); + std::fs::remove_dir_all(&root).ok(); + } + + /// 造一个目录链接:Windows 用 junction(无管理员权限也能建),Unix 用符号链接。 + #[cfg(windows)] + fn make_dir_link(link: &Path, target: &Path) -> bool { + std::process::Command::new("cmd") + .args(["/c", "mklink", "/J"]) + .arg(link) + .arg(target) + .status() + .map(|status| status.success()) + .unwrap_or(false) + } + + #[cfg(not(windows))] + fn make_dir_link(link: &Path, target: &Path) -> bool { + std::os::unix::fs::symlink(target, link).is_ok() + } +} diff --git a/crates/presenter/src/host/game/tests.rs b/crates/presenter/src/host/game/tests.rs index e275619..80d0641 100644 --- a/crates/presenter/src/host/game/tests.rs +++ b/crates/presenter/src/host/game/tests.rs @@ -190,9 +190,9 @@ fn isolation_write_then_read_round_trips() { let id = profiles.profiles[0].id.as_str().to_string(); // 有档案的实例:基底档 = 全局档(`isolated`),与 M273 之前一字不差。 - assert_eq!(isolation_of(&env, &id, "1.20.1"), "isolated"); + assert_eq!(isolation_of(&env, &env.game, &id, "1.20.1"), "isolated"); set_isolation(&env, &id, "shared").expect("切档位"); - assert_eq!(isolation_of(&env, &id, "1.20.1"), "shared"); + assert_eq!(isolation_of(&env, &env.game, &id, "1.20.1"), "shared"); assert!(set_isolation(&env, &id, "nope").is_err()); std::fs::remove_dir_all(&dir).ok(); } diff --git a/crates/presenter/src/host/game/tests_multiroot.rs b/crates/presenter/src/host/game/tests_multiroot.rs new file mode 100644 index 0000000..6c8efa7 --- /dev/null +++ b/crates/presenter/src/host/game/tests_multiroot.rs @@ -0,0 +1,397 @@ +//! M274:**多根发现**(配置根 + 官方启动器的标准位置)的判据用例。 +//! +//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是 +//! `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `/.minecraft`——两个不同的目录。 +//! +//! **本机没有装官方启动器**(`paths::standard_game_home()` 解析出的路径不存在),所以这一组的 +//! 每个根都是**临时目录里造出来的夹具**:`/.minecraft/versions/A/A.json`。这不是缺陷, +//! 是环境事实;「真的去读 `%APPDATA%`」那一步**未验**(见报告)。 +//! +//! | 用例 | 抓得住什么 | 抓不住什么 | +//! |---|---|---| +//! | [`the_same_version_in_two_roots_is_two_instances`] | 两个根里的同名版本 = **2 条**磁贴、id 不同、各自指向自己的根、名字分辨得出来 | 真实官方安装的目录形状(`launcher_profiles.json` / 加密的账户文件一概不读) | +//! | [`a_junction_to_the_configured_root_is_not_a_second_root`] | 同一路径的第二种写法(junction / 符号链接)**不许**让同一批实例列两遍 | 8.3 短名(用例盘上造不出来;判据靠 `canonicalize` 的长名展开,见 `roots::key_of` 的文档) | +//! | [`saves_come_from_the_instance_own_root`] | 每个实例的存档 / 截图列表只来自**它自己那个根**(不许把外部实例的存档当成当前根的) | 游戏进程正在跑时的文件锁 / 半写状态(未处理,见报告) | +//! | [`discovery_writes_nothing_into_the_official_root`] | 只读发现:`instances()` + 列存档前后,官方根里一个条目都不许多 | 「官方启动器同时也在写」的并发(TOCTOU 本来就看不到) | +//! | [`a_lazily_created_profile_stays_on_its_own_root`] | 懒创建档案(用户第一次改设置)之后 id → 根的反查不退化,且没把配置根的同名版本一并抹掉 | 档案里**没有**根字段这一事实本身(根只从 id 反查,见 `discovered::locate`) | +//! | [`the_launch_reads_the_manifest_from_the_instance_root`] | 启动装配的清单 / 运行目录走实例自己的根 | 真的把游戏拉起来(要真 jvm / 账号 / 网络) | + +use std::path::{Path, PathBuf}; + +use unlucky_core::game::GameLayout; + +use super::*; + +/// 临时根(每个用例一个目录;断言 panic 时也会删)。 +mod temp { + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + pub struct Root(pub PathBuf); + + impl Root { + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + } + + impl Drop for Root { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(Path::new(&self.0)); + } + } + + /// 目录下所有条目(排序后比较,用来做「读盘前后」快照)。 + pub fn entries_under(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + let Ok(read) = std::fs::read_dir(¤t) else { + continue; + }; + for entry in read.flatten() { + let path = entry.path(); + if let Ok(relative) = path.strip_prefix(dir) { + out.push(relative.to_string_lossy().replace('\\', "/")); + } + if path.is_dir() { + stack.push(path); + } + } + } + out.sort(); + out + } +} + +/// 造两个根:配置根 = `/.minecraft`,官方位置 = `/official/.minecraft`。 +fn two_roots(tag: &str) -> (temp::Root, HostEnv) { + let root = temp::Root::new(tag); + let mut env = HostEnv::at_root(&root.0); + let official = root.0.join("official").join(".minecraft"); + std::fs::create_dir_all(&official).expect("官方根目录"); + env.standard_game = Some(official); + (root, env) +} + +/// 官方根(=第二个候选根)的**字面**路径。 +fn official(env: &HostEnv) -> PathBuf { + env.standard_game.clone().expect("用例里一定钉了官方根") +} + +fn write_version(game: &GameLayout, id: &str) { + let path = game.version_json(id); + std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录"); + std::fs::write(&path, format!(r#"{{"id":"{id}"}}"#)).expect("写版本 JSON"); +} + +/// 世界目录(`<根>/saves//level.dat`)。 +fn write_world(root: &Path, name: &str) { + let dir = root.join("saves").join(name); + std::fs::create_dir_all(&dir).expect("世界目录"); + std::fs::write(dir.join("level.dat"), b"fixture").expect("level.dat"); +} + +/// 目录链接(Windows 用 junction:不需要管理员;其它平台符号链接)。造不出来返回 `false`。 +#[cfg(windows)] +fn link_dir(link: &Path, target: &Path) -> bool { + std::process::Command::new("cmd") + .args(["/c", "mklink", "/J"]) + .arg(link) + .arg(target) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +#[cfg(not(windows))] +fn link_dir(link: &Path, target: &Path) -> bool { + std::os::unix::fs::symlink(target, link).is_ok() +} + +// ------------------------------------------------------------------ 判据①:两个根各一条 + +/// 两个临时根各放一个同名实例(`versions/A/A.json`)⇒ 列表出 **2 条**,各自指向自己的根。 +#[test] +fn the_same_version_in_two_roots_is_two_instances() { + let (_root, env) = two_roots("ull-host-multiroot-two"); + write_version(&env.game, "A"); + write_version(&GameLayout::new(official(&env)), "A"); + + let snapshot = instances(&env); + assert_eq!( + snapshot.len(), + 2, + "两个根里的同名版本必须是 2 条:{snapshot:?}" + ); + let ids: std::collections::BTreeSet<&str> = + snapshot.iter().map(|tile| tile.id.as_str()).collect(); + assert_eq!( + ids.len(), + 2, + "两个根的 id 撞了(哈希没喂根?):{snapshot:?}" + ); + + for tile in &snapshot { + assert_eq!(tile.version_id, "A"); + assert!( + unlucky_core::platform::layout::is_valid_instance_id(&tile.id), + "合成的实例 id 不合法:{}", + tile.id + ); + let game = game_for(&env, &tile.id); + assert!( + game.root() == env.game.root() || game.root() == official(&env), + "实例 {} 的根既不是配置根也不是官方根:{}", + tile.id, + game.root().display() + ); + } + + // 两条磁贴的**名字**必须分辨得出来(否则用户不知道点哪一条,而两条的存档在不同目录里)。 + assert_ne!( + snapshot[0].name, snapshot[1].name, + "两条磁贴同名,用户无从分辨:{snapshot:?}" + ); + assert!( + snapshot.iter().any(|tile| tile.name == "A"), + "配置根里的那条仍叫版本目录名(绝大多数用户看到的就是它):{snapshot:?}" + ); + let other = snapshot + .iter() + .find(|tile| tile.name != "A") + .expect("另一条存在"); + assert!( + other.name.contains("official"), + "另一条的名字得带上它的根(现在是 {})", + other.name + ); + + // 同一个 (根, 版本) 反查稳定:换一次快照,id 不变(懒创建幂等的前提)。 + let again = instances(&env); + assert_eq!( + again.iter().map(|tile| tile.id.clone()).collect::>(), + snapshot + .iter() + .map(|tile| tile.id.clone()) + .collect::>(), + "同一批盘上内容两次快照给了不同 id(跨重启就会长出第二条档案)" + ); +} + +/// **存在但是空的**官方根(本机的真实形状)⇒ 一条实例都不许多,也不许报错。 +/// +/// 这条不是假想:开发机上 `%APPDATA%\.minecraft` **目录是在的**,里面只有一个空的 `cache/` +/// (没有 `versions/`、没有 `launcher_profiles.json`)。判据必须把「有个空目录」与「装了官方启动器」 +/// 分开——否则用户会看到一条来路不明的实例(或者实例页整页报错)。 +#[test] +fn an_empty_official_root_adds_no_instances() { + let (_root, env) = two_roots("ull-host-multiroot-empty"); + write_version(&env.game, "A"); + std::fs::create_dir_all(official(&env).join("cache")).expect("cache 目录"); + + let snapshot = instances(&env); + assert_eq!(snapshot.len(), 1, "空的官方根不该长出实例:{snapshot:?}"); + assert_eq!(snapshot[0].version_id, "A"); + assert_eq!( + game_for(&env, &snapshot[0].id).root(), + env.game.root(), + "唯一那条属于配置根" + ); +} + +// ------------------------------------------------------- 判据②:同一路径的两种写法只算一个根 + +/// 让两个路径指向同一个目录(junction / 符号链接)⇒ 列表只能出 **1 条**。 +#[test] +fn a_junction_to_the_configured_root_is_not_a_second_root() { + let root = temp::Root::new("ull-host-multiroot-dedupe"); + let mut env = HostEnv::at_root(&root.0); + write_version(&env.game, "A"); + let link = root.0.join("linked"); + if !link_dir(&link, env.game.root()) { + // 造不出链接就**不假绿**:这一条判据在造不出链接的盘上等于没验。 + eprintln!("跳过:这个环境造不出目录链接({})", link.display()); + return; + } + env.standard_game = Some(link); + assert_eq!( + super::roots::all(&env).len(), + 1, + "联接与配置根是同一个物理目录,不该算两个根" + ); + let snapshot = instances(&env); + assert_eq!( + snapshot.len(), + 1, + "同一批实例列了两遍(去重没落到真实路径上):{snapshot:?}" + ); +} + +// -------------------------------------------------------- 判据③:存档按各自的根解析 + +/// 两个根各一个同名实例 + 各一个世界 ⇒ 每条实例的存档列表只来自**它自己那个根**。 +#[test] +fn saves_come_from_the_instance_own_root() { + let (_root, env) = two_roots("ull-host-multiroot-saves"); + write_version(&env.game, "A"); + write_version(&GameLayout::new(official(&env)), "A"); + write_world(env.game.root(), "configured-world"); + write_world(&official(&env), "official-world"); + + let snapshot = instances(&env); + assert_eq!(snapshot.len(), 2, "{snapshot:?}"); + let mut seen: Vec = Vec::new(); + for tile in &snapshot { + let dir = super::super::saves::run_dir(&env, tile).expect("派生运行目录"); + let view = super::super::saves::snapshot(tile, &dir, None).expect("列存档"); + let worlds: Vec<&str> = view.worlds.iter().map(|world| world.dir.as_str()).collect(); + if dir == env.game.root() { + assert_eq!( + worlds, + vec!["configured-world"], + "配置根那条读串了:{view:?}" + ); + } else { + assert_eq!(dir, official(&env), "外部实例的运行目录应当是它自己的根"); + assert_eq!(worlds, vec!["official-world"], "官方根那条读串了:{view:?}"); + } + seen.push(dir); + } + assert_ne!( + seen[0], seen[1], + "两个根的实例派生出同一个运行目录(存档会互相覆盖)" + ); +} + +// ------------------------------------------------------------ 判据④:只读发现(官方根零写入) + +/// 浏览(列实例 + 列存档)不许在官方根里留下任何东西,也不许凭空写 `profiles.json`。 +#[test] +fn discovery_writes_nothing_into_the_official_root() { + let (_root, env) = two_roots("ull-host-multiroot-readonly"); + write_version(&env.game, "A"); + write_version(&GameLayout::new(official(&env)), "A"); + write_world(&official(&env), "official-world"); + + let before = temp::entries_under(&official(&env)); + let snapshot = instances(&env); + for tile in &snapshot { + if let Ok(dir) = super::super::saves::run_dir(&env, tile) { + let _ = super::super::saves::snapshot(tile, &dir, None); + } + } + assert_eq!( + before, + temp::entries_under(&official(&env)), + "读一次实例列表 / 存档就在官方启动器的目录里多出了东西" + ); + let profiles = read_profiles(&env.layout).expect("读档案"); + assert!( + profiles.profiles.is_empty(), + "浏览不该懒创建档案:{:?}", + profiles.profiles + ); +} + +// ------------------------------------------- 判据⑤:懒创建的档案仍认自己那个根(不吞同名) + +/// 用户第一次给官方根里的实例改设置 ⇒ 懒创建档案,但: +/// 1. id → 根的反查**不退化**(存档 / 模组 / 启动还读官方根); +/// 2. 配置根里那个同名版本**还在**(它是另一个实例,档案不该连它一起吞掉)。 +#[test] +fn a_lazily_created_profile_stays_on_its_own_root() { + let (_root, env) = two_roots("ull-host-multiroot-lazy"); + write_version(&env.game, "A"); + write_version(&GameLayout::new(official(&env)), "A"); + // 官方根里那个版本带 `mods/`(「版本隔离」形态)⇒ 基底档是 isolated,换根之后不许变。 + std::fs::create_dir_all( + GameLayout::new(official(&env)) + .version_dir("A") + .join("mods"), + ) + .expect("mods 目录"); + + let snapshot = instances(&env); + let tile = snapshot + .iter() + .find(|tile| tile.name.contains("official")) + .expect("官方根那条在列表里") + .clone(); + let game = game_for(&env, &tile.id); + assert_eq!(game.root(), official(&env), "反查没落到官方根上"); + assert_eq!( + isolation_of(&env, &game, &tile.id, "A"), + "isolated", + "官方根里 `versions/A/mods/` 是「自带数据」的痕迹 ⇒ 独立目录" + ); + + set_isolation(&env, &tile.id, "shared").expect("第一次改档位 = 懒创建档案"); + assert_eq!( + game_for(&env, &tile.id).root(), + official(&env), + "懒创建之后反查退回了配置根(存档会读串)" + ); + + let after = instances(&env); + assert_eq!( + after.len(), + 2, + "懒创建之后配置根那个同名版本被档案吞掉了:{after:?}" + ); + assert_eq!( + after + .iter() + .filter(|tile| tile.id == tile_id(&snapshot, "official")) + .count(), + 1, + "懒创建之后官方根那条被列了两遍(档案 + 合成视图)" + ); + // 档案里的 version_id 只是版本名,**根只从 id 反查**——这条用例钉住的就是这一点。 + let profiles = read_profiles(&env.layout).expect("读档案"); + assert_eq!(profiles.profiles.len(), 1); + assert_eq!(profiles.profiles[0].version_id, "A"); +} + +/// 列表里「名字带 `needle` 的那一条」的 id(懒创建前后的同一个实例)。 +fn tile_id(snapshot: &[crate::dto::InstanceSummary], needle: &str) -> String { + snapshot + .iter() + .find(|tile| tile.name.contains(needle)) + .expect("那一条在列表里") + .id + .clone() +} + +// ------------------------------------------------ 判据⑥:启动装配按实例自己的根读清单 + +/// 清单**只在官方根里** ⇒ 装配仍要成功,且 `gameDir` 是官方根(共用目录档)。 +#[test] +fn the_launch_reads_the_manifest_from_the_instance_root() { + let (_root, env) = two_roots("ull-host-multiroot-launch"); + std::fs::create_dir_all(env.layout.config()).expect("config 目录"); + write_version(&GameLayout::new(official(&env)), "A"); + + let snapshot = instances(&env); + assert_eq!(snapshot.len(), 1, "只有官方根里有版本:{snapshot:?}"); + let tile = &snapshot[0]; + let target = super::super::launch::LaunchTarget { + id: tile.id.clone(), + name: tile.name.clone(), + version_id: "A".to_string(), + account: None, + }; + let (request, _warnings) = + super::super::launch::build_request(&env, &target).expect("清单在官方根里,装配应当成功"); + assert_eq!( + request.dirs.game_dir, + official(&env), + "共用目录档的运行目录应当是**它自己那个根**(不是 ULL 便携根)" + ); + assert_eq!(request.instance.id, tile.id); +} diff --git a/crates/presenter/src/host/game/tests_union.rs b/crates/presenter/src/host/game/tests_union.rs index 98f5f85..23da340 100644 --- a/crates/presenter/src/host/game/tests_union.rs +++ b/crates/presenter/src/host/game/tests_union.rs @@ -143,14 +143,14 @@ fn external_instance_writes_nothing_until_first_edit() { let id = snapshot[0].id.clone(); assert_eq!( - isolation_of(&env, &id, "A"), + isolation_of(&env, &env.game, &id, "A"), "shared", "`versions/A/` 里没有 mods / saves / config / options.txt ⇒ 基底档是 shared(按共用目录读)" ); set_isolation(&env, &id, "isolated").expect("第一次改档位 = 懒创建档案"); assert_eq!( - isolation_of(&env, &id, "A"), + isolation_of(&env, &env.game, &id, "A"), "isolated", "实例文件里写下的档位优先于基底档" ); diff --git a/crates/presenter/src/host/java.rs b/crates/presenter/src/host/java.rs index 51b48e3..4fa4d35 100644 --- a/crates/presenter/src/host/java.rs +++ b/crates/presenter/src/host/java.rs @@ -28,7 +28,9 @@ pub fn target_major(env: &HostEnv) -> u32 { super::game::instances(env) .first() .map(|instance| { - java::requirement_for_version(&env.game, &instance.version_id) + // 版本清单在**它自己那个根**下(M274):官方根里的版本,配置根里读不到要求。 + let game = super::game::game_for(env, &instance.id); + java::requirement_for_version(&game, &instance.version_id) .minimum .max(8) }) diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index ab20b7e..8fa110f 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -63,7 +63,10 @@ pub fn build_request( target: &LaunchTarget, ) -> Result<(LaunchRequest, Vec), LaunchFailureView> { let version_id = target.version_id.clone(); - let manifest_path = env.game.version_json(&version_id); + // **这个实例自己的根**(M274):官方根 / 另一个 `.minecraft` 里的版本,清单、库、natives、 + // assets 全在它自己那个根下。拿 `env.game` 去读会读空(甚至读到同名的另一个版本)。 + let game = super::game::game_for(env, &target.id); + let manifest_path = game.version_json(&version_id); let text = std::fs::read_to_string(&manifest_path).map_err(|error| { LaunchFailureView::new( LaunchFailureStage::VersionManifest, @@ -77,7 +80,7 @@ pub fn build_request( ) })?; let source = |id: &str| -> Option { - std::fs::read_to_string(env.game.version_json(id)) + std::fs::read_to_string(game.version_json(id)) .ok() .and_then(|text| VersionJson::from_json_str(&text).ok()) }; @@ -121,22 +124,22 @@ pub fn build_request( } }; let path = if library.is_local() { - env.game.version_library(&version_id, &coordinate) + game.version_library(&version_id, &coordinate) } else { - env.game.library(&coordinate) + game.library(&coordinate) }; libraries.push(LibraryInput::new(path, name)); } let natives = unlucky_core::launch::natives::collect( - &env.game, + &game, &version_id, resolved.libraries.as_deref().unwrap_or_default(), &environment, ); - let game_dir = run_dir(env, target)?; + let game_dir = run_dir(env, &game, target)?; let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone()); - let assets_root = env.game.assets_dir(); + let assets_root = game.assets_dir(); let assets_index = resolved .assets .clone() @@ -145,7 +148,7 @@ pub fn build_request( // `26w14a-fabric-0.16.5` 这种 id 在 `GameVersion::is_weekly_snapshot`(6 字符形态)上判不出来, // 周快照的 `--graphicsBackend` 会在拼装链里被静默跳过——而详情页的门槛 // (`host::launch_args::backend_gate`)与 CLI 用的都是根版本,两边必须同一口径。 - let (mc_version, _) = super::game::describe_version(env, &version_id); + let (mc_version, _) = super::game::describe_version(&game, &version_id); let snapshot = SnapshotInput { version_id: &version_id, version_name: Some(&target.name), @@ -157,11 +160,11 @@ pub fn build_request( legacy_arguments: resolved.minecraft_arguments.as_deref(), libraries: &libraries, natives: &natives, - primary_jar: env.game.version_jar(&jar_id), + primary_jar: game.version_jar(&jar_id), game_dir, assets_index: assets_index.as_deref(), - natives_dir: env.game.natives_dir(&version_id), - library_dir: env.game.libraries_dir(), + natives_dir: game.natives_dir(&version_id), + library_dir: game.libraries_dir(), assets_root: assets_root.clone(), game_assets: assets_root, }; @@ -197,18 +200,22 @@ pub fn build_request( /// /// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`—— /// 于是这里不需要知道实例是从哪来的,只有一处判据。 -fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result { - let base = super::game::base_mode(env, &target.id, &target.version_id); +fn run_dir( + env: &HostEnv, + game: &unlucky_core::game::GameLayout, + target: &LaunchTarget, +) -> Result { + let base = super::game::base_mode(env, game, &target.id, &target.version_id); let config = read_instance_config(&env.layout, &target.id).map_err(|error| { LaunchFailureView::new(LaunchFailureStage::InstanceConfig, error.to_string()) })?; - let derivation = derive_game_dir(&env.game, &target.version_id, base, config.as_ref(), None) + let derivation = derive_game_dir(game, &target.version_id, base, config.as_ref(), None) .map_err(|error| { LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string()) })?; if let Some(mut config) = config { let at = format_rfc3339_millis(now()); - if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) { + if observe_mode(&mut config, game, &at, derivation.isolation).unwrap_or(false) { let _ = unlucky_core::game::install::instance::write_instance_config(&env.layout, &config); } @@ -227,11 +234,14 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { let logs_dir = env.layout.logs(); launcher_log::set_dir(&logs_dir); let started_at = std::time::SystemTime::now(); + // 崩溃报告里的「游戏目录」必须是**这个实例自己那个根**(M274):官方根里的实例崩了, + // 报告却写着 ULL 便携根的路径,等于把排障指到别人的目录上。 + let game = super::game::game_for(&env, &target.id); let mut report = ReportContext::new( &target.id, &target.name, &target.version_id, - &env.data.game_home, + game.root(), &logs_dir, started_at, unlucky_core::platform::os_info::current().summary_line(), @@ -261,7 +271,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { } }; report.run_dir = request.dirs.game_dir.clone(); - report.version_json = Some(env.game.version_json(&target.version_id)); + report.version_json = Some(game.version_json(&target.version_id)); report.java = launcher_log::java_label(&request.java.binary, request.java.major); for warning in &warnings { let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new( diff --git a/crates/presenter/src/host/launch_args.rs b/crates/presenter/src/host/launch_args.rs index c9f11f4..7e479ae 100644 --- a/crates/presenter/src/host/launch_args.rs +++ b/crates/presenter/src/host/launch_args.rs @@ -18,7 +18,7 @@ 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::game::{self, GameLayout, VersionCapabilities, VersionJson}; use unlucky_core::launch::game_args::{graphics_backend_allowed, GRAPHICS_BACKEND_MIN_VERSION}; use unlucky_core::launch::game_version::GameVersion; @@ -73,11 +73,13 @@ pub fn resolved(env: &HostEnv, instance_id: &str) -> ResolvedLaunch { } /// 版本能力位(解析继承链之后取库表;读不到版本 JSON = 什么能力都没有)。 -fn capabilities(env: &HostEnv, version_id: &str) -> Option { - let text = std::fs::read_to_string(env.game.version_json(version_id)).ok()?; +/// +/// **按实例自己那个根读**(M274):官方根里的版本清单不在配置根下。 +fn capabilities(game_layout: &GameLayout, version_id: &str) -> Option { + let text = std::fs::read_to_string(game_layout.version_json(version_id)).ok()?; let manifest = VersionJson::from_json_str(&text).ok()?; let source = |id: &str| -> Option { - std::fs::read_to_string(env.game.version_json(id)) + std::fs::read_to_string(game_layout.version_json(id)) .ok() .and_then(|text| VersionJson::from_json_str(&text).ok()) }; @@ -88,9 +90,9 @@ fn capabilities(env: &HostEnv, version_id: &str) -> Option { /// /// 判据是 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) +pub fn backend_gate(game_layout: &GameLayout, version_id: &str) -> (bool, String) { + let mc_version = super::game::describe_version(game_layout, version_id).0; + let has_vulkan = capabilities(game_layout, 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); @@ -98,10 +100,15 @@ pub fn backend_gate(env: &HostEnv, version_id: &str) -> (bool, String) { } /// 详情页「启动参数」区的读取视图。 -pub fn view(env: &HostEnv, instance_id: &str, version_id: &str) -> LaunchArgsView { +pub fn view( + env: &HostEnv, + game_layout: &GameLayout, + 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); + let (allowed, mc_version) = backend_gate(game_layout, version_id); LaunchArgsView { instance_id: instance_id.to_string(), jvm_args: instance.jvm_args.unwrap_or_default(), @@ -240,18 +247,18 @@ mod tests { 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"); + let (allowed, mc) = backend_gate(&env.game, "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"); + let (allowed, mc) = backend_gate(&env.game, "26.1"); assert!(!allowed, "26.1 不该放行"); assert_eq!(mc, "26.1"); // 没有版本 JSON:能力位全假 ⇒ 不放行(不 panic)。 - let (allowed, _) = backend_gate(&env, "1.20.4"); + let (allowed, _) = backend_gate(&env.game, "1.20.4"); assert!(!allowed); } @@ -336,7 +343,7 @@ mod tests { &LaunchArgsPatch::backend("vulkan"), ) .expect("写回"); - let view = view(&env, "inst-1", "26.2-snapshot-2"); + let view = view(&env, &env.game, "inst-1", "26.2-snapshot-2"); assert_eq!(view.graphics_backend, "vulkan"); assert_eq!(view.graphics_backend_effective, "vulkan"); assert!(view.graphics_backend_allowed); diff --git a/crates/presenter/src/host/mods.rs b/crates/presenter/src/host/mods.rs index 53a21df..84cf755 100644 --- a/crates/presenter/src/host/mods.rs +++ b/crates/presenter/src/host/mods.rs @@ -5,7 +5,7 @@ //! | 纪律 | 落点 | //! |---|---| //! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_set_enabled`] 走 `job::spawn`,UI 只收快照 | -//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 | +//! | 单根语义(D20,M274 起按**实例自己的根**) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生;`super::game::game_for` 先定根,再把那个根喂给派生 | //! | 游戏在跑就不改名 | `running` 标记在 host 这一侧挡:Windows 上进程正持有 jar,rename 会失败,与其让用户读一句 OS 报错不如直说 | //! //! ## 为什么不复用启动路径的 `run_dir()` @@ -186,10 +186,13 @@ fn toggle(env: &HostEnv, id: &str, file: &str, enabled: bool, running: bool) -> /// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。 fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result { - let base = super::game::base_mode(env, &instance.id, &instance.version_id); + // **这个实例自己那个根**(M274):共用目录的运行目录是这个根的根目录; + // 拿 `env.game` 去派生,官方根里的实例会读到 ULL 根下的存档 / 模组(用户的原话:串了)。 + let game = super::game::game_for(env, &instance.id); + let base = super::game::base_mode(env, &game, &instance.id, &instance.version_id); let config = read_instance_config(&env.layout, &instance.id) .map_err(|error| format!("读实例设置失败:{error}"))?; - let derivation = derive_game_dir(&env.game, &instance.version_id, base, config.as_ref(), None) + let derivation = derive_game_dir(&game, &instance.version_id, base, config.as_ref(), None) .map_err(|error| format!("派生运行目录失败:{error}"))?; Ok(derivation.game_dir) } diff --git a/crates/presenter/src/host/saves.rs b/crates/presenter/src/host/saves.rs index a6dc5b5..2ff82ab 100644 --- a/crates/presenter/src/host/saves.rs +++ b/crates/presenter/src/host/saves.rs @@ -5,7 +5,7 @@ //! | 纪律 | 落点 | //! |---|---| //! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] / [`spawn_rename`] / [`spawn_delete`] / [`spawn_backup`] 全走 `job::spawn`,UI 只收快照 | -//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 | +//! | 单根语义(D20,M274 起按**实例自己的根**) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生;`super::game::game_for` 先定根,再把那个根喂给派生 | //! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) | //! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) | //! | 游戏在跑就不写 | [`dispatch`] 的 `running` 为真时三条写命令**不 spawn、不碰盘**,直接回 [`crate::dto::SavesFailureKind::GameRunning`] | @@ -242,10 +242,13 @@ pub(super) fn target( /// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。 pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result { - let base = super::game::base_mode(env, &instance.id, &instance.version_id); + // **这个实例自己那个根**(M274):共用目录的运行目录是这个根的根目录; + // 拿 `env.game` 去派生,官方根里的实例会读到 ULL 根下的存档 / 模组(用户的原话:串了)。 + let game = super::game::game_for(env, &instance.id); + let base = super::game::base_mode(env, &game, &instance.id, &instance.version_id); let config = read_instance_config(&env.layout, &instance.id) .map_err(|error| format!("读实例设置失败:{error}"))?; - let derivation = derive_game_dir(&env.game, &instance.version_id, base, config.as_ref(), None) + let derivation = derive_game_dir(&game, &instance.version_id, base, config.as_ref(), None) .map_err(|error| format!("派生运行目录失败:{error}"))?; Ok(derivation.game_dir) } From a614b495380859ef3db8df2d6879143da5297c57 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 18:57:20 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(presenter):=20=E5=A4=9A=E6=A0=B9=20r1?= =?UTF-8?q?=20=E5=A4=8D=E5=AE=A1=E4=BF=AE=E5=9B=9B=E5=A4=84=EF=BC=88?= =?UTF-8?q?=E7=A3=81=E8=B4=B4=E6=8C=89=E8=87=AA=E5=B7=B1=E6=A0=B9=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=20/=20=E6=8C=91=20Java=20=E6=8C=89=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E6=A0=B9=E8=A6=81=E6=B1=82=20/=20=E4=B8=80=E6=8B=8D=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E6=89=AB=E6=8F=8F=20/=20=E5=8F=AA=E8=AF=BB=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E7=BA=A7=E5=BF=AB=E7=85=A7=EF=BC=89=EF=BC=88M274=20r1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/presenter/src/backend.rs | 57 ++- crates/presenter/src/host/env.rs | 88 ++++- crates/presenter/src/host/game.rs | 71 ++-- crates/presenter/src/host/game/discovered.rs | 68 +++- crates/presenter/src/host/game/roots.rs | 20 +- .../src/host/game/tests_multiroot.rs | 55 +-- .../src/host/game/tests_multiroot2.rs | 332 ++++++++++++++++++ crates/presenter/src/host/launch.rs | 3 +- crates/presenter/src/host/launch/java.rs | 15 +- 9 files changed, 594 insertions(+), 115 deletions(-) create mode 100644 crates/presenter/src/host/game/tests_multiroot2.rs diff --git a/crates/presenter/src/backend.rs b/crates/presenter/src/backend.rs index 1669aec..a29772b 100644 --- a/crates/presenter/src/backend.rs +++ b/crates/presenter/src/backend.rs @@ -7,10 +7,13 @@ use crate::host::CoreHost; use crate::host::HostEnv; use crate::mock::MockBackend; -/// 启动选项(截图/演示通道用;正常启动全用默认值)。 +/// 启动选项(`--data-root` 与 `--mock` 是产品开关;截图/演示也复用同一份选项)。 #[derive(Debug, Clone, Default)] pub struct AppOptions { - /// 钉死数据根(`/.ULL` + `/.minecraft`);None = 按便携配置解析 + /// 钉死数据根(`/.ULL` + `/.minecraft`);None = 按便携配置解析。 + /// + /// 走 [`HostEnv::at_data_root`]:**照常**发现官方启动器的标准游戏目录(M274)。钉数据根 + /// 不等于「别看官方实例」——那是两个不同的开关。 pub data_root: Option, /// 强制 mock 后端(UI 回归/无数据根演示) pub mock: bool, @@ -21,16 +24,62 @@ pub fn config_path(root: &Path) -> PathBuf { root.join(".ULL").join("config").join("launcher.json") } +/// 钉了数据根时用哪个环境(`None` = 没钉,走解析链)。 +/// +/// **单独一格只为可测**:`--data-root` 是产品通道,钉了数据根照样要看见官方启动器里的实例 +/// (M274 r1 复审 P2-2)。退回 [`HostEnv::at_root`] 会让用户**静默**丢一半实例,而这层 +/// `Box` 在用例里没有 env 取回口(`Backend` 没有访问器),所以判据就落在这个 +/// 纯函数上(`backend::tests::data_root_keeps_discovering_the_official_launcher`)。 +fn host_env_for(options: &AppOptions) -> Option { + options.data_root.as_ref().map(HostEnv::at_data_root) +} + /// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。 pub fn build_backend(options: &AppOptions) -> Box { if options.mock { return Box::new(MockBackend::new()); } - match &options.data_root { - Some(root) => Box::new(CoreHost::new(HostEnv::at_root(root))), + match host_env_for(options) { + Some(env) => Box::new(CoreHost::new(env)), None => match CoreHost::open() { Some(host) => Box::new(host), None => Box::new(MockBackend::new()), }, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// `--data-root`(产品通道)**保留**多根发现;`at_root`(截图/演示/用例)不保留。 + /// + /// 抓得住:`host_env_for` 被改回 `HostEnv::at_root`——用户钉一个数据根就永久静默看不到 + /// 官方启动器里的实例(M274 r1 复审 P2-2)。 + /// 抓不住:`--data-root` 的命令行解析(app 侧);mounted 的数据根真的解析得出来(那是 resolver)。 + #[test] + fn data_root_keeps_discovering_the_official_launcher() { + let root = std::env::temp_dir().join(format!("ull-backend-m274-{}", std::process::id())); + let options = AppOptions { + data_root: Some(root.clone()), + mock: false, + }; + let env = host_env_for(&options).expect("钉了数据根就该有环境"); + assert_eq!( + env.game.root(), + root.join(".minecraft"), + "落点与 `at_root` 一致" + ); + assert_eq!( + env.standard_game, + unlucky_core::platform::paths::standard_game_home( + unlucky_core::platform::paths::Platform::current(), + &unlucky_core::platform::paths::ProcessEnv, + ), + "`--data-root` 是产品通道:不许顺手关掉官方根发现" + ); + // 没钉数据根时这一格不参与(走解析链),`mock` 在前一步就分走了。 + assert!(host_env_for(&AppOptions::default()).is_none()); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/crates/presenter/src/host/env.rs b/crates/presenter/src/host/env.rs index 642c183..401299b 100644 --- a/crates/presenter/src/host/env.rs +++ b/crates/presenter/src/host/env.rs @@ -54,21 +54,31 @@ impl HostEnv { Some(Self::from_data(data)) } - /// 直接钉一个根:`/.ULL` + `/.minecraft`(截图/演示通道)。 + /// **钉根但照常做多根发现**:`--data-root`(生产通道)与 [`HostEnv::at_root`] 同一个落点, + /// 差别只有一格——`standard_game` 保留(用户钉的是数据根,不是「别看官方启动器」)。 + /// + /// 为什么两个入口必须分开(M274 r1 复审 P2-2):`--data-root` 是**产品**开关,之前它走 + /// [`HostEnv::at_root`],于是用户一钉数据根就永久静默看不到官方启动器里的实例。而 + /// [`HostEnv::at_root`] 还被截图 / 演示 / 七十多处用例用着,那些地方**必须**确定性 + /// (不能因为开发机上装了官方启动器就让用例结果变),所以它继续不填 `standard_game`。 + pub fn at_data_root(root: impl AsRef) -> Self { + Self::from_data(portable_data(root.as_ref())) + } + + /// 直接钉一个根:`/.ULL` + `/.minecraft`。 + /// + /// **给谁用**:截图 / 演示 / 单元测试——凡是「结果必须与这台机器上装没装官方启动器无关」的地方。 + /// **生产路径不要用这个**:产品里钉数据根(`--data-root`)走 [`HostEnv::at_data_root`], + /// 那条会照常发现官方启动器里的实例;用错这里,用户会**静默**看不到官方实例(M274 r1 复审 P2-2)。 /// /// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**: /// 演示根常用它把游戏目录指到既有真数据上(避免复制几 GB 的 assets/libraries)。 + /// + /// 实现上**委托**给 `at_data_root` 再关掉发现(不是复制一份构造):两个入口的落点/模式因此 + /// 物理上不可能漂开。 pub fn at_root(root: impl AsRef) -> Self { - let root = root.as_ref(); - let request = request_from_portable(root); - let absolute = |path: Option| path.filter(|path| path.is_absolute()); - let mut env = Self::from_data(ResolvedData { - mode: DataMode::Portable, - launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")), - game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")), - redirected_from: None, - }); - // 钉一个根的通道(截图 / 演示 / 用例)**不做多根发现**:见 `standard_game` 的文档。 + let mut env = Self::at_data_root(root); + // 钉一个根的**测试/截图**通道不做多根发现:见 `standard_game` 的文档。 env.standard_game = None; env } @@ -139,6 +149,18 @@ impl HostEnv { } } +/// 便携钉根的一份 `ResolvedData`(`at_root` / `at_data_root` 共用,两者只在 `standard_game` 上不同)。 +fn portable_data(root: &Path) -> ResolvedData { + let request = request_from_portable(root); + let absolute = |path: Option| path.filter(|path| path.is_absolute()); + ResolvedData { + mode: DataMode::Portable, + launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")), + game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")), + redirected_from: None, + } +} + /// 便携目录那份 `launcher.json` → 数据根请求(只读 `core.*` 四键)。 pub(crate) fn request_from_portable(exe_dir: &Path) -> DataDirRequest { let mut request = DataDirRequest::default(); @@ -184,6 +206,50 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + /// **多根发现的两条入口必须分开**(M274 r1 复审 P2-2): + /// + /// | 入口 | 谁用 | `standard_game` | + /// |---|---|---| + /// | [`HostEnv::at_data_root`] | 产品 `--data-root`(`backend.rs`) | `Some(官方位置)`:钉数据根 ≠ 别看官方实例 | + /// | [`HostEnv::at_root`] | 截图 / 演示 / 七十多处用例 | `None`:同一份用例在任何机器上结果一样 | + /// | [`HostEnv::from_data`] | 生产解析链(便携 / 标准 / 自定义) | `Some(官方位置)` | + /// + /// 抓得住:`--data-root` 退回 `at_root`(用户钉数据根后永久看不到官方实例,且没有任何报错)、 + /// 或 `at_root` 顺手开始发现(用例在装了官方启动器的机器上变红/变绿)。 + /// 抓不住:`paths::standard_game_home` 自己算错(那是 core 的用例)。 + /// + /// 「两个入口不会各自漂开」是**结构性**的:`at_root` 委托 `at_data_root` 再关一格, + /// 不是复制一份构造——所以下面那三行落点断言只是把这条结构钉成行为,而不是唯一防线。 + #[test] + fn only_the_product_entries_discover_the_official_root() { + let root = std::env::temp_dir().join(format!("ull-host-env-m274-{}", std::process::id())); + let expected = unlucky_core::platform::paths::standard_game_home( + Platform::current(), + &unlucky_core::platform::paths::ProcessEnv, + ); + assert!( + expected.is_some(), + "这个平台连标准游戏目录都算不出来,用例的前提不成立" + ); + + assert_eq!( + HostEnv::at_root(&root).standard_game, + None, + "截图/用例钉根不许做多根发现" + ); + assert_eq!( + HostEnv::at_data_root(&root).standard_game, + expected, + "`--data-root` 是产品通道:钉数据根不等于别看官方实例" + ); + // 两条入口除了 `standard_game` 之外必须**完全一致**(落点、模式)。 + let pinned = HostEnv::at_root(&root); + let product = HostEnv::at_data_root(&root); + assert_eq!(pinned.layout.home(), product.layout.home()); + assert_eq!(pinned.game.root(), product.game.root()); + std::fs::remove_dir_all(&root).ok(); + } + /// M188 的守门②:**日志根随数据模式变化**(便携 / 标准 / 自定义各一条断言)。 /// /// 三条走的都是 `HostEnv` 真正用的那条解析链(`Resolver` → `ResolvedData::layout`): diff --git a/crates/presenter/src/host/game.rs b/crates/presenter/src/host/game.rs index fdadf10..bde4165 100644 --- a/crates/presenter/src/host/game.rs +++ b/crates/presenter/src/host/game.rs @@ -31,7 +31,7 @@ mod roots; pub(super) use discovered::{base_mode, game_for}; -use discovered::{display_name, external_summary, external_version_of, external_versions}; +use discovered::{display_name, external_summary, external_version_of}; /// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。 pub fn global_mode(env: &HostEnv) -> IsolationMode { @@ -163,64 +163,70 @@ pub fn isolation_of(env: &HostEnv, game: &GameLayout, id: &str, version_id: &str pub fn instances(env: &HostEnv) -> Vec { let profiles = profiles(env); let played = last_played(env); + // **每个根只扫一遍**(M274 r1 复审 P2-1):这一拍的「根 × 版本」清单读一次,之后 + // ①每份档案属于哪个根、②它是否占住配置根的同名版本,都是内存查表。 + // 反查写进循环里是 `#根 × (1 + #ext 档案)` 次全树扫描——官方根 200 版本 + 10 份 ext 档案 + // 时 `instances()` 会从 236ms 涨到 1.35s(复审实测 ×5.7)。 + let found = discovered::Found::scan(env); + let mut homes: Vec> = Vec::with_capacity(profiles.profiles.len()); + let mut bound: Vec<&str> = Vec::new(); + for profile in &profiles.profiles { + match found.find(profile.id.as_str()) { + Some((root, _)) => { + // 属于**这个根**的档案(懒创建的 `ext-` 档案可能属于官方根):磁贴按那个根描述自己。 + if root.configured() { + bound.push(profile.version_id.as_str()); + } + homes.push(Some(root.game().clone())); + } + None => { + // 反查不到 ⇒ 按旧口径算配置根的(M273 之前合成的 `ext-` id / 那批根已不在盘上)。 + bound.push(profile.version_id.as_str()); + homes.push(None); + } + } + } let mut snapshot: Vec = profiles .profiles .iter() - .map(|profile| profile_summary(env, profile, &played)) - .collect(); - // 两件「每份档案只算一次」的事(反查要扫盘,放进版本循环里就是 O(档案×版本) 次扫描): - // ① 哪些版本被**属于配置根的**档案绑定了 ⇒ 配置根不再为它合成一条; - // ② 现有的档案 id ⇒ 同一个 id 不再合成第二条视图。 - let bound: Vec<&str> = profiles - .profiles - .iter() - .filter(|profile| profile_belongs_here(env, profile)) - .map(|profile| profile.version_id.as_str()) + .zip(&homes) + .map(|(profile, home)| profile_summary(env, profile, home.as_ref(), &played)) .collect(); let known: Vec<&str> = profiles .profiles .iter() .map(|profile| profile.id.as_str()) .collect(); - for root in roots::all(env) { + for (root, versions) in found.roots() { let configured = root.configured(); - for version_id in external_versions(env, root.game()) { - let id = discovered::external_instance_id(root.key(), &version_id); + for version_id in versions { + let id = discovered::external_instance_id(root.key(), version_id); if known.contains(&id.as_str()) { continue; } if configured && bound.contains(&version_id.as_str()) { continue; } - snapshot.push(external_summary(env, &root, &version_id, &played)); + snapshot.push(external_summary(env, root, version_id, &played)); } } snapshot } -/// 这条档案代表的是**配置根**里的版本吗(决定它是否「占住」配置根的同名版本)。 -/// -/// 有档案就按档案出——但多根之后要分清两种 `ext-…` 档案: -/// - 根 B 里的实例被懒创建成档案 ⇒ 它占的是**根 B** 的那个版本,不许连带把配置根里的同名版本 -/// 也一并抹掉(那是另一个实例,有自己的存档); -/// - 反查不到的 id(M273 之前合成的、或那一批根已经不在盘上)⇒ 按旧口径算配置根的, -/// 「档案优先」这条因此不会因为升级而失效。 -fn profile_belongs_here(env: &HostEnv, profile: &InstanceProfile) -> bool { - match discovered::locate(env, profile.id.as_str()) { - Some((root, _)) => root.configured(), - None => true, - } -} - /// 一条档案 → 磁贴(档案路径的唯一翻译点)。 +/// +/// `home` 是这条档案**自己那个根**([`discovered::Found`] 查出来的;`None` = 配置根): +/// 懒创建出来的 `ext-` 档案可能属于官方根,那时「版本在不在盘上 / 隔离档 / MC 版本 / 版本门槛」 +/// 都必须按那个根算——否则磁贴说「文件缺失 · 共用目录」,而存档与启动实际读的是官方根 +/// (M274 r1 复审 P1-1:显示与行为分叉,最坏的一种)。 fn profile_summary( env: &HostEnv, profile: &InstanceProfile, + home: Option<&GameLayout>, played: &BTreeMap, ) -> InstanceSummary { let version_id = profile.version_id.clone(); - // 档案实例属于**配置根**(`profiles.json` 里的 `version_id` 指的就是它)。 - let game = &env.game; + let game = home.unwrap_or(&env.game); let (mc_version, loader) = describe_version(game, &version_id); let id = profile.id.as_str(); InstanceSummary { @@ -340,3 +346,6 @@ mod tests_union; #[cfg(test)] mod tests_multiroot; + +#[cfg(test)] +mod tests_multiroot2; diff --git a/crates/presenter/src/host/game/discovered.rs b/crates/presenter/src/host/game/discovered.rs index 1c52f1c..ecf5046 100644 --- a/crates/presenter/src/host/game/discovered.rs +++ b/crates/presenter/src/host/game/discovered.rs @@ -167,19 +167,66 @@ pub(in crate::host) fn external_summary( /// (否则存档 / 模组 / 启动会退回配置根,那正是 M274 要修的错)。 /// /// 不是 `ext-` 前缀的 id 直接返回 `None`(档案实例属于配置根,不需要反查)。 +/// +/// **代价**(M274 r1 复审实测过):这是一次「每根扫一遍」的全树扫描。单实例动作(存档 / 模组 / +/// 启动 / 改设置)走这里没问题,**但绝不能在 `instances()` 的循环里调**——那里用 [`Found`] +/// 一拍只扫一遍(见它的文档)。 #[must_use] pub(in crate::host) fn locate(env: &HostEnv, id: &str) -> Option<(GameRoot, String)> { - if !crate::dto::is_discovered_instance(id) { - return None; + Found::scan(env) + .find(id) + .map(|(root, version_id)| (root.clone(), version_id.to_string())) +} + +/// **一拍之内**的发现快照:每个根只扫一遍 `versions/`,之后所有「这个 id 属于哪个根」都是内存查表。 +/// +/// 为什么要有它(M274 r1 复审 P2-1 的实测):把反查直接写进 `instances()` 的循环里,扫描次数是 +/// `#根 × (1 + #ext 档案)`;官方根 200 个版本 + 10 份 `ext-` 档案时 `instances()` 从 236ms 涨到 +/// 1.35s(×5.7)。这里把「根 × 版本」的清单一次性读完,反查退化成线性的字符串比较 +/// (`ext-` id 是 16 位十六进制,逐条比一遍远比再扫一次盘便宜)。 +/// +/// 快照是**快照**:一拍里盘变了(用户同时在装版本)不会看到,下一次刷新重建。 +pub(in crate::host) struct Found { + roots: Vec<(GameRoot, Vec)>, +} + +impl Found { + /// 扫一遍全部根(配置根在前,顺序与 `roots::all` 一致)。 + pub(in crate::host) fn scan(env: &HostEnv) -> Self { + let roots = roots::all(env) + .into_iter() + .map(|root| { + let versions = external_versions(env, root.game()); + (root, versions) + }) + .collect(); + Self { roots } } - for root in roots::all(env) { - for version_id in external_versions(env, root.game()) { - if external_instance_id(root.key(), &version_id) == id { - return Some((root, version_id)); - } + + /// 逐个根 + 它这一拍扫到的版本 id(字典序,已滤掉不可登记的)。 + pub(in crate::host) fn roots(&self) -> impl Iterator { + self.roots + .iter() + .map(|(root, versions)| (root, versions.as_slice())) + } + + /// id → (根, 版本 id)。不是 `ext-` 前缀 / 扫不到 ⇒ `None`。 + pub(in crate::host) fn find(&self, id: &str) -> Option<(&GameRoot, &str)> { + if !crate::dto::is_discovered_instance(id) { + return None; } + self.roots().find_map(|(root, versions)| { + versions + .iter() + .find(|version_id| external_instance_id(root.key(), version_id) == id) + .map(|version_id| (root, version_id.as_str())) + }) + } + + /// id → 那个根的布局(查不到 ⇒ `None`,由调用方决定退回哪个根)。 + pub(in crate::host) fn game_of(&self, id: &str) -> Option<&GameLayout> { + self.find(id).map(|(root, _)| root.game()) } - None } /// 实例所属的那个根的布局(读盘路径的唯一入口)。 @@ -187,8 +234,9 @@ pub(in crate::host) fn locate(env: &HostEnv, id: &str) -> Option<(GameRoot, Stri /// 档案实例、以及「反查不到」的 id 一律退回配置根 `env.game`——**没有第二个答案**: /// 宁可读配置根(旧行为),也不把实例指向一个猜出来的目录。 pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout { - locate(env, id) - .map(|(root, _)| root.game().clone()) + Found::scan(env) + .game_of(id) + .cloned() .unwrap_or_else(|| env.game.clone()) } diff --git a/crates/presenter/src/host/game/roots.rs b/crates/presenter/src/host/game/roots.rs index a1c66fa..a065664 100644 --- a/crates/presenter/src/host/game/roots.rs +++ b/crates/presenter/src/host/game/roots.rs @@ -15,10 +15,25 @@ //! 3. **去掉 `\\?\` 前缀**——Windows 的 `canonicalize` 一律返回 verbatim 形式(`\\?\C:\…`), //! 不去掉的话「同一路径的两种写法」永远不相等(这是个已知坑,用例 //! `junction_to_the_same_folder_is_one_root` 钉住); -//! 4. Windows / macOS 折成小写(大小写不敏感的文件系统)。 +//! 4. Windows / macOS 折成小写。 +//! +//! # 已知取舍(这是**我们**的选择,不是文件系统性质) +//! +//! 第 4 步折小写是为了让 `C:\Mc` 与 `c:\mc`(Windows 上同一个目录)算同一个根。代价在 macOS: +//! **APFS 可以按卷选择区分大小写**,那种卷上 `/a` 与 `/A` 是两个真实不同的目录,折小写会把它们 +//! 并成一个键 ⇒ 第二个根(官方位置)被**静默跳过**(不报错、不列它的实例)。触发条件只有 +//! 「用户在区分大小写的 APFS 卷上,且两个根只差大小写」——那种布局几乎不可能是真实安装, +//! 所以接受这个代价:宁可漏一个根,也不要把同一批实例列两遍(后者是更常见的错)。 +//! +//! 另外三条边界(都不是缺陷,记在这里免得下次重新推一遍): +//! - `\\?\UNC\server\share` 剥前缀后成 `unc/server/share`:仍然**唯一**、不会误合并, +//! 只是丢了 UNC 语义(键只用于相等比较,不再当路径用); +//! - `\\?\C:\` 去掉尾斜杠之后是 `c:`:与 `c:/foo` 这类前缀不相同,不会撞; +//! - Linux 的 `/`:去尾斜杠会得到空串,有兜底写回 `"/"`。 //! //! `canonicalize` 失败(根还不存在)时退化成「字面量归一」:`\`→`/`、去尾斜杠、折小写。 -//! 尚不存在的目录没有短名/链接可解析,这一步够用。 +//! 尚不存在的目录没有短名/链接可解析,这一步够用;代价是那个目录**创建之后**键可能变一次 +//! (短名 → 长名、大小写归到真实名字),于是该根下已发现的实例 id 会变一次。 //! //! # 只读 //! @@ -102,6 +117,7 @@ pub(in crate::host) fn key_of(path: &Path) -> String { let resolved: PathBuf = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let text = resolved.to_string_lossy().replace('\\', "/"); // verbatim 前缀(`\\?\C:\…` 在上一步已经变成 `//?/C:/…`):不去掉就与普通写法永不相等。 + // UNC 会因此变成 `unc/server/share`(仍唯一,见模块文档的「已知取舍」)。 let text = text .strip_prefix("//?/") .or_else(|| text.strip_prefix("//./")) diff --git a/crates/presenter/src/host/game/tests_multiroot.rs b/crates/presenter/src/host/game/tests_multiroot.rs index 6c8efa7..99c8e42 100644 --- a/crates/presenter/src/host/game/tests_multiroot.rs +++ b/crates/presenter/src/host/game/tests_multiroot.rs @@ -1,4 +1,4 @@ -//! M274:**多根发现**(配置根 + 官方启动器的标准位置)的判据用例。 +//! M274:**多根发现**(只读那条用例在 `tests_multiroot2.rs`:升级成内容级快照,M274 r1 P2-3)(配置根 + 官方启动器的标准位置)的判据用例。 //! //! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是 //! `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `/.minecraft`——两个不同的目录。 @@ -12,7 +12,6 @@ //! | [`the_same_version_in_two_roots_is_two_instances`] | 两个根里的同名版本 = **2 条**磁贴、id 不同、各自指向自己的根、名字分辨得出来 | 真实官方安装的目录形状(`launcher_profiles.json` / 加密的账户文件一概不读) | //! | [`a_junction_to_the_configured_root_is_not_a_second_root`] | 同一路径的第二种写法(junction / 符号链接)**不许**让同一批实例列两遍 | 8.3 短名(用例盘上造不出来;判据靠 `canonicalize` 的长名展开,见 `roots::key_of` 的文档) | //! | [`saves_come_from_the_instance_own_root`] | 每个实例的存档 / 截图列表只来自**它自己那个根**(不许把外部实例的存档当成当前根的) | 游戏进程正在跑时的文件锁 / 半写状态(未处理,见报告) | -//! | [`discovery_writes_nothing_into_the_official_root`] | 只读发现:`instances()` + 列存档前后,官方根里一个条目都不许多 | 「官方启动器同时也在写」的并发(TOCTOU 本来就看不到) | //! | [`a_lazily_created_profile_stays_on_its_own_root`] | 懒创建档案(用户第一次改设置)之后 id → 根的反查不退化,且没把配置根的同名版本一并抹掉 | 档案里**没有**根字段这一事实本身(根只从 id 反查,见 `discovered::locate`) | //! | [`the_launch_reads_the_manifest_from_the_instance_root`] | 启动装配的清单 / 运行目录走实例自己的根 | 真的把游戏拉起来(要真 jvm / 账号 / 网络) | @@ -45,28 +44,6 @@ mod temp { let _ = std::fs::remove_dir_all(Path::new(&self.0)); } } - - /// 目录下所有条目(排序后比较,用来做「读盘前后」快照)。 - pub fn entries_under(dir: &Path) -> Vec { - let mut out = Vec::new(); - let mut stack = vec![dir.to_path_buf()]; - while let Some(current) = stack.pop() { - let Ok(read) = std::fs::read_dir(¤t) else { - continue; - }; - for entry in read.flatten() { - let path = entry.path(); - if let Ok(relative) = path.strip_prefix(dir) { - out.push(relative.to_string_lossy().replace('\\', "/")); - } - if path.is_dir() { - stack.push(path); - } - } - } - out.sort(); - out - } } /// 造两个根:配置根 = `/.minecraft`,官方位置 = `/official/.minecraft`。 @@ -269,36 +246,6 @@ fn saves_come_from_the_instance_own_root() { ); } -// ------------------------------------------------------------ 判据④:只读发现(官方根零写入) - -/// 浏览(列实例 + 列存档)不许在官方根里留下任何东西,也不许凭空写 `profiles.json`。 -#[test] -fn discovery_writes_nothing_into_the_official_root() { - let (_root, env) = two_roots("ull-host-multiroot-readonly"); - write_version(&env.game, "A"); - write_version(&GameLayout::new(official(&env)), "A"); - write_world(&official(&env), "official-world"); - - let before = temp::entries_under(&official(&env)); - let snapshot = instances(&env); - for tile in &snapshot { - if let Ok(dir) = super::super::saves::run_dir(&env, tile) { - let _ = super::super::saves::snapshot(tile, &dir, None); - } - } - assert_eq!( - before, - temp::entries_under(&official(&env)), - "读一次实例列表 / 存档就在官方启动器的目录里多出了东西" - ); - let profiles = read_profiles(&env.layout).expect("读档案"); - assert!( - profiles.profiles.is_empty(), - "浏览不该懒创建档案:{:?}", - profiles.profiles - ); -} - // ------------------------------------------- 判据⑤:懒创建的档案仍认自己那个根(不吞同名) /// 用户第一次给官方根里的实例改设置 ⇒ 懒创建档案,但: diff --git a/crates/presenter/src/host/game/tests_multiroot2.rs b/crates/presenter/src/host/game/tests_multiroot2.rs new file mode 100644 index 0000000..d7e87da --- /dev/null +++ b/crates/presenter/src/host/game/tests_multiroot2.rs @@ -0,0 +1,332 @@ +//! M274 **r1 复审**补的判据(P1-1 / P1-2 / P2-3)。 +//! +//! 上一轮(`tests_multiroot.rs`)钉的是「两个根里的同名版本是两条实例、各自的书架/存档/启动按自己 +//! 那个根解析」。复审把「同一句话没走完」的两处揪了出来——**根身份有了,但两处「描述 / 前置要求」 +//! 还在读配置根**——这一组就是那两处,加上「只读」那条升级成内容级快照。 +//! +//! | 用例 | 抓得住什么 | 抓不住什么 | +//! |---|---|---| +//! | [`the_tile_describes_a_lazily_created_profile_from_its_own_root`] | 官方根实例被懒创建成档案之后,磁贴的 `version_present` / `isolation` / `mc_version` **逐项不变**(复审 P1-1 的三项同时错:显示与行为分叉) | 磁贴之外的显示(`element` 没有内省接口,见 `pages/instances/tests` 的口径) | +//! | [`pick_java_reads_the_requirement_from_the_instance_own_root`] | 启动挑 Java 时读的是**实例自己那个根**的 `javaVersion`(复审 P1-2:配置根读出来 8,实例是 21,实际选中 17) | 真的有 21 的 JDK 被选中(那要装 JDK;这里钉的是「要求」与回落,不是 `pick` 的最优选择) | +//! | [`nothing_is_written_into_the_official_root`] | 列实例 / 列存档 / 装配启动 / 懒创建档案之后,官方根的**文件内容、大小、mtime** 一个都没变(复审 P2-3:原来只比条目名) | natives 解压(那发生在 `Launcher::spawn`,用例不拉进程);官方启动器同时在写的并发(TOCTOU) | + +use std::path::{Path, PathBuf}; + +use unlucky_core::game::GameLayout; + +use super::*; + +/// 临时根(每个用例一个目录;断言 panic 时也会删)。 +mod temp { + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + pub struct Root(pub PathBuf); + + impl Root { + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + } + + impl Drop for Root { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(Path::new(&self.0)); + } + } +} + +/// 造两个根:配置根 = `/.minecraft`,官方位置 = `/official/.minecraft`。 +fn two_roots(tag: &str) -> (temp::Root, HostEnv) { + let root = temp::Root::new(tag); + let mut env = HostEnv::at_root(&root.0); + let official = root.0.join("official").join(".minecraft"); + std::fs::create_dir_all(&official).expect("官方根目录"); + env.standard_game = Some(official); + (root, env) +} + +/// 官方根(=第二个候选根)的**字面**路径。 +fn official(env: &HostEnv) -> PathBuf { + env.standard_game.clone().expect("用例里一定钉了官方根") +} + +/// 官方根 + 配置根各自的布局。 +fn official_layout(env: &HostEnv) -> GameLayout { + GameLayout::new(official(env)) +} + +fn write_manifest(game: &GameLayout, id: &str, json: &str) { + let path = game.version_json(id); + std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录"); + std::fs::write(&path, json).expect("写版本 JSON"); +} + +// ---------------------------------------------------------------- 判据①(P1-1):磁贴按自己那个根描述 + +/// 官方根里的实例被懒创建成档案之后,磁贴的三格必须**逐项不变**。 +/// +/// 复审 P1-1 的探针(夹具几乎一样):`version_present` true→false、`isolation` isolated→shared、 +/// `mc_version` `1.20.4`→`A`。三格同时错,而实际读盘(saves / mods / launch)走 `game_for` 是对的 +/// ⇒ **显示与行为分叉**:用户以为存档在 ULL 便携根,实际在官方根。 +/// +/// 变异证明:把 `profile_summary` 的 `home.unwrap_or(&env.game)` 改回 `&env.game` 就红。 +#[test] +fn the_tile_describes_a_lazily_created_profile_from_its_own_root() { + let (_root, env) = two_roots("ull-host-multiroot2-tile"); + // 官方根:`A` 继承 `1.20.4`,且 `versions/A/mods/` 存在(版本隔离形态)。配置根里**没有** `A`。 + let official = official_layout(&env); + write_manifest(&official, "1.20.4", r#"{"id":"1.20.4","type":"release"}"#); + write_manifest( + &official, + "A", + r#"{"id":"A","inheritsFrom":"1.20.4","type":"release"}"#, + ); + std::fs::create_dir_all(official.version_dir("A").join("mods")).expect("mods 目录"); + + // `1.20.4` 本身也是个可登记的版本 ⇒ 列表里会有它;这里只盯 `A`。 + let before = instances(&env); + let before = before + .iter() + .find(|tile| tile.version_id == "A") + .expect("`A` 在列表里") + .clone(); + assert_eq!(before.mc_version, "1.20.4", "继承链要走到父版本"); + assert!(before.version_present, "清单在官方根里"); + assert_eq!( + before.isolation, "isolated", + "`versions/A/mods/` 是自带数据的痕迹" + ); + + // 用户第一次改这个实例(写启动参数 = 懒创建档案,**不**写隔离档)——正是「官方实例」的常态路径。 + let patch = crate::dto::LaunchArgsPatch { + jvm_args: Some("-XX:+UseG1GC".to_string()), + ..crate::dto::LaunchArgsPatch::default() + }; + let mut host = super::super::CoreHost::headless(env.clone()); + host.apply_launch_args(&before.id, &patch) + .expect("官方根实例写启动参数不该报「找不到实例」"); + + let after = instances(&env); + let after = after + .iter() + .find(|tile| tile.id == before.id) + .expect("懒创建之后同一条实例还在"); + assert_eq!( + instances(&env).len(), + 2, + "懒创建之后不该多出第三条(`1.20.4` + `A` 就是全部)" + ); + assert_eq!( + after.version_present, before.version_present, + "`version_present` 变了(磁贴会显示「文件缺失」):{before:?} → {after:?}" + ); + assert_eq!( + after.isolation, before.isolation, + "`isolation` 变了(启动提示会说错运行目录):{before:?} → {after:?}" + ); + assert_eq!( + after.mc_version, before.mc_version, + "`mc_version` 变了(继承链丢了):{before:?} → {after:?}" + ); + assert_eq!( + after.launch.jvm_args, "-XX:+UseG1GC", + "写回的那一项要读得到" + ); +} + +// ---------------------------------------------------------------- 判据②(P1-2):挑 Java 按自己那个根 + +/// 两个根里各一个**同名**版本,`javaVersion` 要求不同 ⇒ 各自按自己那个根的要求回落。 +/// +/// 复审 P1-2 的探针:官方根里的自定义名版本声明 `javaVersion.majorVersion = 21`,配置根读不到清单 +/// ⇒ 退回静态表 ⇒ `minimum = 8` ⇒ 实际选中 17,把要 21 的 modpack 拉起来。 +/// +/// 用「显式路径探测失败」这条确定性回落(`probe` 拿不到版本 ⇒ `major = required.minimum`), +/// 所以不需要机器上真的装着 JDK 8/21:断言的是**要求**与回落,不是 `pick` 的最优选择。 +#[test] +fn pick_java_reads_the_requirement_from_the_instance_own_root() { + let (_root, env) = two_roots("ull-host-multiroot2-java"); + let official = official_layout(&env); + write_manifest( + &env.game, + "A", + r#"{"id":"A","javaVersion":{"majorVersion":8}}"#, + ); + write_manifest( + &official, + "A", + r#"{"id":"A","javaVersion":{"majorVersion":21}}"#, + ); + + let tiles = instances(&env); + assert_eq!(tiles.len(), 2, "{tiles:?}"); + // 探测必然失败(路径不存在)⇒ 落回「版本要求」。 + let missing = Path::new("Z:/ull-no-such-java/bin/java.exe"); + let mut majors: Vec = tiles + .iter() + .map(|tile| { + let game = game_for(&env, &tile.id); + super::super::launch::pick_java(&env, &game, "A", Some(missing)) + .selection + .major + }) + .collect(); + majors.sort_unstable(); + assert_eq!( + majors, + vec![8, 21], + "两个根里的同名版本要求不同,挑 Java 必须各读各自的清单" + ); + + // 变异证明:拿配置根去读官方根那个实例 ⇒ 只会得到 8(复审实测这条会导致 17 去跑要 21 的包)。 + let wrong = super::super::launch::pick_java(&env, &env.game, "A", Some(missing)); + assert_eq!( + wrong.selection.major, 8, + "按配置根读会挑错——这条断言钉住「错在哪」" + ); + + // **走真启动装配**(`build_request` → `pick_java`):这条才抓得住「调用点传错根」。 + // 每个实例在自己的设置里钉一个探测必然失败的 java 路径 ⇒ 生效的 major 就是它自己那个根的要求。 + let mut majors: Vec = Vec::new(); + for tile in &tiles { + crate::host::launch_args::apply( + &env, + &tile.id, + &tile.version_id, + &crate::dto::LaunchArgsPatch { + java_path: Some(missing.display().to_string()), + ..crate::dto::LaunchArgsPatch::default() + }, + ) + .expect("钉 java 路径"); + let target = super::super::launch::LaunchTarget { + id: tile.id.clone(), + name: tile.name.clone(), + version_id: tile.version_id.clone(), + account: None, + }; + let (request, _warnings) = + super::super::launch::build_request(&env, &target).expect("装配应当成功"); + majors.push(request.java.major); + } + majors.sort_unstable(); + assert_eq!( + majors, + vec![8, 21], + "启动装配读的必须是实例自己那个根的要求(调用点传 `env.game` 就会全是 8)" + ); +} + +// ---------------------------------------------------------------- 判据③(P2-3):只读发现升级成内容级 + +/// **内容级**快照:相对路径 + 字节数 + mtime(ms) + 内容哈希(fnv-1a)。 +/// +/// 为什么不是只比条目名(复审 P2-3):只比名字抓得住「多出新文件」,抓不住「已有文件被覆盖」 +/// ——natives 的 dll 被改写、mtime 被刷新这两种都漏。这里四样一起比:内容改一个字节、大小变、 +/// mtime 变都会红。 +fn content_snapshot(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + let Ok(read) = std::fs::read_dir(¤t) else { + continue; + }; + for entry in read.flatten() { + let path = entry.path(); + let Ok(relative) = path.strip_prefix(dir) else { + continue; + }; + let name = relative.to_string_lossy().replace('\\', "/"); + if path.is_dir() { + out.push(format!("{name}|dir")); + stack.push(path); + continue; + } + let meta = std::fs::metadata(&path).ok(); + let bytes = meta.as_ref().map_or(0, std::fs::Metadata::len); + let mtime = meta + .and_then(|meta| meta.modified().ok()) + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|elapsed| elapsed.as_millis()) + .unwrap_or(0); + let content = std::fs::read(&path).unwrap_or_default(); + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in content { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + out.push(format!("{name}|{bytes}|{mtime}|{hash:016x}")); + } + } + out.sort(); + out +} + +/// 列实例 / 列存档 / 装配启动 / 懒创建档案之后,官方根里**一个字节都不许变**,也不许凭空建档案。 +/// +/// 升级点(复审 P2-3):原来只比条目名集合,现在比「相对路径 + 大小 + mtime + 内容哈希」, +/// 并且把「懒创建档案」这条写盘路径也带进来(那是本 mission 唯一会在用户动作后写盘的地方, +/// 而它必须写进 `.ULL`、不碰官方根)。 +/// +/// 抓不住:`Launcher::spawn` 解压 natives(用例不拉进程);官方启动器同时在写的并发(TOCTOU)。 +#[test] +fn nothing_is_written_into_the_official_root() { + let (_root, env) = two_roots("ull-host-multiroot2-readonly"); + let official = official_layout(&env); + write_manifest(&official, "1.20.4", r#"{"id":"1.20.4","type":"release"}"#); + write_manifest( + &official, + "A", + r#"{"id":"A","inheritsFrom":"1.20.4","mainClass":"net.minecraft.client.main.Main"}"#, + ); + // 一个已经在的 natives 文件(正是「被覆写」这种错要抓的东西)。 + let natives = official.version_dir("A").join("natives"); + std::fs::create_dir_all(&natives).expect("natives 目录"); + std::fs::write(natives.join("lwjgl.dll"), b"original-content").expect("natives 落盘"); + let world = official.root().join("saves").join("world"); + std::fs::create_dir_all(&world).expect("世界目录"); + std::fs::write(world.join("level.dat"), b"fixture").expect("level.dat"); + + let before = content_snapshot(official.root()); + + // ① 列实例 ② 列存档(含截图目录扫) ③ 装配启动请求(natives 解压发生在 spawn,不在装配里) + let snapshot = instances(&env); + let tile = snapshot + .iter() + .find(|tile| tile.version_id == "A") + .expect("`A` 在列表里"); + for tile in &snapshot { + if let Ok(dir) = super::super::saves::run_dir(&env, tile) { + let _ = super::super::saves::snapshot(tile, &dir, None); + } + let target = super::super::launch::LaunchTarget { + id: tile.id.clone(), + name: tile.name.clone(), + version_id: tile.version_id.clone(), + account: None, + }; + let _ = super::super::launch::build_request(&env, &target); + } + // ④ 用户改一次设置 ⇒ 懒创建档案(写盘必须落 `.ULL`) + set_isolation(&env, &tile.id, "isolated").expect("懒创建档案"); + let _ = instances(&env); + + assert_eq!( + before, + content_snapshot(official.root()), + "官方根的条目 / 大小 / mtime / 内容有任何一格变了(发现与设置都只许读它)" + ); + let profiles = read_profiles(&env.layout).expect("读档案"); + assert_eq!(profiles.profiles.len(), 1, "档案落在 .ULL 里"); + assert!( + env.layout.profiles_config().starts_with(env.layout.home()), + "档案路径必须在 .ULL 内:{}", + env.layout.profiles_config().display() + ); +} diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 8fa110f..1a7b6c1 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -170,7 +170,8 @@ pub fn build_request( }; let overrides = super::launch_args::resolved(env, &target.id); - let java = pick_java(env, &version_id, overrides.java_path.as_deref()); + // 挑 Java 的**版本要求**也按实例自己的根读(M274 r1 复审 P1-2)。 + let java = pick_java(env, &game, &version_id, overrides.java_path.as_deref()); let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?; let options = AdapterOptions { java: java.selection, diff --git a/crates/presenter/src/host/launch/java.rs b/crates/presenter/src/host/launch/java.rs index 9a84f49..fc3e64d 100644 --- a/crates/presenter/src/host/launch/java.rs +++ b/crates/presenter/src/host/launch/java.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; +use unlucky_core::game::GameLayout; use unlucky_core::launch::request::JavaSelection; use unlucky_core::platform::paths::ProcessEnv; @@ -21,11 +22,21 @@ pub struct JavaPick { /// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。 /// +/// `game` 是这个实例**自己那个根**的布局(M274):版本清单里的 `javaVersion` 只在那个根下, +/// 拿配置根读不到 ⇒ 退回「按 id 的静态表」,而第三方启动器的自定义名版本(`樱落怡然客户端`) +/// `version_parts` 为空 ⇒ `minimum = 8`,一个要 21 的 modpack 会被拉去用 17 跑 +/// (M274 r1 复审 P1-2 的探针:实例自己根 minimum=21,配置根读出来 8,实际选中 17)。 +/// /// 显式路径**先探测**(`java -version`):探测失败不报错,按版本要求的基准主版本兜底—— /// 与自动挑的退路同一口径(启动路径不该因为探测不到就拒绝启动)。 -pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick { +pub fn pick_java( + env: &HostEnv, + game: &GameLayout, + 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); + let required = unlucky_core::game::java::requirement_for_version(game, version_id); if let Some(path) = explicit { let runtime = unlucky_core::game::java::probe::probe( path, From 70c6aec6ddc30783713fed6c2e6a1b8ae423fe37 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 19:21:52 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(presenter):=20=E5=86=99=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=82=A3=E9=81=93=E9=97=A8=E6=8C=89(=E6=A0=B9,?= =?UTF-8?q?=E7=89=88=E6=9C=AC)=E5=88=A4=E7=BB=91=E5=AE=9A=20+=20=E5=8F=8D?= =?UTF-8?q?=E6=9F=A5=E5=9B=9E=E8=90=BD=E7=95=99=E6=97=A5=E5=BF=97=20+=20?= =?UTF-8?q?=E6=97=97=E6=A0=87=E6=94=B9=E7=9C=9F=E5=90=8D=EF=BC=88M274=20r2?= =?UTF-8?q?=20P1-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/presenter/src/backend.rs | 10 +- crates/presenter/src/host/env.rs | 12 +- crates/presenter/src/host/game.rs | 21 +-- crates/presenter/src/host/game/discovered.rs | 115 +++++++----- .../src/host/game/tests_multiroot.rs | 5 +- .../src/host/game/tests_multiroot3.rs | 163 ++++++++++++++++++ 6 files changed, 260 insertions(+), 66 deletions(-) create mode 100644 crates/presenter/src/host/game/tests_multiroot3.rs diff --git a/crates/presenter/src/backend.rs b/crates/presenter/src/backend.rs index a29772b..3672d2c 100644 --- a/crates/presenter/src/backend.rs +++ b/crates/presenter/src/backend.rs @@ -7,7 +7,7 @@ use crate::host::CoreHost; use crate::host::HostEnv; use crate::mock::MockBackend; -/// 启动选项(`--data-root` 与 `--mock` 是产品开关;截图/演示也复用同一份选项)。 +/// 启动选项(`--data-dir` 与 `--mock` 是产品开关;截图/演示也复用同一份选项)。 #[derive(Debug, Clone, Default)] pub struct AppOptions { /// 钉死数据根(`/.ULL` + `/.minecraft`);None = 按便携配置解析。 @@ -26,7 +26,7 @@ pub fn config_path(root: &Path) -> PathBuf { /// 钉了数据根时用哪个环境(`None` = 没钉,走解析链)。 /// -/// **单独一格只为可测**:`--data-root` 是产品通道,钉了数据根照样要看见官方启动器里的实例 +/// **单独一格只为可测**:`--data-dir` 是产品通道,钉了数据根照样要看见官方启动器里的实例 /// (M274 r1 复审 P2-2)。退回 [`HostEnv::at_root`] 会让用户**静默**丢一半实例,而这层 /// `Box` 在用例里没有 env 取回口(`Backend` 没有访问器),所以判据就落在这个 /// 纯函数上(`backend::tests::data_root_keeps_discovering_the_official_launcher`)。 @@ -52,11 +52,11 @@ pub fn build_backend(options: &AppOptions) -> Box { mod tests { use super::*; - /// `--data-root`(产品通道)**保留**多根发现;`at_root`(截图/演示/用例)不保留。 + /// `--data-dir`(产品通道)**保留**多根发现;`at_root`(截图/演示/用例)不保留。 /// /// 抓得住:`host_env_for` 被改回 `HostEnv::at_root`——用户钉一个数据根就永久静默看不到 /// 官方启动器里的实例(M274 r1 复审 P2-2)。 - /// 抓不住:`--data-root` 的命令行解析(app 侧);mounted 的数据根真的解析得出来(那是 resolver)。 + /// 抓不住:`--data-dir` 的命令行解析(app 侧);mounted 的数据根真的解析得出来(那是 resolver)。 #[test] fn data_root_keeps_discovering_the_official_launcher() { let root = std::env::temp_dir().join(format!("ull-backend-m274-{}", std::process::id())); @@ -76,7 +76,7 @@ mod tests { unlucky_core::platform::paths::Platform::current(), &unlucky_core::platform::paths::ProcessEnv, ), - "`--data-root` 是产品通道:不许顺手关掉官方根发现" + "`--data-dir` 是产品通道:不许顺手关掉官方根发现" ); // 没钉数据根时这一格不参与(走解析链),`mock` 在前一步就分走了。 assert!(host_env_for(&AppOptions::default()).is_none()); diff --git a/crates/presenter/src/host/env.rs b/crates/presenter/src/host/env.rs index 401299b..2a419f6 100644 --- a/crates/presenter/src/host/env.rs +++ b/crates/presenter/src/host/env.rs @@ -54,10 +54,10 @@ impl HostEnv { Some(Self::from_data(data)) } - /// **钉根但照常做多根发现**:`--data-root`(生产通道)与 [`HostEnv::at_root`] 同一个落点, + /// **钉根但照常做多根发现**:`--data-dir`(生产通道)与 [`HostEnv::at_root`] 同一个落点, /// 差别只有一格——`standard_game` 保留(用户钉的是数据根,不是「别看官方启动器」)。 /// - /// 为什么两个入口必须分开(M274 r1 复审 P2-2):`--data-root` 是**产品**开关,之前它走 + /// 为什么两个入口必须分开(M274 r1 复审 P2-2):`--data-dir` 是**产品**开关,之前它走 /// [`HostEnv::at_root`],于是用户一钉数据根就永久静默看不到官方启动器里的实例。而 /// [`HostEnv::at_root`] 还被截图 / 演示 / 七十多处用例用着,那些地方**必须**确定性 /// (不能因为开发机上装了官方启动器就让用例结果变),所以它继续不填 `standard_game`。 @@ -68,7 +68,7 @@ impl HostEnv { /// 直接钉一个根:`/.ULL` + `/.minecraft`。 /// /// **给谁用**:截图 / 演示 / 单元测试——凡是「结果必须与这台机器上装没装官方启动器无关」的地方。 - /// **生产路径不要用这个**:产品里钉数据根(`--data-root`)走 [`HostEnv::at_data_root`], + /// **生产路径不要用这个**:产品里钉数据根(`--data-dir`)走 [`HostEnv::at_data_root`], /// 那条会照常发现官方启动器里的实例;用错这里,用户会**静默**看不到官方实例(M274 r1 复审 P2-2)。 /// /// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**: @@ -210,11 +210,11 @@ mod tests { /// /// | 入口 | 谁用 | `standard_game` | /// |---|---|---| - /// | [`HostEnv::at_data_root`] | 产品 `--data-root`(`backend.rs`) | `Some(官方位置)`:钉数据根 ≠ 别看官方实例 | + /// | [`HostEnv::at_data_root`] | 产品 `--data-dir`(`backend.rs`) | `Some(官方位置)`:钉数据根 ≠ 别看官方实例 | /// | [`HostEnv::at_root`] | 截图 / 演示 / 七十多处用例 | `None`:同一份用例在任何机器上结果一样 | /// | [`HostEnv::from_data`] | 生产解析链(便携 / 标准 / 自定义) | `Some(官方位置)` | /// - /// 抓得住:`--data-root` 退回 `at_root`(用户钉数据根后永久看不到官方实例,且没有任何报错)、 + /// 抓得住:`--data-dir` 退回 `at_root`(用户钉数据根后永久看不到官方实例,且没有任何报错)、 /// 或 `at_root` 顺手开始发现(用例在装了官方启动器的机器上变红/变绿)。 /// 抓不住:`paths::standard_game_home` 自己算错(那是 core 的用例)。 /// @@ -240,7 +240,7 @@ mod tests { assert_eq!( HostEnv::at_data_root(&root).standard_game, expected, - "`--data-root` 是产品通道:钉数据根不等于别看官方实例" + "`--data-dir` 是产品通道:钉数据根不等于别看官方实例" ); // 两条入口除了 `standard_game` 之外必须**完全一致**(落点、模式)。 let pinned = HostEnv::at_root(&root); diff --git a/crates/presenter/src/host/game.rs b/crates/presenter/src/host/game.rs index bde4165..b038218 100644 --- a/crates/presenter/src/host/game.rs +++ b/crates/presenter/src/host/game.rs @@ -171,20 +171,12 @@ pub fn instances(env: &HostEnv) -> Vec { let mut homes: Vec> = Vec::with_capacity(profiles.profiles.len()); let mut bound: Vec<&str> = Vec::new(); for profile in &profiles.profiles { - match found.find(profile.id.as_str()) { - Some((root, _)) => { - // 属于**这个根**的档案(懒创建的 `ext-` 档案可能属于官方根):磁贴按那个根描述自己。 - if root.configured() { - bound.push(profile.version_id.as_str()); - } - homes.push(Some(root.game().clone())); - } - None => { - // 反查不到 ⇒ 按旧口径算配置根的(M273 之前合成的 `ext-` id / 那批根已不在盘上)。 - bound.push(profile.version_id.as_str()); - homes.push(None); - } + // 两件事用**同一份**判据(`binds_configured_root` 与写盘那道门共用一个函数): + // ① 这条档案属于哪个根(磁贴按它描述自己);② 它是否占住配置根的同名版本。 + if discovered::binds_configured_root(&found, profile) { + bound.push(profile.version_id.as_str()); } + homes.push(found.game_of(profile.id.as_str()).cloned()); } let mut snapshot: Vec = profiles .profiles @@ -349,3 +341,6 @@ mod tests_multiroot; #[cfg(test)] mod tests_multiroot2; + +#[cfg(test)] +mod tests_multiroot3; diff --git a/crates/presenter/src/host/game/discovered.rs b/crates/presenter/src/host/game/discovered.rs index ecf5046..962fe0a 100644 --- a/crates/presenter/src/host/game/discovered.rs +++ b/crates/presenter/src/host/game/discovered.rs @@ -1,11 +1,16 @@ //! 自动发现的实例(M273 · 多根 M274):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。 //! -//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件的两件事: +//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件三件事: //! -//! 1. **哪些盘上的版本算实例**:`versions//.json` 能解析(判据来自 `core::game::adopt::scan`, -//! 本文件只做「哪个根」与「有没有档案」这两步); +//! 1. **哪些盘上的版本算实例**:`versions//.json` 能解析(判据来自 `core::game::adopt::scan`; +//! 本文件只负责「每个根扫一遍」并把结果装进 [`Found`]); //! 2. **它们从哪个目录跑**:[`base_mode`]——`versions//` 里有痕迹(`mods` / `saves` / -//! `config` / `options.txt`)就按独立目录,否则按共用目录(**它自己那个根**)。 +//! `config` / `options.txt`)就按独立目录,否则按共用目录(**它自己那个根**); +//! 3. **身份与判据**:[`external_instance_id`](id 里带根)、[`game_for`](读盘走哪个根)、 +//! [`binds_configured_root`](这条档案占住的是**配置根**里那个版本吗——列表侧与写盘那道门共用)。 +//! +//! 「哪个版本已经被档案绑定」这一步**不在本文件**:它属于 [`super::instances`] 的并集规则, +//! 而写盘那道门([`external_version_of`])用的是同一个 [`binds_configured_root`]。 //! //! # 多根(M274):身份 = (根, 版本) //! @@ -13,9 +18,10 @@ //! [`external_instance_id`](把根的规范化路径喂进哈希)。于是「同一个根 + 同一个版本目录」跨重启 //! 拿到同一个 id,而两个根里的同名版本拿到两个不同 id——列表自然出两条。 //! -//! 反查(id → 根 + 版本)只能靠**重算**:哈希不可逆,所以 [`locate`] 枚举全部根、每个根扫一遍 -//! `versions/`,逐个比 id。因此它只在「用户动了一次某个实例」时被调用(存档 / 模组 / 启动 / 改设置), -//! 不在 `instances()` 的循环里。 +//! 反查(id → 根 + 版本)只能靠**重算**:哈希不可逆,所以 [`Found::scan`] 枚举全部根、每个根扫一遍 +//! `versions/`,逐个比 id。**一拍只扫一遍**:`instances()` 建一份 [`Found`] 给整轮用(扫描次数 = +//! #根,r1 复审实测过旧形状是 `#根 × (1 + #ext 档案)`),单实例动作(存档 / 模组 / 启动 / 改设置) +//! 各建一份(同样是 #根 次)。 //! //! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节, //! 更不往官方启动器的根里写任何东西。 @@ -23,7 +29,7 @@ use std::collections::BTreeMap; use unlucky_core::game::isolation::IsolationMode; -use unlucky_core::game::{GameLayout, ProfileList}; +use unlucky_core::game::{GameLayout, InstanceProfile, ProfileList}; use crate::dto::{InstanceRuntime, InstanceSummary}; @@ -160,24 +166,6 @@ pub(in crate::host) fn external_summary( } } -/// 视图 id → (所属根, 版本 id):**唯一的反查点**(哈希不可逆,只能重算比对)。 -/// -/// 与 [`external_versions`] 同一份判据(可登记 ⇒ 会出现在列表里),但**不**看档案绑定: -/// 一个自动发现的实例被懒创建成档案之后,id 仍然是 `ext-…`,而它的根必须还认得出来 -/// (否则存档 / 模组 / 启动会退回配置根,那正是 M274 要修的错)。 -/// -/// 不是 `ext-` 前缀的 id 直接返回 `None`(档案实例属于配置根,不需要反查)。 -/// -/// **代价**(M274 r1 复审实测过):这是一次「每根扫一遍」的全树扫描。单实例动作(存档 / 模组 / -/// 启动 / 改设置)走这里没问题,**但绝不能在 `instances()` 的循环里调**——那里用 [`Found`] -/// 一拍只扫一遍(见它的文档)。 -#[must_use] -pub(in crate::host) fn locate(env: &HostEnv, id: &str) -> Option<(GameRoot, String)> { - Found::scan(env) - .find(id) - .map(|(root, version_id)| (root.clone(), version_id.to_string())) -} - /// **一拍之内**的发现快照:每个根只扫一遍 `versions/`,之后所有「这个 id 属于哪个根」都是内存查表。 /// /// 为什么要有它(M274 r1 复审 P2-1 的实测):把反查直接写进 `instances()` 的循环里,扫描次数是 @@ -233,31 +221,76 @@ impl Found { /// /// 档案实例、以及「反查不到」的 id 一律退回配置根 `env.game`——**没有第二个答案**: /// 宁可读配置根(旧行为),也不把实例指向一个猜出来的目录。 +/// +/// # 退回的后果(M274 r2 复审 P2-1,任务⑥点名要写) +/// +/// `is_discovered_instance(id)` 却反查不到,只有两种情形: +/// 1. **老档案**(M273 时代的 `ext-` id,哈希里没有根)——退回配置根是对的; +/// 2. **那个版本此刻不可登记**:官方启动器正在更新(清单半写 / 被锁 / 目录被移走)⇒ +/// `adopt::scan` 判 `adoptable() == false` ⇒ 这里查不到。 +/// +/// 第 2 种情形下退回配置根是有代价的:**若配置根里恰好有同名版本**,装配会去读它、 +/// `gameDir` 也落在配置根 ⇒ 可能**启动/读档到另一个根**。所以这里留一行启动器日志 +/// (`logs/launcher.log`,分类 `multiroot`),让这条回落可观测,而不是让调用方无感。 pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout { - Found::scan(env) - .game_of(id) - .cloned() - .unwrap_or_else(|| env.game.clone()) + let found = Found::scan(env); + if let Some(game) = found.game_of(id) { + return game.clone(); + } + if crate::dto::is_discovered_instance(id) { + unlucky_core::launch::launcher_log::record( + "multiroot", + &format!( + "实例 {id} 反查不到所属根(版本此刻不可登记:清单半写/被锁/目录已不在)⇒ 按配置根 {} 解析", + env.game.root().display() + ), + ); + } + env.game.clone() } -/// 视图 id → 版本 id 的反查(只认「可登记、且**还没有档案绑定**」的版本)。 +/// 这条档案**占住的是配置根里的那个版本吗**(`instances()` 的 `bound` 与写盘那道门共用同一份判据)。 /// -/// 与 `instances()` 同一份判据:合成的视图 id 只在那个集合里有意义,于是「界面上出现过的外部实例」 -/// 与「能懒创建的外部实例」永远是同一批(有了档案就走档案,不再重复列一条)。 +/// - 反查到某个根 ⇒ 看那个根是不是配置根; +/// - 反查不到 ⇒ 按配置根口径算(老 M273 的 `ext-` id、用户/向导建的自建档案、那批根已不在盘上)。 +/// +/// 为什么必须是**一个**函数(M274 r2 复审 P1-1):这两处曾经各写一份,列表侧对、写盘侧只比 +/// `version_id`——于是官方根那个 `A` 被懒创建成档案后,**配置根**里同名那个 `A` 被判成 +/// 「已被绑定」,`ensure_profile` 返回 `找不到实例 ext-…`,而磁贴还好端端列着。 +/// 判据分叉的代价是「有时能改有时不能改」,最难排的那一种。 +pub(in crate::host) fn binds_configured_root(found: &Found, profile: &InstanceProfile) -> bool { + found + .find(profile.id.as_str()) + .is_none_or(|(root, _)| root.configured()) +} + +/// 视图 id → (所属根, 版本 id) 的反查(只认「可登记、且**还没有档案绑定**」的版本)。 +/// +/// 与 `instances()` 同一份判据(含 [`binds_configured_root`]):合成的视图 id 只在那个集合里有意义, +/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批(有了档案就走档案, +/// 不再重复列一条)。 #[must_use] pub(in crate::host) fn external_version_of( env: &HostEnv, profiles: &ProfileList, id: &str, ) -> Option<(GameRoot, String)> { - let (root, version_id) = locate(env, id)?; - let bound = profiles - .profiles - .iter() - .any(|profile| profile.version_id == version_id); - // 档案绑定只对**配置根**有意义(别处的同名版本是另一个实例,同一个 id 也不会撞)。 - if root.configured() && bound { - return None; + // 一拍只扫一次:下面「查自己」与「查绑定者」都用这一份。 + let found = Found::scan(env); + let (root, version_id) = found + .find(id) + .map(|(root, version_id)| (root.clone(), version_id.to_string()))?; + // 档案绑定只对**配置根**有意义(别处的同名版本是另一个实例,id 不会撞)。 + // 而「绑定者」是不是配置根里的那一条,也按它**自己的根**判——不许只比 `version_id`: + // 官方根的 `A` 与配置根的 `A` 是两个实例(r2 复审 P1-1 的现场)。 + if root.configured() { + let taken = profiles.profiles.iter().any(|profile| { + profile.version_id == version_id + && (profile.id.as_str() == id || binds_configured_root(&found, profile)) + }); + if taken { + return None; + } } Some((root, version_id)) } diff --git a/crates/presenter/src/host/game/tests_multiroot.rs b/crates/presenter/src/host/game/tests_multiroot.rs index 99c8e42..0a7008c 100644 --- a/crates/presenter/src/host/game/tests_multiroot.rs +++ b/crates/presenter/src/host/game/tests_multiroot.rs @@ -1,4 +1,7 @@ -//! M274:**多根发现**(只读那条用例在 `tests_multiroot2.rs`:升级成内容级快照,M274 r1 P2-3)(配置根 + 官方启动器的标准位置)的判据用例。 +//! M274:**多根发现**(配置根 + 官方启动器的标准位置)的判据用例。 +//! +//! 只读那条用例搬到了 `tests_multiroot2.rs` 并升级成内容级快照(M274 r1 复审 P2-3); +//! 写设置那道门的两条方向用例在 `tests_multiroot3.rs`(M274 r2 复审 P1-1)。 //! //! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是 //! `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `/.minecraft`——两个不同的目录。 diff --git a/crates/presenter/src/host/game/tests_multiroot3.rs b/crates/presenter/src/host/game/tests_multiroot3.rs new file mode 100644 index 0000000..250c6ba --- /dev/null +++ b/crates/presenter/src/host/game/tests_multiroot3.rs @@ -0,0 +1,163 @@ +//! M274 **r2 复审 P1-1**:写设置那道门(`ensure_profile` → `external_version_of`)必须按 +//! **(根, 版本)** 判「这条档案是否占住配置根的同名版本」,不许只比 `version_id`。 +//! +//! 现场(复审探针,夹具就是 mission 判据⑤那份「两根各一个同名 `A`」): +//! +//! ```text +//! 官方那条 set_isolation => Ok(()) +//! 配置根那条 set_isolation => Err("找不到实例 `ext-cf5883f9d893ad24`") ← 而磁贴还好好列着 +//! ``` +//! +//! 方向还是不对称的(先改配置根那条再改官方那条是通的)——同一台机器上「有时能改有时不能改」, +//! 最难排的那种。根因是列表侧(`instances()` 的 `bound`)按根判、写侧(`external_version_of`) +//! 只比 `version_id`;现在两处共用 [`super::discovered::binds_configured_root`]。 +//! +//! | 用例 | 抓得住什么 | 抓不住什么 | +//! |---|---|---| +//! | [`both_same_named_instances_take_settings_in_either_order`] | 两个根的**同名**实例都改得动设置,两个方向都行,且写进去的值读得回来 | 图形界面点按钮那一路(`Element` 无内省;这里走的是同一条 host 函数) | + +use std::path::PathBuf; + +use unlucky_core::game::install::instance::{read_instance_config, Isolation}; +use unlucky_core::game::GameLayout; + +use super::*; + +/// 临时根(每个用例一个目录;断言 panic 时也会删)。 +mod temp { + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + pub struct Root(pub PathBuf); + + impl Root { + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + } + + impl Drop for Root { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(Path::new(&self.0)); + } + } +} + +/// 造两个根:配置根 = `/.minecraft`,官方位置 = `/official/.minecraft`。 +fn two_roots(tag: &str) -> (temp::Root, HostEnv) { + let root = temp::Root::new(tag); + let mut env = HostEnv::at_root(&root.0); + let official = root.0.join("official").join(".minecraft"); + std::fs::create_dir_all(&official).expect("官方根目录"); + env.standard_game = Some(official); + (root, env) +} + +fn official(env: &HostEnv) -> PathBuf { + env.standard_game.clone().expect("用例里一定钉了官方根") +} + +fn write_version(game: &GameLayout, id: &str) { + let path = game.version_json(id); + std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录"); + std::fs::write(&path, format!(r#"{{"id":"{id}"}}"#)).expect("写版本 JSON"); +} + +/// 列表里满足 `hit` 的那一条的 id(两条磁贴都含 `A`,所以判据写在调用处)。 +fn tile_id(env: &HostEnv, hit: impl Fn(&crate::dto::InstanceSummary) -> bool) -> String { + instances(env) + .into_iter() + .find(|tile| hit(tile)) + .unwrap_or_else(|| panic!("列表里没有那一条:{:?}", instances(env))) + .id +} + +/// 两个根的**同名**实例都改得动设置:正序、反序各跑一遍,每条都断言「写进去读得回来」。 +/// +/// 变异证明(复审给的形状):把 `external_version_of` 的 `bound` 改回 +/// 「只比 `version_id`」⇒ 正序那条(先官方、后配置根)立刻红,报 +/// `找不到实例 ext-…`,而两个方向的第二条都还是绿——这正是它被抓到的现场形状。 +#[test] +fn both_same_named_instances_take_settings_in_either_order() { + for official_first in [true, false] { + let (_root, env) = two_roots("ull-host-multiroot3-order"); + write_version(&env.game, "A"); + write_version(&GameLayout::new(official(&env)), "A"); + + // 官方根那条的名字带根路径;配置根那条保持版本目录名(见 `discovered::display_name`)。 + let official_id = tile_id(&env, |tile| tile.name.contains("official")); + let configured_id = tile_id(&env, |tile| tile.name == "A"); + assert_ne!(official_id, configured_id, "两个根的同名版本必须是两条实例"); + + let order: [(&str, &str); 2] = if official_first { + [(&official_id, "official"), (&configured_id, "configured")] + } else { + [(&configured_id, "configured"), (&official_id, "official")] + }; + + for (index, (id, which)) in order.iter().enumerate() { + // 两条生产入口铺开用:一条走 `set_isolation`,另一条走 `apply_launch_args`。 + if index == 0 { + set_isolation(&env, id, "isolated").expect("改隔离档不该报「找不到实例」"); + let config = read_instance_config(&env.layout, id) + .expect("读实例设置") + .expect("实例设置文件应当已写盘"); + assert_eq!( + config.isolation, + Some(Isolation::Isolated), + "{which} 那条写进去的隔离档读不回来" + ); + } else { + let patch = crate::dto::LaunchArgsPatch { + jvm_args: Some("-XX:+UseG1GC".to_string()), + ..crate::dto::LaunchArgsPatch::default() + }; + let mut host = super::super::CoreHost::headless(env.clone()); + host.apply_launch_args(id, &patch) + .expect("写启动参数不该报「找不到实例」"); + let config = read_instance_config(&env.layout, id) + .expect("读实例设置") + .expect("实例设置文件应当已写盘"); + assert_eq!( + config.launch.jvm_args.as_deref(), + Some("-XX:+UseG1GC"), + "{which} 那条写进去的启动参数读不回来" + ); + } + // 写完一次之后,另一条**还在列表里**、而且还能继续改(不许被吞掉)。 + let snapshot = instances(&env); + assert_eq!( + snapshot.len(), + 2, + "写了 {which} 那条之后列表变了:{snapshot:?}" + ); + assert!( + snapshot.iter().any(|tile| tile.id == official_id), + "官方根那条消失了:{snapshot:?}" + ); + assert!( + snapshot.iter().any(|tile| tile.id == configured_id), + "配置根那条消失了:{snapshot:?}" + ); + } + + // 两条都写过之后:id → 根的反查不许漂(写盘与读盘同一份身份)。 + assert_eq!( + game_for(&env, &official_id).root(), + official(&env), + "官方根那条写完设置之后反查退回了配置根" + ); + assert_eq!( + game_for(&env, &configured_id).root(), + env.game.root(), + "配置根那条反查错了根" + ); + let profiles = read_profiles(&env.layout).expect("读档案"); + assert_eq!(profiles.profiles.len(), 2, "两条实例各一份档案,不多不少"); + } +} From 72137dc658b358e9ba98334d29bff85f4b617915 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 19:43:52 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(presenter):=20=E5=9B=9E=E8=90=BD?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=B8=A4=E5=9B=A0=E5=B9=B6=E5=88=97=20+=20?= =?UTF-8?q?=E8=80=81=20ext=20=E6=A1=A3=E6=A1=88=E8=A6=86=E7=9B=96=20+=20?= =?UTF-8?q?=E6=AD=BB=E6=A0=B9=E6=A1=A3=E6=A1=88=E8=BE=B9=E7=95=8C=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=EF=BC=88M274=20r3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/presenter/src/host/game/discovered.rs | 6 +- .../src/host/game/tests_multiroot.rs | 2 +- .../src/host/game/tests_multiroot3.rs | 143 ++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/crates/presenter/src/host/game/discovered.rs b/crates/presenter/src/host/game/discovered.rs index 962fe0a..b028202 100644 --- a/crates/presenter/src/host/game/discovered.rs +++ b/crates/presenter/src/host/game/discovered.rs @@ -238,10 +238,14 @@ pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout { return game.clone(); } if crate::dto::is_discovered_instance(id) { + // 两因并列,**不要**只写后一种:`ext-` 前缀在 M273 时代就有,那时 id 只哈希版本目录名 + // (没有根),所以「M274 之前合成的老档案」**永远**反查不到——那是所有老用户的常态, + // 不是故障。只写「清单半写/被锁」会让每次点开详情页都往日志里加一条像是在报错的假警报 + // (M274 r3 复审 P2-1)。 unlucky_core::launch::launcher_log::record( "multiroot", &format!( - "实例 {id} 反查不到所属根(版本此刻不可登记:清单半写/被锁/目录已不在)⇒ 按配置根 {} 解析", + "实例 {id} 反查不到所属根(老档案:M273 之前合成,id 不含根;或该版本此刻不可登记:清单半写/被锁/目录已不在)⇒ 按配置根 {} 解析", env.game.root().display() ), ); diff --git a/crates/presenter/src/host/game/tests_multiroot.rs b/crates/presenter/src/host/game/tests_multiroot.rs index 0a7008c..16585f3 100644 --- a/crates/presenter/src/host/game/tests_multiroot.rs +++ b/crates/presenter/src/host/game/tests_multiroot.rs @@ -15,7 +15,7 @@ //! | [`the_same_version_in_two_roots_is_two_instances`] | 两个根里的同名版本 = **2 条**磁贴、id 不同、各自指向自己的根、名字分辨得出来 | 真实官方安装的目录形状(`launcher_profiles.json` / 加密的账户文件一概不读) | //! | [`a_junction_to_the_configured_root_is_not_a_second_root`] | 同一路径的第二种写法(junction / 符号链接)**不许**让同一批实例列两遍 | 8.3 短名(用例盘上造不出来;判据靠 `canonicalize` 的长名展开,见 `roots::key_of` 的文档) | //! | [`saves_come_from_the_instance_own_root`] | 每个实例的存档 / 截图列表只来自**它自己那个根**(不许把外部实例的存档当成当前根的) | 游戏进程正在跑时的文件锁 / 半写状态(未处理,见报告) | -//! | [`a_lazily_created_profile_stays_on_its_own_root`] | 懒创建档案(用户第一次改设置)之后 id → 根的反查不退化,且没把配置根的同名版本一并抹掉 | 档案里**没有**根字段这一事实本身(根只从 id 反查,见 `discovered::locate`) | +//! | [`a_lazily_created_profile_stays_on_its_own_root`] | 懒创建档案(用户第一次改设置)之后 id → 根的反查不退化,且没把配置根的同名版本一并抹掉 | 档案里**没有**根字段这一事实本身(根只从 id 反查,见 `discovered::Found`) | //! | [`the_launch_reads_the_manifest_from_the_instance_root`] | 启动装配的清单 / 运行目录走实例自己的根 | 真的把游戏拉起来(要真 jvm / 账号 / 网络) | use std::path::{Path, PathBuf}; diff --git a/crates/presenter/src/host/game/tests_multiroot3.rs b/crates/presenter/src/host/game/tests_multiroot3.rs index 250c6ba..99d543c 100644 --- a/crates/presenter/src/host/game/tests_multiroot3.rs +++ b/crates/presenter/src/host/game/tests_multiroot3.rs @@ -161,3 +161,146 @@ fn both_same_named_instances_take_settings_in_either_order() { assert_eq!(profiles.profiles.len(), 2, "两条实例各一份档案,不多不少"); } } + +// ------------------------------------------------- 反查回落(M274 r3 复审 P2-1 / ②) + +/// M273 时代的 `ext-` id:**只哈希版本目录名**(不含根)。M274 之后这种 id 永远反查不到。 +fn m273_external_id(version_id: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in version_id.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("ext-{hash:016x}") +} + +/// 在**我们自己的日志目录**里数这个实例的回落行(重试到「登记窗口没被并行用例抢走」为止)。 +/// +/// `launcher_log` 的目录是**进程级全局**(`set_dir` 以最后一次为准),而 presenter 的用例并行跑、 +/// 七十多处 `HostEnv::at_root` 都会重新登记。所以这里每次尝试换一个**新目录**:抢到了就能在 +/// 一个全新文件里看到**恰好一行**;没抢到(文件不存在)就再试。抢不到 64 次视为用例失败(不当跳过)。 +fn fallback_lines(env: &HostEnv, id: &str) -> Vec { + let file = unlucky_core::launch::launcher_log::FILE_NAME; + let mut saw_file = false; + for attempt in 0..64 { + let dir = std::env::temp_dir().join(format!( + "ull-host-multiroot3-log-{}-{attempt}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + unlucky_core::launch::launcher_log::set_dir(&dir); + let _ = game_for(env, id); + let Ok(text) = std::fs::read_to_string(dir.join(file)) else { + // 文件没建出来:这一拍的登记窗口被并行用例抢走了(它们也会 `set_dir`),再试。 + continue; + }; + let lines: Vec = text + .lines() + .filter(|line| line.contains(id)) + .map(str::to_string) + .collect(); + std::fs::remove_dir_all(&dir).ok(); + if !lines.is_empty() { + return lines; + } + // 文件在、却没有我们的行:可能是别人的行占了这个文件,也可能这条路径**根本没写**。 + // 再试几次,全都不中就当「0 行」交给断言去红(比在这里 panic 好读)。 + saw_file = true; + } + if saw_file { + return Vec::new(); + } + panic!("64 次都没看到日志文件:回落日志没写,或登记窗口每次都被并行用例抢走"); +} + +/// **老 `ext-` 档案**(M273 形状)反查不到 ⇒ 落配置根,并且日志里**恰好一行** `[multiroot]`, +/// 文案**两因并列**(老档案 ‖ 此刻不可登记)。 +/// +/// 这条路径此前零覆盖(M274 r3 复审指出):它是所有 M273 老用户升级后的**常态**,不是故障; +/// 文案只写「清单半写/被锁/目录已不在」会让每次点开详情页都往日志里加一条假警报。 +/// +/// 变异证明:把文案只留「此刻不可登记」那半句 ⇒ 下面的关键词断言红;把回落日志整段删掉 ⇒ +/// 「恰好一行」红(0 行)。 +#[test] +fn an_old_m273_instance_falls_back_with_one_two_cause_log_line() { + let (_root, env) = two_roots("ull-host-multiroot3-fallback"); + write_version(&env.game, "A"); + let old_id = m273_external_id("A"); + assert!( + crate::dto::is_discovered_instance(&old_id), + "老 id 也带 `ext-` 前缀(这正是「永远反查不到」的来源)" + ); + + assert_eq!( + game_for(&env, &old_id).root(), + env.game.root(), + "老档案反查不到 ⇒ 只能按配置根解析" + ); + let lines = fallback_lines(&env, &old_id); + assert_eq!( + lines.len(), + 1, + "一次回落只该写一行(多了会把日志刷成噪声):{lines:?}" + ); + let line = &lines[0]; + assert!(line.contains("[multiroot]"), "分类名要可 grep:{line}"); + assert!( + line.contains("老档案"), + "M273 老 id 是**常态**,文案必须并列这一因,不能只说「清单半写/被锁」:{line}" + ); + assert!( + line.contains("不可登记"), + "另一因(官方启动器正在更新)也要在:{line}" + ); +} + +/// **已知边界**(不是设计意图):官方根的版本目录消失后,那条死档案会「吞掉」配置根的同名版本。 +/// +/// 现场(M274 r3 复审 ②):两根各一个 `A`,官方那条先懒创建;官方根的 `versions/` 随后不见 +/// (官方启动器升级 / 清理 / 卸载 / 换盘)。此时: +/// - 列表只剩 **1 条**,名字还写着 `A · …\official\.minecraft`(指向官方根); +/// - 而 `version_present` / 读盘路径却全从**配置根**读出来(`game_for` 回落); +/// - ⇒ 真正的配置根那条 `A` **从列表消失**(被 `bound` 判成「已被档案占住」)。 +/// +/// 这是 r1 判 P1 的那种「显示与行为分叉」的反向形态。**这轮不改结构**:真正的修法是给 +/// `InstanceProfile` 加「根」字段(`profiles.json` schema + 迁移),代价超出本 mission。 +/// 所以这条用例钉的是**现状**——将来做 schema 迁移时它会红,届时要跟着改成「两条都在」。 +#[test] +fn a_dead_official_root_is_swallowed_by_its_stale_profile() { + let (_root, env) = two_roots("ull-host-multiroot3-deadroot"); + write_version(&env.game, "A"); + write_version(&GameLayout::new(official(&env)), "A"); + + let official_id = tile_id(&env, |tile| tile.name.contains("official")); + set_isolation(&env, &official_id, "shared").expect("官方那条先懒创建档案"); + // 官方启动器把那个版本清理掉了(目录整个不见)。 + std::fs::remove_dir_all(GameLayout::new(official(&env)).versions_dir()) + .expect("删官方 versions"); + + let snapshot = instances(&env); + assert_eq!( + snapshot.len(), + 1, + "现状:只有档案那一条(配置根的同名版本被 bound 吞掉):{snapshot:?}" + ); + let tile = &snapshot[0]; + assert_eq!(tile.id, official_id); + assert!( + tile.name.contains("official"), + "名字还指着**已经不存在的**官方根:{tile:?}" + ); + assert!( + tile.version_present && game_for(&env, &tile.id).root() == env.game.root(), + "而读盘/显示已经悄悄落回配置根(显示与行为分叉的反向形态):{tile:?}" + ); + let dir = super::super::saves::run_dir(&env, tile).expect("派生运行目录"); + assert_eq!( + dir, + env.game.root(), + "存档也落回配置根——这就是「死档案吞掉同名版本」的后果" + ); +}