fix(presenter): 多根 r1 复审修四处(磁贴按自己根描述 / 挑 Java 按自己根要求 / 一拍一次扫描 / 只读内容级快照)(M274 r1)
This commit is contained in:
1 parent
dc8323531b
commit
a614b49538
9 files changed
+594
-115
No files matched your search
@@ -7,10 +7,13 @@ use crate::host::CoreHost;
|
||||
use crate::host::HostEnv;
|
||||
use crate::mock::MockBackend;
|
||||
|
||||
/// 启动选项(截图/演示通道用;正常启动全用默认值)。
|
||||
/// 启动选项(`--data-root` 与 `--mock` 是产品开关;截图/演示也复用同一份选项)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AppOptions {
|
||||
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析
|
||||
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析。
|
||||
///
|
||||
/// 走 [`HostEnv::at_data_root`]:**照常**发现官方启动器的标准游戏目录(M274)。钉数据根
|
||||
/// 不等于「别看官方实例」——那是两个不同的开关。
|
||||
pub data_root: Option<PathBuf>,
|
||||
/// 强制 mock 后端(UI 回归/无数据根演示)
|
||||
pub mock: bool,
|
||||
@@ -21,16 +24,62 @@ pub fn config_path(root: &Path) -> PathBuf {
|
||||
root.join(".ULL").join("config").join("launcher.json")
|
||||
}
|
||||
|
||||
/// 钉了数据根时用哪个环境(`None` = 没钉,走解析链)。
|
||||
///
|
||||
/// **单独一格只为可测**:`--data-root` 是产品通道,钉了数据根照样要看见官方启动器里的实例
|
||||
/// (M274 r1 复审 P2-2)。退回 [`HostEnv::at_root`] 会让用户**静默**丢一半实例,而这层
|
||||
/// `Box<dyn Backend>` 在用例里没有 env 取回口(`Backend` 没有访问器),所以判据就落在这个
|
||||
/// 纯函数上(`backend::tests::data_root_keeps_discovering_the_official_launcher`)。
|
||||
fn host_env_for(options: &AppOptions) -> Option<HostEnv> {
|
||||
options.data_root.as_ref().map(HostEnv::at_data_root)
|
||||
}
|
||||
|
||||
/// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。
|
||||
pub fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
|
||||
if options.mock {
|
||||
return Box::new(MockBackend::new());
|
||||
}
|
||||
match &options.data_root {
|
||||
Some(root) => Box::new(CoreHost::new(HostEnv::at_root(root))),
|
||||
match host_env_for(options) {
|
||||
Some(env) => Box::new(CoreHost::new(env)),
|
||||
None => match CoreHost::open() {
|
||||
Some(host) => Box::new(host),
|
||||
None => Box::new(MockBackend::new()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `--data-root`(产品通道)**保留**多根发现;`at_root`(截图/演示/用例)不保留。
|
||||
///
|
||||
/// 抓得住:`host_env_for` 被改回 `HostEnv::at_root`——用户钉一个数据根就永久静默看不到
|
||||
/// 官方启动器里的实例(M274 r1 复审 P2-2)。
|
||||
/// 抓不住:`--data-root` 的命令行解析(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-root` 是产品通道:不许顺手关掉官方根发现"
|
||||
);
|
||||
// 没钉数据根时这一格不参与(走解析链),`mock` 在前一步就分走了。
|
||||
assert!(host_env_for(&AppOptions::default()).is_none());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
}
|
||||
@@ -54,21 +54,31 @@ impl HostEnv {
|
||||
Some(Self::from_data(data))
|
||||
}
|
||||
|
||||
/// 直接钉一个根:`<root>/.ULL` + `<root>/.minecraft`(截图/演示通道)。
|
||||
/// **钉根但照常做多根发现**:`--data-root`(生产通道)与 [`HostEnv::at_root`] 同一个落点,
|
||||
/// 差别只有一格——`standard_game` 保留(用户钉的是数据根,不是「别看官方启动器」)。
|
||||
///
|
||||
/// 为什么两个入口必须分开(M274 r1 复审 P2-2):`--data-root` 是**产品**开关,之前它走
|
||||
/// [`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-root`)走 [`HostEnv::at_data_root`],
|
||||
/// 那条会照常发现官方启动器里的实例;用错这里,用户会**静默**看不到官方实例(M274 r1 复审 P2-2)。
|
||||
///
|
||||
/// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**:
|
||||
/// 演示根常用它把游戏目录指到既有真数据上(避免复制几 GB 的 assets/libraries)。
|
||||
///
|
||||
/// 实现上**委托**给 `at_data_root` 再关掉发现(不是复制一份构造):两个入口的落点/模式因此
|
||||
/// 物理上不可能漂开。
|
||||
pub fn at_root(root: impl AsRef<Path>) -> Self {
|
||||
let root = root.as_ref();
|
||||
let request = request_from_portable(root);
|
||||
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
|
||||
let mut env = Self::from_data(ResolvedData {
|
||||
mode: DataMode::Portable,
|
||||
launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")),
|
||||
game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")),
|
||||
redirected_from: None,
|
||||
});
|
||||
// 钉一个根的通道(截图 / 演示 / 用例)**不做多根发现**:见 `standard_game` 的文档。
|
||||
let mut env = Self::at_data_root(root);
|
||||
// 钉一个根的**测试/截图**通道不做多根发现:见 `standard_game` 的文档。
|
||||
env.standard_game = None;
|
||||
env
|
||||
}
|
||||
@@ -139,6 +149,18 @@ impl HostEnv {
|
||||
}
|
||||
}
|
||||
|
||||
/// 便携钉根的一份 `ResolvedData`(`at_root` / `at_data_root` 共用,两者只在 `standard_game` 上不同)。
|
||||
fn portable_data(root: &Path) -> ResolvedData {
|
||||
let request = request_from_portable(root);
|
||||
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
|
||||
ResolvedData {
|
||||
mode: DataMode::Portable,
|
||||
launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")),
|
||||
game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")),
|
||||
redirected_from: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 便携目录那份 `launcher.json` → 数据根请求(只读 `core.*` 四键)。
|
||||
pub(crate) fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
|
||||
let mut request = DataDirRequest::default();
|
||||
@@ -184,6 +206,50 @@ mod tests {
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// **多根发现的两条入口必须分开**(M274 r1 复审 P2-2):
|
||||
///
|
||||
/// | 入口 | 谁用 | `standard_game` |
|
||||
/// |---|---|---|
|
||||
/// | [`HostEnv::at_data_root`] | 产品 `--data-root`(`backend.rs`) | `Some(官方位置)`:钉数据根 ≠ 别看官方实例 |
|
||||
/// | [`HostEnv::at_root`] | 截图 / 演示 / 七十多处用例 | `None`:同一份用例在任何机器上结果一样 |
|
||||
/// | [`HostEnv::from_data`] | 生产解析链(便携 / 标准 / 自定义) | `Some(官方位置)` |
|
||||
///
|
||||
/// 抓得住:`--data-root` 退回 `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-root` 是产品通道:钉数据根不等于别看官方实例"
|
||||
);
|
||||
// 两条入口除了 `standard_game` 之外必须**完全一致**(落点、模式)。
|
||||
let pinned = HostEnv::at_root(&root);
|
||||
let product = HostEnv::at_data_root(&root);
|
||||
assert_eq!(pinned.layout.home(), product.layout.home());
|
||||
assert_eq!(pinned.game.root(), product.game.root());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// M188 的守门②:**日志根随数据模式变化**(便携 / 标准 / 自定义各一条断言)。
|
||||
///
|
||||
/// 三条走的都是 `HostEnv` 真正用的那条解析链(`Resolver` → `ResolvedData::layout`):
|
||||
|
||||
@@ -31,7 +31,7 @@ mod roots;
|
||||
|
||||
pub(super) use discovered::{base_mode, game_for};
|
||||
|
||||
use discovered::{display_name, external_summary, external_version_of, external_versions};
|
||||
use discovered::{display_name, external_summary, external_version_of};
|
||||
|
||||
/// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。
|
||||
pub fn global_mode(env: &HostEnv) -> IsolationMode {
|
||||
@@ -163,64 +163,70 @@ pub fn isolation_of(env: &HostEnv, game: &GameLayout, id: &str, version_id: &str
|
||||
pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
|
||||
let profiles = profiles(env);
|
||||
let played = last_played(env);
|
||||
// **每个根只扫一遍**(M274 r1 复审 P2-1):这一拍的「根 × 版本」清单读一次,之后
|
||||
// ①每份档案属于哪个根、②它是否占住配置根的同名版本,都是内存查表。
|
||||
// 反查写进循环里是 `#根 × (1 + #ext 档案)` 次全树扫描——官方根 200 版本 + 10 份 ext 档案
|
||||
// 时 `instances()` 会从 236ms 涨到 1.35s(复审实测 ×5.7)。
|
||||
let found = discovered::Found::scan(env);
|
||||
let mut homes: Vec<Option<GameLayout>> = Vec::with_capacity(profiles.profiles.len());
|
||||
let mut bound: Vec<&str> = Vec::new();
|
||||
for profile in &profiles.profiles {
|
||||
match found.find(profile.id.as_str()) {
|
||||
Some((root, _)) => {
|
||||
// 属于**这个根**的档案(懒创建的 `ext-` 档案可能属于官方根):磁贴按那个根描述自己。
|
||||
if root.configured() {
|
||||
bound.push(profile.version_id.as_str());
|
||||
}
|
||||
homes.push(Some(root.game().clone()));
|
||||
}
|
||||
None => {
|
||||
// 反查不到 ⇒ 按旧口径算配置根的(M273 之前合成的 `ext-` id / 那批根已不在盘上)。
|
||||
bound.push(profile.version_id.as_str());
|
||||
homes.push(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut snapshot: Vec<InstanceSummary> = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| profile_summary(env, profile, &played))
|
||||
.collect();
|
||||
// 两件「每份档案只算一次」的事(反查要扫盘,放进版本循环里就是 O(档案×版本) 次扫描):
|
||||
// ① 哪些版本被**属于配置根的**档案绑定了 ⇒ 配置根不再为它合成一条;
|
||||
// ② 现有的档案 id ⇒ 同一个 id 不再合成第二条视图。
|
||||
let bound: Vec<&str> = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.filter(|profile| profile_belongs_here(env, profile))
|
||||
.map(|profile| profile.version_id.as_str())
|
||||
.zip(&homes)
|
||||
.map(|(profile, home)| profile_summary(env, profile, home.as_ref(), &played))
|
||||
.collect();
|
||||
let known: Vec<&str> = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| profile.id.as_str())
|
||||
.collect();
|
||||
for root in roots::all(env) {
|
||||
for (root, versions) in found.roots() {
|
||||
let configured = root.configured();
|
||||
for version_id in external_versions(env, root.game()) {
|
||||
let id = discovered::external_instance_id(root.key(), &version_id);
|
||||
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.push(external_summary(env, root, version_id, &played));
|
||||
}
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// 这条档案代表的是**配置根**里的版本吗(决定它是否「占住」配置根的同名版本)。
|
||||
///
|
||||
/// 有档案就按档案出——但多根之后要分清两种 `ext-…` 档案:
|
||||
/// - 根 B 里的实例被懒创建成档案 ⇒ 它占的是**根 B** 的那个版本,不许连带把配置根里的同名版本
|
||||
/// 也一并抹掉(那是另一个实例,有自己的存档);
|
||||
/// - 反查不到的 id(M273 之前合成的、或那一批根已经不在盘上)⇒ 按旧口径算配置根的,
|
||||
/// 「档案优先」这条因此不会因为升级而失效。
|
||||
fn profile_belongs_here(env: &HostEnv, profile: &InstanceProfile) -> bool {
|
||||
match discovered::locate(env, profile.id.as_str()) {
|
||||
Some((root, _)) => root.configured(),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条档案 → 磁贴(档案路径的唯一翻译点)。
|
||||
///
|
||||
/// `home` 是这条档案**自己那个根**([`discovered::Found`] 查出来的;`None` = 配置根):
|
||||
/// 懒创建出来的 `ext-` 档案可能属于官方根,那时「版本在不在盘上 / 隔离档 / MC 版本 / 版本门槛」
|
||||
/// 都必须按那个根算——否则磁贴说「文件缺失 · 共用目录」,而存档与启动实际读的是官方根
|
||||
/// (M274 r1 复审 P1-1:显示与行为分叉,最坏的一种)。
|
||||
fn profile_summary(
|
||||
env: &HostEnv,
|
||||
profile: &InstanceProfile,
|
||||
home: Option<&GameLayout>,
|
||||
played: &BTreeMap<String, String>,
|
||||
) -> InstanceSummary {
|
||||
let version_id = profile.version_id.clone();
|
||||
// 档案实例属于**配置根**(`profiles.json` 里的 `version_id` 指的就是它)。
|
||||
let game = &env.game;
|
||||
let game = home.unwrap_or(&env.game);
|
||||
let (mc_version, loader) = describe_version(game, &version_id);
|
||||
let id = profile.id.as_str();
|
||||
InstanceSummary {
|
||||
@@ -340,3 +346,6 @@ mod tests_union;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_multiroot;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_multiroot2;
|
||||
@@ -167,19 +167,66 @@ pub(in crate::host) fn external_summary(
|
||||
/// (否则存档 / 模组 / 启动会退回配置根,那正是 M274 要修的错)。
|
||||
///
|
||||
/// 不是 `ext-` 前缀的 id 直接返回 `None`(档案实例属于配置根,不需要反查)。
|
||||
///
|
||||
/// **代价**(M274 r1 复审实测过):这是一次「每根扫一遍」的全树扫描。单实例动作(存档 / 模组 /
|
||||
/// 启动 / 改设置)走这里没问题,**但绝不能在 `instances()` 的循环里调**——那里用 [`Found`]
|
||||
/// 一拍只扫一遍(见它的文档)。
|
||||
#[must_use]
|
||||
pub(in crate::host) fn locate(env: &HostEnv, id: &str) -> Option<(GameRoot, String)> {
|
||||
if !crate::dto::is_discovered_instance(id) {
|
||||
return None;
|
||||
Found::scan(env)
|
||||
.find(id)
|
||||
.map(|(root, version_id)| (root.clone(), version_id.to_string()))
|
||||
}
|
||||
|
||||
/// **一拍之内**的发现快照:每个根只扫一遍 `versions/`,之后所有「这个 id 属于哪个根」都是内存查表。
|
||||
///
|
||||
/// 为什么要有它(M274 r1 复审 P2-1 的实测):把反查直接写进 `instances()` 的循环里,扫描次数是
|
||||
/// `#根 × (1 + #ext 档案)`;官方根 200 个版本 + 10 份 `ext-` 档案时 `instances()` 从 236ms 涨到
|
||||
/// 1.35s(×5.7)。这里把「根 × 版本」的清单一次性读完,反查退化成线性的字符串比较
|
||||
/// (`ext-` id 是 16 位十六进制,逐条比一遍远比再扫一次盘便宜)。
|
||||
///
|
||||
/// 快照是**快照**:一拍里盘变了(用户同时在装版本)不会看到,下一次刷新重建。
|
||||
pub(in crate::host) struct Found {
|
||||
roots: Vec<(GameRoot, Vec<String>)>,
|
||||
}
|
||||
|
||||
impl Found {
|
||||
/// 扫一遍全部根(配置根在前,顺序与 `roots::all` 一致)。
|
||||
pub(in crate::host) fn scan(env: &HostEnv) -> Self {
|
||||
let roots = roots::all(env)
|
||||
.into_iter()
|
||||
.map(|root| {
|
||||
let versions = external_versions(env, root.game());
|
||||
(root, versions)
|
||||
})
|
||||
.collect();
|
||||
Self { roots }
|
||||
}
|
||||
for root in roots::all(env) {
|
||||
for version_id in external_versions(env, root.game()) {
|
||||
if external_instance_id(root.key(), &version_id) == id {
|
||||
return Some((root, version_id));
|
||||
}
|
||||
|
||||
/// 逐个根 + 它这一拍扫到的版本 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())
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 实例所属的那个根的布局(读盘路径的唯一入口)。
|
||||
@@ -187,8 +234,9 @@ pub(in crate::host) fn locate(env: &HostEnv, id: &str) -> Option<(GameRoot, Stri
|
||||
/// 档案实例、以及「反查不到」的 id 一律退回配置根 `env.game`——**没有第二个答案**:
|
||||
/// 宁可读配置根(旧行为),也不把实例指向一个猜出来的目录。
|
||||
pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout {
|
||||
locate(env, id)
|
||||
.map(|(root, _)| root.game().clone())
|
||||
Found::scan(env)
|
||||
.game_of(id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| env.game.clone())
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,25 @@
|
||||
//! 3. **去掉 `\\?\` 前缀**——Windows 的 `canonicalize` 一律返回 verbatim 形式(`\\?\C:\…`),
|
||||
//! 不去掉的话「同一路径的两种写法」永远不相等(这是个已知坑,用例
|
||||
//! `junction_to_the_same_folder_is_one_root` 钉住);
|
||||
//! 4. Windows / macOS 折成小写(大小写不敏感的文件系统)。
|
||||
//! 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 会变一次。
|
||||
//!
|
||||
//! # 只读
|
||||
//!
|
||||
@@ -102,6 +117,7 @@ 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("//./"))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! M274:**多根发现**(配置根 + 官方启动器的标准位置)的判据用例。
|
||||
//! M274:**多根发现**(只读那条用例在 `tests_multiroot2.rs`:升级成内容级快照,M274 r1 P2-3)(配置根 + 官方启动器的标准位置)的判据用例。
|
||||
//!
|
||||
//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是
|
||||
//! `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `<exe>/.minecraft`——两个不同的目录。
|
||||
@@ -12,7 +12,6 @@
|
||||
//! | [`the_same_version_in_two_roots_is_two_instances`] | 两个根里的同名版本 = **2 条**磁贴、id 不同、各自指向自己的根、名字分辨得出来 | 真实官方安装的目录形状(`launcher_profiles.json` / 加密的账户文件一概不读) |
|
||||
//! | [`a_junction_to_the_configured_root_is_not_a_second_root`] | 同一路径的第二种写法(junction / 符号链接)**不许**让同一批实例列两遍 | 8.3 短名(用例盘上造不出来;判据靠 `canonicalize` 的长名展开,见 `roots::key_of` 的文档) |
|
||||
//! | [`saves_come_from_the_instance_own_root`] | 每个实例的存档 / 截图列表只来自**它自己那个根**(不许把外部实例的存档当成当前根的) | 游戏进程正在跑时的文件锁 / 半写状态(未处理,见报告) |
|
||||
//! | [`discovery_writes_nothing_into_the_official_root`] | 只读发现:`instances()` + 列存档前后,官方根里一个条目都不许多 | 「官方启动器同时也在写」的并发(TOCTOU 本来就看不到) |
|
||||
//! | [`a_lazily_created_profile_stays_on_its_own_root`] | 懒创建档案(用户第一次改设置)之后 id → 根的反查不退化,且没把配置根的同名版本一并抹掉 | 档案里**没有**根字段这一事实本身(根只从 id 反查,见 `discovered::locate`) |
|
||||
//! | [`the_launch_reads_the_manifest_from_the_instance_root`] | 启动装配的清单 / 运行目录走实例自己的根 | 真的把游戏拉起来(要真 jvm / 账号 / 网络) |
|
||||
|
||||
@@ -45,28 +44,6 @@ mod temp {
|
||||
let _ = std::fs::remove_dir_all(Path::new(&self.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录下所有条目(排序后比较,用来做「读盘前后」快照)。
|
||||
pub fn entries_under(dir: &Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(current) = stack.pop() {
|
||||
let Ok(read) = std::fs::read_dir(¤t) else {
|
||||
continue;
|
||||
};
|
||||
for entry in read.flatten() {
|
||||
let path = entry.path();
|
||||
if let Ok(relative) = path.strip_prefix(dir) {
|
||||
out.push(relative.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// 造两个根:配置根 = `<root>/.minecraft`,官方位置 = `<root>/official/.minecraft`。
|
||||
@@ -269,36 +246,6 @@ fn saves_come_from_the_instance_own_root() {
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 判据④:只读发现(官方根零写入)
|
||||
|
||||
/// 浏览(列实例 + 列存档)不许在官方根里留下任何东西,也不许凭空写 `profiles.json`。
|
||||
#[test]
|
||||
fn discovery_writes_nothing_into_the_official_root() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-readonly");
|
||||
write_version(&env.game, "A");
|
||||
write_version(&GameLayout::new(official(&env)), "A");
|
||||
write_world(&official(&env), "official-world");
|
||||
|
||||
let before = temp::entries_under(&official(&env));
|
||||
let snapshot = instances(&env);
|
||||
for tile in &snapshot {
|
||||
if let Ok(dir) = super::super::saves::run_dir(&env, tile) {
|
||||
let _ = super::super::saves::snapshot(tile, &dir, None);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
before,
|
||||
temp::entries_under(&official(&env)),
|
||||
"读一次实例列表 / 存档就在官方启动器的目录里多出了东西"
|
||||
);
|
||||
let profiles = read_profiles(&env.layout).expect("读档案");
|
||||
assert!(
|
||||
profiles.profiles.is_empty(),
|
||||
"浏览不该懒创建档案:{:?}",
|
||||
profiles.profiles
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------- 判据⑤:懒创建的档案仍认自己那个根(不吞同名)
|
||||
|
||||
/// 用户第一次给官方根里的实例改设置 ⇒ 懒创建档案,但:
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -170,7 +170,8 @@ pub fn build_request(
|
||||
};
|
||||
|
||||
let overrides = super::launch_args::resolved(env, &target.id);
|
||||
let java = pick_java(env, &version_id, overrides.java_path.as_deref());
|
||||
// 挑 Java 的**版本要求**也按实例自己的根读(M274 r1 复审 P1-2)。
|
||||
let java = pick_java(env, &game, &version_id, overrides.java_path.as_deref());
|
||||
let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
|
||||
let options = AdapterOptions {
|
||||
java: java.selection,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::game::GameLayout;
|
||||
use unlucky_core::launch::request::JavaSelection;
|
||||
use unlucky_core::platform::paths::ProcessEnv;
|
||||
|
||||
@@ -21,11 +22,21 @@ pub struct JavaPick {
|
||||
|
||||
/// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。
|
||||
///
|
||||
/// `game` 是这个实例**自己那个根**的布局(M274):版本清单里的 `javaVersion` 只在那个根下,
|
||||
/// 拿配置根读不到 ⇒ 退回「按 id 的静态表」,而第三方启动器的自定义名版本(`樱落怡然客户端`)
|
||||
/// `version_parts` 为空 ⇒ `minimum = 8`,一个要 21 的 modpack 会被拉去用 17 跑
|
||||
/// (M274 r1 复审 P1-2 的探针:实例自己根 minimum=21,配置根读出来 8,实际选中 17)。
|
||||
///
|
||||
/// 显式路径**先探测**(`java -version`):探测失败不报错,按版本要求的基准主版本兜底——
|
||||
/// 与自动挑的退路同一口径(启动路径不该因为探测不到就拒绝启动)。
|
||||
pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick {
|
||||
pub fn pick_java(
|
||||
env: &HostEnv,
|
||||
game: &GameLayout,
|
||||
version_id: &str,
|
||||
explicit: Option<&std::path::Path>,
|
||||
) -> JavaPick {
|
||||
let store = env.store();
|
||||
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
|
||||
let required = unlucky_core::game::java::requirement_for_version(game, version_id);
|
||||
if let Some(path) = explicit {
|
||||
let runtime = unlucky_core::game::java::probe::probe(
|
||||
path,
|
||||
|
||||
Reference in new issue
Block a user