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(), + "存档也落回配置根——这就是「死档案吞掉同名版本」的后果" + ); +}