From dc8323531bfe8ea71972873916097bd336810d53 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 18:32:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(presenter):=20=E5=A4=9A=E6=A0=B9=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=EF=BC=88=E9=85=8D=E7=BD=AE=E6=A0=B9=20+=20=E5=AE=98?= =?UTF-8?q?=E6=96=B9=E5=90=AF=E5=8A=A8=E5=99=A8=E6=A0=87=E5=87=86=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=89=EF=BC=8C=E5=AE=9E=E4=BE=8B=E6=8C=89=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E7=9A=84=E6=A0=B9=E8=A7=A3=E6=9E=90=E5=AD=98=E6=A1=A3?= =?UTF-8?q?/=E6=A8=A1=E7=BB=84/=E5=90=AF=E5=8A=A8=EF=BC=88M274=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) }