Merge branch 'feat/multiroot-discover-official-launcher-gam'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-12 19:56:45 +08:00
commit c4706c759c
24 files changed
+1780 -138

No files matched your search

@@ -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,
@@ -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 禁用` 的那种顺序错,这里从根上绕开。
@@ -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;
@@ -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)
}
+1
View File
@@ -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`)。
+5
View File
@@ -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}"),
} }
};
}
+53 -4
View File
@@ -7,10 +7,13 @@ use crate::host::CoreHost;
use crate::host::HostEnv;
use crate::mock::MockBackend;
/// 启动选项(截图/演示通道用;正常启动全用默认值)。
/// 启动选项(`--data-dir` 与 `--mock` 是产品开关;截图/演示也复用同一份选项)。
#[derive(Debug, Clone, Default)]
pub struct AppOptions {
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析。
///
/// 走 [`HostEnv::at_data_root`]:**照常**发现官方启动器的标准游戏目录(M274)。钉数据根
/// 不等于「别看官方实例」——那是两个不同的开关。
pub data_root: Option<PathBuf>,
/// 强制 mock 后端(UI 回归/无数据根演示)
pub mock: bool,
@@ -21,16 +24,62 @@ pub fn config_path(root: &Path) -> PathBuf {
root.join(".ULL").join("config").join("launcher.json")
}
/// 钉了数据根时用哪个环境(`None` = 没钉,走解析链)。
///
/// **单独一格只为可测**:`--data-dir` 是产品通道,钉了数据根照样要看见官方启动器里的实例
/// (M274 r1 复审 P2-2)。退回 [`HostEnv::at_root`] 会让用户**静默**丢一半实例,而这层
/// `Box<dyn Backend>` 在用例里没有 env 取回口(`Backend` 没有访问器),所以判据就落在这个
/// 纯函数上(`backend::tests::data_root_keeps_discovering_the_official_launcher`)。
fn host_env_for(options: &AppOptions) -> Option<HostEnv> {
options.data_root.as_ref().map(HostEnv::at_data_root)
}
/// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。
pub fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
if options.mock {
return Box::new(MockBackend::new());
}
match &options.data_root {
Some(root) => Box::new(CoreHost::new(HostEnv::at_root(root))),
match host_env_for(options) {
Some(env) => Box::new(CoreHost::new(env)),
None => match CoreHost::open() {
Some(host) => Box::new(host),
None => Box::new(MockBackend::new()),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `--data-dir`(产品通道)**保留**多根发现;`at_root`(截图/演示/用例)不保留。
///
/// 抓得住:`host_env_for` 被改回 `HostEnv::at_root`——用户钉一个数据根就永久静默看不到
/// 官方启动器里的实例(M274 r1 复审 P2-2)。
/// 抓不住:`--data-dir` 的命令行解析(app 侧);mounted 的数据根真的解析得出来(那是 resolver)。
#[test]
fn data_root_keeps_discovering_the_official_launcher() {
let root = std::env::temp_dir().join(format!("ull-backend-m274-{}", std::process::id()));
let options = AppOptions {
data_root: Some(root.clone()),
mock: false,
};
let env = host_env_for(&options).expect("钉了数据根就该有环境");
assert_eq!(
env.game.root(),
root.join(".minecraft"),
"落点与 `at_root` 一致"
);
assert_eq!(
env.standard_game,
unlucky_core::platform::paths::standard_game_home(
unlucky_core::platform::paths::Platform::current(),
&unlucky_core::platform::paths::ProcessEnv,
),
"`--data-dir` 是产品通道:不许顺手关掉官方根发现"
);
// 没钉数据根时这一格不参与(走解析链),`mock` 在前一步就分走了。
assert!(host_env_for(&AppOptions::default()).is_none());
std::fs::remove_dir_all(&root).ok();
}
}
+5 -2
View File
@@ -290,10 +290,13 @@ fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, ContentFail
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 基底档,见 `game::base_mode`)。
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, ContentFailure> {
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}")))
}
+6 -4
View File
@@ -286,10 +286,11 @@ pub(super) fn instance_root(env: &HostEnv, id: &InstanceId) -> Result<PathBuf, S
.into_iter()
.find(|instance| &instance.id == id)
.ok_or_else(|| format!("instance not found: {id}"))?;
let base = super::super::game::base_mode(env, &instance.id, &instance.version_id);
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| format!("read instance config failed: {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| format!("derive game dir failed: {error}"))
}
@@ -300,8 +301,9 @@ pub(super) fn snapshot_of(env: &HostEnv, id: &InstanceId) -> Option<InstanceSnap
let instance = super::super::game::instances(env)
.into_iter()
.find(|instance| &instance.id == id)?;
// 隔离实例的 `versions/` 在全局根下,所以试全局根
read_instance_snapshot(env.game.root(), Some(&instance.version_id)).ok()
// 隔离实例的 `versions/` 在**它自己那个根**下(M274:官方根里的版本清单不在配置根下)
let game = super::super::game::game_for(env, &instance.id);
read_instance_snapshot(game.root(), Some(&instance.version_id)).ok()
}
/// 格式视图 → core 的静态格式(MultiMC 只读,不能当导出目标)。
+94 -12
View File
@@ -30,10 +30,16 @@ pub struct HostEnv {
pub data: ResolvedData,
/// `.ULL/` 布局
pub layout: LauncherLayout,
/// `.minecraft/` 布局
/// `.minecraft/` 布局(**配置的这个根**;多根发现的判据在 `game/roots.rs`)
pub game: GameLayout,
/// 平台
pub platform: Platform,
/// 官方启动器的标准游戏目录(`paths::standard_game_home`):多根发现的**第二个候选**。
///
/// `None` = 不去发现([`HostEnv::at_root`] 钉根时就是这样:截图/演示/用例钉了一个根,
/// 就不该顺手把用户真实的 `%APPDATA%\.minecraft` 也读进来,否则同一份用例在不同机器上
/// 结果不同)。生产路径由 [`HostEnv::from_data`] 填。
pub standard_game: Option<PathBuf>,
}
impl HostEnv {
@@ -48,20 +54,33 @@ impl HostEnv {
Some(Self::from_data(data))
}
/// 直接钉一个根:`<root>/.ULL` + `<root>/.minecraft`(截图/演示通道)。
/// **钉根但照常做多根发现**:`--data-dir`(生产通道)与 [`HostEnv::at_root`] 同一个落点,
/// 差别只有一格——`standard_game` 保留(用户钉的是数据根,不是「别看官方启动器」)。
///
/// 为什么两个入口必须分开(M274 r1 复审 P2-2):`--data-dir` 是**产品**开关,之前它走
/// [`HostEnv::at_root`],于是用户一钉数据根就永久静默看不到官方启动器里的实例。而
/// [`HostEnv::at_root`] 还被截图 / 演示 / 七十多处用例用着,那些地方**必须**确定性
/// (不能因为开发机上装了官方启动器就让用例结果变),所以它继续不填 `standard_game`。
pub fn at_data_root(root: impl AsRef<Path>) -> Self {
Self::from_data(portable_data(root.as_ref()))
}
/// 直接钉一个根:`<root>/.ULL` + `<root>/.minecraft`。
///
/// **给谁用**:截图 / 演示 / 单元测试——凡是「结果必须与这台机器上装没装官方启动器无关」的地方。
/// **生产路径不要用这个**:产品里钉数据根(`--data-dir`)走 [`HostEnv::at_data_root`],
/// 那条会照常发现官方启动器里的实例;用错这里,用户会**静默**看不到官方实例(M274 r1 复审 P2-2)。
///
/// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**:
/// 演示根常用它把游戏目录指到既有真数据上(避免复制几 GB 的 assets/libraries)。
///
/// 实现上**委托**给 `at_data_root` 再关掉发现(不是复制一份构造):两个入口的落点/模式因此
/// 物理上不可能漂开。
pub fn at_root(root: impl AsRef<Path>) -> Self {
let root = root.as_ref();
let request = request_from_portable(root);
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
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,
})
let mut env = Self::at_data_root(root);
// 钉一个根的**测试/截图**通道不做多根发现:见 `standard_game` 的文档。
env.standard_game = None;
env
}
/// 由解析结果建环境。
@@ -74,10 +93,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,
}
}
@@ -123,6 +149,18 @@ impl HostEnv {
}
}
/// 便携钉根的一份 `ResolvedData`(`at_root` / `at_data_root` 共用,两者只在 `standard_game` 上不同)。
fn portable_data(root: &Path) -> ResolvedData {
let request = request_from_portable(root);
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
ResolvedData {
mode: DataMode::Portable,
launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")),
game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")),
redirected_from: None,
}
}
/// 便携目录那份 `launcher.json` → 数据根请求(只读 `core.*` 四键)。
pub(crate) fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
let mut request = DataDirRequest::default();
@@ -168,6 +206,50 @@ mod tests {
std::fs::remove_dir_all(&root).ok();
}
/// **多根发现的两条入口必须分开**(M274 r1 复审 P2-2):
///
/// | 入口 | 谁用 | `standard_game` |
/// |---|---|---|
/// | [`HostEnv::at_data_root`] | 产品 `--data-dir`(`backend.rs`) | `Some(官方位置)`:钉数据根 ≠ 别看官方实例 |
/// | [`HostEnv::at_root`] | 截图 / 演示 / 七十多处用例 | `None`:同一份用例在任何机器上结果一样 |
/// | [`HostEnv::from_data`] | 生产解析链(便携 / 标准 / 自定义) | `Some(官方位置)` |
///
/// 抓得住:`--data-dir` 退回 `at_root`(用户钉数据根后永久看不到官方实例,且没有任何报错)、
/// 或 `at_root` 顺手开始发现(用例在装了官方启动器的机器上变红/变绿)。
/// 抓不住:`paths::standard_game_home` 自己算错(那是 core 的用例)。
///
/// 「两个入口不会各自漂开」是**结构性**的:`at_root` 委托 `at_data_root` 再关一格,
/// 不是复制一份构造——所以下面那三行落点断言只是把这条结构钉成行为,而不是唯一防线。
#[test]
fn only_the_product_entries_discover_the_official_root() {
let root = std::env::temp_dir().join(format!("ull-host-env-m274-{}", std::process::id()));
let expected = unlucky_core::platform::paths::standard_game_home(
Platform::current(),
&unlucky_core::platform::paths::ProcessEnv,
);
assert!(
expected.is_some(),
"这个平台连标准游戏目录都算不出来,用例的前提不成立"
);
assert_eq!(
HostEnv::at_root(&root).standard_game,
None,
"截图/用例钉根不许做多根发现"
);
assert_eq!(
HostEnv::at_data_root(&root).standard_game,
expected,
"`--data-dir` 是产品通道:钉数据根不等于别看官方实例"
);
// 两条入口除了 `standard_game` 之外必须**完全一致**(落点、模式)。
let pinned = HostEnv::at_root(&root);
let product = HostEnv::at_data_root(&root);
assert_eq!(pinned.layout.home(), product.layout.home());
assert_eq!(pinned.game.root(), product.game.root());
std::fs::remove_dir_all(&root).ok();
}
/// M188 的守门②:**日志根随数据模式变化**(便携 / 标准 / 自定义各一条断言)。
///
/// 三条走的都是 `HostEnv` 真正用的那条解析链(`Resolver` → `ResolvedData::layout`):
+88 -28
View File
@@ -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};
/// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。
pub fn global_mode(env: &HostEnv) -> IsolationMode {
@@ -59,13 +64,15 @@ pub fn last_played(env: &HostEnv) -> BTreeMap<String, String> {
}
/// 版本 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(&current);
let path = game.version_json(&current);
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,44 +141,85 @@ pub fn isolation_of(env: &HostEnv, id: &str, version_id: &str) -> String {
/// 存在 `versions/<id>/<id>.json`,**没有**我们的档案记录。只读档案 ⇒ 已装好的实例在界面上
/// 不存在,用户以为「读不到这个 .minecraft」。
///
/// 所以:档案照旧(名字 / 隔离档 / 启动参数都以档案为准),**盘上有清单但没有档案绑定的版本**
/// 就地合成一条视图挂上去。判据不在这里另写一套:扫描走
/// [`unlucky_core::game::adopt::scan`](`versions/<id>/<id>.json` 能解出 `id`、继承链走到底、
/// 符号链接不越界、坏目录逐条报问题),本文件只做 DTO 翻译 + 「有没有档案绑定」这一步。
/// ## 为什么是多个根(M274)
///
/// 官方启动器用的是 `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `<exe>/.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<InstanceSummary> {
let profiles = profiles(env);
let played = last_played(env);
// **每个根只扫一遍**(M274 r1 复审 P2-1):这一拍的「根 × 版本」清单读一次,之后
// ①每份档案属于哪个根、②它是否占住配置根的同名版本,都是内存查表。
// 反查写进循环里是 `#根 × (1 + #ext 档案)` 次全树扫描——官方根 200 版本 + 10 份 ext 档案
// 时 `instances()` 会从 236ms 涨到 1.35s(复审实测 ×5.7)。
let found = discovered::Found::scan(env);
let mut homes: Vec<Option<GameLayout>> = Vec::with_capacity(profiles.profiles.len());
let mut bound: Vec<&str> = Vec::new();
for profile in &profiles.profiles {
// 两件事用**同一份**判据(`binds_configured_root` 与写盘那道门共用一个函数):
// ① 这条档案属于哪个根(磁贴按它描述自己);② 它是否占住配置根的同名版本。
if discovered::binds_configured_root(&found, profile) {
bound.push(profile.version_id.as_str());
}
homes.push(found.game_of(profile.id.as_str()).cloned());
}
let mut snapshot: Vec<InstanceSummary> = profiles
.profiles
.iter()
.map(|profile| profile_summary(env, profile, &played))
.zip(&homes)
.map(|(profile, home)| profile_summary(env, profile, home.as_ref(), &played))
.collect();
snapshot.extend(
external_versions(env, &profiles)
.iter()
.map(|version_id| external_summary(env, version_id, &played)),
);
let known: Vec<&str> = profiles
.profiles
.iter()
.map(|profile| profile.id.as_str())
.collect();
for (root, versions) in found.roots() {
let configured = root.configured();
for version_id in versions {
let id = discovered::external_instance_id(root.key(), version_id);
if known.contains(&id.as_str()) {
continue;
}
if configured && bound.contains(&version_id.as_str()) {
continue;
}
snapshot.push(external_summary(env, root, version_id, &played));
}
}
snapshot
}
/// 一条档案 → 磁贴(档案路径的唯一翻译点)。
///
/// `home` 是这条档案**自己那个根**([`discovered::Found`] 查出来的;`None` = 配置根):
/// 懒创建出来的 `ext-` 档案可能属于官方根,那时「版本在不在盘上 / 隔离档 / MC 版本 / 版本门槛」
/// 都必须按那个根算——否则磁贴说「文件缺失 · 共用目录」,而存档与启动实际读的是官方根
/// (M274 r1 复审 P1-1:显示与行为分叉,最坏的一种)。
fn profile_summary(
env: &HostEnv,
profile: &InstanceProfile,
home: Option<&GameLayout>,
played: &BTreeMap<String, String>,
) -> InstanceSummary {
let version_id = profile.version_id.clone();
let (mc_version, loader) = describe_version(env, &version_id);
let game = home.unwrap_or(&env.game);
let (mc_version, loader) = describe_version(game, &version_id);
let id = profile.id.as_str();
InstanceSummary {
id: id.to_string(),
@@ -179,9 +229,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 +256,13 @@ pub(super) fn ensure_profile(env: &HostEnv, id: &str) -> Result<InstanceProfile,
{
return Ok(existing.clone());
}
let Some(version_id) = external_version_of(env, &list, id) else {
let Some((root, version_id)) = external_version_of(env, &list, id) else {
return Err(format!("找不到实例 `{id}`"));
};
let profile = InstanceProfile {
id: ProfileId::new(id),
name: version_id.clone(),
// 显示名与磁贴同源:别的根里的同名版本带上根路径,否则两条磁贴分不清(M274)。
name: display_name(&root, &version_id),
version_id,
..InstanceProfile::default()
};
@@ -284,3 +335,12 @@ pub fn short_path(path: &Path) -> String {
mod tests;
#[cfg(test)]
mod tests_union;
#[cfg(test)]
mod tests_multiroot;
#[cfg(test)]
mod tests_multiroot2;
#[cfg(test)]
mod tests_multiroot3;
+206 -44
View File
@@ -1,22 +1,40 @@
//! 自动发现的实例(M273):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
//! 自动发现的实例(M273 · 多根 M274):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
//!
//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件的两件事:
//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件三件事:
//!
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析、没有被任何档案绑定
//! (判据来自 `core::game::adopt::scan`,本文件只做「有没有档案」这一步);
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析(判据来自 `core::game::adopt::scan`;
//! 本文件只负责「每个根扫一遍」并把结果装进 [`Found`]);
//! 2. **它们从哪个目录跑**:[`base_mode`]——`versions/<id>/` 里有痕迹(`mods` / `saves` /
//! `config` / `options.txt`)就按独立目录,否则按共用目录(游戏根)。
//! `config` / `options.txt`)就按独立目录,否则按共用目录(**它自己那个根**);
//! 3. **身份与判据**:[`external_instance_id`](id 里带根)、[`game_for`](读盘走哪个根)、
//! [`binds_configured_root`](这条档案占住的是**配置根**里那个版本吗——列表侧与写盘那道门共用)。
//!
//! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节。
//! 「哪个版本已经被档案绑定」这一步**不在本文件**:它属于 [`super::instances`] 的并集规则,
//! 而写盘那道门([`external_version_of`])用的是同一个 [`binds_configured_root`]。
//!
//! # 多根(M274):身份 = (根, 版本)
//!
//! 两个根里都可能有 `1.20.4`,所以版本 id **不再唯一**。身份的落点只有一个:
//! [`external_instance_id`](把根的规范化路径喂进哈希)。于是「同一个根 + 同一个版本目录」跨重启
//! 拿到同一个 id,而两个根里的同名版本拿到两个不同 id——列表自然出两条。
//!
//! 反查(id → 根 + 版本)只能靠**重算**:哈希不可逆,所以 [`Found::scan`] 枚举全部根、每个根扫一遍
//! `versions/`,逐个比 id。**一拍只扫一遍**:`instances()` 建一份 [`Found`] 给整轮用(扫描次数 =
//! #根,r1 复审实测过旧形状是 `#根 × (1 + #ext 档案)`),单实例动作(存档 / 模组 / 启动 / 改设置)
//! 各建一份(同样是 #根 次)。
//!
//! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节,
//! 更不往官方启动器的根里写任何东西。
use std::collections::BTreeMap;
use unlucky_core::game::isolation::IsolationMode;
use unlucky_core::game::ProfileList;
use unlucky_core::game::{GameLayout, InstanceProfile, 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 +45,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 +63,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 +110,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 +166,135 @@ pub(super) fn external_summary(
}
}
/// 视图 id → 版本 id 的反查(只认「可登记、且还没有档案绑定」的版本)。
/// **一拍之内**的发现快照:每个根只扫一遍 `versions/`,之后所有「这个 id 属于哪个根」都是内存查表。
///
/// 与 [`external_versions`] 同一份判据:合成的视图 id 只在那个集合里有意义,
/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批。
pub(super) fn external_version_of(
/// 为什么要有它(M274 r1 复审 P2-1 的实测):把反查直接写进 `instances()` 的循环里,扫描次数是
/// `#根 × (1 + #ext 档案)`;官方根 200 个版本 + 10 份 `ext-` 档案时 `instances()` 从 236ms 涨到
/// 1.35s(×5.7)。这里把「根 × 版本」的清单一次性读完,反查退化成线性的字符串比较
/// (`ext-` id 是 16 位十六进制,逐条比一遍远比再扫一次盘便宜)。
///
/// 快照是**快照**:一拍里盘变了(用户同时在装版本)不会看到,下一次刷新重建。
pub(in crate::host) struct Found {
roots: Vec<(GameRoot, Vec<String>)>,
}
impl Found {
/// 扫一遍全部根(配置根在前,顺序与 `roots::all` 一致)。
pub(in crate::host) fn scan(env: &HostEnv) -> Self {
let roots = roots::all(env)
.into_iter()
.map(|root| {
let versions = external_versions(env, root.game());
(root, versions)
})
.collect();
Self { roots }
}
/// 逐个根 + 它这一拍扫到的版本 id(字典序,已滤掉不可登记的)。
pub(in crate::host) fn roots(&self) -> impl Iterator<Item = (&GameRoot, &[String])> {
self.roots
.iter()
.map(|(root, versions)| (root, versions.as_slice()))
}
/// id → (根, 版本 id)。不是 `ext-` 前缀 / 扫不到 ⇒ `None`。
pub(in crate::host) fn find(&self, id: &str) -> Option<(&GameRoot, &str)> {
if !crate::dto::is_discovered_instance(id) {
return None;
}
self.roots().find_map(|(root, versions)| {
versions
.iter()
.find(|version_id| external_instance_id(root.key(), version_id) == id)
.map(|version_id| (root, version_id.as_str()))
})
}
/// id → 那个根的布局(查不到 ⇒ `None`,由调用方决定退回哪个根)。
pub(in crate::host) fn game_of(&self, id: &str) -> Option<&GameLayout> {
self.find(id).map(|(root, _)| root.game())
}
}
/// 实例所属的那个根的布局(读盘路径的唯一入口)。
///
/// 档案实例、以及「反查不到」的 id 一律退回配置根 `env.game`——**没有第二个答案**:
/// 宁可读配置根(旧行为),也不把实例指向一个猜出来的目录。
///
/// # 退回的后果(M274 r2 复审 P2-1,任务⑥点名要写)
///
/// `is_discovered_instance(id)` 却反查不到,只有两种情形:
/// 1. **老档案**(M273 时代的 `ext-` id,哈希里没有根)——退回配置根是对的;
/// 2. **那个版本此刻不可登记**:官方启动器正在更新(清单半写 / 被锁 / 目录被移走)⇒
/// `adopt::scan` 判 `adoptable() == false` ⇒ 这里查不到。
///
/// 第 2 种情形下退回配置根是有代价的:**若配置根里恰好有同名版本**,装配会去读它、
/// `gameDir` 也落在配置根 ⇒ 可能**启动/读档到另一个根**。所以这里留一行启动器日志
/// (`logs/launcher.log`,分类 `multiroot`),让这条回落可观测,而不是让调用方无感。
pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout {
let found = Found::scan(env);
if let Some(game) = found.game_of(id) {
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} 反查不到所属根(老档案:M273 之前合成,id 不含根;或该版本此刻不可登记:清单半写/被锁/目录已不在)⇒ 按配置根 {} 解析",
env.game.root().display()
),
);
}
env.game.clone()
}
/// 这条档案**占住的是配置根里的那个版本吗**(`instances()` 的 `bound` 与写盘那道门共用同一份判据)。
///
/// - 反查到某个根 ⇒ 看那个根是不是配置根;
/// - 反查不到 ⇒ 按配置根口径算(老 M273 的 `ext-` id、用户/向导建的自建档案、那批根已不在盘上)。
///
/// 为什么必须是**一个**函数(M274 r2 复审 P1-1):这两处曾经各写一份,列表侧对、写盘侧只比
/// `version_id`——于是官方根那个 `A` 被懒创建成档案后,**配置根**里同名那个 `A` 被判成
/// 「已被绑定」,`ensure_profile` 返回 `找不到实例 ext-…`,而磁贴还好端端列着。
/// 判据分叉的代价是「有时能改有时不能改」,最难排的那一种。
pub(in crate::host) fn binds_configured_root(found: &Found, profile: &InstanceProfile) -> bool {
found
.find(profile.id.as_str())
.is_none_or(|(root, _)| root.configured())
}
/// 视图 id → (所属根, 版本 id) 的反查(只认「可登记、且**还没有档案绑定**」的版本)。
///
/// 与 `instances()` 同一份判据(含 [`binds_configured_root`]):合成的视图 id 只在那个集合里有意义,
/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批(有了档案就走档案,
/// 不再重复列一条)。
#[must_use]
pub(in crate::host) fn external_version_of(
env: &HostEnv,
profiles: &ProfileList,
id: &str,
) -> Option<String> {
external_versions(env, profiles)
.into_iter()
.find(|version_id| external_instance_id(version_id) == id)
) -> Option<(GameRoot, String)> {
// 一拍只扫一次:下面「查自己」与「查绑定者」都用这一份。
let found = Found::scan(env);
let (root, version_id) = found
.find(id)
.map(|(root, version_id)| (root.clone(), version_id.to_string()))?;
// 档案绑定只对**配置根**有意义(别处的同名版本是另一个实例,id 不会撞)。
// 而「绑定者」是不是配置根里的那一条,也按它**自己的根**判——不许只比 `version_id`:
// 官方根的 `A` 与配置根的 `A` 是两个实例(r2 复审 P1-1 的现场)。
if root.configured() {
let taken = profiles.profiles.iter().any(|profile| {
profile.version_id == version_id
&& (profile.id.as_str() == id || binds_configured_root(&found, profile))
});
if taken {
return None;
}
}
Some((root, version_id))
}
+206
View File
@@ -0,0 +1,206 @@
//! 游戏根的发现与去重(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 折成小写。
//!
//! # 已知取舍(这是**我们**的选择,不是文件系统性质)
//!
//! 第 4 步折小写是为了让 `C:\Mc` 与 `c:\mc`(Windows 上同一个目录)算同一个根。代价在 macOS:
//! **APFS 可以按卷选择区分大小写**,那种卷上 `/a` 与 `/A` 是两个真实不同的目录,折小写会把它们
//! 并成一个键 ⇒ 第二个根(官方位置)被**静默跳过**(不报错、不列它的实例)。触发条件只有
//! 「用户在区分大小写的 APFS 卷上,且两个根只差大小写」——那种布局几乎不可能是真实安装,
//! 所以接受这个代价:宁可漏一个根,也不要把同一批实例列两遍(后者是更常见的错)。
//!
//! 另外三条边界(都不是缺陷,记在这里免得下次重新推一遍):
//! - `\\?\UNC\server\share` 剥前缀后成 `unc/server/share`:仍然**唯一**、不会误合并,
//! 只是丢了 UNC 语义(键只用于相等比较,不再当路径用);
//! - `\\?\C:\` 去掉尾斜杠之后是 `c:`:与 `c:/foo` 这类前缀不相同,不会撞;
//! - Linux 的 `/`:去尾斜杠会得到空串,有兜底写回 `"/"`。
//!
//! `canonicalize` 失败(根还不存在)时退化成「字面量归一」:`\`→`/`、去尾斜杠、折小写。
//! 尚不存在的目录没有短名/链接可解析,这一步够用;代价是那个目录**创建之后**键可能变一次
//! (短名 → 长名、大小写归到真实名字),于是该根下已发现的实例 id 会变一次。
//!
//! # 只读
//!
//! 本模块只 `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:/…`):不去掉就与普通写法永不相等。
// UNC 会因此变成 `unc/server/share`(仍唯一,见模块文档的「已知取舍」)。
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,347 @@
//! M274:**多根发现**(配置根 + 官方启动器的标准位置)的判据用例。
//!
//! 只读那条用例搬到了 `tests_multiroot2.rs` 并升级成内容级快照(M274 r1 复审 P2-3);
//! 写设置那道门的两条方向用例在 `tests_multiroot3.rs`(M274 r2 复审 P1-1)。
//!
//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是
//! `%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`] | 每个实例的存档 / 截图列表只来自**它自己那个根**(不许把外部实例的存档当成当前根的) | 游戏进程正在跑时的文件锁 / 半写状态(未处理,见报告) |
//! | [`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};
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));
}
}
}
/// 造两个根:配置根 = `<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],
"两个根的实例派生出同一个运行目录(存档会互相覆盖)"
);
}
// ------------------------------------------- 判据⑤:懒创建的档案仍认自己那个根(不吞同名)
/// 用户第一次给官方根里的实例改设置 ⇒ 懒创建档案,但:
/// 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);
}
@@ -0,0 +1,332 @@
//! M274 **r1 复审**补的判据(P1-1 / P1-2 / P2-3)。
//!
//! 上一轮(`tests_multiroot.rs`)钉的是「两个根里的同名版本是两条实例、各自的书架/存档/启动按自己
//! 那个根解析」。复审把「同一句话没走完」的两处揪了出来——**根身份有了,但两处「描述 / 前置要求」
//! 还在读配置根**——这一组就是那两处,加上「只读」那条升级成内容级快照。
//!
//! | 用例 | 抓得住什么 | 抓不住什么 |
//! |---|---|---|
//! | [`the_tile_describes_a_lazily_created_profile_from_its_own_root`] | 官方根实例被懒创建成档案之后,磁贴的 `version_present` / `isolation` / `mc_version` **逐项不变**(复审 P1-1 的三项同时错:显示与行为分叉) | 磁贴之外的显示(`element` 没有内省接口,见 `pages/instances/tests` 的口径) |
//! | [`pick_java_reads_the_requirement_from_the_instance_own_root`] | 启动挑 Java 时读的是**实例自己那个根**的 `javaVersion`(复审 P1-2:配置根读出来 8,实例是 21,实际选中 17) | 真的有 21 的 JDK 被选中(那要装 JDK;这里钉的是「要求」与回落,不是 `pick` 的最优选择) |
//! | [`nothing_is_written_into_the_official_root`] | 列实例 / 列存档 / 装配启动 / 懒创建档案之后,官方根的**文件内容、大小、mtime** 一个都没变(复审 P2-3:原来只比条目名) | natives 解压(那发生在 `Launcher::spawn`,用例不拉进程);官方启动器同时在写的并发(TOCTOU) |
use std::path::{Path, PathBuf};
use unlucky_core::game::GameLayout;
use super::*;
/// 临时根(每个用例一个目录;断言 panic 时也会删)。
mod temp {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
pub struct Root(pub PathBuf);
impl Root {
pub fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
}
impl Drop for Root {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(Path::new(&self.0));
}
}
}
/// 造两个根:配置根 = `<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 official_layout(env: &HostEnv) -> GameLayout {
GameLayout::new(official(env))
}
fn write_manifest(game: &GameLayout, id: &str, json: &str) {
let path = game.version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, json).expect("写版本 JSON");
}
// ---------------------------------------------------------------- 判据①(P1-1):磁贴按自己那个根描述
/// 官方根里的实例被懒创建成档案之后,磁贴的三格必须**逐项不变**。
///
/// 复审 P1-1 的探针(夹具几乎一样):`version_present` true→false、`isolation` isolated→shared、
/// `mc_version` `1.20.4`→`A`。三格同时错,而实际读盘(saves / mods / launch)走 `game_for` 是对的
/// ⇒ **显示与行为分叉**:用户以为存档在 ULL 便携根,实际在官方根。
///
/// 变异证明:把 `profile_summary` 的 `home.unwrap_or(&env.game)` 改回 `&env.game` 就红。
#[test]
fn the_tile_describes_a_lazily_created_profile_from_its_own_root() {
let (_root, env) = two_roots("ull-host-multiroot2-tile");
// 官方根:`A` 继承 `1.20.4`,且 `versions/A/mods/` 存在(版本隔离形态)。配置根里**没有** `A`。
let official = official_layout(&env);
write_manifest(&official, "1.20.4", r#"{"id":"1.20.4","type":"release"}"#);
write_manifest(
&official,
"A",
r#"{"id":"A","inheritsFrom":"1.20.4","type":"release"}"#,
);
std::fs::create_dir_all(official.version_dir("A").join("mods")).expect("mods 目录");
// `1.20.4` 本身也是个可登记的版本 ⇒ 列表里会有它;这里只盯 `A`。
let before = instances(&env);
let before = before
.iter()
.find(|tile| tile.version_id == "A")
.expect("`A` 在列表里")
.clone();
assert_eq!(before.mc_version, "1.20.4", "继承链要走到父版本");
assert!(before.version_present, "清单在官方根里");
assert_eq!(
before.isolation, "isolated",
"`versions/A/mods/` 是自带数据的痕迹"
);
// 用户第一次改这个实例(写启动参数 = 懒创建档案,**不**写隔离档)——正是「官方实例」的常态路径。
let patch = crate::dto::LaunchArgsPatch {
jvm_args: Some("-XX:+UseG1GC".to_string()),
..crate::dto::LaunchArgsPatch::default()
};
let mut host = super::super::CoreHost::headless(env.clone());
host.apply_launch_args(&before.id, &patch)
.expect("官方根实例写启动参数不该报「找不到实例」");
let after = instances(&env);
let after = after
.iter()
.find(|tile| tile.id == before.id)
.expect("懒创建之后同一条实例还在");
assert_eq!(
instances(&env).len(),
2,
"懒创建之后不该多出第三条(`1.20.4` + `A` 就是全部)"
);
assert_eq!(
after.version_present, before.version_present,
"`version_present` 变了(磁贴会显示「文件缺失」):{before:?} → {after:?}"
);
assert_eq!(
after.isolation, before.isolation,
"`isolation` 变了(启动提示会说错运行目录):{before:?} → {after:?}"
);
assert_eq!(
after.mc_version, before.mc_version,
"`mc_version` 变了(继承链丢了):{before:?} → {after:?}"
);
assert_eq!(
after.launch.jvm_args, "-XX:+UseG1GC",
"写回的那一项要读得到"
);
}
// ---------------------------------------------------------------- 判据②(P1-2):挑 Java 按自己那个根
/// 两个根里各一个**同名**版本,`javaVersion` 要求不同 ⇒ 各自按自己那个根的要求回落。
///
/// 复审 P1-2 的探针:官方根里的自定义名版本声明 `javaVersion.majorVersion = 21`,配置根读不到清单
/// ⇒ 退回静态表 ⇒ `minimum = 8` ⇒ 实际选中 17,把要 21 的 modpack 拉起来。
///
/// 用「显式路径探测失败」这条确定性回落(`probe` 拿不到版本 ⇒ `major = required.minimum`),
/// 所以不需要机器上真的装着 JDK 8/21:断言的是**要求**与回落,不是 `pick` 的最优选择。
#[test]
fn pick_java_reads_the_requirement_from_the_instance_own_root() {
let (_root, env) = two_roots("ull-host-multiroot2-java");
let official = official_layout(&env);
write_manifest(
&env.game,
"A",
r#"{"id":"A","javaVersion":{"majorVersion":8}}"#,
);
write_manifest(
&official,
"A",
r#"{"id":"A","javaVersion":{"majorVersion":21}}"#,
);
let tiles = instances(&env);
assert_eq!(tiles.len(), 2, "{tiles:?}");
// 探测必然失败(路径不存在)⇒ 落回「版本要求」。
let missing = Path::new("Z:/ull-no-such-java/bin/java.exe");
let mut majors: Vec<u32> = tiles
.iter()
.map(|tile| {
let game = game_for(&env, &tile.id);
super::super::launch::pick_java(&env, &game, "A", Some(missing))
.selection
.major
})
.collect();
majors.sort_unstable();
assert_eq!(
majors,
vec![8, 21],
"两个根里的同名版本要求不同,挑 Java 必须各读各自的清单"
);
// 变异证明:拿配置根去读官方根那个实例 ⇒ 只会得到 8(复审实测这条会导致 17 去跑要 21 的包)。
let wrong = super::super::launch::pick_java(&env, &env.game, "A", Some(missing));
assert_eq!(
wrong.selection.major, 8,
"按配置根读会挑错——这条断言钉住「错在哪」"
);
// **走真启动装配**(`build_request` → `pick_java`):这条才抓得住「调用点传错根」。
// 每个实例在自己的设置里钉一个探测必然失败的 java 路径 ⇒ 生效的 major 就是它自己那个根的要求。
let mut majors: Vec<u32> = Vec::new();
for tile in &tiles {
crate::host::launch_args::apply(
&env,
&tile.id,
&tile.version_id,
&crate::dto::LaunchArgsPatch {
java_path: Some(missing.display().to_string()),
..crate::dto::LaunchArgsPatch::default()
},
)
.expect("钉 java 路径");
let target = super::super::launch::LaunchTarget {
id: tile.id.clone(),
name: tile.name.clone(),
version_id: tile.version_id.clone(),
account: None,
};
let (request, _warnings) =
super::super::launch::build_request(&env, &target).expect("装配应当成功");
majors.push(request.java.major);
}
majors.sort_unstable();
assert_eq!(
majors,
vec![8, 21],
"启动装配读的必须是实例自己那个根的要求(调用点传 `env.game` 就会全是 8)"
);
}
// ---------------------------------------------------------------- 判据③(P2-3):只读发现升级成内容级
/// **内容级**快照:相对路径 + 字节数 + mtime(ms) + 内容哈希(fnv-1a)。
///
/// 为什么不是只比条目名(复审 P2-3):只比名字抓得住「多出新文件」,抓不住「已有文件被覆盖」
/// ——natives 的 dll 被改写、mtime 被刷新这两种都漏。这里四样一起比:内容改一个字节、大小变、
/// mtime 变都会红。
fn content_snapshot(dir: &Path) -> Vec<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();
let Ok(relative) = path.strip_prefix(dir) else {
continue;
};
let name = relative.to_string_lossy().replace('\\', "/");
if path.is_dir() {
out.push(format!("{name}|dir"));
stack.push(path);
continue;
}
let meta = std::fs::metadata(&path).ok();
let bytes = meta.as_ref().map_or(0, std::fs::Metadata::len);
let mtime = meta
.and_then(|meta| meta.modified().ok())
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|elapsed| elapsed.as_millis())
.unwrap_or(0);
let content = std::fs::read(&path).unwrap_or_default();
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in content {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
out.push(format!("{name}|{bytes}|{mtime}|{hash:016x}"));
}
}
out.sort();
out
}
/// 列实例 / 列存档 / 装配启动 / 懒创建档案之后,官方根里**一个字节都不许变**,也不许凭空建档案。
///
/// 升级点(复审 P2-3):原来只比条目名集合,现在比「相对路径 + 大小 + mtime + 内容哈希」,
/// 并且把「懒创建档案」这条写盘路径也带进来(那是本 mission 唯一会在用户动作后写盘的地方,
/// 而它必须写进 `.ULL`、不碰官方根)。
///
/// 抓不住:`Launcher::spawn` 解压 natives(用例不拉进程);官方启动器同时在写的并发(TOCTOU)。
#[test]
fn nothing_is_written_into_the_official_root() {
let (_root, env) = two_roots("ull-host-multiroot2-readonly");
let official = official_layout(&env);
write_manifest(&official, "1.20.4", r#"{"id":"1.20.4","type":"release"}"#);
write_manifest(
&official,
"A",
r#"{"id":"A","inheritsFrom":"1.20.4","mainClass":"net.minecraft.client.main.Main"}"#,
);
// 一个已经在的 natives 文件(正是「被覆写」这种错要抓的东西)。
let natives = official.version_dir("A").join("natives");
std::fs::create_dir_all(&natives).expect("natives 目录");
std::fs::write(natives.join("lwjgl.dll"), b"original-content").expect("natives 落盘");
let world = official.root().join("saves").join("world");
std::fs::create_dir_all(&world).expect("世界目录");
std::fs::write(world.join("level.dat"), b"fixture").expect("level.dat");
let before = content_snapshot(official.root());
// ① 列实例 ② 列存档(含截图目录扫) ③ 装配启动请求(natives 解压发生在 spawn,不在装配里)
let snapshot = instances(&env);
let tile = snapshot
.iter()
.find(|tile| tile.version_id == "A")
.expect("`A` 在列表里");
for tile in &snapshot {
if let Ok(dir) = super::super::saves::run_dir(&env, tile) {
let _ = super::super::saves::snapshot(tile, &dir, None);
}
let target = super::super::launch::LaunchTarget {
id: tile.id.clone(),
name: tile.name.clone(),
version_id: tile.version_id.clone(),
account: None,
};
let _ = super::super::launch::build_request(&env, &target);
}
// ④ 用户改一次设置 ⇒ 懒创建档案(写盘必须落 `.ULL`)
set_isolation(&env, &tile.id, "isolated").expect("懒创建档案");
let _ = instances(&env);
assert_eq!(
before,
content_snapshot(official.root()),
"官方根的条目 / 大小 / mtime / 内容有任何一格变了(发现与设置都只许读它)"
);
let profiles = read_profiles(&env.layout).expect("读档案");
assert_eq!(profiles.profiles.len(), 1, "档案落在 .ULL 里");
assert!(
env.layout.profiles_config().starts_with(env.layout.home()),
"档案路径必须在 .ULL 内:{}",
env.layout.profiles_config().display()
);
}
@@ -0,0 +1,306 @@
//! M274 **r2 复审 P1-1**:写设置那道门(`ensure_profile` → `external_version_of`)必须按
//! **(根, 版本)** 判「这条档案是否占住配置根的同名版本」,不许只比 `version_id`。
//!
//! 现场(复审探针,夹具就是 mission 判据⑤那份「两根各一个同名 `A`」):
//!
//! ```text
//! 官方那条 set_isolation => Ok(())
//! 配置根那条 set_isolation => Err("找不到实例 `ext-cf5883f9d893ad24`") ← 而磁贴还好好列着
//! ```
//!
//! 方向还是不对称的(先改配置根那条再改官方那条是通的)——同一台机器上「有时能改有时不能改」,
//! 最难排的那种。根因是列表侧(`instances()` 的 `bound`)按根判、写侧(`external_version_of`)
//! 只比 `version_id`;现在两处共用 [`super::discovered::binds_configured_root`]。
//!
//! | 用例 | 抓得住什么 | 抓不住什么 |
//! |---|---|---|
//! | [`both_same_named_instances_take_settings_in_either_order`] | 两个根的**同名**实例都改得动设置,两个方向都行,且写进去的值读得回来 | 图形界面点按钮那一路(`Element` 无内省;这里走的是同一条 host 函数) |
use std::path::PathBuf;
use unlucky_core::game::install::instance::{read_instance_config, Isolation};
use unlucky_core::game::GameLayout;
use super::*;
/// 临时根(每个用例一个目录;断言 panic 时也会删)。
mod temp {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
pub struct Root(pub PathBuf);
impl Root {
pub fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
}
impl Drop for Root {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(Path::new(&self.0));
}
}
}
/// 造两个根:配置根 = `<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");
}
/// 列表里满足 `hit` 的那一条的 id(两条磁贴都含 `A`,所以判据写在调用处)。
fn tile_id(env: &HostEnv, hit: impl Fn(&crate::dto::InstanceSummary) -> bool) -> String {
instances(env)
.into_iter()
.find(|tile| hit(tile))
.unwrap_or_else(|| panic!("列表里没有那一条:{:?}", instances(env)))
.id
}
/// 两个根的**同名**实例都改得动设置:正序、反序各跑一遍,每条都断言「写进去读得回来」。
///
/// 变异证明(复审给的形状):把 `external_version_of` 的 `bound` 改回
/// 「只比 `version_id`」⇒ 正序那条(先官方、后配置根)立刻红,报
/// `找不到实例 ext-…`,而两个方向的第二条都还是绿——这正是它被抓到的现场形状。
#[test]
fn both_same_named_instances_take_settings_in_either_order() {
for official_first in [true, false] {
let (_root, env) = two_roots("ull-host-multiroot3-order");
write_version(&env.game, "A");
write_version(&GameLayout::new(official(&env)), "A");
// 官方根那条的名字带根路径;配置根那条保持版本目录名(见 `discovered::display_name`)。
let official_id = tile_id(&env, |tile| tile.name.contains("official"));
let configured_id = tile_id(&env, |tile| tile.name == "A");
assert_ne!(official_id, configured_id, "两个根的同名版本必须是两条实例");
let order: [(&str, &str); 2] = if official_first {
[(&official_id, "official"), (&configured_id, "configured")]
} else {
[(&configured_id, "configured"), (&official_id, "official")]
};
for (index, (id, which)) in order.iter().enumerate() {
// 两条生产入口铺开用:一条走 `set_isolation`,另一条走 `apply_launch_args`。
if index == 0 {
set_isolation(&env, id, "isolated").expect("改隔离档不该报「找不到实例」");
let config = read_instance_config(&env.layout, id)
.expect("读实例设置")
.expect("实例设置文件应当已写盘");
assert_eq!(
config.isolation,
Some(Isolation::Isolated),
"{which} 那条写进去的隔离档读不回来"
);
} else {
let patch = crate::dto::LaunchArgsPatch {
jvm_args: Some("-XX:+UseG1GC".to_string()),
..crate::dto::LaunchArgsPatch::default()
};
let mut host = super::super::CoreHost::headless(env.clone());
host.apply_launch_args(id, &patch)
.expect("写启动参数不该报「找不到实例」");
let config = read_instance_config(&env.layout, id)
.expect("读实例设置")
.expect("实例设置文件应当已写盘");
assert_eq!(
config.launch.jvm_args.as_deref(),
Some("-XX:+UseG1GC"),
"{which} 那条写进去的启动参数读不回来"
);
}
// 写完一次之后,另一条**还在列表里**、而且还能继续改(不许被吞掉)。
let snapshot = instances(&env);
assert_eq!(
snapshot.len(),
2,
"写了 {which} 那条之后列表变了:{snapshot:?}"
);
assert!(
snapshot.iter().any(|tile| tile.id == official_id),
"官方根那条消失了:{snapshot:?}"
);
assert!(
snapshot.iter().any(|tile| tile.id == configured_id),
"配置根那条消失了:{snapshot:?}"
);
}
// 两条都写过之后:id → 根的反查不许漂(写盘与读盘同一份身份)。
assert_eq!(
game_for(&env, &official_id).root(),
official(&env),
"官方根那条写完设置之后反查退回了配置根"
);
assert_eq!(
game_for(&env, &configured_id).root(),
env.game.root(),
"配置根那条反查错了根"
);
let profiles = read_profiles(&env.layout).expect("读档案");
assert_eq!(profiles.profiles.len(), 2, "两条实例各一份档案,不多不少");
}
}
// ------------------------------------------------- 反查回落(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<String> {
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<String> = 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(),
"存档也落回配置根——这就是「死档案吞掉同名版本」的后果"
);
}
@@ -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",
"实例文件里写下的档位优先于基底档"
);
+3 -1
View File
@@ -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)
})
+29 -18
View File
@@ -63,7 +63,10 @@ pub fn build_request(
target: &LaunchTarget,
) -> Result<(LaunchRequest, Vec<String>), 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<VersionJson> {
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,17 +160,18 @@ 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,
};
let overrides = super::launch_args::resolved(env, &target.id);
let java = pick_java(env, &version_id, overrides.java_path.as_deref());
// 挑 Java 的**版本要求**也按实例自己的根读(M274 r1 复审 P1-2)。
let java = pick_java(env, &game, &version_id, overrides.java_path.as_deref());
let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
let options = AdapterOptions {
java: java.selection,
@@ -197,18 +201,22 @@ pub fn build_request(
///
/// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`——
/// 于是这里不需要知道实例是从哪来的,只有一处判据。
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, LaunchFailureView> {
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<PathBuf, LaunchFailureView> {
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 +235,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 +272,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(
+13 -2
View File
@@ -6,6 +6,7 @@
use std::path::PathBuf;
use unlucky_core::game::GameLayout;
use unlucky_core::launch::request::JavaSelection;
use unlucky_core::platform::paths::ProcessEnv;
@@ -21,11 +22,21 @@ pub struct JavaPick {
/// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。
///
/// `game` 是这个实例**自己那个根**的布局(M274):版本清单里的 `javaVersion` 只在那个根下,
/// 拿配置根读不到 ⇒ 退回「按 id 的静态表」,而第三方启动器的自定义名版本(`樱落怡然客户端`)
/// `version_parts` 为空 ⇒ `minimum = 8`,一个要 21 的 modpack 会被拉去用 17 跑
/// (M274 r1 复审 P1-2 的探针:实例自己根 minimum=21,配置根读出来 8,实际选中 17)。
///
/// 显式路径**先探测**(`java -version`):探测失败不报错,按版本要求的基准主版本兜底——
/// 与自动挑的退路同一口径(启动路径不该因为探测不到就拒绝启动)。
pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick {
pub fn pick_java(
env: &HostEnv,
game: &GameLayout,
version_id: &str,
explicit: Option<&std::path::Path>,
) -> JavaPick {
let store = env.store();
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
let required = unlucky_core::game::java::requirement_for_version(game, version_id);
if let Some(path) = explicit {
let runtime = unlucky_core::game::java::probe::probe(
path,
+20 -13
View File
@@ -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<VersionJson> {
let text = std::fs::read_to_string(env.game.version_json(version_id)).ok()?;
///
/// **按实例自己那个根读**(M274):官方根里的版本清单不在配置根下。
fn capabilities(game_layout: &GameLayout, version_id: &str) -> Option<VersionJson> {
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<VersionJson> {
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<VersionJson> {
///
/// 判据是 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);
+6 -3
View File
@@ -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<PathBuf, String> {
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)
}
+6 -3
View File
@@ -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<PathBuf, String> {
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)
}