feat(presenter): 多根发现(配置根 + 官方启动器标准位置),实例按自己的根解析存档/模组/启动(M274)

This commit is contained in:
AskaEth committed 2026-11-12 19:55:51 +08:00
1 parent dfc0f4bcb3
commit dc8323531b
20 files changed
+943 -121

No files matched your search

+120 -43
View File
@@ -1,22 +1,34 @@
//! 自动发现的实例(M273):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
//! 自动发现的实例(M273 · 多根 M274):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
//!
//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件的两件事:
//!
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析、没有被任何档案绑定
//! (判据来自 `core::game::adopt::scan`,本文件只做「有没有档案」这一步);
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析(判据来自 `core::game::adopt::scan`,
//! 本文件只做「哪个根」与「有没有档案」这两步);
//! 2. **它们从哪个目录跑**:[`base_mode`]——`versions/<id>/` 里有痕迹(`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/<version_id>/` 里这些名字出现任何一个,就说明这个版本本来就被当成
@@ -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/<id>/`。一个从没跑过、也没有 `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<String> {
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<String> {
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<St
/// 这个白名单校验),而版本目录名可以是中文——用户实测的那个就是 `樱落怡然客户端`。
/// 直接沿用版本 id 会让**启动**在写盘前就死在 `LaunchFailureStage::InstanceConfig`。
///
/// 前缀 + 稳定哈希 ⇒ 同一个版本每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。
/// 前缀 + 稳定哈希 ⇒ 同一个 (根, 版本) 每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。
///
/// **M274(多根发现)**:两个根里都可能有 `1.20.4`,所以这里是**唯一**该把「根」并进身份的
/// 地方——到时把 `root` 也喂进哈希(`external_instance_id(root, version_id)`),
/// 列表就自然出两条,不必动 [`instances`] 的其余部分。
pub(super) fn external_instance_id(version_id: &str) -> 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<String, String>,
) -> 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<String> {
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))
}
+190
View File
@@ -0,0 +1,190 @@
//! 游戏根的发现与去重(M274):**当前配置的根** + **官方启动器的标准位置**。
//!
//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是
//! `%APPDATA%\.minecraft` / `~/Library/Application Support/minecraft` / `~/.minecraft`,
//! 而便携模式下的 ULL 用的是 `<exe>/.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<GameRoot> {
let mut roots = vec![GameRoot {
key: key_of(env.game.root()),
game: env.game.clone(),
configured: true,
}];
let mut seen: BTreeSet<String> = 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()
}
}
+2 -2
View File
@@ -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();
}
@@ -0,0 +1,397 @@
//! M274:**多根发现**(配置根 + 官方启动器的标准位置)的判据用例。
//!
//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是
//! `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `<exe>/.minecraft`——两个不同的目录。
//!
//! **本机没有装官方启动器**(`paths::standard_game_home()` 解析出的路径不存在),所以这一组的
//! 每个根都是**临时目录里造出来的夹具**:`<root>/.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<String> {
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(&current) 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
}
}
/// 造两个根:配置根 = `<root>/.minecraft`,官方位置 = `<root>/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/<name>/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::<Vec<_>>(),
snapshot
.iter()
.map(|tile| tile.id.clone())
.collect::<Vec<_>>(),
"同一批盘上内容两次快照给了不同 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<PathBuf> = 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);
}
@@ -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",
"实例文件里写下的档位优先于基底档"
);