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
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:
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()));
|
body = body.push(note(ui, texts.saves_loading()));
|
||||||
return frame::card(ui, body);
|
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() {
|
if let Some(error) = saves.error.as_deref() {
|
||||||
body = body.push(frame::text_role(
|
body = body.push(frame::text_role(
|
||||||
ui,
|
ui,
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ pub struct Saves {
|
|||||||
pub retryable: bool,
|
pub retryable: bool,
|
||||||
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
|
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
|
||||||
pub has_view: bool,
|
pub has_view: bool,
|
||||||
|
/// 来源那一行(M274 多根):`存档目录:<绝对路径>`,**在这一层拼好**。
|
||||||
|
///
|
||||||
|
/// 便携目录与官方启动器是两个不同的根,两个根里都可能有一个叫 `A` 的实例、各有一批世界。
|
||||||
|
/// 世界名分辨不出来源,所以把路径原样摆出来(也是排障时要问的第一件事)。
|
||||||
|
/// 为什么不是页面里现拼:iced 的 `Element` 只借这一帧的数据,`view()` 返回的树借的是局部
|
||||||
|
/// `String`,一出栈就 `E0515`(与 [`Saves::count`] 同一条理由)。
|
||||||
|
pub source: String,
|
||||||
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
|
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
|
||||||
pub missing: bool,
|
pub missing: bool,
|
||||||
/// 世界行。
|
/// 世界行。
|
||||||
@@ -162,6 +169,7 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
|||||||
};
|
};
|
||||||
if let Some(view) = &state.view {
|
if let Some(view) = &state.view {
|
||||||
out.has_view = true;
|
out.has_view = true;
|
||||||
|
out.source = texts.saves_source(&view.dir);
|
||||||
out.missing = !view.exists && !view.screenshots_exists;
|
out.missing = !view.exists && !view.screenshots_exists;
|
||||||
// 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍——
|
// 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍——
|
||||||
// M224 出图实测过"先算计数后装列表"画出 `0 启用 · 0 禁用` 的那种顺序错,这里从根上绕开。
|
// 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 preview;
|
||||||
mod write;
|
mod write;
|
||||||
@@ -37,6 +37,10 @@ impl Texts {
|
|||||||
pub(in crate::pages::instances) fn saves_missing_dir(self) -> &'static str {
|
pub(in crate::pages::instances) fn saves_missing_dir(self) -> &'static str {
|
||||||
self.text(Key::InstancesSavesMissingDir)
|
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 {
|
pub(in crate::pages::instances) fn saves_worlds(self) -> &'static str {
|
||||||
self.text(Key::InstancesSavesWorlds)
|
self.text(Key::InstancesSavesWorlds)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.", "在版本目录里自动发现:它从哪里启动,由盘上已有的文件决定。"),
|
("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 根目录)。"),
|
("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/,这次启动按独立目录运行(它自己的版本文件夹)。"),
|
("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`)。
|
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
|
||||||
|
|||||||
@@ -231,6 +231,11 @@ macro_rules! instances_entries {
|
|||||||
InstancesDiscoveredNote => ("Discovered in versions/: where it runs is decided by the files that are already there.", "在版本目录里自动发现:它从哪里启动,由盘上已有的文件决定。"),
|
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 根目录)。"),
|
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/,这次启动按独立目录运行(它自己的版本文件夹)。"),
|
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}"),
|
||||||
} }
|
} }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ use crate::host::CoreHost;
|
|||||||
use crate::host::HostEnv;
|
use crate::host::HostEnv;
|
||||||
use crate::mock::MockBackend;
|
use crate::mock::MockBackend;
|
||||||
|
|
||||||
/// 启动选项(截图/演示通道用;正常启动全用默认值)。
|
/// 启动选项(`--data-dir` 与 `--mock` 是产品开关;截图/演示也复用同一份选项)。
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct AppOptions {
|
pub struct AppOptions {
|
||||||
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析
|
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析。
|
||||||
|
///
|
||||||
|
/// 走 [`HostEnv::at_data_root`]:**照常**发现官方启动器的标准游戏目录(M274)。钉数据根
|
||||||
|
/// 不等于「别看官方实例」——那是两个不同的开关。
|
||||||
pub data_root: Option<PathBuf>,
|
pub data_root: Option<PathBuf>,
|
||||||
/// 强制 mock 后端(UI 回归/无数据根演示)
|
/// 强制 mock 后端(UI 回归/无数据根演示)
|
||||||
pub mock: bool,
|
pub mock: bool,
|
||||||
@@ -21,16 +24,62 @@ pub fn config_path(root: &Path) -> PathBuf {
|
|||||||
root.join(".ULL").join("config").join("launcher.json")
|
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` 时退回假后端。
|
/// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。
|
||||||
pub fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
|
pub fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
|
||||||
if options.mock {
|
if options.mock {
|
||||||
return Box::new(MockBackend::new());
|
return Box::new(MockBackend::new());
|
||||||
}
|
}
|
||||||
match &options.data_root {
|
match host_env_for(options) {
|
||||||
Some(root) => Box::new(CoreHost::new(HostEnv::at_root(root))),
|
Some(env) => Box::new(CoreHost::new(env)),
|
||||||
None => match CoreHost::open() {
|
None => match CoreHost::open() {
|
||||||
Some(host) => Box::new(host),
|
Some(host) => Box::new(host),
|
||||||
None => Box::new(MockBackend::new()),
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -290,10 +290,13 @@ fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, ContentFail
|
|||||||
|
|
||||||
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 基底档,见 `game::base_mode`)。
|
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 基底档,见 `game::base_mode`)。
|
||||||
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, ContentFailure> {
|
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)
|
let config = read_instance_config(&env.layout, &instance.id)
|
||||||
.map_err(|error| local(format!("读实例设置失败:{error}")))?;
|
.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(|derivation| derivation.game_dir)
|
||||||
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
|
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,10 +286,11 @@ pub(super) fn instance_root(env: &HostEnv, id: &InstanceId) -> Result<PathBuf, S
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|instance| &instance.id == id)
|
.find(|instance| &instance.id == id)
|
||||||
.ok_or_else(|| format!("instance not found: {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)
|
let config = read_instance_config(&env.layout, &instance.id)
|
||||||
.map_err(|error| format!("read instance config failed: {error}"))?;
|
.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(|derivation| derivation.game_dir)
|
||||||
.map_err(|error| format!("derive game dir failed: {error}"))
|
.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)
|
let instance = super::super::game::instances(env)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|instance| &instance.id == id)?;
|
.find(|instance| &instance.id == id)?;
|
||||||
// 隔离实例的 `versions/` 在全局根下,所以试全局根
|
// 隔离实例的 `versions/` 在**它自己那个根**下(M274:官方根里的版本清单不在配置根下)
|
||||||
read_instance_snapshot(env.game.root(), Some(&instance.version_id)).ok()
|
let game = super::super::game::game_for(env, &instance.id);
|
||||||
|
read_instance_snapshot(game.root(), Some(&instance.version_id)).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 格式视图 → core 的静态格式(MultiMC 只读,不能当导出目标)。
|
/// 格式视图 → core 的静态格式(MultiMC 只读,不能当导出目标)。
|
||||||
|
|||||||
@@ -30,10 +30,16 @@ pub struct HostEnv {
|
|||||||
pub data: ResolvedData,
|
pub data: ResolvedData,
|
||||||
/// `.ULL/` 布局
|
/// `.ULL/` 布局
|
||||||
pub layout: LauncherLayout,
|
pub layout: LauncherLayout,
|
||||||
/// `.minecraft/` 布局
|
/// `.minecraft/` 布局(**配置的这个根**;多根发现的判据在 `game/roots.rs`)
|
||||||
pub game: GameLayout,
|
pub game: GameLayout,
|
||||||
/// 平台
|
/// 平台
|
||||||
pub platform: Platform,
|
pub platform: Platform,
|
||||||
|
/// 官方启动器的标准游戏目录(`paths::standard_game_home`):多根发现的**第二个候选**。
|
||||||
|
///
|
||||||
|
/// `None` = 不去发现([`HostEnv::at_root`] 钉根时就是这样:截图/演示/用例钉了一个根,
|
||||||
|
/// 就不该顺手把用户真实的 `%APPDATA%\.minecraft` 也读进来,否则同一份用例在不同机器上
|
||||||
|
/// 结果不同)。生产路径由 [`HostEnv::from_data`] 填。
|
||||||
|
pub standard_game: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostEnv {
|
impl HostEnv {
|
||||||
@@ -48,20 +54,33 @@ impl HostEnv {
|
|||||||
Some(Self::from_data(data))
|
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`**:
|
/// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**:
|
||||||
/// 演示根常用它把游戏目录指到既有真数据上(避免复制几 GB 的 assets/libraries)。
|
/// 演示根常用它把游戏目录指到既有真数据上(避免复制几 GB 的 assets/libraries)。
|
||||||
|
///
|
||||||
|
/// 实现上**委托**给 `at_data_root` 再关掉发现(不是复制一份构造):两个入口的落点/模式因此
|
||||||
|
/// 物理上不可能漂开。
|
||||||
pub fn at_root(root: impl AsRef<Path>) -> Self {
|
pub fn at_root(root: impl AsRef<Path>) -> Self {
|
||||||
let root = root.as_ref();
|
let mut env = Self::at_data_root(root);
|
||||||
let request = request_from_portable(root);
|
// 钉一个根的**测试/截图**通道不做多根发现:见 `standard_game` 的文档。
|
||||||
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
|
env.standard_game = None;
|
||||||
Self::from_data(ResolvedData {
|
env
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 由解析结果建环境。
|
/// 由解析结果建环境。
|
||||||
@@ -74,10 +93,17 @@ impl HostEnv {
|
|||||||
pub fn from_data(data: ResolvedData) -> Self {
|
pub fn from_data(data: ResolvedData) -> Self {
|
||||||
let layout = data.layout();
|
let layout = data.layout();
|
||||||
unlucky_core::launch::launcher_log::set_dir(layout.logs());
|
unlucky_core::launch::launcher_log::set_dir(layout.logs());
|
||||||
|
let platform = Platform::current();
|
||||||
Self {
|
Self {
|
||||||
layout,
|
layout,
|
||||||
game: GameLayout::new(data.game_home.clone()),
|
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,
|
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.*` 四键)。
|
/// 便携目录那份 `launcher.json` → 数据根请求(只读 `core.*` 四键)。
|
||||||
pub(crate) fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
|
pub(crate) fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
|
||||||
let mut request = DataDirRequest::default();
|
let mut request = DataDirRequest::default();
|
||||||
@@ -168,6 +206,50 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&root).ok();
|
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 的守门②:**日志根随数据模式变化**(便携 / 标准 / 自定义各一条断言)。
|
/// M188 的守门②:**日志根随数据模式变化**(便携 / 标准 / 自定义各一条断言)。
|
||||||
///
|
///
|
||||||
/// 三条走的都是 `HostEnv` 真正用的那条解析链(`Resolver` → `ResolvedData::layout`):
|
/// 三条走的都是 `HostEnv` 真正用的那条解析链(`Resolver` → `ResolvedData::layout`):
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
//! 隔离档位取自实例设置(缺省跟全局 `instances.mode`)。
|
//! 隔离档位取自实例设置(缺省跟全局 `instances.mode`)。
|
||||||
//!
|
//!
|
||||||
//! **列表是并集**(M273):档案 + 盘上有清单但没有档案绑定的版本目录,见 [`instances`] 的文档。
|
//! **列表是并集**(M273):档案 + 盘上有清单但没有档案绑定的版本目录,见 [`instances`] 的文档。
|
||||||
|
//!
|
||||||
|
//! **根是多个**(M274):配置的 `game_home` + 官方启动器的标准位置(`%APPDATA%\.minecraft` 等),
|
||||||
|
//! 规范化真实路径去重([`roots`])。每个自动发现的实例因此带**自己的根**——读版本清单、派生运行
|
||||||
|
//! 目录、列存档 / 模组都走 [`game_for`],不全是 `env.game`。
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -14,7 +18,7 @@ use unlucky_core::game::install::instance::{
|
|||||||
read_instance_config, read_profiles, write_instance_config, write_profiles,
|
read_instance_config, read_profiles, write_instance_config, write_profiles,
|
||||||
};
|
};
|
||||||
use unlucky_core::game::isolation::IsolationMode;
|
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::{
|
use crate::dto::{
|
||||||
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
|
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
|
||||||
@@ -23,10 +27,11 @@ use crate::dto::{
|
|||||||
use super::env::HostEnv;
|
use super::env::HostEnv;
|
||||||
|
|
||||||
mod discovered;
|
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`)。
|
/// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。
|
||||||
pub fn global_mode(env: &HostEnv) -> IsolationMode {
|
pub fn global_mode(env: &HostEnv) -> IsolationMode {
|
||||||
@@ -59,13 +64,15 @@ pub fn last_played(env: &HostEnv) -> BTreeMap<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 版本 JSON 的继承链根 id + 加载器(读不到返回 `(version_id, 原版)`)。
|
/// 版本 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 loader = loader_from_id(version_id);
|
||||||
let mut current = version_id.to_string();
|
let mut current = version_id.to_string();
|
||||||
let mut seen = 0;
|
let mut seen = 0;
|
||||||
while seen < 16 {
|
while seen < 16 {
|
||||||
seen += 1;
|
seen += 1;
|
||||||
let path = env.game.version_json(¤t);
|
let path = game.version_json(¤t);
|
||||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
@@ -115,15 +122,17 @@ fn dto_kind(kind: unlucky_core::game::LoaderKind) -> LoaderKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 实例的隔离档位(实例文件优先,缺省 = [`base_mode`])。
|
/// 实例的隔离档位(实例文件优先,缺省 = [`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) {
|
match read_instance_config(&env.layout, id) {
|
||||||
Ok(Some(config)) => config.effective_isolation(base).as_str().to_string(),
|
Ok(Some(config)) => config.effective_isolation(base).as_str().to_string(),
|
||||||
_ => base.as_str().to_string(),
|
_ => base.as_str().to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 实例快照:**档案 ∪ 盘上的版本目录**。
|
/// 实例快照:**档案 ∪ 盘上的版本目录**(M273),版本目录来自**多个游戏根**(M274)。
|
||||||
///
|
///
|
||||||
/// ## 为什么是并集(M273)
|
/// ## 为什么是并集(M273)
|
||||||
///
|
///
|
||||||
@@ -132,44 +141,85 @@ pub fn isolation_of(env: &HostEnv, id: &str, version_id: &str) -> String {
|
|||||||
/// 存在 `versions/<id>/<id>.json`,**没有**我们的档案记录。只读档案 ⇒ 已装好的实例在界面上
|
/// 存在 `versions/<id>/<id>.json`,**没有**我们的档案记录。只读档案 ⇒ 已装好的实例在界面上
|
||||||
/// 不存在,用户以为「读不到这个 .minecraft」。
|
/// 不存在,用户以为「读不到这个 .minecraft」。
|
||||||
///
|
///
|
||||||
/// 所以:档案照旧(名字 / 隔离档 / 启动参数都以档案为准),**盘上有清单但没有档案绑定的版本**
|
/// ## 为什么是多个根(M274)
|
||||||
/// 就地合成一条视图挂上去。判据不在这里另写一套:扫描走
|
///
|
||||||
/// [`unlucky_core::game::adopt::scan`](`versions/<id>/<id>.json` 能解出 `id`、继承链走到底、
|
/// 官方启动器用的是 `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `<exe>/.minecraft`——**两个
|
||||||
/// 符号链接不越界、坏目录逐条报问题),本文件只做 DTO 翻译 + 「有没有档案绑定」这一步。
|
/// 不同的目录**。只扫配置根等于「装了官方启动器也看不见它的实例」。所以这里遍历
|
||||||
|
/// `roots::all(env)`:配置根 + 官方启动器的标准位置(规范化真实路径去重,见 `roots::key_of`)。
|
||||||
|
///
|
||||||
|
/// 那一批根里**只有配置根**有档案语义:`profiles.json` 的 `version_id` 指的是配置根下的版本,
|
||||||
|
/// 所以「已被档案绑定 ⇒ 不重复列」这条只对配置根成立;别的根里的同名版本是**另一个实例**
|
||||||
|
/// (id 里带着根,见 `discovered::external_instance_id`)。
|
||||||
///
|
///
|
||||||
/// ## 三条不变量
|
/// ## 三条不变量
|
||||||
///
|
///
|
||||||
/// 1. **只读**:合成视图不建目录、不写 `profiles.json`、不碰 `versions/` 里一个字节
|
/// 1. **只读**:合成视图不建目录、不写 `profiles.json`、不碰任何 `versions/` 里一个字节
|
||||||
/// (`instances()` 会被每次刷新调用,写盘会变成「一打开启动器就改别人的文件夹」);
|
/// (`instances()` 会被每次刷新调用,写盘会变成「一打开启动器就改别人的文件夹」);
|
||||||
/// 2. **档案优先**:某个版本已经有档案绑定(不论档案 id 是什么)⇒ 只按档案出**一条**,
|
/// 2. **档案优先**:配置根里某个版本已经有档案绑定(不论档案 id 是什么)⇒ 只按档案出**一条**,
|
||||||
/// 不重复挂合成视图(用户改过的名字/隔离档不会因为盘上有同名版本被顶掉);
|
/// 不重复挂合成视图(用户改过的名字/隔离档不会因为盘上有同名版本被顶掉);
|
||||||
/// 3. **坏东西不当实例**:清单缺 / JSON 坏 / `id` 与目录名不一致 / 缺父版本 / 越界符号链接
|
/// 3. **坏东西不当实例**:清单缺 / JSON 坏 / `id` 与目录名不一致 / 缺父版本 / 越界符号链接
|
||||||
/// ⇒ 不进列表(`AdoptCandidate::adoptable()`);`versions/` 整个读不出来也只是退回
|
/// ⇒ 不进列表(`AdoptCandidate::adoptable()`);某个根整个读不出来也只是少列它那几条,
|
||||||
/// 只有档案的旧行为,不让实例页因此报错。
|
/// 不让实例页因此报错。
|
||||||
pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
|
pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
|
||||||
let profiles = profiles(env);
|
let profiles = profiles(env);
|
||||||
let played = last_played(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
|
let mut snapshot: Vec<InstanceSummary> = profiles
|
||||||
.profiles
|
.profiles
|
||||||
.iter()
|
.iter()
|
||||||
.map(|profile| profile_summary(env, profile, &played))
|
.zip(&homes)
|
||||||
|
.map(|(profile, home)| profile_summary(env, profile, home.as_ref(), &played))
|
||||||
.collect();
|
.collect();
|
||||||
snapshot.extend(
|
let known: Vec<&str> = profiles
|
||||||
external_versions(env, &profiles)
|
.profiles
|
||||||
.iter()
|
.iter()
|
||||||
.map(|version_id| external_summary(env, version_id, &played)),
|
.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
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一条档案 → 磁贴(档案路径的唯一翻译点)。
|
/// 一条档案 → 磁贴(档案路径的唯一翻译点)。
|
||||||
|
///
|
||||||
|
/// `home` 是这条档案**自己那个根**([`discovered::Found`] 查出来的;`None` = 配置根):
|
||||||
|
/// 懒创建出来的 `ext-` 档案可能属于官方根,那时「版本在不在盘上 / 隔离档 / MC 版本 / 版本门槛」
|
||||||
|
/// 都必须按那个根算——否则磁贴说「文件缺失 · 共用目录」,而存档与启动实际读的是官方根
|
||||||
|
/// (M274 r1 复审 P1-1:显示与行为分叉,最坏的一种)。
|
||||||
fn profile_summary(
|
fn profile_summary(
|
||||||
env: &HostEnv,
|
env: &HostEnv,
|
||||||
profile: &InstanceProfile,
|
profile: &InstanceProfile,
|
||||||
|
home: Option<&GameLayout>,
|
||||||
played: &BTreeMap<String, String>,
|
played: &BTreeMap<String, String>,
|
||||||
) -> InstanceSummary {
|
) -> InstanceSummary {
|
||||||
let version_id = profile.version_id.clone();
|
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();
|
let id = profile.id.as_str();
|
||||||
InstanceSummary {
|
InstanceSummary {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
@@ -179,9 +229,9 @@ fn profile_summary(
|
|||||||
last_played: played.get(id).cloned(),
|
last_played: played.get(id).cloned(),
|
||||||
runtime: InstanceRuntime::Idle,
|
runtime: InstanceRuntime::Idle,
|
||||||
icon: None,
|
icon: None,
|
||||||
isolation: isolation_of(env, id, &version_id),
|
isolation: isolation_of(env, game, id, &version_id),
|
||||||
version_present: env.game.is_version_installed(&version_id),
|
version_present: game.is_version_installed(&version_id),
|
||||||
launch: super::launch_args::view(env, id, &version_id),
|
launch: super::launch_args::view(env, game, id, &version_id),
|
||||||
version_id,
|
version_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,12 +256,13 @@ pub(super) fn ensure_profile(env: &HostEnv, id: &str) -> Result<InstanceProfile,
|
|||||||
{
|
{
|
||||||
return Ok(existing.clone());
|
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}`"));
|
return Err(format!("找不到实例 `{id}`"));
|
||||||
};
|
};
|
||||||
let profile = InstanceProfile {
|
let profile = InstanceProfile {
|
||||||
id: ProfileId::new(id),
|
id: ProfileId::new(id),
|
||||||
name: version_id.clone(),
|
// 显示名与磁贴同源:别的根里的同名版本带上根路径,否则两条磁贴分不清(M274)。
|
||||||
|
name: display_name(&root, &version_id),
|
||||||
version_id,
|
version_id,
|
||||||
..InstanceProfile::default()
|
..InstanceProfile::default()
|
||||||
};
|
};
|
||||||
@@ -284,3 +335,12 @@ pub fn short_path(path: &Path) -> String {
|
|||||||
mod tests;
|
mod tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_union;
|
mod tests_union;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_multiroot;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_multiroot2;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_multiroot3;
|
||||||
@@ -1,22 +1,40 @@
|
|||||||
//! 自动发现的实例(M273):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
|
//! 自动发现的实例(M273 · 多根 M274):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
|
||||||
//!
|
//!
|
||||||
//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件的两件事:
|
//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件三件事:
|
||||||
//!
|
//!
|
||||||
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析、没有被任何档案绑定
|
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析(判据来自 `core::game::adopt::scan`;
|
||||||
//! (判据来自 `core::game::adopt::scan`,本文件只做「有没有档案」这一步);
|
//! 本文件只负责「每个根扫一遍」并把结果装进 [`Found`]);
|
||||||
//! 2. **它们从哪个目录跑**:[`base_mode`]——`versions/<id>/` 里有痕迹(`mods` / `saves` /
|
//! 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 std::collections::BTreeMap;
|
||||||
|
|
||||||
use unlucky_core::game::isolation::IsolationMode;
|
use unlucky_core::game::isolation::IsolationMode;
|
||||||
use unlucky_core::game::ProfileList;
|
use unlucky_core::game::{GameLayout, InstanceProfile, ProfileList};
|
||||||
|
|
||||||
use crate::dto::{InstanceRuntime, InstanceSummary};
|
use crate::dto::{InstanceRuntime, InstanceSummary};
|
||||||
|
|
||||||
use super::super::env::HostEnv;
|
use super::super::env::HostEnv;
|
||||||
|
use super::roots::{self, GameRoot};
|
||||||
use super::{describe_version, global_mode, isolation_of};
|
use super::{describe_version, global_mode, isolation_of};
|
||||||
|
|
||||||
/// `versions/<version_id>/` 里这些名字出现任何一个,就说明这个版本本来就被当成
|
/// `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"];
|
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())
|
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` 处理,
|
/// `versions/<id>/`。一个从没跑过、也没有 `mods`/`saves` 的外部版本若按 `isolated` 处理,
|
||||||
/// 第一次启动就会在**别人的版本目录**里新长出 `mods/` `saves/` `config/`(D20 的
|
/// 第一次启动就会在**别人的版本目录**里新长出 `mods/` `saves/` `config/`(D20 的
|
||||||
/// `AdoptTarget` 缺省值正是为了躲开这件事而选 `InstanceDir`)。反过来,盘上已经有这些痕迹的
|
/// `AdoptTarget` 缺省值正是为了躲开这件事而选 `InstanceDir`)。反过来,盘上已经有这些痕迹的
|
||||||
/// 版本本来就是独立目录实例,按 `shared` 处理会让它突然读到 `.minecraft` 根下别人的存档 ——
|
/// 版本本来就是独立目录实例,按 `shared` 处理会让它突然读到根下别人的存档 ——
|
||||||
/// 两种错都真实存在,所以判据只能是**盘上的事实**。
|
/// 两种错都真实存在,所以判据只能是**盘上的事实**。
|
||||||
///
|
///
|
||||||
|
/// **M274**:痕迹按**这个实例自己的根**判(`game` 参数)。换根之后判定不变:官方根里那个
|
||||||
|
/// 带 `mods/` 的版本仍是 `isolated`,ULL 根里那个干净的版本仍是 `shared`。
|
||||||
|
///
|
||||||
/// 只读:这里不建目录、不写任何文件(写盘只发生在用户第一次改设置时,见 [`super::ensure_profile`])。
|
/// 只读:这里不建目录、不写任何文件(写盘只发生在用户第一次改设置时,见 [`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) {
|
if !crate::dto::is_discovered_instance(id) {
|
||||||
return global_mode(env);
|
return global_mode(env);
|
||||||
}
|
}
|
||||||
if has_own_run_dir(env, version_id) {
|
if has_own_run_dir(game, version_id) {
|
||||||
IsolationMode::Isolated
|
IsolationMode::Isolated
|
||||||
} else {
|
} else {
|
||||||
IsolationMode::Shared
|
IsolationMode::Shared
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 盘上有版本、但没有档案绑定的那些版本 id(字典序,来自 core 的扫描)。
|
/// 一个根下「盘上有版本、可登记」的那些版本 id(字典序,来自 core 的扫描)。
|
||||||
///
|
///
|
||||||
/// 扫描失败(`versions/` 读不出来 / 断链联接)时返回空:列表退回「只有档案」的旧行为。
|
/// 扫描失败(`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();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
scan.candidates
|
scan.candidates
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|candidate| candidate.adoptable())
|
.filter(|candidate| candidate.adoptable())
|
||||||
.filter(|candidate| {
|
|
||||||
!profiles
|
|
||||||
.profiles
|
|
||||||
.iter()
|
|
||||||
.any(|profile| profile.version_id == candidate.version_id)
|
|
||||||
})
|
|
||||||
.map(|candidate| candidate.version_id)
|
.map(|candidate| candidate.version_id)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -86,34 +110,53 @@ pub(super) fn external_versions(env: &HostEnv, profiles: &ProfileList) -> Vec<St
|
|||||||
/// 这个白名单校验),而版本目录名可以是中文——用户实测的那个就是 `樱落怡然客户端`。
|
/// 这个白名单校验),而版本目录名可以是中文——用户实测的那个就是 `樱落怡然客户端`。
|
||||||
/// 直接沿用版本 id 会让**启动**在写盘前就死在 `LaunchFailureStage::InstanceConfig`。
|
/// 直接沿用版本 id 会让**启动**在写盘前就死在 `LaunchFailureStage::InstanceConfig`。
|
||||||
///
|
///
|
||||||
/// 前缀 + 稳定哈希 ⇒ 同一个版本每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。
|
/// 前缀 + 稳定哈希 ⇒ 同一个 (根, 版本) 每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。
|
||||||
///
|
///
|
||||||
/// **M274(多根发现)**:两个根里都可能有 `1.20.4`,所以这里是**唯一**该把「根」并进身份的
|
/// **M274**:`root_key` 也进哈希(`fnv(root || 0 || version)`)——两个根里的 `1.20.4` 因此是两个
|
||||||
/// 地方——到时把 `root` 也喂进哈希(`external_instance_id(root, version_id)`),
|
/// 实例。分隔符用 `0`:路径里不可能有 NUL,所以 `("ab","c")` 与 `("a","bc")` 不会撞。
|
||||||
/// 列表就自然出两条,不必动 [`instances`] 的其余部分。
|
pub(in crate::host) fn external_instance_id(root_key: &str, version_id: &str) -> String {
|
||||||
pub(super) fn external_instance_id(version_id: &str) -> String {
|
|
||||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
for byte in version_id.as_bytes() {
|
let bytes = root_key
|
||||||
hash ^= u64::from(*byte);
|
.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);
|
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
}
|
}
|
||||||
format!("{}{hash:016x}", crate::dto::DISCOVERED_ID_PREFIX)
|
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,
|
env: &HostEnv,
|
||||||
|
root: &GameRoot,
|
||||||
version_id: &str,
|
version_id: &str,
|
||||||
played: &BTreeMap<String, String>,
|
played: &BTreeMap<String, String>,
|
||||||
) -> InstanceSummary {
|
) -> InstanceSummary {
|
||||||
let id = external_instance_id(version_id);
|
let id = external_instance_id(root.key(), version_id);
|
||||||
let (mc_version, loader) = describe_version(env, version_id);
|
let game = root.game();
|
||||||
|
let (mc_version, loader) = describe_version(game, version_id);
|
||||||
InstanceSummary {
|
InstanceSummary {
|
||||||
last_played: played.get(&id).cloned(),
|
last_played: played.get(&id).cloned(),
|
||||||
isolation: isolation_of(env, &id, version_id),
|
isolation: isolation_of(env, game, &id, version_id),
|
||||||
launch: super::super::launch_args::view(env, &id, version_id),
|
launch: super::super::launch_args::view(env, game, &id, version_id),
|
||||||
version_present: env.game.is_version_installed(version_id),
|
version_present: game.is_version_installed(version_id),
|
||||||
name: version_id.to_string(),
|
name: display_name(root, version_id),
|
||||||
version_id: version_id.to_string(),
|
version_id: version_id.to_string(),
|
||||||
mc_version,
|
mc_version,
|
||||||
loader,
|
loader,
|
||||||
@@ -123,16 +166,135 @@ pub(super) fn external_summary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 视图 id → 版本 id 的反查(只认「可登记、且还没有档案绑定」的版本)。
|
/// **一拍之内**的发现快照:每个根只扫一遍 `versions/`,之后所有「这个 id 属于哪个根」都是内存查表。
|
||||||
///
|
///
|
||||||
/// 与 [`external_versions`] 同一份判据:合成的视图 id 只在那个集合里有意义,
|
/// 为什么要有它(M274 r1 复审 P2-1 的实测):把反查直接写进 `instances()` 的循环里,扫描次数是
|
||||||
/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批。
|
/// `#根 × (1 + #ext 档案)`;官方根 200 个版本 + 10 份 `ext-` 档案时 `instances()` 从 236ms 涨到
|
||||||
pub(super) fn external_version_of(
|
/// 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,
|
env: &HostEnv,
|
||||||
profiles: &ProfileList,
|
profiles: &ProfileList,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Option<String> {
|
) -> Option<(GameRoot, String)> {
|
||||||
external_versions(env, profiles)
|
// 一拍只扫一次:下面「查自己」与「查绑定者」都用这一份。
|
||||||
.into_iter()
|
let found = Found::scan(env);
|
||||||
.find(|version_id| external_instance_id(version_id) == id)
|
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))
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,9 +190,9 @@ fn isolation_write_then_read_round_trips() {
|
|||||||
let id = profiles.profiles[0].id.as_str().to_string();
|
let id = profiles.profiles[0].id.as_str().to_string();
|
||||||
|
|
||||||
// 有档案的实例:基底档 = 全局档(`isolated`),与 M273 之前一字不差。
|
// 有档案的实例:基底档 = 全局档(`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("切档位");
|
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());
|
assert!(set_isolation(&env, &id, "nope").is_err());
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
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(¤t) 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();
|
let id = snapshot[0].id.clone();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
isolation_of(&env, &id, "A"),
|
isolation_of(&env, &env.game, &id, "A"),
|
||||||
"shared",
|
"shared",
|
||||||
"`versions/A/` 里没有 mods / saves / config / options.txt ⇒ 基底档是 shared(按共用目录读)"
|
"`versions/A/` 里没有 mods / saves / config / options.txt ⇒ 基底档是 shared(按共用目录读)"
|
||||||
);
|
);
|
||||||
|
|
||||||
set_isolation(&env, &id, "isolated").expect("第一次改档位 = 懒创建档案");
|
set_isolation(&env, &id, "isolated").expect("第一次改档位 = 懒创建档案");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
isolation_of(&env, &id, "A"),
|
isolation_of(&env, &env.game, &id, "A"),
|
||||||
"isolated",
|
"isolated",
|
||||||
"实例文件里写下的档位优先于基底档"
|
"实例文件里写下的档位优先于基底档"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ pub fn target_major(env: &HostEnv) -> u32 {
|
|||||||
super::game::instances(env)
|
super::game::instances(env)
|
||||||
.first()
|
.first()
|
||||||
.map(|instance| {
|
.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
|
.minimum
|
||||||
.max(8)
|
.max(8)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ pub fn build_request(
|
|||||||
target: &LaunchTarget,
|
target: &LaunchTarget,
|
||||||
) -> Result<(LaunchRequest, Vec<String>), LaunchFailureView> {
|
) -> Result<(LaunchRequest, Vec<String>), LaunchFailureView> {
|
||||||
let version_id = target.version_id.clone();
|
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| {
|
let text = std::fs::read_to_string(&manifest_path).map_err(|error| {
|
||||||
LaunchFailureView::new(
|
LaunchFailureView::new(
|
||||||
LaunchFailureStage::VersionManifest,
|
LaunchFailureStage::VersionManifest,
|
||||||
@@ -77,7 +80,7 @@ pub fn build_request(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let source = |id: &str| -> Option<VersionJson> {
|
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()
|
.ok()
|
||||||
.and_then(|text| VersionJson::from_json_str(&text).ok())
|
.and_then(|text| VersionJson::from_json_str(&text).ok())
|
||||||
};
|
};
|
||||||
@@ -121,22 +124,22 @@ pub fn build_request(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let path = if library.is_local() {
|
let path = if library.is_local() {
|
||||||
env.game.version_library(&version_id, &coordinate)
|
game.version_library(&version_id, &coordinate)
|
||||||
} else {
|
} else {
|
||||||
env.game.library(&coordinate)
|
game.library(&coordinate)
|
||||||
};
|
};
|
||||||
libraries.push(LibraryInput::new(path, name));
|
libraries.push(LibraryInput::new(path, name));
|
||||||
}
|
}
|
||||||
let natives = unlucky_core::launch::natives::collect(
|
let natives = unlucky_core::launch::natives::collect(
|
||||||
&env.game,
|
&game,
|
||||||
&version_id,
|
&version_id,
|
||||||
resolved.libraries.as_deref().unwrap_or_default(),
|
resolved.libraries.as_deref().unwrap_or_default(),
|
||||||
&environment,
|
&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 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
|
let assets_index = resolved
|
||||||
.assets
|
.assets
|
||||||
.clone()
|
.clone()
|
||||||
@@ -145,7 +148,7 @@ pub fn build_request(
|
|||||||
// `26w14a-fabric-0.16.5` 这种 id 在 `GameVersion::is_weekly_snapshot`(6 字符形态)上判不出来,
|
// `26w14a-fabric-0.16.5` 这种 id 在 `GameVersion::is_weekly_snapshot`(6 字符形态)上判不出来,
|
||||||
// 周快照的 `--graphicsBackend` 会在拼装链里被静默跳过——而详情页的门槛
|
// 周快照的 `--graphicsBackend` 会在拼装链里被静默跳过——而详情页的门槛
|
||||||
// (`host::launch_args::backend_gate`)与 CLI 用的都是根版本,两边必须同一口径。
|
// (`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 {
|
let snapshot = SnapshotInput {
|
||||||
version_id: &version_id,
|
version_id: &version_id,
|
||||||
version_name: Some(&target.name),
|
version_name: Some(&target.name),
|
||||||
@@ -157,17 +160,18 @@ pub fn build_request(
|
|||||||
legacy_arguments: resolved.minecraft_arguments.as_deref(),
|
legacy_arguments: resolved.minecraft_arguments.as_deref(),
|
||||||
libraries: &libraries,
|
libraries: &libraries,
|
||||||
natives: &natives,
|
natives: &natives,
|
||||||
primary_jar: env.game.version_jar(&jar_id),
|
primary_jar: game.version_jar(&jar_id),
|
||||||
game_dir,
|
game_dir,
|
||||||
assets_index: assets_index.as_deref(),
|
assets_index: assets_index.as_deref(),
|
||||||
natives_dir: env.game.natives_dir(&version_id),
|
natives_dir: game.natives_dir(&version_id),
|
||||||
library_dir: env.game.libraries_dir(),
|
library_dir: game.libraries_dir(),
|
||||||
assets_root: assets_root.clone(),
|
assets_root: assets_root.clone(),
|
||||||
game_assets: assets_root,
|
game_assets: assets_root,
|
||||||
};
|
};
|
||||||
|
|
||||||
let overrides = super::launch_args::resolved(env, &target.id);
|
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 (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
|
||||||
let options = AdapterOptions {
|
let options = AdapterOptions {
|
||||||
java: java.selection,
|
java: java.selection,
|
||||||
@@ -197,18 +201,22 @@ pub fn build_request(
|
|||||||
///
|
///
|
||||||
/// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`——
|
/// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`——
|
||||||
/// 于是这里不需要知道实例是从哪来的,只有一处判据。
|
/// 于是这里不需要知道实例是从哪来的,只有一处判据。
|
||||||
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, LaunchFailureView> {
|
fn run_dir(
|
||||||
let base = super::game::base_mode(env, &target.id, &target.version_id);
|
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| {
|
let config = read_instance_config(&env.layout, &target.id).map_err(|error| {
|
||||||
LaunchFailureView::new(LaunchFailureStage::InstanceConfig, error.to_string())
|
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| {
|
.map_err(|error| {
|
||||||
LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string())
|
LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string())
|
||||||
})?;
|
})?;
|
||||||
if let Some(mut config) = config {
|
if let Some(mut config) = config {
|
||||||
let at = format_rfc3339_millis(now());
|
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 _ =
|
let _ =
|
||||||
unlucky_core::game::install::instance::write_instance_config(&env.layout, &config);
|
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();
|
let logs_dir = env.layout.logs();
|
||||||
launcher_log::set_dir(&logs_dir);
|
launcher_log::set_dir(&logs_dir);
|
||||||
let started_at = std::time::SystemTime::now();
|
let started_at = std::time::SystemTime::now();
|
||||||
|
// 崩溃报告里的「游戏目录」必须是**这个实例自己那个根**(M274):官方根里的实例崩了,
|
||||||
|
// 报告却写着 ULL 便携根的路径,等于把排障指到别人的目录上。
|
||||||
|
let game = super::game::game_for(&env, &target.id);
|
||||||
let mut report = ReportContext::new(
|
let mut report = ReportContext::new(
|
||||||
&target.id,
|
&target.id,
|
||||||
&target.name,
|
&target.name,
|
||||||
&target.version_id,
|
&target.version_id,
|
||||||
&env.data.game_home,
|
game.root(),
|
||||||
&logs_dir,
|
&logs_dir,
|
||||||
started_at,
|
started_at,
|
||||||
unlucky_core::platform::os_info::current().summary_line(),
|
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.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);
|
report.java = launcher_log::java_label(&request.java.binary, request.java.major);
|
||||||
for warning in &warnings {
|
for warning in &warnings {
|
||||||
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
|
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use unlucky_core::game::GameLayout;
|
||||||
use unlucky_core::launch::request::JavaSelection;
|
use unlucky_core::launch::request::JavaSelection;
|
||||||
use unlucky_core::platform::paths::ProcessEnv;
|
use unlucky_core::platform::paths::ProcessEnv;
|
||||||
|
|
||||||
@@ -21,11 +22,21 @@ pub struct JavaPick {
|
|||||||
|
|
||||||
/// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。
|
/// 选一个能用的 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`):探测失败不报错,按版本要求的基准主版本兜底——
|
/// 显式路径**先探测**(`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 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 {
|
if let Some(path) = explicit {
|
||||||
let runtime = unlucky_core::game::java::probe::probe(
|
let runtime = unlucky_core::game::java::probe::probe(
|
||||||
path,
|
path,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use unlucky_core::game::launch_overrides::{
|
|||||||
resolve as resolve_overrides, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides,
|
resolve as resolve_overrides, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides,
|
||||||
LaunchOverridesPatch, ResolvedLaunch,
|
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_args::{graphics_backend_allowed, GRAPHICS_BACKEND_MIN_VERSION};
|
||||||
use unlucky_core::launch::game_version::GameVersion;
|
use unlucky_core::launch::game_version::GameVersion;
|
||||||
|
|
||||||
@@ -73,11 +73,13 @@ pub fn resolved(env: &HostEnv, instance_id: &str) -> ResolvedLaunch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 版本能力位(解析继承链之后取库表;读不到版本 JSON = 什么能力都没有)。
|
/// 版本能力位(解析继承链之后取库表;读不到版本 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 manifest = VersionJson::from_json_str(&text).ok()?;
|
||||||
let source = |id: &str| -> Option<VersionJson> {
|
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()
|
.ok()
|
||||||
.and_then(|text| VersionJson::from_json_str(&text).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 不复制**:
|
/// 判据是 core 的两件套(库表能力位 + 版本门槛),**UI 不复制**:
|
||||||
/// `graphics_backend_allowed(GameVersion, has_vulkan_backend)`。
|
/// `graphics_backend_allowed(GameVersion, has_vulkan_backend)`。
|
||||||
pub fn backend_gate(env: &HostEnv, version_id: &str) -> (bool, String) {
|
pub fn backend_gate(game_layout: &GameLayout, version_id: &str) -> (bool, String) {
|
||||||
let mc_version = super::game::describe_version(env, version_id).0;
|
let mc_version = super::game::describe_version(game_layout, version_id).0;
|
||||||
let has_vulkan = capabilities(env, version_id)
|
let has_vulkan = capabilities(game_layout, version_id)
|
||||||
.map(|json| VersionCapabilities::from_json(&json).has_vulkan_backend)
|
.map(|json| VersionCapabilities::from_json(&json).has_vulkan_backend)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let allowed = graphics_backend_allowed(&GameVersion::parse(&mc_version), has_vulkan);
|
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 instance = instance_overrides(env, instance_id);
|
||||||
let effective = resolved(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 {
|
LaunchArgsView {
|
||||||
instance_id: instance_id.to_string(),
|
instance_id: instance_id.to_string(),
|
||||||
jvm_args: instance.jvm_args.unwrap_or_default(),
|
jvm_args: instance.jvm_args.unwrap_or_default(),
|
||||||
@@ -240,18 +247,18 @@ mod tests {
|
|||||||
fn gate_uses_core_capability_and_version_threshold() {
|
fn gate_uses_core_capability_and_version_threshold() {
|
||||||
let (env, _dir) = env();
|
let (env, _dir) = env();
|
||||||
write_version(&env, "26.2-snapshot-2", VULKAN_VERSION);
|
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!(allowed, "26.2-snapshot-2 应当放行");
|
||||||
assert_eq!(mc, "26.2-snapshot-2");
|
assert_eq!(mc, "26.2-snapshot-2");
|
||||||
|
|
||||||
// 26.1:版本不够(即使库表里硬塞了 vulkan 也不放行——精确门限优先)。
|
// 26.1:版本不够(即使库表里硬塞了 vulkan 也不放行——精确门限优先)。
|
||||||
write_version(&env, "26.1", VULKAN_VERSION);
|
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!(!allowed, "26.1 不该放行");
|
||||||
assert_eq!(mc, "26.1");
|
assert_eq!(mc, "26.1");
|
||||||
|
|
||||||
// 没有版本 JSON:能力位全假 ⇒ 不放行(不 panic)。
|
// 没有版本 JSON:能力位全假 ⇒ 不放行(不 panic)。
|
||||||
let (allowed, _) = backend_gate(&env, "1.20.4");
|
let (allowed, _) = backend_gate(&env.game, "1.20.4");
|
||||||
assert!(!allowed);
|
assert!(!allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +343,7 @@ mod tests {
|
|||||||
&LaunchArgsPatch::backend("vulkan"),
|
&LaunchArgsPatch::backend("vulkan"),
|
||||||
)
|
)
|
||||||
.expect("写回");
|
.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, "vulkan");
|
||||||
assert_eq!(view.graphics_backend_effective, "vulkan");
|
assert_eq!(view.graphics_backend_effective, "vulkan");
|
||||||
assert!(view.graphics_backend_allowed);
|
assert!(view.graphics_backend_allowed);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! | 纪律 | 落点 |
|
//! | 纪律 | 落点 |
|
||||||
//! |---|---|
|
//! |---|---|
|
||||||
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_set_enabled`] 走 `job::spawn`,UI 只收快照 |
|
//! | 磁盘只在后台线程碰 | [`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 报错不如直说 |
|
//! | 游戏在跑就不改名 | `running` 标记在 host 这一侧挡:Windows 上进程正持有 jar,rename 会失败,与其让用户读一句 OS 报错不如直说 |
|
||||||
//!
|
//!
|
||||||
//! ## 为什么不复用启动路径的 `run_dir()`
|
//! ## 为什么不复用启动路径的 `run_dir()`
|
||||||
@@ -186,10 +186,13 @@ fn toggle(env: &HostEnv, id: &str, file: &str, enabled: bool, running: bool) ->
|
|||||||
|
|
||||||
/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。
|
/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。
|
||||||
fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
|
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)
|
let config = read_instance_config(&env.layout, &instance.id)
|
||||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
.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}"))?;
|
.map_err(|error| format!("派生运行目录失败:{error}"))?;
|
||||||
Ok(derivation.game_dir)
|
Ok(derivation.game_dir)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! | 纪律 | 落点 |
|
//! | 纪律 | 落点 |
|
||||||
//! |---|---|
|
//! |---|---|
|
||||||
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] / [`spawn_rename`] / [`spawn_delete`] / [`spawn_backup`] 全走 `job::spawn`,UI 只收快照 |
|
//! | 磁盘只在后台线程碰 | [`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` 同理) |
|
//! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
|
||||||
//! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) |
|
//! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) |
|
||||||
//! | 游戏在跑就不写 | [`dispatch`] 的 `running` 为真时三条写命令**不 spawn、不碰盘**,直接回 [`crate::dto::SavesFailureKind::GameRunning`] |
|
//! | 游戏在跑就不写 | [`dispatch`] 的 `running` 为真时三条写命令**不 spawn、不碰盘**,直接回 [`crate::dto::SavesFailureKind::GameRunning`] |
|
||||||
@@ -242,10 +242,13 @@ pub(super) fn target(
|
|||||||
|
|
||||||
/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。
|
/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。
|
||||||
pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
|
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)
|
let config = read_instance_config(&env.layout, &instance.id)
|
||||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
.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}"))?;
|
.map_err(|error| format!("派生运行目录失败:{error}"))?;
|
||||||
Ok(derivation.game_dir)
|
Ok(derivation.game_dir)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user