//! 自动发现的实例(M273):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。 //! //! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件的两件事: //! //! 1. **哪些盘上的版本算实例**:`versions//.json` 能解析、没有被任何档案绑定 //! (判据来自 `core::game::adopt::scan`,本文件只做「有没有档案」这一步); //! 2. **它们从哪个目录跑**:[`base_mode`]——`versions//` 里有痕迹(`mods` / `saves` / //! `config` / `options.txt`)就按独立目录,否则按共用目录(游戏根)。 //! //! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节。 use std::collections::BTreeMap; use unlucky_core::game::isolation::IsolationMode; use unlucky_core::game::ProfileList; use crate::dto::{InstanceRuntime, InstanceSummary}; use super::super::env::HostEnv; use super::{describe_version, global_mode, isolation_of}; /// `versions//` 里这些名字出现任何一个,就说明这个版本本来就被当成 /// **独立目录**在用(它自己的 mods / 存档 / 配置都在里面)。判据只 `exists()`,不读内容。 /// /// 为什么是这四个:HMCL / PCL / 官方启动器的「版本隔离」都把运行目录放在 `versions//`, /// 于是跑过一次就会留下 `mods/` `saves/` `config/` `options.txt` 中的至少一个。 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); RUN_DIR_TRACES.iter().any(|name| dir.join(name).exists()) } /// 实例的**基底隔离档**(实例文件之下那一层)。 /// /// | 实例 | 基底 | 依据 | /// |---|---|---| /// | 有档案(向导 / 用户 / AI 建的) | 全局 `instances.mode` | D20 原口径,一字不变 | /// | **自动发现的**(id 带 [`crate::dto::DISCOVERED_ID_PREFIX`]) | `versions/` 里有痕迹 ⇒ `isolated`;否则 ⇒ `shared` | 「别人怎么存我们就怎么读」 | /// /// 为什么自动发现的实例不能一律 `isolated`:「版本隔离」下 `isolated` 的**运行目录就是** /// `versions//`。一个从没跑过、也没有 `mods`/`saves` 的外部版本若按 `isolated` 处理, /// 第一次启动就会在**别人的版本目录**里新长出 `mods/` `saves/` `config/`(D20 的 /// `AdoptTarget` 缺省值正是为了躲开这件事而选 `InstanceDir`)。反过来,盘上已经有这些痕迹的 /// 版本本来就是独立目录实例,按 `shared` 处理会让它突然读到 `.minecraft` 根下别人的存档 —— /// 两种错都真实存在,所以判据只能是**盘上的事实**。 /// /// 只读:这里不建目录、不写任何文件(写盘只发生在用户第一次改设置时,见 [`super::ensure_profile`])。 pub(in crate::host) fn base_mode(env: &HostEnv, 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) { IsolationMode::Isolated } else { IsolationMode::Shared } } /// 盘上有版本、但没有档案绑定的那些版本 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 { 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() } /// 外部实例的视图 id(= 懒创建时写进 `profiles.json` 的档案 id)。 /// /// **不能直接拿版本 id 当实例 id**:`core::platform::layout::is_valid_instance_id` 只收 /// `[A-Za-z0-9_-]{1,64}`(`config/instances/.json` 与启动链的 `read_instance_config` 都按 /// 这个白名单校验),而版本目录名可以是中文——用户实测的那个就是 `樱落怡然客户端`。 /// 直接沿用版本 id 会让**启动**在写盘前就死在 `LaunchFailureStage::InstanceConfig`。 /// /// 前缀 + 稳定哈希 ⇒ 同一个版本每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。 /// /// **M274(多根发现)**:两个根里都可能有 `1.20.4`,所以这里是**唯一**该把「根」并进身份的 /// 地方——到时把 `root` 也喂进哈希(`external_instance_id(root, version_id)`), /// 列表就自然出两条,不必动 [`instances`] 的其余部分。 pub(super) fn external_instance_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!("{}{hash:016x}", crate::dto::DISCOVERED_ID_PREFIX) } /// 一条外部版本 → 磁贴(显示名 = 版本目录名;**只读**,不落任何文件)。 pub(super) fn external_summary( env: &HostEnv, version_id: &str, played: &BTreeMap, ) -> InstanceSummary { let id = external_instance_id(version_id); let (mc_version, loader) = describe_version(env, 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(), version_id: version_id.to_string(), mc_version, loader, runtime: InstanceRuntime::Idle, icon: None, id, } } /// 视图 id → 版本 id 的反查(只认「可登记、且还没有档案绑定」的版本)。 /// /// 与 [`external_versions`] 同一份判据:合成的视图 id 只在那个集合里有意义, /// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批。 pub(super) 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) }