1 Commits
Author SHA1 Message Date
AskaEth 0522a41a52 release: v0.2.5
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
2026-11-12 12:06:59 +08:00
38 changed files with 414 additions and 3061 deletions

No files matched your search

+29 -30
View File
@@ -109,7 +109,8 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.os }}
# `--workspace` 现在是 8 个 crate,不含 `unlucky-app`(M118 的 exclude)⇒ 不拉起 slint / usvg / i-slint-*。
# `--workspace` 现在是 8 个 crate,不含 `unlucky-app`(M118 的 workspace exclude),
# 所以这条命令不会拉起 slint / usvg / i-slint-*。
- run: cargo clippy --workspace --all-targets -- -D warnings
test:
@@ -186,15 +187,16 @@ jobs:
# 只会打一条 `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
#
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
# M144 的 `test (windows)` 只留 `Process completed with exit code 1`(cargo 自己的失败码是 **101**)
# ⇒ 那个 1 来源不明;job log 要登录才能读(匿名 403),但**注解匿名可读**
# (`GET /check-runs/<id>/annotations` → 200,M144 实证)。三件事各对准一种「看不见」:
# 1) `--no-fail-fast`:cargo 默认首个失败即停(M144 实测 Linux 只跑 7 个 test binary 就退出),
# 后面还有没有第二条失败看不到;
# 2) `error` / `failures:` / panicked 行升成 `::error::`:拿不到 job log 时注解是匿名侧唯一正文
# (M275 把同一手法补到末尾的 `update-e2e` 步骤);
# 3) 显式打印 cargo 退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀只给码、
# 零诊断」分开——这正是 M144 没定的那一半。
# M144 的第三条红 `test (windows)` 只留下 `Process completed with exit code 1`,而 cargo 自己的失败码
# 是 **101** ⇒ 那个 1 的来源不明(包装层洗码 / 进程被杀,两说都未排除);job log 要登录才能读
# (匿名 403),但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。三件事
# 各对准一种「看不见」:
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑 7 个 test
# binary 就退出),后面还有没有第二条失败完全看不到;
# 2) `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,注解是匿名侧
# 唯一能取到的失败正文;
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀只给码、零诊断」
# 分开——这正是 M144 没定的那一半。
- name: cargo test --workspace --all-targets(ubuntu,失败升注解)
if: runner.os == 'Linux'
run: |
@@ -362,14 +364,20 @@ jobs:
path: ${{ runner.temp }}/smoke/
if-no-files-found: warn
# M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 / 篡改包体 /
# 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。只在 Windows:真实产物是裸 `.exe` +
# `portable-exe`,Linux/macOS 是 `.tar.gz` + `portable-zip`(`ensure_self_replaceable` 直接拒),
# 在 ubuntu 上跑绿只证明「世上不存在的 linux portable-exe 能装上」(r1 复审 P2-4)。
# 步骤名 `update-e2e` 与 run 里的 `--test update_e2e_test` 被 `packaging/check_test_source_off.py` 的
# `ALLOWED_STEP` 钉死(`update-test-source` 只许在这一项里出现)——改这里的 name / run 之前先读那段注释。
# 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions` / 优化 / panic 行为
# 都不同,用 debug 跑这条 E2E 就失去它存在的理由)。能跑起来比跑得快重要。
# M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 /
# 篡改包体 / 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。
#
# 独立 job、只在 Windows:夹具的「真形状」只在 Windows 成立——真实产物是裸 `.exe` +
# `portable-exe`,而 Linux/macOS 是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable`
# 直接拒)。在 ubuntu 上跑绿只能证明「一个世上不存在的 linux portable-exe 能装上」,是假绿
# (r1 复审 P2-4)。
#
# 本 job 的步骤名 `update-e2e` 是**唯一**被允许出现 `update-test-source` 的地方,判据在
# `packaging/check_test_source_off.py` 的 `ALLOWED_STEP`:文件名 / 步骤名 / `--test
# update_e2e_test` 三者任一不符即红——改这里的 name 或 run 之前先读那段注释。
#
# 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions`、优化与
# panic 行为都不一样,用 debug 跑这条 E2E 就失去了它存在的理由)。tower 明确:能跑起来比跑得快重要。
update-e2e:
name: update-e2e (windows x64)
runs-on: windows-latest
@@ -383,16 +391,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: windows-latest
# M275:裸 `cargo test` 失败只留一句 `exit code 1`、check-run 上零注解(匿名侧等于信息为零),所以照
# test 作业同一手法升注解:`$LASTEXITCODE` 是 cargo 的真码(Tee-Object 是 cmdlet,不改它)、`%` → `%25`。
- name: update-e2e
shell: pwsh
run: |
$log = Join-Path $env:RUNNER_TEMP 'update-e2e.log'
cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_test --test update_e2e_tamper_test 2>&1 | Tee-Object -FilePath $log
$code = $LASTEXITCODE; Write-Host "cargo exit=$code"
if ($code -ne 0) {
Select-String -Path $log -Pattern '(error(\[E\d+\])?:|^failures:|test result: FAILED|thread .* panicked at|^\s+--> )' | Select-Object -First 40 |
ForEach-Object { Write-Host "::error::$($_.Line.Replace('%', '%25'))" }
}
exit $code
run: >
cargo test --release -p unlucky-cli --features update-test-source
--test update_e2e_test --test update_e2e_tamper_test
+236 -12
View File
@@ -57,17 +57,7 @@ impl App {
// M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**——`previous_view` 是一份完整的页面
// 模型,留着就是「每换一次页多一份」的线性增长(长时间常驻的启动器上不是"小浪费")。
//
// M277:**这一档本来就没有旧页时也必须丢**。判据是 `Kind::has_old_page()`(M256 写好的
// 设计口径,此前全仓无人调用 ⇒ 首屏照样画幽灵)。为什么它必须在这里:`pages::render` 把
// `previous_view` 整棵画在当前页**下面**,只在空隙里露出来——而 boot 那份快照是
// `construct.rs` 的零实例初始模型(被 `refresh.rs` 在第一次 refresh 时换进去),
// 于是「每次启动闪一张『还没有实例…』的空态页」约 250~300ms。
// 为什么不在 `refresh.rs` 存之前拦:本 mission 的 scope 不含 `app/state/**`;
// 而这里比"存之前拦"更靠下游一格,任何来源(含将来新增的)都被一并兜住。
// 丢在这里的时机是 `update` 的尾巴、`view` 之前 ⇒ 一帧都画不到。
let keeps_old_page = motion::kind().is_none_or(motion::Kind::has_old_page);
if !motion::needs_frames() || !keeps_old_page {
if !motion::needs_frames() {
self.previous_view = None;
}
}
@@ -171,4 +161,238 @@ fn nav_rank(route: &Route) -> Option<u8> {
}
#[cfg(test)]
mod tests;
mod tests {
use super::*;
use crate::app::components::motion::FRAME_MS;
use crate::app::message::Message;
use crate::cli::Options;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 推时间轴到没有轨道(每步 16 ms,上限 20 s)。
fn run_until_idle() {
for _ in 0..1250 {
if !motion::needs_frames() {
return;
}
motion::advance_by(FRAME_MS);
}
}
/// 有窗口、首屏动效已跑完、旧页模型已清空的 App。定档走 `configure_for_test`(P2-5,见
/// `a_pinned_scale_survives_boot_with_motion_off`:它把档**钉住**,`on_boot` 的重定档赢不了)。
fn idle_app() -> App {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
let _ = app.update(Message::Frame); // 让 `sync_motion` 把启动期那次换页的旧页烧掉
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。
///
/// 抓得住:①`Screen::of` 与 `Screen::ALL` 的对应被改坏;②把 `nav_rank` 改成返回屏键(或退回
/// 比较 `Screen::ALL` 下标)——下面那条「轨序里 Settings 在 Logs 之后」会红,因为屏键顺序恰好
/// 相反;③谁调了 `Screen::ALL` 的声明顺序(本用例把这对**相反的不等关系**两侧都钉住)。
/// 抓不住:导航轨的视觉几何(`shell/nav.rs` 的间距/药丸位置归它自己的单测)。
#[test]
fn the_rail_order_and_the_screen_table_disagree_on_purpose() {
let keyed = |route: &Route| (screen_key(route), nav_rank(route));
assert_eq!(
keyed(&Route::Detail("survival".to_string())),
(Some(1), Some(0)),
"详情归「实例」"
);
assert_eq!(
keyed(&Route::Accounts),
(None, None),
"账户不归 pages 画、也不在轨上"
);
assert_eq!(
keyed(&Route::Agent),
(None, Some(4)),
"AI 不归 pages 画,但在轨最下面"
);
assert_eq!(
nav_rank(&Route::Content),
Some(1),
"内容库与 Download 共用导航项"
);
// 两条**相反**的不等关系:这就是 P2-1 修的镜像
assert!(
screen_key(&Route::Settings) < screen_key(&Route::Logs),
"屏键里 Settings 在 Logs 之前"
);
assert_eq!(
nav_rank(&Route::Logs),
Some(2),
"轨上 Logs 在 Settings 之前"
);
assert_eq!(nav_rank(&Route::Settings), Some(3));
}
/// **旧页模型用完即焚,且快速连点也不累积**(门槛 B)。
///
/// 抓得住:删掉 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` ⇒ 两条
/// `is_none()` 断言红。**抓不住、也不假装抓**「中途不会攒到 2 份」:字段是
/// `Option<pages::Model>`,结构上装不下第二份(`refresh.rs` 用 `mem::replace` **移动**);
/// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。
#[test]
fn the_previous_page_model_is_dropped_and_never_accumulates() {
let mut app = idle_app();
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert_eq!(
app.previous_view.as_ref().map(|model| model.screen),
Some(pages::Screen::Instances),
"换页那一拍必须留下**切换前**那一屏"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "过渡结束必须当拍丢掉旧页模型");
// 快速连点:中间只走 4 帧就点下一次
for route in [Route::Content, Route::Settings, Route::Instances] {
let _ = app.update(Message::Page(pages::Message::Navigate(route)));
for _ in 0..4 {
motion::advance_by(FRAME_MS);
}
assert!(app.previous_view.is_some(), "过渡中途必须留着旧页");
}
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "连点 4 次之后仍应是 None");
}
/// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。
///
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成每次刷新都换 ⇒ 旧页跟着 presenter 变,
/// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。
#[test]
fn the_previous_page_is_a_frozen_snapshot() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
let before = app
.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width));
assert!(before.is_some());
app.view_dirty = true;
app.refresh_view(); // 同屏刷新(主题 / 下载进度那一类的形状)
motion::advance_by(FRAME_MS);
assert_eq!(
app.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width)),
before,
"同屏刷新不该换掉旧页快照"
);
}
/// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空;
/// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes`
/// 不涨 = `on_dialog` 的闸门);④**Esc 那条路径**也挡住(`escape` 的闸门)。
/// **抓得住**:删掉任一道闸门(`on_dialog` / `escape`)或把逻辑关闭改成延后,①②③④ 各自红。
/// **抓不住**:几何级命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。
#[test]
fn the_exiting_dialog_is_already_closed_and_swallows_clicks() {
use crate::app::dialog::Message as DialogMessage;
use crate::app::update::dialog::{escape, on_dialog};
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
let mut app = idle_app();
let _ = app.state.dialogs.push(
DialogSource::UserAction,
"删除世界",
"不可撤销",
DialogKind::Confirm {
primary: "删除".to_string(),
secondary: "取消".to_string(),
},
);
app.view_dirty = true;
app.refresh_view();
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机顺序一致)
let _ = app.update(Message::Frame);
assert!(motion::needs_frames(), "开框必须在同一拍起跑进场");
run_until_idle();
assert!(app.dialog_model.is_some(), "前提:框已经画出来了");
let _ = on_dialog(&mut app, DialogMessage::Secondary);
assert!(app.state.dialogs.current().is_none(), "①逻辑上已经关掉");
assert!(motion::modal_closing(), "②画面还在退场");
assert!(app.dialog_model.is_some(), "②仍画得到旧框(不是硬跳)");
let refreshes = app.refreshes;
let _ = on_dialog(&mut app, DialogMessage::Primary);
assert_eq!(app.refreshes, refreshes, "③退场期点击必须被吞掉");
assert!(
app.state.dialogs.current().is_none(),
"③幽灵点击不许复活那个框"
);
assert!(
escape(&mut app),
"④退场期 Esc 也必须被吃掉(不许落到 AI 栏)"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.dialog_model.is_none(), "退场结束后不许留残影");
assert!(!motion::needs_frames(), "退场结束后帧泵必须停");
}
/// **钉住的档必须压过产品启动路径**(复审 r2 的根因守卫):走真 `Message::Boot` ——
/// `on_boot` → `configure_motion` → `boot_env::motion_scale(..)`(本机 CPU 档 ⇒ HALF)→
/// `configure`,全程都不许把测试钉住的 STANDARD 顶掉。
///
/// 为什么不去 `set_var("ULL_MOTION","off")`:那是**进程级写**,并行用例里别的线程正在
/// `getenv`(`boot_env` 每次 Boot 都读),Linux 上这是真竞态。等价做法:把该 env 下
/// `boot_env` 会产出的**取值**(`NONE`)直接喂给 `configure`——见
/// `components::motion::tests::modal::a_pinned_scale_survives_every_later_configure`。
///
/// 抓得住:删掉 `configure` 里「`FORCED_SCALE` 优先」那段 ⇒ 本条红(r2 之前那 7 条红之一
/// 的同一条路)。抓不住:`boot_env` 自己的分支表(env / 后端 tier 的规则,不在本 scope)。
#[test]
fn a_pinned_scale_survives_boot() {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(
motion::scale(),
MotionScale::STANDARD,
"on_boot 的重定档不许顶掉测试钉住的档(r2 的根因)"
);
motion::reset();
}
}
@@ -1,331 +0,0 @@
use super::*;
use crate::app::components::motion::FRAME_MS;
use crate::app::message::Message;
use crate::cli::Options;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 推时间轴到没有轨道(每步 16 ms,上限 20 s)。
fn run_until_idle() {
for _ in 0..1250 {
if !motion::needs_frames() {
return;
}
motion::advance_by(FRAME_MS);
}
}
/// 有窗口、首屏动效已跑完、旧页模型已清空的 App。定档走 `configure_for_test`(P2-5,见
/// `a_pinned_scale_survives_boot_with_motion_off`:它把档**钉住**,`on_boot` 的重定档赢不了)。
fn idle_app() -> App {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
let _ = app.update(Message::Frame); // 让 `sync_motion` 把启动期那次换页的旧页烧掉
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。
///
/// 抓得住:①`Screen::of` 与 `Screen::ALL` 的对应被改坏;②把 `nav_rank` 改成返回屏键(或退回
/// 比较 `Screen::ALL` 下标)——下面那条「轨序里 Settings 在 Logs 之后」会红,因为屏键顺序恰好
/// 相反;③谁调了 `Screen::ALL` 的声明顺序(本用例把这对**相反的不等关系**两侧都钉住)。
/// 抓不住:导航轨的视觉几何(`shell/nav.rs` 的间距/药丸位置归它自己的单测)。
#[test]
fn the_rail_order_and_the_screen_table_disagree_on_purpose() {
let keyed = |route: &Route| (screen_key(route), nav_rank(route));
assert_eq!(
keyed(&Route::Detail("survival".to_string())),
(Some(1), Some(0)),
"详情归「实例」"
);
assert_eq!(
keyed(&Route::Accounts),
(None, None),
"账户不归 pages 画、也不在轨上"
);
assert_eq!(
keyed(&Route::Agent),
(None, Some(4)),
"AI 不归 pages 画,但在轨最下面"
);
assert_eq!(
nav_rank(&Route::Content),
Some(1),
"内容库与 Download 共用导航项"
);
// 两条**相反**的不等关系:这就是 P2-1 修的镜像
assert!(
screen_key(&Route::Settings) < screen_key(&Route::Logs),
"屏键里 Settings 在 Logs 之前"
);
assert_eq!(
nav_rank(&Route::Logs),
Some(2),
"轨上 Logs 在 Settings 之前"
);
assert_eq!(nav_rank(&Route::Settings), Some(3));
}
/// **旧页模型用完即焚,且快速连点也不累积**(门槛 B)。
///
/// 抓得住:删掉 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` ⇒ 两条
/// `is_none()` 断言红。**抓不住、也不假装抓**「中途不会攒到 2 份」:字段是
/// `Option<pages::Model>`,结构上装不下第二份(`refresh.rs` 用 `mem::replace` **移动**);
/// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。
#[test]
fn the_previous_page_model_is_dropped_and_never_accumulates() {
let mut app = idle_app();
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert_eq!(
app.previous_view.as_ref().map(|model| model.screen),
Some(pages::Screen::Instances),
"换页那一拍必须留下**切换前**那一屏"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "过渡结束必须当拍丢掉旧页模型");
// 快速连点:中间只走 4 帧就点下一次
for route in [Route::Content, Route::Settings, Route::Instances] {
let _ = app.update(Message::Page(pages::Message::Navigate(route)));
for _ in 0..4 {
motion::advance_by(FRAME_MS);
}
assert!(app.previous_view.is_some(), "过渡中途必须留着旧页");
}
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "连点 4 次之后仍应是 None");
}
/// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。
///
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成每次刷新都换 ⇒ 旧页跟着 presenter 变,
/// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。
#[test]
fn the_previous_page_is_a_frozen_snapshot() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
let before = app
.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width));
assert!(before.is_some());
app.view_dirty = true;
app.refresh_view(); // 同屏刷新(主题 / 下载进度那一类的形状)
motion::advance_by(FRAME_MS);
assert_eq!(
app.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width)),
before,
"同屏刷新不该换掉旧页快照"
);
}
/// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空;
/// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes`
/// 不涨 = `on_dialog` 的闸门);④**Esc 那条路径**也挡住(`escape` 的闸门)。
/// **抓得住**:删掉任一道闸门(`on_dialog` / `escape`)或把逻辑关闭改成延后,①②③④ 各自红。
/// **抓不住**:几何级命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。
#[test]
fn the_exiting_dialog_is_already_closed_and_swallows_clicks() {
use crate::app::dialog::Message as DialogMessage;
use crate::app::update::dialog::{escape, on_dialog};
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
let mut app = idle_app();
let _ = app.state.dialogs.push(
DialogSource::UserAction,
"删除世界",
"不可撤销",
DialogKind::Confirm {
primary: "删除".to_string(),
secondary: "取消".to_string(),
},
);
app.view_dirty = true;
app.refresh_view();
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机顺序一致)
let _ = app.update(Message::Frame);
assert!(motion::needs_frames(), "开框必须在同一拍起跑进场");
run_until_idle();
assert!(app.dialog_model.is_some(), "前提:框已经画出来了");
let _ = on_dialog(&mut app, DialogMessage::Secondary);
assert!(app.state.dialogs.current().is_none(), "①逻辑上已经关掉");
assert!(motion::modal_closing(), "②画面还在退场");
assert!(app.dialog_model.is_some(), "②仍画得到旧框(不是硬跳)");
let refreshes = app.refreshes;
let _ = on_dialog(&mut app, DialogMessage::Primary);
assert_eq!(app.refreshes, refreshes, "③退场期点击必须被吞掉");
assert!(
app.state.dialogs.current().is_none(),
"③幽灵点击不许复活那个框"
);
assert!(
escape(&mut app),
"④退场期 Esc 也必须被吃掉(不许落到 AI 栏)"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.dialog_model.is_none(), "退场结束后不许留残影");
assert!(!motion::needs_frames(), "退场结束后帧泵必须停");
}
/// **钉住的档必须压过产品启动路径**(复审 r2 的根因守卫):走真 `Message::Boot` ——
/// `on_boot` → `configure_motion` → `boot_env::motion_scale(..)`(本机 CPU 档 ⇒ HALF)→
/// `configure`,全程都不许把测试钉住的 STANDARD 顶掉。
///
/// 为什么不去 `set_var("ULL_MOTION","off")`:那是**进程级写**,并行用例里别的线程正在
/// `getenv`(`boot_env` 每次 Boot 都读),Linux 上这是真竞态。等价做法:把该 env 下
/// `boot_env` 会产出的**取值**(`NONE`)直接喂给 `configure`——见
/// `components::motion::tests::modal::a_pinned_scale_survives_every_later_configure`。
///
/// 抓得住:删掉 `configure` 里「`FORCED_SCALE` 优先」那段 ⇒ 本条红(r2 之前那 7 条红之一
/// 的同一条路)。抓不住:`boot_env` 自己的分支表(env / 后端 tier 的规则,不在本 scope)。
#[test]
fn a_pinned_scale_survives_boot() {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(
motion::scale(),
MotionScale::STANDARD,
"on_boot 的重定档不许顶掉测试钉住的档(r2 的根因)"
);
motion::reset();
}
/// **首屏(`Kind::Boot`)没有旧页**(M277 / 复审 M257-r1 P1-1 的修):boot 期间 `previous_view`
/// 不许留在那儿等 `pages::render` 去画。
///
/// 幽灵是怎么来的(不必重跑也能读出来):`pages::render` 把 `previous_view` 整棵画在当前页**下面**
/// (`stack![opaque(ghost), current]`),当前页不透明处盖住它,只在空隙里露出文字与图标磁贴;
/// 而 boot 那份快照是 `app/state/construct.rs` 的**零实例初始模型**,被 `refresh.rs` 在第一次
/// refresh 时换进 `previous_view` ⇒ 每次启动闪一张「这里还没有实例…」的空态页直到退场腿走完
/// (`Kind::Boot` 的退场腿 = `PEER_EXIT` 250ms,`transition.rs` 的表)。
///
/// 抓得住:把 `sync_motion` 的判据退回 `if !motion::needs_frames()`(即删掉 `keeps_old_page`
/// 那半句)⇒ boot 那一拍结束时的第一条 `is_none()` 立刻红(实测:`前提/首屏` 那条断言位置报
/// 「首屏(Kind::Boot)不许留旧页 …」),后面「每拍都跑」那条也红。`frame.exiting` 那条断言是
/// 配套的:它证明**那一帧本来会画**,所以这不是「没什么可画」的假绿。
/// 抓不住:`pages::render` 的调用点本身(单测没有 iced 布局树);那一处只认
/// 「`previous_view.is_some()` 且 `frame.exiting`」,本用例把前一半钉成 `None`。
#[test]
fn the_first_screen_never_keeps_a_previous_page_to_draw() {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(
motion::kind(),
Some(motion::Kind::Boot),
"前提:首屏起的是 Boot 档(这是「没有旧页」的那一档)"
);
assert!(
motion::frame().exiting,
"前提:Boot 的退场腿还在跑 ⇒ 没有守卫时这一帧就会把旧页画出来"
);
assert!(motion::needs_frames(), "前提:Boot 轨道还在跑");
assert!(
app.previous_view.is_none(),
"首屏(Kind::Boot)不许留旧页:boot 那一拍的 refresh 已经把零实例初始模型换进 \
previous_view,删掉 `keeps_old_page` 那半句即红"
);
// 再钉一次「不是只清了第一拍」:boot 期间又一份快照落地(换页判据会在屏幕上冒出一次
// 「屏或详情变了」)⇒ 守卫必须每一拍都生效,而不是只在 Boot 头一帧清一次。
app.previous_view = Some(pages::Model::new(
pages::Screen::Instances,
app.width,
app.height,
Some(pages::Screen::Instances.nav()),
));
let _ = app.update(Message::Frame);
assert_eq!(
motion::kind(),
Some(motion::Kind::Boot),
"一帧之后仍在 Boot 档(`exiting` 为真的窗口里)"
);
assert!(
app.previous_view.is_none(),
"首屏每一拍都不许留旧页(守卫要每拍都跑,不是只在 Boot 头一帧清一次)"
);
}
/// **正常换页仍然看得见旧页退场**(M277 的反向守卫:不许为首屏把这条一起关掉)。
///
/// Peer 换页(Instances → Settings 这一档)的旧页退场是产品要的;误关掉比幽灵更显眼。
/// 抓得住:把 `keeps_old_page` 写死成假、或把 `sync_motion` 改成无条件清 `previous_view` ⇒ 红。
#[test]
fn a_peer_page_change_still_keeps_the_old_page_for_the_exit_leg() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings)));
motion::advance_by(FRAME_MS);
assert!(
matches!(motion::kind(), Some(motion::Kind::Peer { .. })),
"前提:同级换页走 Peer 档(有旧页)"
);
assert!(motion::frame().exiting, "前提:退场腿还没走完");
assert_eq!(
app.previous_view.as_ref().map(|model| model.screen),
Some(pages::Screen::Instances),
"Instances → Settings 的旧页必须还留着(删掉旧页退场即红)"
);
}
/// `MotionScale::NONE`:既不起页面轨道,也不留旧页(两条一起钉住,M277 回归项之一)。
///
/// 抓得住:把 `sync_motion` 的 `!needs_frames()` 那半句删掉后若又给 NONE 档插了轨道;
/// 或把守卫写成「只有 Boot 才丢」却让 NONE 档留下快照(`kind()` 为 `None` 时 `is_none_or` 为真,
/// 靠 `!needs_frames()` 兜住——这一条就是那道兜底的守卫)。
#[test]
fn motion_off_leaves_neither_a_page_track_nor_a_previous_page() {
motion::reset();
motion::configure_for_test(MotionScale::NONE, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(motion::kind(), None, "关掉动效连轨道都不插");
assert!(!motion::needs_frames());
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings)));
assert!(app.previous_view.is_none(), "没有过渡就没有旧页可留");
motion::reset();
}
@@ -24,7 +24,6 @@ use unlucky_i18n::{Lang, LangChoice};
use crate::app::diag::diag;
use crate::pages;
use crate::ui::lang::LangState;
use unlucky_presenter::toast::ToastKind;
use super::*;
@@ -55,13 +54,7 @@ impl App {
self.ensure_content_loaded();
}
}
// M273 r1:自动发现的实例在**启动这一拍**说清「这一次用哪一边的运行目录」——
// 判据(盘上有没有 mods/saves)决定的是「这次启动会读到哪份存档」,用户最需要知道的
// 就是这一刻。文案走目录(`pages::instances::discovered_run_toast`),这里只搬。
pages::Message::Launch(id) => {
self.hint_discovered_run_dir(&id);
self.dispatch(AppCommand::LaunchInstance(id))
}
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
// M159:自更新动作(检查 / 下载安装 / 忽略 / 立即重启 / 开关自动检查)
pages::Message::Update(action) => return self.on_update_action(action),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
@@ -135,35 +128,6 @@ impl App {
}
impl App {
/// 自动发现实例的**运行目录提示**(M273 r1):按下启动时说清这一次用的是哪一边。
///
/// 判据全在 presenter(`isolation` 已经是「盘上有没有痕迹」的结论),这里不判盘、不猜。
/// 同一条文案已经在屏幕上时不重复叠加——Toast 只有 4 秒 TTL,连点两次不该看到两行一样的字。
fn hint_discovered_run_dir(&mut self, id: &str) {
let Some(instance) = self
.state
.instances
.iter()
.find(|instance| instance.id == id)
else {
return;
};
if !unlucky_presenter::dto::is_discovered_instance(&instance.id) {
return;
}
let text = pages::discovered_run_toast(self.lang().catalog(), &instance.isolation);
if self
.state
.toasts
.items()
.iter()
.any(|toast| toast.text == text)
{
return;
}
self.state.toasts.push(ToastKind::Info, text);
}
/// 当前生效的界面语言(= 目录选择;页面文案一律由它派生)。
pub(in crate::app) fn lang(&self) -> Lang {
self.lang.lang()
+6 -23
View File
@@ -99,14 +99,6 @@ pub fn mods_failure_line(
) -> String {
mods::failure_line(failure, Texts::of(catalog))
}
/// 自动发现的实例按下「启动」时那句提示(**这一次**用的是哪一边的运行目录)。
///
/// 取值口径只有一处:[`Texts::discovered_run`],判据是快照里的 `isolation`
/// (`shared` = 共用游戏根;其余 = 它自己的版本目录)。界面层不自己判盘。
pub fn discovered_run_toast(catalog: unlucky_i18n::Catalog, isolation: &str) -> String {
Texts::of(catalog).discovered_run(isolation).to_string()
}
pub use state::{LaunchForm, Message as InstanceMessage};
pub(super) use text::Texts;
@@ -169,7 +161,10 @@ pub fn view<'a>(
))
.action(buttons::text(
ui,
forced_state(Button::new(texts.import(), adopt_action()), forced),
forced_state(
Button::new(texts.import(), Message::Action(Action::Import)),
forced,
),
));
let body = match metrics.layout {
@@ -187,7 +182,7 @@ pub fn view<'a>(
forced,
texts,
),
None => detail::detail_empty(ui, texts, !block.rows.is_empty()),
None => detail::detail_empty(ui, texts),
},
),
_ => match &block.detail {
@@ -201,25 +196,13 @@ pub fn view<'a>(
forced,
texts,
),
None => frame::body(
metrics,
detail::detail_empty(ui, texts, !block.rows.is_empty()),
),
None => frame::body(metrics, detail::detail_empty(ui, texts)),
},
};
frame::page(ui, metrics, header, body)
}
/// 「导入已有版本」向导的入口意图——**页面里唯一产地**。
///
/// 两个入口共用它:页头的「导入」与空态的主行动(一个实例都没有时用户只看得到后一个)。
/// 落点是 `app/update/page_messages.rs` 的 `Action::Import` 臂(→ `AdoptMessage::Open`),
/// 所以中间任何一段被拆掉,向导就再也打不开——`instances/tests/empty_entry.rs` 专门钉这条链。
pub(crate) fn adopt_action() -> Message {
Message::Action(Action::Import)
}
// --------------------------------------------------------------- 列表列
fn list_column<'a>(
+1 -65
View File
@@ -47,9 +47,6 @@ pub(super) fn detail_column<'a>(
.spacing(ui.space(Space::X4))
.width(Length::Fill);
if let Some(note) = discovered_note(ui, detail, texts) {
body = body.push(note);
}
body = push_text(ui, metrics, body, detail);
// M224:MODS 区排在启动参数**之前**——详情列是一个滚动区,而启动参数卡本身就有一屏高,
// 排在它后面意味着「本地 mod 列表」永远在折叠线以下(M224 出图实测:1180×1000 完全看不见)。
@@ -98,9 +95,6 @@ pub(super) fn narrow_detail<'a>(
.spacing(ui.space(Space::X4))
.width(Length::Fill);
if let Some(note) = discovered_note(ui, detail, texts) {
body = body.push(note);
}
body = push_text(ui, metrics, body, detail);
// 顺序与宽窗那一支一致:内容(MODS)在选项(启动参数)之前,理由见 `detail_column`。
body = body.push(super::mods::section(ui, mods, forced, texts));
@@ -235,23 +229,6 @@ fn detail_head<'a>(
.into()
}
/// 自动发现的实例多一句话:**运行目录由盘上已有的文件决定**(M273 r1)。
///
/// 具体的档位就在上面的 ISOLATION 那一格(共用目录 / 独立目录),这里补的是「为什么」——
/// 用户看到「共用目录」时必须能回答「我明明在 HMCL 里给它装过 mod」。
/// 判据只有 id 前缀([`unlucky_presenter::dto::is_discovered_instance`]),界面层不自己判盘。
fn discovered_note<'a>(ui: &'a Ui, detail: &Detail, texts: Texts) -> Option<Element<'a, Message>> {
unlucky_presenter::dto::is_discovered_instance(&detail.id).then(|| {
frame::text_role(
ui,
TypeRole::BodySmall,
false,
texts.discovered_note(),
ui.color(Role::OnSurfaceVariant),
)
})
}
/// 元信息网格:列数由 [`layout::meta_columns`](按正文列宽)决定 —— **1180 与 900 都是 2 列**。
///
/// 3 列需要 `3 × META_CARD_MIN_WIDTH + 2 × META_GAP` = 504px,而行宽上限是 424,**数学上不可行**;
@@ -315,34 +292,7 @@ fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>, texts: Texts) -> Elem
.into()
}
/// 详情列的**空态**,两种含义分得很清楚:
///
/// | `has_instances` | 标题 / 说明 | 主行动 | 画在哪 |
/// |---|---|---|---|
/// | `true`(列表有东西,但没选中) | 「未选择实例」+ 「在左侧选一个」 | **无** | [`empty_not_selected`] |
/// | `false`(一个实例都没有) | 「还没有实例」+ 「导入一个已经装在盘上的版本」 | **导入已有版本**(实心按钮) | [`empty_no_instances`] |
///
/// 为什么要分开(M273):用户现场 `instances=0`,却看到「未选择实例」——那句话在暗示
/// 「你已经有的东西只是没点」,于是他找不到任何入口。一个都没有时必须给一条**看得见**的出路:
/// 接管盘上已经装好的版本。
///
/// 两个分支各自成一个函数,不是为了好看:CTA 落在哪一支、另一支有没有动作,只能这样分开断言。
/// 「传参有没有传反」由调用点那一行的源码断言守(`tests/empty_entry.rs`)——那是本函数看不见的
/// 事实,r1 复审的变异(把 `!block.rows.is_empty()` 写成 `block.rows.is_empty()`)就是从这里溜的。
pub(super) fn detail_empty<'a>(
ui: &'a Ui,
texts: Texts,
has_instances: bool,
) -> Element<'a, Message> {
if has_instances {
empty_not_selected(ui, texts)
} else {
empty_no_instances(ui, texts)
}
}
/// 有实例、但没选中:只说明「去左边点一个」,**不给动作**(动作在左列,点了就有)。
fn empty_not_selected<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
pub(super) fn detail_empty<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
frame::empty_state(
ui,
IconKind::Instances,
@@ -351,17 +301,3 @@ fn empty_not_selected<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
Vec::new(),
)
}
/// 一个实例都没有:给一条主行动(导入已有版本 = 打开已有的接管向导)。
fn empty_no_instances<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
frame::empty_state(
ui,
IconKind::Instances,
texts.empty_none_title(),
texts.empty_none_body(),
vec![buttons::filled(
ui,
Button::new(texts.adopt_cta(), super::adopt_action()),
)],
)
}
@@ -116,10 +116,6 @@ pub(super) fn section<'a>(
body = body.push(note(ui, texts.saves_loading()));
return frame::card(ui, body);
}
// 来源那一行(M274):多根之后「这个实例的存档到底在哪个目录」必须看得见。
if saves.has_view && !saves.source.is_empty() {
body = body.push(note(ui, &saves.source));
}
if let Some(error) = saves.error.as_deref() {
body = body.push(frame::text_role(
ui,
@@ -86,13 +86,6 @@ pub struct Saves {
pub retryable: bool,
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
pub has_view: bool,
/// 来源那一行(M274 多根):`存档目录:<绝对路径>`,**在这一层拼好**。
///
/// 便携目录与官方启动器是两个不同的根,两个根里都可能有一个叫 `A` 的实例、各有一批世界。
/// 世界名分辨不出来源,所以把路径原样摆出来(也是排障时要问的第一件事)。
/// 为什么不是页面里现拼:iced 的 `Element` 只借这一帧的数据,`view()` 返回的树借的是局部
/// `String`,一出栈就 `E0515`(与 [`Saves::count`] 同一条理由)。
pub source: String,
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
pub missing: bool,
/// 世界行。
@@ -169,7 +162,6 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
};
if let Some(view) = &state.view {
out.has_view = true;
out.source = texts.saves_source(&view.dir);
out.missing = !view.exists && !view.screenshots_exists;
// 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍——
// M224 出图实测过"先算计数后装列表"画出 `0 启用 · 0 禁用` 的那种顺序错,这里从根上绕开。
@@ -13,9 +13,6 @@ mod saves;
/// M252 / P18 的**回车**接线(行内重命名:回车 = 点「改名」)。
mod rename;
/// M273 的**入口接线**(空态的「导入已有版本」⇒ `AdoptMessage::Open`)。
mod empty_entry;
use super::super::layout::source;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
@@ -1,173 +0,0 @@
//! M273 · 「导入已有版本」向导的**入口接线**(空态那一半)。
//!
//! ## 为什么要单独一组
//!
//! 这条功能的失败形态不是「算错了」,而是「界面上没有入口」(M223 之后用户实测:
//! `instances=0` 的空态只有一句「未选择实例」,他找不到任何能接管 `.minecraft` 的地方)。
//! 逻辑测试抓不住这种洞——`on_adopt(Open)` 的用例一直是绿的,因为**没有人从界面派发它**。
//!
//! | 用例 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | [`the_empty_state_hands_out_the_adopt_intent`] | 空态主行动发的是**哪一条**意图(被换成新建 / 删除 / 复制就红) | 那条意图真的从按钮里发出来(`Element` 没有内省接口,见 `pages::layout::source`) |
//! | [`the_empty_state_wires_the_cta_to_that_intent`] | 空态里那枚按钮**被删掉**、被注释掉、或接到别的消息上 | 按钮画出来好不好看 / 真的可点(出图 + 人工) |
//! | [`the_intent_still_reaches_the_adopt_wizard`] | `pages::Action::Import` → `AdoptMessage::Open` 这一跳被拆掉(那正是「UI 存在但没人派发」的复发形态) | `Open` 之后向导自己的四段(`app/update/adopt.rs` 的用例覆盖) |
//!
//! 三条合起来是一条链:空态按钮 ⇒ [`adopt_action`] ⇒ `Action::Import` ⇒ `AdoptMessage::Open`。
//! 任何一段断开,这里至少红一条。
use crate::pages::layout::source;
use super::super::adopt_action;
use super::super::Texts;
use crate::pages::{Action, Message};
use unlucky_i18n::Lang;
/// 空态主行动的发的是**导入**意图(不是新建、不是删除、不是占位动作)。
#[test]
fn the_empty_state_hands_out_the_adopt_intent() {
assert_eq!(
format!("{:?}", adopt_action()),
format!("{:?}", Message::Action(Action::Import)),
"空态主行动必须发「导入已有版本」,否则向导依旧打不开"
);
let text = format!("{:?}", adopt_action());
for wrong in ["NewInstance", "Delete", "Duplicate", "OpenFolder"] {
assert!(!text.contains(wrong), "空态主行动接到了 {wrong} 上:{text}");
}
}
/// 空态的按钮**在代码里**接 `adopt_action()`(判据是代码行:注释掉等于没有)。
///
/// 两种空态还必须分开:一个实例都没有时用 [`Texts::empty_none_title`],不能再拿
/// 「未选择实例」敷衍——那句话在告诉用户「你已经有的东西只是没点」。
#[test]
fn the_empty_state_wires_the_cta_to_that_intent() {
let detail = include_str!("../detail.rs");
let body = source::function_body(detail, "fn empty_no_instances");
let cta = source::code_line(body, "Button::new(texts.adopt_cta()")
.unwrap_or_else(|| panic!("空态里没有一行**代码**建那枚「导入已有版本」按钮:\n{body}"));
assert!(
cta.contains("adopt_action()"),
"空态按钮没接 `adopt_action()`(接成别的消息 = 入口又断了):{cta}"
);
let en = Texts::of(Lang::En.catalog());
assert_ne!(
en.empty_none_title(),
en.empty_title(),
"「一个实例都没有」与「没选中」必须是两句话,否则空态又变成误导"
);
assert!(
!en.adopt_cta().is_empty(),
"空态主行动必须有可读标签(空标签 = 看不见的按钮)"
);
}
/// **CTA 只属于「一个都没有」那一支**:`has_instances == true` 的空态不许有任何动作。
///
/// r1 复审实测的缺口:把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`,于是「0 个实例」
/// 反而走「没选中」那一支、**没有按钮**,而当时的三条用例全绿。这条与下一条就是为它补的:
/// 这里钉「每个分支自己长什么样」,下一条钉「传参有没有传反」。
///
/// 抓得住:CTA 被挪到「没选中」那一支(用户有实例时反而看见「导入已有版本」);
/// 「没选中」那一支被塞进动作(`Vec::new()` 被换掉);两个分支被写成一模一样。
/// 抓不住:`frame::empty_state` 真的把那枚按钮画出来(`Element` 无内省,见 `pages::layout::source`)。
#[test]
fn the_cta_lives_only_in_the_no_instances_branch() {
let detail = include_str!("../detail.rs");
let selected = source::code_text(source::function_body(detail, "fn empty_not_selected"));
let none = source::code_text(source::function_body(detail, "fn empty_no_instances"));
assert!(
selected.contains("Vec::new()"),
"「有实例但没选中」那一支不该有任何动作(动作在左列):\n{selected}"
);
for needle in ["adopt_cta", "adopt_action"] {
assert!(
!selected.contains(needle),
"「有实例但没选中」那一支混进了 `{needle}`(CTA 挪支了):\n{selected}"
);
}
for needle in ["adopt_cta()", "adopt_action()"] {
assert!(
none.contains(needle),
"「一个实例都没有」那一支缺 `{needle}`(用户又找不到入口):\n{none}"
);
}
// 分派本身:`if has_instances` 走「没选中」,`else` 走带 CTA 的那一支。
// 行首判据 ⇒ 写成 `if !has_instances` 立刻红(那种取反翻转下面两条顺序断言看不出来)。
let dispatch = source::function_body(detail, "fn detail_empty");
assert!(
source::has_code_line(dispatch, "if has_instances {"),
"`detail_empty` 必须以 `if has_instances {{` 分派(取反 = 两个空态互换):\n{dispatch}"
);
let code = source::code_text(dispatch);
let if_at = code.find("if has_instances").expect("分派条件");
let else_at = code.find("} else {").expect("else 分支");
let selected_at = code
.find("empty_not_selected(ui, texts)")
.expect("没选中分支");
let none_at = code
.find("empty_no_instances(ui, texts)")
.expect("一个都没有分支");
assert!(
if_at < selected_at && selected_at < else_at,
"`has_instances` 为真必须走 `empty_not_selected`:\n{code}"
);
assert!(
else_at < none_at,
"`has_instances` 为假必须走 `empty_no_instances`:\n{code}"
);
}
/// **调用点**:实例页量「有没有实例」的那一行不许传反。
///
/// 这是 r1 复审的变异原样:把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`,上面的
/// 分支断言照样绿(两个分支各自都还对),但**用户看到的是错的那一支**。所以判据必须落在
/// 调用点上——宽窗与窄窗各有一条,两条都要有 `!`。
///
/// 抓得住:传参取反(`!` 被删);只改掉一处(宽窗对了、窄窗忘了);新增第三条调用点却传了别的
/// 值(条数断言只认 2 条,多出来就红)。
/// 抓不住:等价写法(`block.rows.len() == 0`)——本仓构建链一行一条,判据按行首取。
#[test]
fn the_page_asks_the_empty_state_with_the_negated_row_count() {
let page = include_str!("../../instances.rs");
let calls: Vec<String> = source::code_lines(page)
.into_iter()
.filter(|line| line.contains("detail_empty("))
.map(|line| line.trim().to_string())
.collect();
assert_eq!(
calls.len(),
2,
"实例页应当只有两条空态调用点(宽窗 + 窄窗),实际:{calls:?}"
);
for line in &calls {
assert!(
line.contains("!block.rows.is_empty()"),
"调用点把「有没有实例」传反了(`!` 丢了 ⇒ 一个都没有时反而没有按钮):{line}"
);
}
}
/// `pages::Action::Import` → `AdoptMessage::Open`:跨层的这一跳不许被拆。
///
/// 判据走 `code_text`(注释已剥):把那条 match 臂注释掉、或改成只打一行日志,这里就红——
/// 那正是「功能做了但没接线」的原始形态(M223 之前 `Action::Import` 就是一条占位动作)。
#[test]
fn the_intent_still_reaches_the_adopt_wizard() {
let router = source::code_text(include_str!("../../../app/update/page_messages.rs"));
assert!(
source::has_code_line(
include_str!("../../../app/update/page_messages.rs"),
"pages::Message::Action(pages::Action::Import) =>"
),
"`Action::Import` 在页面消息路由里没有分支了"
);
assert!(
router.contains("self.on_adopt(crate::app::adopt::AdoptMessage::Open)"),
"`Action::Import` 不再派发 `AdoptMessage::Open`(向导从界面上又变得不可达)"
);
}
@@ -48,9 +48,6 @@ const ALLOWED: &[&str] = &[
// M247:`zip` 是**归档格式名**(备份导出成什么,玩家拿去给别的东西解压时看的也是这个词)。
// 中文界面上写「压缩包」会让「备份出来的是 .zip 吗」这个问题没有答案。
"zip",
// M273 r1:`.minecraft` 是**目录名**(用户要照着去文件管理器里找的那一层)。共用目录那句
// 必须原样写它,翻成「游戏根目录」会让用户对不上盘上那个文件夹。
"minecraft",
];
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
@@ -253,39 +253,5 @@ fn millisecond_stamps_are_rendered_in_utc() {
);
}
/// **来源那一行**(M274 多根):必须是这一份快照读的那个目录,两种语言都说出来。
///
/// 为什么非要有这一行:便携目录与官方启动器是两个不同的根,两个根里都可能有一个叫 `A` 的实例。
/// 世界名分辨不出来源,只有路径能分辨(也是排障时要问的第一件事)。
///
/// 抓得住:来源行拿了别的实例 / 别的根的路径(界面上一模一样的错),或切了根却没跟着变;
/// 抓不住:页面真的把这一行画出来(`Element` 没有内省接口,见模块文档)。
#[test]
fn the_source_line_names_the_folder_that_was_read() {
let mut state = bound_state("a");
state.changed(view("a"));
let zh = from_state(&state, Lang::ZhCn.catalog());
let en = from_state(&state, Lang::En.catalog());
assert!(
zh.source.contains("C:/mc/saves"),
"来源行没带目录:{:?}",
zh.source
);
assert!(en.source.contains("C:/mc/saves"), "{:?}", en.source);
assert_ne!(zh.source, en.source, "切换语言时这一行要变");
// 换一个根(另一个 `.minecraft`)⇒ 这一行跟着变(不许留在旧路径上)。
let mut other = view("a");
other.dir = "D:/official/.minecraft/saves".to_string();
let mut state = bound_state("a");
state.changed(other);
let moved = from_state(&state, Lang::ZhCn.catalog());
assert!(
moved.source.contains("D:/official"),
"换了根之后来源行没跟着变:{:?}",
moved.source
);
}
mod preview;
mod write;
@@ -85,32 +85,6 @@ impl Texts {
pub(super) fn empty_body(self) -> &'static str {
self.text(Key::InstancesEmptyBody)
}
/// 一个实例都没有时的标题(与「有实例但没选」分开,见目录里 M273 那条注释)。
pub(super) fn empty_none_title(self) -> &'static str {
self.text(Key::InstancesEmptyNoneTitle)
}
/// 一个实例都没有时的说明。
pub(super) fn empty_none_body(self) -> &'static str {
self.text(Key::InstancesEmptyNoneBody)
}
/// 空态的主行动:「导入已有版本」。
pub(super) fn adopt_cta(self) -> &'static str {
self.text(Key::InstancesAdoptCta)
}
/// 自动发现实例的运行目录规则(详情页常驻的一句话)。
pub(super) fn discovered_note(self) -> &'static str {
self.text(Key::InstancesDiscoveredNote)
}
/// 自动发现实例**这一次**按哪一边跑(启动时弹的那一句)。
///
/// 判据只有 `isolation` 这一档:`shared` = 共用游戏根,其余(`isolated`)= 它自己的版本目录。
pub(super) fn discovered_run(self, isolation: &str) -> &'static str {
if isolation == "shared" {
self.text(Key::InstancesDiscoveredRunShared)
} else {
self.text(Key::InstancesDiscoveredRunIsolated)
}
}
pub(super) fn narrow_note(self) -> &'static str {
self.text(Key::InstancesNarrowNote)
}
@@ -37,10 +37,6 @@ impl Texts {
pub(in crate::pages::instances) fn saves_missing_dir(self) -> &'static str {
self.text(Key::InstancesSavesMissingDir)
}
/// 「这一份列表读自哪个目录」(M274 多根:世界名分辨不出来源,绝对路径可以)。
pub(in crate::pages::instances) fn saves_source(self, dir: &str) -> String {
self.0.render(Key::InstancesSavesSource, &[("dir", dir)])
}
pub(in crate::pages::instances) fn saves_worlds(self) -> &'static str {
self.text(Key::InstancesSavesWorlds)
}
+2 -2
View File
@@ -124,8 +124,8 @@ mod tests;
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
pub(crate) use instances::demo_form as demo_launch_form;
pub use instances::{
discovered_run_toast, mods_failure_line, mods_from_state, saves_delete_prompt,
saves_failure_line, saves_from_state, InstanceMessage, LaunchForm, Mods, Saves,
mods_failure_line, mods_from_state, saves_delete_prompt, saves_failure_line, saves_from_state,
InstanceMessage, LaunchForm, Mods, Saves,
};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
@@ -5,7 +5,6 @@
//! 也抓不住「目录本来就在、但被重写/清空」的情形。
use std::path::{Path, PathBuf};
use std::time::Duration;
use unlucky_core::launch::natives::{self, NativeJar, NativesPlan};
@@ -20,9 +19,8 @@ use crate::prelude::*;
/// 瞬时写入只留父目录 mtime 一条线索,而 NTFS 会把该更新合并掉 —— 本仓实测:全量并行跑
/// 测试时这条偶发看不见(单跑又看得见),所以瞬时建删只剩「`natives` 目录不存在」兜底。
///
/// 时间戳口径:**精度 ≠ 可靠性**。NTFS / ext4 的 mtime 字段精度是 100ns 级,但「两个操作能不能
/// 分辨」取决于时间戳**何时被采样、以多大粒度更新**(同内容立即重写在 NTFS 上可能落在同一系统
/// 时钟 tick 内、句柄关闭时才按粗粒度落盘),所以快照只在「有人显式改过时间戳」时才是判据。
/// 时间戳口径:NTFS / ext4 的 mtime 精度是 100ns 级,快照取纳秒整数比较,同一秒内的
/// 两次操作(写文件 → 再写一次)分辨得出来,不需要 `sleep` 把操作推到不同秒。
/// 判据自检见 `tree_snapshot_sees_new_nodes_and_mtime_changes`(防假绿)。
fn tree_snapshot(root: &Path) -> Vec<String> {
fn line(meta: &std::fs::Metadata, relative: &str) -> String {
@@ -213,22 +211,14 @@ fn dry_run_after_real_extract_reports_ready_without_writing() {
///
/// 这里**不**用「建了立刻删」来探父目录 mtime:NTFS 会把瞬时建删对父目录 mtime 的影响
/// 合并掉(本仓实测:全量并行跑时偶发看不见、单跑看得见),拿它当门禁就是引入 flaky。
///
/// 时间维度也**不**靠「同内容立刻重写」:NTFS 上 mtime 不保证「每次写都变」——同一个系统
/// 时钟 tick 内的两次写、或时间戳只在句柄关闭时按粗粒度落盘,都会让重写后的 mtime 与写之前
/// **逐字节相同**。M275 的实际失败正是这一条:main 上 `test (windows-latest)` 偶发红在这行
/// `assert_ne!`(`crates\core\tests\launch\natives_dry_run_cases.rs:241`),本机永远复现不出来。
/// `sleep` + 重试轮询只是把概率压低(把「不可靠观察」换成「不可靠观察 + 更长的墙钟」),
/// 不是消除。确定性判据是**显式时间戳**:`File::set_modified` 把 mtime 直接写成
/// 「基线 + 5s」——值由我们指定,与时钟 tick / 落盘粒度无关;快照若看不见它,说明快照真的
/// 丢了时间维度,而不是运气不好。
/// 改成两种确定性变化:新节点(结构维度)+ 同内容重写(时间维度,只有文件 mtime 变,
/// 不依赖父目录时间戳的合并行为)。
#[test]
fn tree_snapshot_sees_new_nodes_and_mtime_changes() {
let root = temp_dir("natives-snapshot-probe");
let version_dir = root.join("versions/1.16.5");
std::fs::create_dir_all(&version_dir).expect("建版本目录");
let manifest = version_dir.join("1.16.5.json");
std::fs::write(&manifest, b"{}").expect("放清单占位");
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("放清单占位");
let baseline = tree_snapshot(&version_dir);
// 1) 结构维度:多一个目录节点(正是 `create_dir_all(natives)` 留下的痕迹)。
@@ -240,20 +230,8 @@ fn tree_snapshot_sees_new_nodes_and_mtime_changes() {
);
assert_ne!(baseline, with_dir);
// 2) 时间维度:显式把清单文件的 mtime 推到基线之后(节点集合不变 ⇒ 差异只可能来自时间戳)。
// 不改内容长度(那动的是 size,不是 mtime)、不 `sleep`、不重试。
let before = std::fs::metadata(&manifest)
.expect("清单元数据")
.modified()
.expect("基线 mtime");
let pushed = before + Duration::from_secs(5);
let file = std::fs::File::options()
.write(true)
.open(&manifest)
.expect("打开清单占位");
file.set_modified(pushed).expect("把 mtime 推到基线之后");
drop(file);
// 2) 时间维度:同一份内容重写一遍,节点集合不变、文件 mtime 必须变。
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("重写清单占位");
let rewritten = tree_snapshot(&version_dir);
assert_eq!(
with_dir.len(),
@@ -264,14 +242,4 @@ fn tree_snapshot_sees_new_nodes_and_mtime_changes() {
with_dir, rewritten,
"快照必须看得见已有文件的 mtime 变化,否则零落盘断言是假绿"
);
// 差异必须是我们推的那一下,而不是两次 `stat` 的偶然抖动(粗粒度文件系统会把 5s 取整到
// 4s 或 6s,所以判据是「至少前进 4s」而不是「恰好 5s」)。
let after = std::fs::metadata(&manifest)
.expect("清单元数据")
.modified()
.expect("推进后的 mtime");
assert!(
after.duration_since(before).expect("mtime 应当前进了") >= Duration::from_secs(4),
"mtime 应当被推到基线之后:{before:?} → {after:?}"
);
}
-7
View File
@@ -154,13 +154,6 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("InstancesSavesPreviewTooLarge", "too large for a preview (over 8 MiB)", "文件过大,未生成预览(超过 8 MiB)"),
("InstancesSavesPreviewUnreadable", "the file could not be read", "读不出这张图"),
("InstancesSavesPreviewDecode", "not a decodable image", "解不出这张图(可能已损坏或格式不支持)"),
("InstancesEmptyNoneTitle", "No instances yet", "还没有实例"),
("InstancesEmptyNoneBody", "Nothing has been registered yet. Import a version that is already on disk to get started.", "这里还没有实例。导入一个已经装在盘上的版本就能开始。"),
("InstancesAdoptCta", "Import existing version", "导入已有版本"),
("InstancesDiscoveredNote", "Discovered in versions/: where it runs is decided by the files that are already there.", "在版本目录里自动发现:它从哪里启动,由盘上已有的文件决定。"),
("InstancesDiscoveredRunShared", "Detected instance: it has no mods/ or saves/ of its own, so this launch uses the .minecraft folder (shared).", "自动发现的实例:它的版本目录里没有 mods/ 或 saves/,这次启动按共用目录运行(.minecraft 根目录)。"),
("InstancesDiscoveredRunIsolated", "Detected instance: it already has its own mods/ or saves/, so this launch uses its version folder (isolated).", "自动发现的实例:它的版本目录里已经有 mods/ 或 saves/,这次启动按独立目录运行(它自己的版本文件夹)。"),
("InstancesSavesSource", "Saves folder: {dir}", "存档目录:{dir}"),
];
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
-20
View File
@@ -216,26 +216,6 @@ macro_rules! instances_entries {
InstancesSavesPreviewTooLarge => ("too large for a preview (over 8 MiB)", "文件过大,未生成预览(超过 8 MiB)"),
InstancesSavesPreviewUnreadable => ("the file could not be read", "读不出这张图"),
InstancesSavesPreviewDecode => ("not a decodable image", "解不出这张图(可能已损坏或格式不支持)"),
// ── 空态(M273 · ADOPTWIRE)。
// 原来空态只有一句「未选择实例」——那是对「有实例但没点」说的。**一个实例都没有**时
// 同一条文案在骗人:用户看到的是「未选择」,以为自己漏点了什么。现在两种空态分开,
// 「一个都没有」那一态给一条主行动(导入已有版本),否则用户根本不知道还能接管
// 别人 `.minecraft` 里已经装好的实例。
InstancesEmptyNoneTitle => ("No instances yet", "还没有实例"),
InstancesEmptyNoneBody => ("Nothing has been registered yet. Import a version that is already on disk to get started.", "这里还没有实例。导入一个已经装在盘上的版本就能开始。"),
InstancesAdoptCta => ("Import existing version", "导入已有版本"),
// ── 自动发现实例的**运行目录判据**(M273 r1 · P2-1)。
// 判据是盘上的事实(`versions/<id>/` 里有没有 mods / saves / config / options.txt),
// 不是我们的偏好。必须让用户看得见,否则「为什么我的存档不在了」无从回答:
// 一句在详情页(常驻,说明规则),两句在启动那一拍(说明**这一次**用的是哪一边)。
InstancesDiscoveredNote => ("Discovered in versions/: where it runs is decided by the files that are already there.", "在版本目录里自动发现:它从哪里启动,由盘上已有的文件决定。"),
InstancesDiscoveredRunShared => ("Detected instance: it has no mods/ or saves/ of its own, so this launch uses the .minecraft folder (shared).", "自动发现的实例:它的版本目录里没有 mods/ 或 saves/,这次启动按共用目录运行(.minecraft 根目录)。"),
InstancesDiscoveredRunIsolated => ("Detected instance: it already has its own mods/ or saves/, so this launch uses its version folder (isolated).", "自动发现的实例:它的版本目录里已经有 mods/ 或 saves/,这次启动按独立目录运行(它自己的版本文件夹)。"),
// ── 多根发现(M274):存档区标明**这一份列表读自哪个目录**。
// 一打开启动器就能看见两个 `.minecraft`(便携目录 + 官方启动器那个)各自的实例,
// 两个根都可能有一个叫 `A` 的实例、各有一批世界。世界名本身分辨不出来源,
// 所以这份列表把**绝对路径**摆出来:那是唯一不会说谎的来源标识(也是排障要的东西)。
InstancesSavesSource => ("Saves folder: {dir}", "存档目录:{dir}"),
} }
};
}
+4 -53
View File
@@ -7,13 +7,10 @@ use crate::host::CoreHost;
use crate::host::HostEnv;
use crate::mock::MockBackend;
/// 启动选项(`--data-dir` 与 `--mock` 是产品开关;截图/演示也复用同一份选项)。
/// 启动选项(截图/演示通道用;正常启动全用默认值)。
#[derive(Debug, Clone, Default)]
pub struct AppOptions {
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析。
///
/// 走 [`HostEnv::at_data_root`]:**照常**发现官方启动器的标准游戏目录(M274)。钉数据根
/// 不等于「别看官方实例」——那是两个不同的开关。
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析
pub data_root: Option<PathBuf>,
/// 强制 mock 后端(UI 回归/无数据根演示)
pub mock: bool,
@@ -24,62 +21,16 @@ pub fn config_path(root: &Path) -> PathBuf {
root.join(".ULL").join("config").join("launcher.json")
}
/// 钉了数据根时用哪个环境(`None` = 没钉,走解析链)。
///
/// **单独一格只为可测**:`--data-dir` 是产品通道,钉了数据根照样要看见官方启动器里的实例
/// (M274 r1 复审 P2-2)。退回 [`HostEnv::at_root`] 会让用户**静默**丢一半实例,而这层
/// `Box<dyn Backend>` 在用例里没有 env 取回口(`Backend` 没有访问器),所以判据就落在这个
/// 纯函数上(`backend::tests::data_root_keeps_discovering_the_official_launcher`)。
fn host_env_for(options: &AppOptions) -> Option<HostEnv> {
options.data_root.as_ref().map(HostEnv::at_data_root)
}
/// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。
pub fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
if options.mock {
return Box::new(MockBackend::new());
}
match host_env_for(options) {
Some(env) => Box::new(CoreHost::new(env)),
match &options.data_root {
Some(root) => Box::new(CoreHost::new(HostEnv::at_root(root))),
None => match CoreHost::open() {
Some(host) => Box::new(host),
None => Box::new(MockBackend::new()),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `--data-dir`(产品通道)**保留**多根发现;`at_root`(截图/演示/用例)不保留。
///
/// 抓得住:`host_env_for` 被改回 `HostEnv::at_root`——用户钉一个数据根就永久静默看不到
/// 官方启动器里的实例(M274 r1 复审 P2-2)。
/// 抓不住:`--data-dir` 的命令行解析(app 侧);mounted 的数据根真的解析得出来(那是 resolver)。
#[test]
fn data_root_keeps_discovering_the_official_launcher() {
let root = std::env::temp_dir().join(format!("ull-backend-m274-{}", std::process::id()));
let options = AppOptions {
data_root: Some(root.clone()),
mock: false,
};
let env = host_env_for(&options).expect("钉了数据根就该有环境");
assert_eq!(
env.game.root(),
root.join(".minecraft"),
"落点与 `at_root` 一致"
);
assert_eq!(
env.standard_game,
unlucky_core::platform::paths::standard_game_home(
unlucky_core::platform::paths::Platform::current(),
&unlucky_core::platform::paths::ProcessEnv,
),
"`--data-dir` 是产品通道:不许顺手关掉官方根发现"
);
// 没钉数据根时这一格不参与(走解析链),`mock` 在前一步就分走了。
assert!(host_env_for(&AppOptions::default()).is_none());
std::fs::remove_dir_all(&root).ok();
}
}
-15
View File
@@ -26,18 +26,3 @@ pub use super::launch_failure::{LaunchFailureCause, LaunchFailureStage, LaunchFa
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
pub type InstanceId = String;
/// **自动发现**实例的 id 前缀(M273):`versions/<id>` 里扫出来、没有档案的版本用
/// `ext-<hash>` 作实例 id。
///
/// 为什么让它是公开常量而不给 [`InstanceSummary`] 加字段:加字段要改 24 处结构体字面量
/// (横跨 presenter / agent / cli / app-iced),而「这个实例是不是自动发现的」在界面上的
/// 用途只有一处——详情页那句「为什么它按独立目录 / 共用目录跑」。前缀是**稳定契约**,
/// 由 `host::game` 的 `external_instance_id` 唯一生产(M274 多根时前缀不变,哈希里加根)。
pub const DISCOVERED_ID_PREFIX: &str = "ext-";
/// 这个实例是不是「从盘上自动发现」的(没有用户 / 向导建过的档案)。
#[must_use]
pub fn is_discovered_instance(id: &str) -> bool {
id.starts_with(DISCOVERED_ID_PREFIX)
}
+2 -3
View File
@@ -61,9 +61,8 @@ pub use install::{
InstallStage,
};
pub use instances::{
is_discovered_instance, CreateRequest, InstanceId, InstanceRuntime, InstanceSummary,
LaunchFailureCause, LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind,
Progress, DISCOVERED_ID_PREFIX,
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause,
LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress,
};
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
+9 -6
View File
@@ -288,15 +288,18 @@ fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, ContentFail
.ok_or_else(|| local(format!("找不到实例 {id}")))
}
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 基底档,见 `game::base_mode`)。
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, ContentFailure> {
// 装进**这个实例自己那个根**的运行目录(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 global = super::super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id)
.map_err(|error| local(format!("读实例设置失败:{error}")))?;
derive_game_dir(&game, &instance.version_id, base, config.as_ref(), None)
derive_game_dir(
&env.game,
&instance.version_id,
global,
config.as_ref(),
None,
)
.map(|derivation| derivation.game_dir)
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
}
+11 -7
View File
@@ -280,17 +280,22 @@ pub(super) fn send(tx: &JobSender, epoch: u64, event: PackEvent) {
});
}
/// 实例 → 运行目录(与 `addon/install.rs` 同一条派生:实例隔离档 > 基底档)。
/// 实例 → 运行目录(与 `addon/install.rs` 同一条派生:实例隔离档 > 全局档)。
pub(super) fn instance_root(env: &HostEnv, id: &InstanceId) -> Result<PathBuf, String> {
let instance = super::super::game::instances(env)
.into_iter()
.find(|instance| &instance.id == id)
.ok_or_else(|| format!("instance not found: {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 global = super::super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id)
.map_err(|error| format!("read instance config failed: {error}"))?;
derive_game_dir(&game, &instance.version_id, base, config.as_ref(), None)
derive_game_dir(
&env.game,
&instance.version_id,
global,
config.as_ref(),
None,
)
.map(|derivation| derivation.game_dir)
.map_err(|error| format!("derive game dir failed: {error}"))
}
@@ -301,9 +306,8 @@ pub(super) fn snapshot_of(env: &HostEnv, id: &InstanceId) -> Option<InstanceSnap
let instance = super::super::game::instances(env)
.into_iter()
.find(|instance| &instance.id == id)?;
// 隔离实例的 `versions/` 在**它自己那个根**下(M274:官方根里的版本清单不在配置根下)
let game = super::super::game::game_for(env, &instance.id);
read_instance_snapshot(game.root(), Some(&instance.version_id)).ok()
// 隔离实例的 `versions/` 在全局根下,所以试全局根
read_instance_snapshot(env.game.root(), Some(&instance.version_id)).ok()
}
/// 格式视图 → core 的静态格式(MultiMC 只读,不能当导出目标)。
+12 -94
View File
@@ -30,16 +30,10 @@ pub struct HostEnv {
pub data: ResolvedData,
/// `.ULL/` 布局
pub layout: LauncherLayout,
/// `.minecraft/` 布局(**配置的这个根**;多根发现的判据在 `game/roots.rs`)
/// `.minecraft/` 布局
pub game: GameLayout,
/// 平台
pub platform: Platform,
/// 官方启动器的标准游戏目录(`paths::standard_game_home`):多根发现的**第二个候选**。
///
/// `None` = 不去发现([`HostEnv::at_root`] 钉根时就是这样:截图/演示/用例钉了一个根,
/// 就不该顺手把用户真实的 `%APPDATA%\.minecraft` 也读进来,否则同一份用例在不同机器上
/// 结果不同)。生产路径由 [`HostEnv::from_data`] 填。
pub standard_game: Option<PathBuf>,
}
impl HostEnv {
@@ -54,33 +48,20 @@ impl HostEnv {
Some(Self::from_data(data))
}
/// **钉根但照常做多根发现**:`--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)。
/// 直接钉一个根:`<root>/.ULL` + `<root>/.minecraft`(截图/演示通道)。
///
/// 根下已有 `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 mut env = Self::at_data_root(root);
// 钉一个根的**测试/截图**通道不做多根发现:见 `standard_game` 的文档。
env.standard_game = None;
env
let root = root.as_ref();
let request = request_from_portable(root);
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
Self::from_data(ResolvedData {
mode: DataMode::Portable,
launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")),
game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")),
redirected_from: None,
})
}
/// 由解析结果建环境。
@@ -93,17 +74,10 @@ impl HostEnv {
pub fn from_data(data: ResolvedData) -> Self {
let layout = data.layout();
unlucky_core::launch::launcher_log::set_dir(layout.logs());
let platform = Platform::current();
Self {
layout,
game: GameLayout::new(data.game_home.clone()),
platform,
// M274:多根发现的第二个候选永远是**官方启动器**的标准位置(HMCL / PCL 用的是
// 同一个地方),与配置的 `game_home` 是不是同一个由 `roots::all` 的去重判据管。
standard_game: unlucky_core::platform::paths::standard_game_home(
platform,
&unlucky_core::platform::paths::ProcessEnv,
),
platform: Platform::current(),
data,
}
}
@@ -149,18 +123,6 @@ 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();
@@ -206,50 +168,6 @@ mod tests {
std::fs::remove_dir_all(&root).ok();
}
/// **多根发现的两条入口必须分开**(M274 r1 复审 P2-2):
///
/// | 入口 | 谁用 | `standard_game` |
/// |---|---|---|
/// | [`HostEnv::at_data_root`] | 产品 `--data-dir`(`backend.rs`) | `Some(官方位置)`:钉数据根 ≠ 别看官方实例 |
/// | [`HostEnv::at_root`] | 截图 / 演示 / 七十多处用例 | `None`:同一份用例在任何机器上结果一样 |
/// | [`HostEnv::from_data`] | 生产解析链(便携 / 标准 / 自定义) | `Some(官方位置)` |
///
/// 抓得住:`--data-dir` 退回 `at_root`(用户钉数据根后永久看不到官方实例,且没有任何报错)、
/// 或 `at_root` 顺手开始发现(用例在装了官方启动器的机器上变红/变绿)。
/// 抓不住:`paths::standard_game_home` 自己算错(那是 core 的用例)。
///
/// 「两个入口不会各自漂开」是**结构性**的:`at_root` 委托 `at_data_root` 再关一格,
/// 不是复制一份构造——所以下面那三行落点断言只是把这条结构钉成行为,而不是唯一防线。
#[test]
fn only_the_product_entries_discover_the_official_root() {
let root = std::env::temp_dir().join(format!("ull-host-env-m274-{}", std::process::id()));
let expected = unlucky_core::platform::paths::standard_game_home(
Platform::current(),
&unlucky_core::platform::paths::ProcessEnv,
);
assert!(
expected.is_some(),
"这个平台连标准游戏目录都算不出来,用例的前提不成立"
);
assert_eq!(
HostEnv::at_root(&root).standard_game,
None,
"截图/用例钉根不许做多根发现"
);
assert_eq!(
HostEnv::at_data_root(&root).standard_game,
expected,
"`--data-dir` 是产品通道:钉数据根不等于别看官方实例"
);
// 两条入口除了 `standard_game` 之外必须**完全一致**(落点、模式)。
let pinned = HostEnv::at_root(&root);
let product = HostEnv::at_data_root(&root);
assert_eq!(pinned.layout.home(), product.layout.home());
assert_eq!(pinned.game.root(), product.game.root());
std::fs::remove_dir_all(&root).ok();
}
/// M188 的守门②:**日志根随数据模式变化**(便携 / 标准 / 自定义各一条断言)。
///
/// 三条走的都是 `HostEnv` 真正用的那条解析链(`Resolver` → `ResolvedData::layout`):
+25 -163
View File
@@ -1,24 +1,17 @@
//! 实例快照:`profiles.json` ∪ 盘上的 `versions/*` + 版本 JSON + 会话索引 + 实例设置
//! → [`InstanceSummary`]。
//! 实例快照:`profiles.json` + 版本 JSON + 会话索引 + 实例设置 → [`InstanceSummary`]。
//!
//! 「磁贴上的每个字」都在这里翻译完:版本号取自继承链根、加载器取自版本 id(解析在
//! [`unlucky_core::game::loader`],本文件只做 DTO 翻译)、最后游玩时间取自会话索引、
//! 隔离档位取自实例设置(缺省跟全局 `instances.mode`)。
//!
//! **列表是并集**(M273):档案 + 盘上有清单但没有档案绑定的版本目录,见 [`instances`] 的文档。
//!
//! **根是多个**(M274):配置的 `game_home` + 官方启动器的标准位置(`%APPDATA%\.minecraft` 等),
//! 规范化真实路径去重([`roots`])。每个自动发现的实例因此带**自己的根**——读版本清单、派生运行
//! 目录、列存档 / 模组都走 [`game_for`],不全是 `env.game`。
use std::collections::BTreeMap;
use std::path::Path;
use unlucky_core::game::install::instance::{
read_instance_config, read_profiles, write_instance_config, write_profiles,
read_instance_config, read_profiles, write_instance_config,
};
use unlucky_core::game::isolation::IsolationMode;
use unlucky_core::game::{GameLayout, InstanceProfile, ProfileId, ProfileList, VersionJson};
use unlucky_core::game::{ProfileList, VersionJson};
use crate::dto::{
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
@@ -26,13 +19,6 @@ use crate::dto::{
use super::env::HostEnv;
mod discovered;
mod roots;
pub(super) use discovered::{base_mode, game_for};
use discovered::{display_name, external_summary, external_version_of};
/// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。
pub fn global_mode(env: &HostEnv) -> IsolationMode {
env.store()
@@ -64,15 +50,13 @@ pub fn last_played(env: &HostEnv) -> BTreeMap<String, String> {
}
/// 版本 JSON 的继承链根 id + 加载器(读不到返回 `(version_id, 原版)`)。
///
/// **按传入的那个根读**(M274):外部实例的版本清单在自己那个根下,拿配置根去读会读空。
pub fn describe_version(game: &GameLayout, version_id: &str) -> (String, LoaderInfo) {
pub fn describe_version(env: &HostEnv, version_id: &str) -> (String, LoaderInfo) {
let loader = loader_from_id(version_id);
let mut current = version_id.to_string();
let mut seen = 0;
while seen < 16 {
seen += 1;
let path = game.version_json(&current);
let path = env.game.version_json(&current);
let Ok(text) = std::fs::read_to_string(&path) else {
break;
};
@@ -121,105 +105,25 @@ fn dto_kind(kind: unlucky_core::game::LoaderKind) -> LoaderKind {
})
}
/// 实例的隔离档位(实例文件优先,缺省 = [`base_mode`])。
///
/// `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);
/// 实例的隔离档位(实例文件优先,缺省显示全局档位)。
pub fn isolation_of(env: &HostEnv, id: &str) -> String {
let global = global_mode(env);
match read_instance_config(&env.layout, id) {
Ok(Some(config)) => config.effective_isolation(base).as_str().to_string(),
_ => base.as_str().to_string(),
Ok(Some(config)) => config.effective_isolation(global).as_str().to_string(),
_ => global.as_str().to_string(),
}
}
/// 实例快照:**档案 ∪ 盘上的版本目录**(M273),版本目录来自**多个游戏根**(M274)。
///
/// ## 为什么是并集(M273)
///
/// 用户实测:HMCL 能看见 `<.minecraft>/versions/樱落怡然客户端`,ULL 的实例页却是空的——
/// 因为这里以前只列 `profiles.json` 里的档案。第三方启动器(HMCL / PCL / 官方启动器)把实例
/// 存在 `versions/<id>/<id>.json`,**没有**我们的档案记录。只读档案 ⇒ 已装好的实例在界面上
/// 不存在,用户以为「读不到这个 .minecraft」。
///
/// ## 为什么是多个根(M274)
///
/// 官方启动器用的是 `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `<exe>/.minecraft`——**两个
/// 不同的目录**。只扫配置根等于「装了官方启动器也看不见它的实例」。所以这里遍历
/// `roots::all(env)`:配置根 + 官方启动器的标准位置(规范化真实路径去重,见 `roots::key_of`)。
///
/// 那一批根里**只有配置根**有档案语义:`profiles.json` 的 `version_id` 指的是配置根下的版本,
/// 所以「已被档案绑定 ⇒ 不重复列」这条只对配置根成立;别的根里的同名版本是**另一个实例**
/// (id 里带着根,见 `discovered::external_instance_id`)。
///
/// ## 三条不变量
///
/// 1. **只读**:合成视图不建目录、不写 `profiles.json`、不碰任何 `versions/` 里一个字节
/// (`instances()` 会被每次刷新调用,写盘会变成「一打开启动器就改别人的文件夹」);
/// 2. **档案优先**:配置根里某个版本已经有档案绑定(不论档案 id 是什么)⇒ 只按档案出**一条**,
/// 不重复挂合成视图(用户改过的名字/隔离档不会因为盘上有同名版本被顶掉);
/// 3. **坏东西不当实例**:清单缺 / JSON 坏 / `id` 与目录名不一致 / 缺父版本 / 越界符号链接
/// ⇒ 不进列表(`AdoptCandidate::adoptable()`);某个根整个读不出来也只是少列它那几条,
/// 不让实例页因此报错。
/// 实例快照。
pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
let profiles = profiles(env);
let played = last_played(env);
// **每个根只扫一遍**(M274 r1 复审 P2-1):这一拍的「根 × 版本」清单读一次,之后
// ①每份档案属于哪个根、②它是否占住配置根的同名版本,都是内存查表。
// 反查写进循环里是 `#根 × (1 + #ext 档案)` 次全树扫描——官方根 200 版本 + 10 份 ext 档案
// 时 `instances()` 会从 236ms 涨到 1.35s(复审实测 ×5.7)。
let found = discovered::Found::scan(env);
let mut homes: Vec<Option<GameLayout>> = Vec::with_capacity(profiles.profiles.len());
let mut bound: Vec<&str> = Vec::new();
for profile in &profiles.profiles {
// 两件事用**同一份**判据(`binds_configured_root` 与写盘那道门共用一个函数):
// ① 这条档案属于哪个根(磁贴按它描述自己);② 它是否占住配置根的同名版本。
if discovered::binds_configured_root(&found, profile) {
bound.push(profile.version_id.as_str());
}
homes.push(found.game_of(profile.id.as_str()).cloned());
}
let mut snapshot: Vec<InstanceSummary> = profiles
profiles
.profiles
.iter()
.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, versions) in found.roots() {
let configured = root.configured();
for version_id in versions {
let id = discovered::external_instance_id(root.key(), version_id);
if known.contains(&id.as_str()) {
continue;
}
if configured && bound.contains(&version_id.as_str()) {
continue;
}
snapshot.push(external_summary(env, root, version_id, &played));
}
}
snapshot
}
/// 一条档案 → 磁贴(档案路径的唯一翻译点)。
///
/// `home` 是这条档案**自己那个根**([`discovered::Found`] 查出来的;`None` = 配置根):
/// 懒创建出来的 `ext-` 档案可能属于官方根,那时「版本在不在盘上 / 隔离档 / MC 版本 / 版本门槛」
/// 都必须按那个根算——否则磁贴说「文件缺失 · 共用目录」,而存档与启动实际读的是官方根
/// (M274 r1 复审 P1-1:显示与行为分叉,最坏的一种)。
fn profile_summary(
env: &HostEnv,
profile: &InstanceProfile,
home: Option<&GameLayout>,
played: &BTreeMap<String, String>,
) -> InstanceSummary {
.map(|profile| {
let version_id = profile.version_id.clone();
let game = home.unwrap_or(&env.game);
let (mc_version, loader) = describe_version(game, &version_id);
let (mc_version, loader) = describe_version(env, &version_id);
let id = profile.id.as_str();
InstanceSummary {
id: id.to_string(),
@@ -229,60 +133,29 @@ fn profile_summary(
last_played: played.get(id).cloned(),
runtime: InstanceRuntime::Idle,
icon: None,
isolation: isolation_of(env, game, id, &version_id),
version_present: game.is_version_installed(&version_id),
launch: super::launch_args::view(env, game, id, &version_id),
isolation: isolation_of(env, id),
version_present: env.game.is_version_installed(&version_id),
launch: super::launch_args::view(env, id, &version_id),
version_id,
}
}
/// 拿实例的档案;外部实例(盘上有版本、没有档案)**就地补一条并落盘**。
///
/// 只在「用户第一次改这个实例」时才走到这里([`set_isolation`] / 启动参数写回),于是
/// 「读」永远不写盘:`instances()` 合成视图、启动、改运行目录都不碰 `profiles.json`。
///
/// 幂等:同样的 `id` 第二次调用直接返回已落盘的那条,不会长出第二条。
///
/// # Errors
///
/// 档案既不存在、盘上也找不到对应版本时返回用户可读文案;读写 `profiles.json` 失败同样。
pub(super) fn ensure_profile(env: &HostEnv, id: &str) -> Result<InstanceProfile, String> {
let mut list =
read_profiles(&env.layout).map_err(|error| format!("读实例清单失败:{error}"))?;
if let Some(existing) = list
.profiles
.iter()
.find(|profile| profile.id.as_str() == id)
{
return Ok(existing.clone());
}
let Some((root, version_id)) = external_version_of(env, &list, id) else {
return Err(format!("找不到实例 `{id}`"));
};
let profile = InstanceProfile {
id: ProfileId::new(id),
// 显示名与磁贴同源:别的根里的同名版本带上根路径,否则两条磁贴分不清(M274)。
name: display_name(&root, &version_id),
version_id,
..InstanceProfile::default()
};
list.profiles.push(profile.clone());
write_profiles(&env.layout, &list).map_err(|error| format!("写实例清单失败:{error}"))?;
Ok(profile)
})
.collect()
}
/// 改实例隔离档位(写实例设置文件;不动任何数据)。
///
/// 外部实例(盘上有版本、还没有档案)**第一次**改档位时才懒创建档案并落盘
/// ([`ensure_profile`])——在那之前,浏览列表不会在用户的盘上多出任何文件。
///
/// # Errors
///
/// 实例不存在、文件读不了/写不了时返回用户可读文案。
pub fn set_isolation(env: &HostEnv, id: &str, mode: &str) -> Result<(), String> {
let parsed = IsolationMode::parse(mode)
.ok_or_else(|| format!("未知隔离档位 `{mode}`(isolated / shared)"))?;
let profile = ensure_profile(env, id)?;
let profiles = profiles(env);
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == id)
.ok_or_else(|| format!("找不到实例 `{id}`"))?;
let mut config = read_instance_config(&env.layout, id)
.map_err(|error| format!("读实例设置失败:{error}"))?
.unwrap_or_else(|| {
@@ -333,14 +206,3 @@ pub fn short_path(path: &Path) -> String {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_union;
#[cfg(test)]
mod tests_multiroot;
#[cfg(test)]
mod tests_multiroot2;
#[cfg(test)]
mod tests_multiroot3;
@@ -1,300 +0,0 @@
//! 自动发现的实例(M273 · 多根 M274):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
//!
//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件三件事:
//!
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析(判据来自 `core::game::adopt::scan`;
//! 本文件只负责「每个根扫一遍」并把结果装进 [`Found`]);
//! 2. **它们从哪个目录跑**:[`base_mode`]——`versions/<id>/` 里有痕迹(`mods` / `saves` /
//! `config` / `options.txt`)就按独立目录,否则按共用目录(**它自己那个根**);
//! 3. **身份与判据**:[`external_instance_id`](id 里带根)、[`game_for`](读盘走哪个根)、
//! [`binds_configured_root`](这条档案占住的是**配置根**里那个版本吗——列表侧与写盘那道门共用)。
//!
//! 「哪个版本已经被档案绑定」这一步**不在本文件**:它属于 [`super::instances`] 的并集规则,
//! 而写盘那道门([`external_version_of`])用的是同一个 [`binds_configured_root`]。
//!
//! # 多根(M274):身份 = (根, 版本)
//!
//! 两个根里都可能有 `1.20.4`,所以版本 id **不再唯一**。身份的落点只有一个:
//! [`external_instance_id`](把根的规范化路径喂进哈希)。于是「同一个根 + 同一个版本目录」跨重启
//! 拿到同一个 id,而两个根里的同名版本拿到两个不同 id——列表自然出两条。
//!
//! 反查(id → 根 + 版本)只能靠**重算**:哈希不可逆,所以 [`Found::scan`] 枚举全部根、每个根扫一遍
//! `versions/`,逐个比 id。**一拍只扫一遍**:`instances()` 建一份 [`Found`] 给整轮用(扫描次数 =
//! #根,r1 复审实测过旧形状是 `#根 × (1 + #ext 档案)`),单实例动作(存档 / 模组 / 启动 / 改设置)
//! 各建一份(同样是 #根 次)。
//!
//! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节,
//! 更不往官方启动器的根里写任何东西。
use std::collections::BTreeMap;
use unlucky_core::game::isolation::IsolationMode;
use unlucky_core::game::{GameLayout, InstanceProfile, ProfileList};
use crate::dto::{InstanceRuntime, InstanceSummary};
use super::super::env::HostEnv;
use super::roots::{self, GameRoot};
use super::{describe_version, global_mode, isolation_of};
/// `versions/<version_id>/` 里这些名字出现任何一个,就说明这个版本本来就被当成
/// **独立目录**在用(它自己的 mods / 存档 / 配置都在里面)。判据只 `exists()`,不读内容。
///
/// 为什么是这四个:HMCL / PCL / 官方启动器的「版本隔离」都把运行目录放在 `versions/<id>/`,
/// 于是跑过一次就会留下 `mods/` `saves/` `config/` `options.txt` 中的至少一个。
const RUN_DIR_TRACES: [&str; 4] = ["mods", "saves", "config", "options.txt"];
/// 版本目录里有没有「它自己用过」的痕迹(只读)。
///
/// **必须传这个实例自己的根**:同一个版本 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())
}
/// 实例的**基底隔离档**(实例文件之下那一层)。
///
/// | 实例 | 基底 | 依据 |
/// |---|---|---|
/// | 有档案(向导 / 用户 / AI 建的) | 全局 `instances.mode` | D20 原口径,一字不变 |
/// | **自动发现的**(id 带 [`crate::dto::DISCOVERED_ID_PREFIX`]) | `versions/<id>` 里有痕迹 ⇒ `isolated`;否则 ⇒ `shared` | 「别人怎么存我们就怎么读」 |
///
/// 为什么自动发现的实例不能一律 `isolated`:「版本隔离」下 `isolated` 的**运行目录就是**
/// `versions/<id>/`。一个从没跑过、也没有 `mods`/`saves` 的外部版本若按 `isolated` 处理,
/// 第一次启动就会在**别人的版本目录**里新长出 `mods/` `saves/` `config/`(D20 的
/// `AdoptTarget` 缺省值正是为了躲开这件事而选 `InstanceDir`)。反过来,盘上已经有这些痕迹的
/// 版本本来就是独立目录实例,按 `shared` 处理会让它突然读到根下别人的存档 ——
/// 两种错都真实存在,所以判据只能是**盘上的事实**。
///
/// **M274**:痕迹按**这个实例自己的根**判(`game` 参数)。换根之后判定不变:官方根里那个
/// 带 `mods/` 的版本仍是 `isolated`,ULL 根里那个干净的版本仍是 `shared`。
///
/// 只读:这里不建目录、不写任何文件(写盘只发生在用户第一次改设置时,见 [`super::ensure_profile`])。
pub(in crate::host) fn base_mode(
env: &HostEnv,
game: &GameLayout,
id: &str,
version_id: &str,
) -> IsolationMode {
if !crate::dto::is_discovered_instance(id) {
return global_mode(env);
}
if has_own_run_dir(game, version_id) {
IsolationMode::Isolated
} else {
IsolationMode::Shared
}
}
/// 一个根下「盘上有版本、可登记」的那些版本 id(字典序,来自 core 的扫描)。
///
/// 扫描失败(`versions/` 读不出来 / 断链联接)时返回空:列表退回「只有档案」的旧行为。
/// 这里**不**把错误往上抛——实例页打不开是比「少列几个外部实例」严重得多的故障。
///
/// 档案绑定(`profile.version_id`)**不在这一层过滤**:那只对配置根有意义,由 [`super::instances`] 做。
pub(in crate::host) fn external_versions(env: &HostEnv, game: &GameLayout) -> Vec<String> {
let Ok(scan) = unlucky_core::game::adopt::scan(game, &env.layout) else {
return Vec::new();
};
scan.candidates
.into_iter()
.filter(|candidate| candidate.adoptable())
.map(|candidate| candidate.version_id)
.collect()
}
/// 外部实例的视图 id(= 懒创建时写进 `profiles.json` 的档案 id)。
///
/// **不能直接拿版本 id 当实例 id**:`core::platform::layout::is_valid_instance_id` 只收
/// `[A-Za-z0-9_-]{1,64}`(`config/instances/<id>.json` 与启动链的 `read_instance_config` 都按
/// 这个白名单校验),而版本目录名可以是中文——用户实测的那个就是 `樱落怡然客户端`。
/// 直接沿用版本 id 会让**启动**在写盘前就死在 `LaunchFailureStage::InstanceConfig`。
///
/// 前缀 + 稳定哈希 ⇒ 同一个 (根, 版本) 每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。
///
/// **M274**:`root_key` 也进哈希(`fnv(root || 0 || version)`)——两个根里的 `1.20.4` 因此是两个
/// 实例。分隔符用 `0`:路径里不可能有 NUL,所以 `("ab","c")` 与 `("a","bc")` 不会撞。
pub(in crate::host) fn external_instance_id(root_key: &str, version_id: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let bytes = root_key
.as_bytes()
.iter()
.copied()
.chain(std::iter::once(0))
.chain(version_id.as_bytes().iter().copied());
for byte in bytes {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{}{hash:016x}", crate::dto::DISCOVERED_ID_PREFIX)
}
/// 磁贴的显示名:配置根里的版本就是版本目录名;**别的根**里的版本带上根路径。
///
/// 为什么非带不可:两个根里都可能有一个叫 `A` 的版本,列表上两条同名磁贴,用户无从分辨点哪一条
/// (而两条的存档在不同的目录里)。路径是**事实**,直接摆出来;只对配置根保持原样是为了不让
/// 绝大多数用户看到一堆长路径。
pub(in crate::host) fn display_name(root: &GameRoot, version_id: &str) -> String {
if root.configured() {
return version_id.to_string();
}
format!("{version_id} · {}", root.path().display())
}
/// 一条外部版本 → 磁贴(**只读**,不落任何文件)。
pub(in crate::host) fn external_summary(
env: &HostEnv,
root: &GameRoot,
version_id: &str,
played: &BTreeMap<String, String>,
) -> InstanceSummary {
let id = external_instance_id(root.key(), version_id);
let game = root.game();
let (mc_version, loader) = describe_version(game, version_id);
InstanceSummary {
last_played: played.get(&id).cloned(),
isolation: isolation_of(env, game, &id, version_id),
launch: super::super::launch_args::view(env, game, &id, version_id),
version_present: game.is_version_installed(version_id),
name: display_name(root, version_id),
version_id: version_id.to_string(),
mc_version,
loader,
runtime: InstanceRuntime::Idle,
icon: None,
id,
}
}
/// **一拍之内**的发现快照:每个根只扫一遍 `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 }
}
/// 逐个根 + 它这一拍扫到的版本 id(字典序,已滤掉不可登记的)。
pub(in crate::host) fn roots(&self) -> impl Iterator<Item = (&GameRoot, &[String])> {
self.roots
.iter()
.map(|(root, versions)| (root, versions.as_slice()))
}
/// id → (根, 版本 id)。不是 `ext-` 前缀 / 扫不到 ⇒ `None`。
pub(in crate::host) fn find(&self, id: &str) -> Option<(&GameRoot, &str)> {
if !crate::dto::is_discovered_instance(id) {
return None;
}
self.roots().find_map(|(root, versions)| {
versions
.iter()
.find(|version_id| external_instance_id(root.key(), version_id) == id)
.map(|version_id| (root, version_id.as_str()))
})
}
/// id → 那个根的布局(查不到 ⇒ `None`,由调用方决定退回哪个根)。
pub(in crate::host) fn game_of(&self, id: &str) -> Option<&GameLayout> {
self.find(id).map(|(root, _)| root.game())
}
}
/// 实例所属的那个根的布局(读盘路径的唯一入口)。
///
/// 档案实例、以及「反查不到」的 id 一律退回配置根 `env.game`——**没有第二个答案**:
/// 宁可读配置根(旧行为),也不把实例指向一个猜出来的目录。
///
/// # 退回的后果(M274 r2 复审 P2-1,任务⑥点名要写)
///
/// `is_discovered_instance(id)` 却反查不到,只有两种情形:
/// 1. **老档案**(M273 时代的 `ext-` id,哈希里没有根)——退回配置根是对的;
/// 2. **那个版本此刻不可登记**:官方启动器正在更新(清单半写 / 被锁 / 目录被移走)⇒
/// `adopt::scan` 判 `adoptable() == false` ⇒ 这里查不到。
///
/// 第 2 种情形下退回配置根是有代价的:**若配置根里恰好有同名版本**,装配会去读它、
/// `gameDir` 也落在配置根 ⇒ 可能**启动/读档到另一个根**。所以这里留一行启动器日志
/// (`logs/launcher.log`,分类 `multiroot`),让这条回落可观测,而不是让调用方无感。
pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout {
let found = Found::scan(env);
if let Some(game) = found.game_of(id) {
return game.clone();
}
if crate::dto::is_discovered_instance(id) {
// 两因并列,**不要**只写后一种:`ext-` 前缀在 M273 时代就有,那时 id 只哈希版本目录名
// (没有根),所以「M274 之前合成的老档案」**永远**反查不到——那是所有老用户的常态,
// 不是故障。只写「清单半写/被锁」会让每次点开详情页都往日志里加一条像是在报错的假警报
// (M274 r3 复审 P2-1)。
unlucky_core::launch::launcher_log::record(
"multiroot",
&format!(
"实例 {id} 反查不到所属根(老档案:M273 之前合成,id 不含根;或该版本此刻不可登记:清单半写/被锁/目录已不在)⇒ 按配置根 {} 解析",
env.game.root().display()
),
);
}
env.game.clone()
}
/// 这条档案**占住的是配置根里的那个版本吗**(`instances()` 的 `bound` 与写盘那道门共用同一份判据)。
///
/// - 反查到某个根 ⇒ 看那个根是不是配置根;
/// - 反查不到 ⇒ 按配置根口径算(老 M273 的 `ext-` id、用户/向导建的自建档案、那批根已不在盘上)。
///
/// 为什么必须是**一个**函数(M274 r2 复审 P1-1):这两处曾经各写一份,列表侧对、写盘侧只比
/// `version_id`——于是官方根那个 `A` 被懒创建成档案后,**配置根**里同名那个 `A` 被判成
/// 「已被绑定」,`ensure_profile` 返回 `找不到实例 ext-…`,而磁贴还好端端列着。
/// 判据分叉的代价是「有时能改有时不能改」,最难排的那一种。
pub(in crate::host) fn binds_configured_root(found: &Found, profile: &InstanceProfile) -> bool {
found
.find(profile.id.as_str())
.is_none_or(|(root, _)| root.configured())
}
/// 视图 id → (所属根, 版本 id) 的反查(只认「可登记、且**还没有档案绑定**」的版本)。
///
/// 与 `instances()` 同一份判据(含 [`binds_configured_root`]):合成的视图 id 只在那个集合里有意义,
/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批(有了档案就走档案,
/// 不再重复列一条)。
#[must_use]
pub(in crate::host) fn external_version_of(
env: &HostEnv,
profiles: &ProfileList,
id: &str,
) -> Option<(GameRoot, String)> {
// 一拍只扫一次:下面「查自己」与「查绑定者」都用这一份。
let found = Found::scan(env);
let (root, version_id) = found
.find(id)
.map(|(root, version_id)| (root.clone(), version_id.to_string()))?;
// 档案绑定只对**配置根**有意义(别处的同名版本是另一个实例,id 不会撞)。
// 而「绑定者」是不是配置根里的那一条,也按它**自己的根**判——不许只比 `version_id`:
// 官方根的 `A` 与配置根的 `A` 是两个实例(r2 复审 P1-1 的现场)。
if root.configured() {
let taken = profiles.profiles.iter().any(|profile| {
profile.version_id == version_id
&& (profile.id.as_str() == id || binds_configured_root(&found, profile))
});
if taken {
return None;
}
}
Some((root, version_id))
}
-206
View File
@@ -1,206 +0,0 @@
//! 游戏根的发现与去重(M274):**当前配置的根** + **官方启动器的标准位置**。
//!
//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是
//! `%APPDATA%\.minecraft` / `~/Library/Application Support/minecraft` / `~/.minecraft`,
//! 而便携模式下的 ULL 用的是 `<exe>/.minecraft`——**两个根,不是一回事**。所以列表要能同时读两边。
//!
//! # 只有一件事不是显然的:什么算「同一个根」
//!
//! 同一个目录可以有四种写法:大小写不同、`\` 与 `/` 混用、8.3 短名(`PROGRA~1`)、
//! 符号链接 / 目录联接(Windows 的 junction)。按字面量比较会让**同一批实例列两遍**,
//! 所以判据落在 [`key_of`] 的**规范化真实路径**上:
//!
//! 1. `canonicalize`(Windows 上是 `GetFinalPathNameByHandle`:短名展开成长名,联接解析到目标);
//! 2. `\` → `/`;
//! 3. **去掉 `\\?\` 前缀**——Windows 的 `canonicalize` 一律返回 verbatim 形式(`\\?\C:\…`),
//! 不去掉的话「同一路径的两种写法」永远不相等(这是个已知坑,用例
//! `junction_to_the_same_folder_is_one_root` 钉住);
//! 4. Windows / macOS 折成小写。
//!
//! # 已知取舍(这是**我们**的选择,不是文件系统性质)
//!
//! 第 4 步折小写是为了让 `C:\Mc` 与 `c:\mc`(Windows 上同一个目录)算同一个根。代价在 macOS:
//! **APFS 可以按卷选择区分大小写**,那种卷上 `/a` 与 `/A` 是两个真实不同的目录,折小写会把它们
//! 并成一个键 ⇒ 第二个根(官方位置)被**静默跳过**(不报错、不列它的实例)。触发条件只有
//! 「用户在区分大小写的 APFS 卷上,且两个根只差大小写」——那种布局几乎不可能是真实安装,
//! 所以接受这个代价:宁可漏一个根,也不要把同一批实例列两遍(后者是更常见的错)。
//!
//! 另外三条边界(都不是缺陷,记在这里免得下次重新推一遍):
//! - `\\?\UNC\server\share` 剥前缀后成 `unc/server/share`:仍然**唯一**、不会误合并,
//! 只是丢了 UNC 语义(键只用于相等比较,不再当路径用);
//! - `\\?\C:\` 去掉尾斜杠之后是 `c:`:与 `c:/foo` 这类前缀不相同,不会撞;
//! - Linux 的 `/`:去尾斜杠会得到空串,有兜底写回 `"/"`。
//!
//! `canonicalize` 失败(根还不存在)时退化成「字面量归一」:`\`→`/`、去尾斜杠、折小写。
//! 尚不存在的目录没有短名/链接可解析,这一步够用;代价是那个目录**创建之后**键可能变一次
//! (短名 → 长名、大小写归到真实名字),于是该根下已发现的实例 id 会变一次。
//!
//! # 只读
//!
//! 本模块只 `is_dir()` / `canonicalize()`:不建目录、不写一个字节。官方启动器那个根上尤其要紧
//! ——那是**别人的**目录。
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use unlucky_core::game::GameLayout;
use super::super::env::HostEnv;
/// 一个被发现的游戏根。
///
/// `configured` 那一格区分「用户在设置里指定的根」与「官方启动器的标准位置」:档案绑定、
/// 新建/安装版本都只对前者有意义(我们**从不往官方根写东西**,见 M274 的只读纪律)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(in crate::host) struct GameRoot {
key: String,
game: GameLayout,
configured: bool,
}
impl GameRoot {
/// 这个根的布局(`versions/` / `libraries/` / `assets/` 都在它下面)。
pub(in crate::host) fn game(&self) -> &GameLayout {
&self.game
}
/// 规范化后的比较键(实例 id 的哈希输入之一,见 [`super::discovered::external_instance_id`])。
pub(in crate::host) fn key(&self) -> &str {
&self.key
}
/// 是不是**当前配置**的那个根(`env.game`)。
pub(in crate::host) fn configured(&self) -> bool {
self.configured
}
/// 根目录本身(显示用)。
pub(in crate::host) fn path(&self) -> &Path {
self.game.root()
}
}
/// 全部游戏根:**配置根永远第一位**,官方启动器的标准位置(存在、且不是同一个物理目录)第二位。
///
/// 官方位置不存在 ⇒ 不列。「没装官方启动器」不该在列表上留一个空根——那是「这里本来就没有」,
/// 与「有个空目录」是两件事。
pub(in crate::host) fn all(env: &HostEnv) -> Vec<GameRoot> {
let mut roots = vec![GameRoot {
key: key_of(env.game.root()),
game: env.game.clone(),
configured: true,
}];
let mut seen: BTreeSet<String> = roots.iter().map(|root| root.key.clone()).collect();
for extra in env.standard_game.iter() {
if !extra.is_dir() {
continue;
}
let key = key_of(extra);
// 同一个物理目录(符号链接 / 目录联接 / 大小写 / 短名)⇒ 只留配置根那一条。
if !seen.insert(key.clone()) {
continue;
}
roots.push(GameRoot {
key,
game: GameLayout::new(extra.clone()),
configured: false,
});
}
roots
}
/// 路径 → **比较键**(判据的完整理由见模块文档)。
///
/// 同一个物理目录的所有写法必须给同一个键;不同的目录必须给不同的键(大小写不敏感的平台除外,
/// 那里它们本来就是同一个目录)。
pub(in crate::host) fn key_of(path: &Path) -> String {
let resolved: PathBuf = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let text = resolved.to_string_lossy().replace('\\', "/");
// verbatim 前缀(`\\?\C:\…` 在上一步已经变成 `//?/C:/…`):不去掉就与普通写法永不相等。
// UNC 会因此变成 `unc/server/share`(仍唯一,见模块文档的「已知取舍」)。
let text = text
.strip_prefix("//?/")
.or_else(|| text.strip_prefix("//./"))
.unwrap_or(text.as_str());
let text = text.trim_end_matches('/');
let text = if text.is_empty() { "/" } else { text };
if cfg!(any(windows, target_os = "macos")) {
text.to_lowercase()
} else {
text.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root(tag: &str) -> PathBuf {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
path
}
/// 斜杠方向 / 尾斜杠 / 大小写不影响比较键(`canonicalize` 之后三者本来就该相等)。
#[test]
fn key_ignores_separators_case_and_trailing_slash() {
let root = temp_root("ull-roots-key");
let nested = root.join("Minecraft/.minecraft");
std::fs::create_dir_all(&nested).expect("建目录");
let plain = key_of(&nested);
assert_eq!(plain, key_of(&PathBuf::from(format!("{}/", plain))));
let mixed = PathBuf::from(plain.replace('/', "\\"));
assert_eq!(plain, key_of(&mixed), "反斜杠写法必须是同一个根");
assert!(
!plain.starts_with("//?/"),
"verbatim 前缀没去掉,两种写法永远不相等:{plain}"
);
std::fs::remove_dir_all(&root).ok();
}
/// **去重判据**:`root_b` 是指向 `root_a` 的目录联接(junction)⇒ 两个写法是同一个根。
///
/// 这一条是整个多根发现最容易假绿的地方:只比字面量时它必然「通过」两条,而用户看到同一批
/// 实例列了两遍。Windows 上用 `mklink /J`(不需要管理员),其它平台用符号链接。
#[test]
fn junction_to_the_same_folder_is_one_root() {
let root = temp_root("ull-roots-junction");
let target = root.join("official/.minecraft");
std::fs::create_dir_all(target.join("versions")).expect("建目录");
let link = root.join("linked");
if !make_dir_link(&link, &target) {
// 造不出链接就**不假绿**:这一条判据在没链接的盘上等于没验,如实标出来。
eprintln!("跳过:这个环境造不出目录链接({})", link.display());
std::fs::remove_dir_all(&root).ok();
return;
}
assert_eq!(
key_of(&target),
key_of(&link),
"联接与目标是同一个根:{} vs {}",
key_of(&target),
key_of(&link)
);
std::fs::remove_dir_all(&root).ok();
}
/// 造一个目录链接:Windows 用 junction(无管理员权限也能建),Unix 用符号链接。
#[cfg(windows)]
fn make_dir_link(link: &Path, target: &Path) -> bool {
std::process::Command::new("cmd")
.args(["/c", "mklink", "/J"])
.arg(link)
.arg(target)
.status()
.map(|status| status.success())
.unwrap_or(false)
}
#[cfg(not(windows))]
fn make_dir_link(link: &Path, target: &Path) -> bool {
std::os::unix::fs::symlink(target, link).is_ok()
}
}
+2 -11
View File
@@ -141,14 +141,6 @@ fn own_installed_instances_snapshot_carries_their_loader() {
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles).expect("写清单");
let snapshot = instances(&env);
// M273 之后 `versions/*` 也是实例来源:这个夹具为了造 `inheritsFrom` 还写了三个父版本目录
// (`1.20.4` / `1.20.1` / `1.21.1`)而**没有**给它们建档案,于是它们会作为「外部实例」出现。
// 这条用例守的是「这条链把加载器/版本号翻对」——所以只看它自己那四个版本,条数也在这一层断言;
// 「盘上有版本就出现在列表里」由 `tests_union.rs` 独占。
let snapshot: Vec<_> = snapshot
.into_iter()
.filter(|tile| cases.iter().any(|case| case.0 == tile.version_id))
.collect();
assert_eq!(snapshot.len(), cases.len());
for ((version_id, mc_version, kind, badge), tile) in cases.iter().zip(&snapshot) {
assert_eq!(tile.version_id.as_str(), *version_id);
@@ -189,10 +181,9 @@ fn isolation_write_then_read_round_trips() {
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles).expect("写清单");
let id = profiles.profiles[0].id.as_str().to_string();
// 有档案的实例:基底档 = 全局档(`isolated`),与 M273 之前一字不差。
assert_eq!(isolation_of(&env, &env.game, &id, "1.20.1"), "isolated");
assert_eq!(isolation_of(&env, &id), "isolated");
set_isolation(&env, &id, "shared").expect("切档位");
assert_eq!(isolation_of(&env, &env.game, &id, "1.20.1"), "shared");
assert_eq!(isolation_of(&env, &id), "shared");
assert!(set_isolation(&env, &id, "nope").is_err());
std::fs::remove_dir_all(&dir).ok();
}
@@ -1,347 +0,0 @@
//! 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);
}
@@ -1,332 +0,0 @@
//! M274 **r1 复审**补的判据(P1-1 / P1-2 / P2-3)。
//!
//! 上一轮(`tests_multiroot.rs`)钉的是「两个根里的同名版本是两条实例、各自的书架/存档/启动按自己
//! 那个根解析」。复审把「同一句话没走完」的两处揪了出来——**根身份有了,但两处「描述 / 前置要求」
//! 还在读配置根**——这一组就是那两处,加上「只读」那条升级成内容级快照。
//!
//! | 用例 | 抓得住什么 | 抓不住什么 |
//! |---|---|---|
//! | [`the_tile_describes_a_lazily_created_profile_from_its_own_root`] | 官方根实例被懒创建成档案之后,磁贴的 `version_present` / `isolation` / `mc_version` **逐项不变**(复审 P1-1 的三项同时错:显示与行为分叉) | 磁贴之外的显示(`element` 没有内省接口,见 `pages/instances/tests` 的口径) |
//! | [`pick_java_reads_the_requirement_from_the_instance_own_root`] | 启动挑 Java 时读的是**实例自己那个根**的 `javaVersion`(复审 P1-2:配置根读出来 8,实例是 21,实际选中 17) | 真的有 21 的 JDK 被选中(那要装 JDK;这里钉的是「要求」与回落,不是 `pick` 的最优选择) |
//! | [`nothing_is_written_into_the_official_root`] | 列实例 / 列存档 / 装配启动 / 懒创建档案之后,官方根的**文件内容、大小、mtime** 一个都没变(复审 P2-3:原来只比条目名) | natives 解压(那发生在 `Launcher::spawn`,用例不拉进程);官方启动器同时在写的并发(TOCTOU) |
use std::path::{Path, PathBuf};
use unlucky_core::game::GameLayout;
use super::*;
/// 临时根(每个用例一个目录;断言 panic 时也会删)。
mod temp {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
pub struct Root(pub PathBuf);
impl Root {
pub fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
}
impl Drop for Root {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(Path::new(&self.0));
}
}
}
/// 造两个根:配置根 = `<root>/.minecraft`,官方位置 = `<root>/official/.minecraft`。
fn two_roots(tag: &str) -> (temp::Root, HostEnv) {
let root = temp::Root::new(tag);
let mut env = HostEnv::at_root(&root.0);
let official = root.0.join("official").join(".minecraft");
std::fs::create_dir_all(&official).expect("官方根目录");
env.standard_game = Some(official);
(root, env)
}
/// 官方根(=第二个候选根)的**字面**路径。
fn official(env: &HostEnv) -> PathBuf {
env.standard_game.clone().expect("用例里一定钉了官方根")
}
/// 官方根 + 配置根各自的布局。
fn official_layout(env: &HostEnv) -> GameLayout {
GameLayout::new(official(env))
}
fn write_manifest(game: &GameLayout, id: &str, json: &str) {
let path = game.version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, json).expect("写版本 JSON");
}
// ---------------------------------------------------------------- 判据①(P1-1):磁贴按自己那个根描述
/// 官方根里的实例被懒创建成档案之后,磁贴的三格必须**逐项不变**。
///
/// 复审 P1-1 的探针(夹具几乎一样):`version_present` true→false、`isolation` isolated→shared、
/// `mc_version` `1.20.4`→`A`。三格同时错,而实际读盘(saves / mods / launch)走 `game_for` 是对的
/// ⇒ **显示与行为分叉**:用户以为存档在 ULL 便携根,实际在官方根。
///
/// 变异证明:把 `profile_summary` 的 `home.unwrap_or(&env.game)` 改回 `&env.game` 就红。
#[test]
fn the_tile_describes_a_lazily_created_profile_from_its_own_root() {
let (_root, env) = two_roots("ull-host-multiroot2-tile");
// 官方根:`A` 继承 `1.20.4`,且 `versions/A/mods/` 存在(版本隔离形态)。配置根里**没有** `A`。
let official = official_layout(&env);
write_manifest(&official, "1.20.4", r#"{"id":"1.20.4","type":"release"}"#);
write_manifest(
&official,
"A",
r#"{"id":"A","inheritsFrom":"1.20.4","type":"release"}"#,
);
std::fs::create_dir_all(official.version_dir("A").join("mods")).expect("mods 目录");
// `1.20.4` 本身也是个可登记的版本 ⇒ 列表里会有它;这里只盯 `A`。
let before = instances(&env);
let before = before
.iter()
.find(|tile| tile.version_id == "A")
.expect("`A` 在列表里")
.clone();
assert_eq!(before.mc_version, "1.20.4", "继承链要走到父版本");
assert!(before.version_present, "清单在官方根里");
assert_eq!(
before.isolation, "isolated",
"`versions/A/mods/` 是自带数据的痕迹"
);
// 用户第一次改这个实例(写启动参数 = 懒创建档案,**不**写隔离档)——正是「官方实例」的常态路径。
let patch = crate::dto::LaunchArgsPatch {
jvm_args: Some("-XX:+UseG1GC".to_string()),
..crate::dto::LaunchArgsPatch::default()
};
let mut host = super::super::CoreHost::headless(env.clone());
host.apply_launch_args(&before.id, &patch)
.expect("官方根实例写启动参数不该报「找不到实例」");
let after = instances(&env);
let after = after
.iter()
.find(|tile| tile.id == before.id)
.expect("懒创建之后同一条实例还在");
assert_eq!(
instances(&env).len(),
2,
"懒创建之后不该多出第三条(`1.20.4` + `A` 就是全部)"
);
assert_eq!(
after.version_present, before.version_present,
"`version_present` 变了(磁贴会显示「文件缺失」):{before:?} → {after:?}"
);
assert_eq!(
after.isolation, before.isolation,
"`isolation` 变了(启动提示会说错运行目录):{before:?} → {after:?}"
);
assert_eq!(
after.mc_version, before.mc_version,
"`mc_version` 变了(继承链丢了):{before:?} → {after:?}"
);
assert_eq!(
after.launch.jvm_args, "-XX:+UseG1GC",
"写回的那一项要读得到"
);
}
// ---------------------------------------------------------------- 判据②(P1-2):挑 Java 按自己那个根
/// 两个根里各一个**同名**版本,`javaVersion` 要求不同 ⇒ 各自按自己那个根的要求回落。
///
/// 复审 P1-2 的探针:官方根里的自定义名版本声明 `javaVersion.majorVersion = 21`,配置根读不到清单
/// ⇒ 退回静态表 ⇒ `minimum = 8` ⇒ 实际选中 17,把要 21 的 modpack 拉起来。
///
/// 用「显式路径探测失败」这条确定性回落(`probe` 拿不到版本 ⇒ `major = required.minimum`),
/// 所以不需要机器上真的装着 JDK 8/21:断言的是**要求**与回落,不是 `pick` 的最优选择。
#[test]
fn pick_java_reads_the_requirement_from_the_instance_own_root() {
let (_root, env) = two_roots("ull-host-multiroot2-java");
let official = official_layout(&env);
write_manifest(
&env.game,
"A",
r#"{"id":"A","javaVersion":{"majorVersion":8}}"#,
);
write_manifest(
&official,
"A",
r#"{"id":"A","javaVersion":{"majorVersion":21}}"#,
);
let tiles = instances(&env);
assert_eq!(tiles.len(), 2, "{tiles:?}");
// 探测必然失败(路径不存在)⇒ 落回「版本要求」。
let missing = Path::new("Z:/ull-no-such-java/bin/java.exe");
let mut majors: Vec<u32> = tiles
.iter()
.map(|tile| {
let game = game_for(&env, &tile.id);
super::super::launch::pick_java(&env, &game, "A", Some(missing))
.selection
.major
})
.collect();
majors.sort_unstable();
assert_eq!(
majors,
vec![8, 21],
"两个根里的同名版本要求不同,挑 Java 必须各读各自的清单"
);
// 变异证明:拿配置根去读官方根那个实例 ⇒ 只会得到 8(复审实测这条会导致 17 去跑要 21 的包)。
let wrong = super::super::launch::pick_java(&env, &env.game, "A", Some(missing));
assert_eq!(
wrong.selection.major, 8,
"按配置根读会挑错——这条断言钉住「错在哪」"
);
// **走真启动装配**(`build_request` → `pick_java`):这条才抓得住「调用点传错根」。
// 每个实例在自己的设置里钉一个探测必然失败的 java 路径 ⇒ 生效的 major 就是它自己那个根的要求。
let mut majors: Vec<u32> = Vec::new();
for tile in &tiles {
crate::host::launch_args::apply(
&env,
&tile.id,
&tile.version_id,
&crate::dto::LaunchArgsPatch {
java_path: Some(missing.display().to_string()),
..crate::dto::LaunchArgsPatch::default()
},
)
.expect("钉 java 路径");
let target = super::super::launch::LaunchTarget {
id: tile.id.clone(),
name: tile.name.clone(),
version_id: tile.version_id.clone(),
account: None,
};
let (request, _warnings) =
super::super::launch::build_request(&env, &target).expect("装配应当成功");
majors.push(request.java.major);
}
majors.sort_unstable();
assert_eq!(
majors,
vec![8, 21],
"启动装配读的必须是实例自己那个根的要求(调用点传 `env.game` 就会全是 8)"
);
}
// ---------------------------------------------------------------- 判据③(P2-3):只读发现升级成内容级
/// **内容级**快照:相对路径 + 字节数 + mtime(ms) + 内容哈希(fnv-1a)。
///
/// 为什么不是只比条目名(复审 P2-3):只比名字抓得住「多出新文件」,抓不住「已有文件被覆盖」
/// ——natives 的 dll 被改写、mtime 被刷新这两种都漏。这里四样一起比:内容改一个字节、大小变、
/// mtime 变都会红。
fn content_snapshot(dir: &Path) -> Vec<String> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
let Ok(read) = std::fs::read_dir(&current) else {
continue;
};
for entry in read.flatten() {
let path = entry.path();
let Ok(relative) = path.strip_prefix(dir) else {
continue;
};
let name = relative.to_string_lossy().replace('\\', "/");
if path.is_dir() {
out.push(format!("{name}|dir"));
stack.push(path);
continue;
}
let meta = std::fs::metadata(&path).ok();
let bytes = meta.as_ref().map_or(0, std::fs::Metadata::len);
let mtime = meta
.and_then(|meta| meta.modified().ok())
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|elapsed| elapsed.as_millis())
.unwrap_or(0);
let content = std::fs::read(&path).unwrap_or_default();
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in content {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
out.push(format!("{name}|{bytes}|{mtime}|{hash:016x}"));
}
}
out.sort();
out
}
/// 列实例 / 列存档 / 装配启动 / 懒创建档案之后,官方根里**一个字节都不许变**,也不许凭空建档案。
///
/// 升级点(复审 P2-3):原来只比条目名集合,现在比「相对路径 + 大小 + mtime + 内容哈希」,
/// 并且把「懒创建档案」这条写盘路径也带进来(那是本 mission 唯一会在用户动作后写盘的地方,
/// 而它必须写进 `.ULL`、不碰官方根)。
///
/// 抓不住:`Launcher::spawn` 解压 natives(用例不拉进程);官方启动器同时在写的并发(TOCTOU)。
#[test]
fn nothing_is_written_into_the_official_root() {
let (_root, env) = two_roots("ull-host-multiroot2-readonly");
let official = official_layout(&env);
write_manifest(&official, "1.20.4", r#"{"id":"1.20.4","type":"release"}"#);
write_manifest(
&official,
"A",
r#"{"id":"A","inheritsFrom":"1.20.4","mainClass":"net.minecraft.client.main.Main"}"#,
);
// 一个已经在的 natives 文件(正是「被覆写」这种错要抓的东西)。
let natives = official.version_dir("A").join("natives");
std::fs::create_dir_all(&natives).expect("natives 目录");
std::fs::write(natives.join("lwjgl.dll"), b"original-content").expect("natives 落盘");
let world = official.root().join("saves").join("world");
std::fs::create_dir_all(&world).expect("世界目录");
std::fs::write(world.join("level.dat"), b"fixture").expect("level.dat");
let before = content_snapshot(official.root());
// ① 列实例 ② 列存档(含截图目录扫) ③ 装配启动请求(natives 解压发生在 spawn,不在装配里)
let snapshot = instances(&env);
let tile = snapshot
.iter()
.find(|tile| tile.version_id == "A")
.expect("`A` 在列表里");
for tile in &snapshot {
if let Ok(dir) = super::super::saves::run_dir(&env, tile) {
let _ = super::super::saves::snapshot(tile, &dir, None);
}
let target = super::super::launch::LaunchTarget {
id: tile.id.clone(),
name: tile.name.clone(),
version_id: tile.version_id.clone(),
account: None,
};
let _ = super::super::launch::build_request(&env, &target);
}
// ④ 用户改一次设置 ⇒ 懒创建档案(写盘必须落 `.ULL`)
set_isolation(&env, &tile.id, "isolated").expect("懒创建档案");
let _ = instances(&env);
assert_eq!(
before,
content_snapshot(official.root()),
"官方根的条目 / 大小 / mtime / 内容有任何一格变了(发现与设置都只许读它)"
);
let profiles = read_profiles(&env.layout).expect("读档案");
assert_eq!(profiles.profiles.len(), 1, "档案落在 .ULL 里");
assert!(
env.layout.profiles_config().starts_with(env.layout.home()),
"档案路径必须在 .ULL 内:{}",
env.layout.profiles_config().display()
);
}
@@ -1,306 +0,0 @@
//! 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(),
"存档也落回配置根——这就是「死档案吞掉同名版本」的后果"
);
}
@@ -1,304 +0,0 @@
//! M273:实例快照 = **档案 ∪ 盘上的 `versions/*`**(外部实例发现)。
//!
//! 用户实测的症状是「HMCL 能看见 `<.minecraft>/versions/樱落怡然客户端`,ULL 的实例页是空的」。
//! 这组的三个用例正好按那次事故的形状造数据:临时数据根里只有 `versions/`,**没有**
//! `profiles.json`。
//!
//! | 用例 | 抓得住什么 | 抓不住什么 |
//! |---|---|---|
//! | [`versions_on_disk_become_instances`] | 列表漏读外部版本(用户那次事故的正面);坏目录被当成实例;档案绑定的版本被重复列一遍 | 外部实例的**加载器/版本号识别是否准确**(那是 `describe_version` 的事);真 `versions/<id>/<id>.json` 的全部变体(扫描判据在 core 的 `adopt` 测试里) |
//! | [`external_instance_writes_nothing_until_first_edit`] | 「一打开启动器就改别人的文件夹」——`instances()` 前后目录里不许多个文件;第一次改档位之后档案必须落盘且**幂等** | 别的写路径(启动参数写回、安装 mod)是否也守「先不写」——那条由 [`external_instance_launch_args_are_usable`] 与 core 的 adopt 用例分担 |
//! | [`external_instance_launch_args_are_usable`] | 外部实例能拿到可用的启动参数视图、能写回(不再报「找不到实例」) | 启动真的能进游戏(要真 jvm/账号/网络,见报告「没验的部分」) |
use super::super::CoreHost;
use super::*;
/// 临时数据根(每个用例一个目录;守卫在断言 panic 时也会删)。
mod temp {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
pub struct Root(pub PathBuf);
impl Root {
pub fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
}
impl Drop for Root {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(Path::new(&self.0));
}
}
/// 目录下所有文件/目录的相对路径(排序后比较,用来做「写盘前后」快照)。
pub fn entries_under(dir: &Path) -> Vec<String> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
let Ok(read) = std::fs::read_dir(&current) else {
continue;
};
for entry in read.flatten() {
let path = entry.path();
if let Ok(relative) = path.strip_prefix(dir) {
out.push(relative.to_string_lossy().replace('\\', "/"));
}
if path.is_dir() {
stack.push(path);
}
}
}
out.sort();
out
}
}
/// 造一个「别人的 `.minecraft`」:`versions/<id>/<id>.json`,没有 `profiles.json`。
fn write_version(env: &HostEnv, id: &str) {
let path = env.game.version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, format!(r#"{{"id":"{id}"}}"#)).expect("写版本 JSON");
}
/// 盘上有版本、没有档案 ⇒ 列表里必须有它(`versions/A/A.json` 合法 + `versions/B` 无清单)。
///
/// 「B 没有清单」是用户现场的另一半形状(别的启动器的残留目录):它**不是**实例。
#[test]
fn versions_on_disk_become_instances() {
let root = temp::Root::new("ull-host-game-union");
let env = HostEnv::at_root(&root.0);
write_version(&env, "A");
std::fs::create_dir_all(env.game.version_dir("B")).expect("B 目录");
let snapshot = instances(&env);
assert_eq!(
snapshot.len(),
1,
"只有 A 算实例(B 没有清单):{snapshot:?}"
);
let tile = &snapshot[0];
assert_eq!(tile.version_id, "A");
assert_eq!(tile.name, "A", "显示名 = 版本目录名");
assert_eq!(tile.mc_version, "A", "没有 inheritsFrom 时根就是自己");
assert!(tile.version_present, "清单在盘上,磁贴不该亮黄条");
// 实例 id 必须是**合法实例 id**(`config/instances/<id>.json` 与启动链都按它校验)。
// 版本目录名可以是中文(用户现场就是),所以 id 不能直接沿用版本 id。
assert!(
unlucky_core::platform::layout::is_valid_instance_id(&tile.id),
"合成的实例 id 不合法,启动链会在读实例设置时失败:{}",
tile.id
);
// 同一个版本再被档案绑定(不论档案 id)⇒ 只出一条,且以档案为准(名字是用户改过的那个)。
let profile = InstanceProfile {
id: ProfileId::new("renamed-1"),
name: "改过名的 A".to_string(),
version_id: "A".to_string(),
..InstanceProfile::default()
};
write_profiles(
&env.layout,
&ProfileList {
profiles: vec![profile],
..ProfileList::default()
},
)
.expect("写清单");
let snapshot = instances(&env);
assert_eq!(
snapshot.len(),
1,
"档案与外部版本不许重复列两遍:{snapshot:?}"
);
assert_eq!(snapshot[0].name, "改过名的 A");
assert_eq!(snapshot[0].id, "renamed-1");
}
/// 浏览**不写盘**;第一次改隔离档才懒创建档案(并且幂等)。
///
/// 这是「不擅自动别人的文件夹」这条不变的唯一可验证形态:`instances()` 前后做一次目录快照,
/// 两边必须一模一样。
#[test]
fn external_instance_writes_nothing_until_first_edit() {
let root = temp::Root::new("ull-host-game-union-readonly");
let env = HostEnv::at_root(&root.0);
write_version(&env, "A");
let before = temp::entries_under(&root.0);
let snapshot = instances(&env);
let after = temp::entries_under(&root.0);
assert_eq!(
before, after,
"读一次列表就在盘上多了东西(用户明确不要自动导入)"
);
let id = snapshot[0].id.clone();
assert_eq!(
isolation_of(&env, &env.game, &id, "A"),
"shared",
"`versions/A/` 里没有 mods / saves / config / options.txt ⇒ 基底档是 shared(按共用目录读)"
);
set_isolation(&env, &id, "isolated").expect("第一次改档位 = 懒创建档案");
assert_eq!(
isolation_of(&env, &env.game, &id, "A"),
"isolated",
"实例文件里写下的档位优先于基底档"
);
let list = unlucky_core::game::install::instance::read_profiles(&env.layout).expect("读档案");
assert_eq!(list.profiles.len(), 1, "懒创建只该写一条");
let created = &list.profiles[0];
assert_eq!(
created.id.as_str(),
id,
"档案 id 必须与视图 id 一致(否则下次列表出两条)"
);
assert_eq!(created.version_id, "A");
assert_eq!(created.name, "A");
// 幂等:再改一次不会长出第二条档案。
set_isolation(&env, &id, "shared").expect("第二次改档位");
let list = unlucky_core::game::install::instance::read_profiles(&env.layout).expect("读档案");
assert_eq!(list.profiles.len(), 1, "重复改档位不该再写第二条档案");
// 外部实例仍然只出现一条(档案那条)。
assert_eq!(instances(&env).len(), 1);
}
/// 外部实例的启动参数:读得到视图、写回不报「找不到实例」。
#[test]
fn external_instance_launch_args_are_usable() {
let root = temp::Root::new("ull-host-game-union-args");
let env = HostEnv::at_root(&root.0);
write_version(&env, "A");
let snapshot = instances(&env);
let tile = &snapshot[0];
assert_eq!(
tile.launch.instance_id, tile.id,
"启动参数视图绑的是同一个实例"
);
assert_eq!(tile.launch.mc_version, "A");
let patch = crate::dto::LaunchArgsPatch {
jvm_args: Some("-XX:+UseG1GC".to_string()),
..crate::dto::LaunchArgsPatch::default()
};
let mut host = CoreHost::headless(env.clone());
host.apply_launch_args(&tile.id, &patch)
.expect("外部实例写启动参数不该报「找不到实例」");
let list = unlucky_core::game::install::instance::read_profiles(&env.layout).expect("读档案");
assert_eq!(list.profiles.len(), 1, "写参数同时懒创建了档案");
let reread = instances(&env);
assert_eq!(reread.len(), 1);
assert_eq!(reread[0].launch.jvm_args, "-XX:+UseG1GC");
}
/// **用户现场逐字复现**:`versions/樱落怡然客户端/樱落怡然客户端.json`
/// (`id` 与文件夹同名、`type` 是 `release`、没有 `inheritsFrom`)⇒ 列表里 1 条,名字就是它。
///
/// 这一条与 [`versions_on_disk_become_instances`] 的区别只在**目录名是中文**:那正是让
/// 「实例 id 直接沿用版本 id」这个看起来最自然的写法当场失败的地方
/// (`is_valid_instance_id` 只收 ASCII),而且失败点离现场很远(启动时才报实例设置读不出来)。
#[test]
fn the_chinese_version_folder_from_the_field_report_shows_up() {
let root = temp::Root::new("ull-host-game-union-cjk");
let env = HostEnv::at_root(&root.0);
let name = "樱落怡然客户端";
let path = env.game.version_json(name);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, format!(r#"{{"id":"{name}","type":"release"}}"#)).expect("写版本 JSON");
let snapshot = instances(&env);
assert_eq!(snapshot.len(), 1, "用户现场应当看到 1 个实例:{snapshot:?}");
let tile = &snapshot[0];
assert_eq!(tile.name, name, "显示名 = 文件夹名(不是哈希)");
assert_eq!(tile.version_id, name);
assert!(
unlucky_core::platform::layout::is_valid_instance_id(&tile.id),
"中文版本目录合成了一个非法实例 id:{}",
tile.id
);
assert!(
!tile.id.contains(name),
"实例 id 不该是版本目录名本身(那份白名单只收 ASCII):{}",
tile.id
);
}
// ------------------------------------------------------- 运行目录判据(M273 r1 · P2-1)
//
// **真值表**(自动发现的实例):
//
// | `versions/<id>/` 里有 | 隔离档 | gameDir | 为什么 |
// |---|---|---|---|
// | `mods` / `saves` / `config` / `options.txt` 任一 | `isolated` | `versions/<id>` | 它本来就是「版本隔离」形态,别人的数据就放在里面 |
// | 一个都没有 | `shared` | 游戏根(`.minecraft`) | 从没用过隔离目录,写进 `versions/<id>` 等于往别人的版本目录里拉屎 |
//
// 两条用例都顺带断言**派生前后盘上零变化**:判据是只读的,写盘只在用户第一次改设置时发生。
/// `versions/A/` 里只有清单 ⇒ 按**共用目录**跑(gameDir = 游戏根)。
#[test]
fn external_instance_without_traces_runs_from_the_game_root() {
let root = temp::Root::new("ull-host-game-union-shared");
let env = HostEnv::at_root(&root.0);
write_version(&env, "A");
let before = temp::entries_under(&root.0);
let snapshot = instances(&env);
assert_eq!(snapshot.len(), 1);
assert_eq!(
snapshot[0].isolation, "shared",
"没有 mods / saves / config / options.txt ⇒ 共用目录"
);
let dir = super::super::saves::run_dir(&env, &snapshot[0]).expect("派生运行目录");
assert_eq!(
dir,
env.game.root(),
"共用目录的运行目录应当就是 .minecraft 根"
);
assert_eq!(
before,
temp::entries_under(&root.0),
"判定 + 派生运行目录不许在盘上多出任何东西"
);
}
/// `versions/B/mods/` 存在 ⇒ 按**独立目录**跑(gameDir = `versions/B`)。
#[test]
fn external_instance_with_mods_runs_in_its_version_folder() {
let root = temp::Root::new("ull-host-game-union-isolated");
let env = HostEnv::at_root(&root.0);
write_version(&env, "B");
std::fs::create_dir_all(env.game.version_dir("B").join("mods")).expect("mods 目录");
let before = temp::entries_under(&root.0);
let snapshot = instances(&env);
assert_eq!(snapshot.len(), 1);
assert_eq!(
snapshot[0].isolation, "isolated",
"`versions/B/mods/` 是「这个版本自带数据」的痕迹 ⇒ 独立目录"
);
let dir = super::super::saves::run_dir(&env, &snapshot[0]).expect("派生运行目录");
assert_eq!(
dir,
env.game.version_dir("B"),
"独立目录的运行目录应当是它自己的版本文件夹"
);
assert_eq!(
before,
temp::entries_under(&root.0),
"判定 + 派生运行目录不许在盘上多出任何东西"
);
}
+5 -10
View File
@@ -19,18 +19,13 @@ use super::job::{spawn, HostTask, JobSender};
pub const DEFAULT_JDK_MAJOR: u32 = 17;
/// 该装哪个大版本:取第一个实例版本的要求,没有实例就给 17。
///
/// 「第一个实例」读的是**并集快照**([`super::game::instances`]),不是 `profiles.json`:
/// M273 之后只有自动发现实例的用户(别的启动器装好了版本、ULL 一个档案都没有)完全合法,
/// 按旧写法他们会拿到默认 JDK17 而不是那个版本真正要求的 JDK,而 `profiles.json` 里
/// 恰好什么都没有。有档案时顺序与旧写法一致(档案在前),所以既有行为不变。
pub fn target_major(env: &HostEnv) -> u32 {
super::game::instances(env)
let profiles = super::game::profiles(env);
profiles
.profiles
.first()
.map(|instance| {
// 版本清单在**它自己那个根**下(M274):官方根里的版本,配置根里读不到要求。
let game = super::game::game_for(env, &instance.id);
java::requirement_for_version(&game, &instance.version_id)
.map(|profile| {
java::requirement_for_version(&env.game, &profile.version_id)
.minimum
.max(8)
})
+19 -33
View File
@@ -63,10 +63,7 @@ pub fn build_request(
target: &LaunchTarget,
) -> Result<(LaunchRequest, Vec<String>), LaunchFailureView> {
let version_id = target.version_id.clone();
// **这个实例自己的根**(M274):官方根 / 另一个 `.minecraft` 里的版本,清单、库、natives、
// assets 全在它自己那个根下。拿 `env.game` 去读会读空(甚至读到同名的另一个版本)。
let game = super::game::game_for(env, &target.id);
let manifest_path = game.version_json(&version_id);
let manifest_path = env.game.version_json(&version_id);
let text = std::fs::read_to_string(&manifest_path).map_err(|error| {
LaunchFailureView::new(
LaunchFailureStage::VersionManifest,
@@ -80,7 +77,7 @@ pub fn build_request(
)
})?;
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(game.version_json(id))
std::fs::read_to_string(env.game.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
@@ -124,22 +121,22 @@ pub fn build_request(
}
};
let path = if library.is_local() {
game.version_library(&version_id, &coordinate)
env.game.version_library(&version_id, &coordinate)
} else {
game.library(&coordinate)
env.game.library(&coordinate)
};
libraries.push(LibraryInput::new(path, name));
}
let natives = unlucky_core::launch::natives::collect(
&game,
&env.game,
&version_id,
resolved.libraries.as_deref().unwrap_or_default(),
&environment,
);
let game_dir = run_dir(env, &game, target)?;
let game_dir = run_dir(env, target)?;
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
let assets_root = game.assets_dir();
let assets_root = env.game.assets_dir();
let assets_index = resolved
.assets
.clone()
@@ -148,7 +145,7 @@ pub fn build_request(
// `26w14a-fabric-0.16.5` 这种 id 在 `GameVersion::is_weekly_snapshot`(6 字符形态)上判不出来,
// 周快照的 `--graphicsBackend` 会在拼装链里被静默跳过——而详情页的门槛
// (`host::launch_args::backend_gate`)与 CLI 用的都是根版本,两边必须同一口径。
let (mc_version, _) = super::game::describe_version(&game, &version_id);
let (mc_version, _) = super::game::describe_version(env, &version_id);
let snapshot = SnapshotInput {
version_id: &version_id,
version_name: Some(&target.name),
@@ -160,18 +157,17 @@ pub fn build_request(
legacy_arguments: resolved.minecraft_arguments.as_deref(),
libraries: &libraries,
natives: &natives,
primary_jar: game.version_jar(&jar_id),
primary_jar: env.game.version_jar(&jar_id),
game_dir,
assets_index: assets_index.as_deref(),
natives_dir: game.natives_dir(&version_id),
library_dir: game.libraries_dir(),
natives_dir: env.game.natives_dir(&version_id),
library_dir: env.game.libraries_dir(),
assets_root: assets_root.clone(),
game_assets: assets_root,
};
let overrides = super::launch_args::resolved(env, &target.id);
// 挑 Java 的**版本要求**也按实例自己的根读(M274 r1 复审 P1-2)。
let java = pick_java(env, &game, &version_id, overrides.java_path.as_deref());
let java = pick_java(env, &version_id, overrides.java_path.as_deref());
let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
let options = AdapterOptions {
java: java.selection,
@@ -197,26 +193,19 @@ pub fn build_request(
Ok((request, warnings))
}
/// 派生运行目录(D20 覆盖链:实例 `isolation` > **基底档**,见 [`super::game::base_mode`])。
///
/// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`——
/// 于是这里不需要知道实例是从哪来的,只有一处判据。
fn run_dir(
env: &HostEnv,
game: &unlucky_core::game::GameLayout,
target: &LaunchTarget,
) -> Result<PathBuf, LaunchFailureView> {
let base = super::game::base_mode(env, game, &target.id, &target.version_id);
/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, LaunchFailureView> {
let global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &target.id).map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::InstanceConfig, error.to_string())
})?;
let derivation = derive_game_dir(game, &target.version_id, base, config.as_ref(), None)
let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None)
.map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string())
})?;
if let Some(mut config) = config {
let at = format_rfc3339_millis(now());
if observe_mode(&mut config, game, &at, derivation.isolation).unwrap_or(false) {
if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) {
let _ =
unlucky_core::game::install::instance::write_instance_config(&env.layout, &config);
}
@@ -235,14 +224,11 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
let logs_dir = env.layout.logs();
launcher_log::set_dir(&logs_dir);
let started_at = std::time::SystemTime::now();
// 崩溃报告里的「游戏目录」必须是**这个实例自己那个根**(M274):官方根里的实例崩了,
// 报告却写着 ULL 便携根的路径,等于把排障指到别人的目录上。
let game = super::game::game_for(&env, &target.id);
let mut report = ReportContext::new(
&target.id,
&target.name,
&target.version_id,
game.root(),
&env.data.game_home,
&logs_dir,
started_at,
unlucky_core::platform::os_info::current().summary_line(),
@@ -272,7 +258,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
}
};
report.run_dir = request.dirs.game_dir.clone();
report.version_json = Some(game.version_json(&target.version_id));
report.version_json = Some(env.game.version_json(&target.version_id));
report.java = launcher_log::java_label(&request.java.binary, request.java.major);
for warning in &warnings {
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
+2 -13
View File
@@ -6,7 +6,6 @@
use std::path::PathBuf;
use unlucky_core::game::GameLayout;
use unlucky_core::launch::request::JavaSelection;
use unlucky_core::platform::paths::ProcessEnv;
@@ -22,21 +21,11 @@ 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,
game: &GameLayout,
version_id: &str,
explicit: Option<&std::path::Path>,
) -> JavaPick {
pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick {
let store = env.store();
let required = unlucky_core::game::java::requirement_for_version(game, version_id);
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
if let Some(path) = explicit {
let runtime = unlucky_core::game::java::probe::probe(
path,
+21 -23
View File
@@ -18,7 +18,7 @@ use unlucky_core::game::launch_overrides::{
resolve as resolve_overrides, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides,
LaunchOverridesPatch, ResolvedLaunch,
};
use unlucky_core::game::{self, GameLayout, VersionCapabilities, VersionJson};
use unlucky_core::game::{self, VersionCapabilities, VersionJson};
use unlucky_core::launch::game_args::{graphics_backend_allowed, GRAPHICS_BACKEND_MIN_VERSION};
use unlucky_core::launch::game_version::GameVersion;
@@ -73,13 +73,11 @@ pub fn resolved(env: &HostEnv, instance_id: &str) -> ResolvedLaunch {
}
/// 版本能力位(解析继承链之后取库表;读不到版本 JSON = 什么能力都没有)。
///
/// **按实例自己那个根读**(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()?;
fn capabilities(env: &HostEnv, version_id: &str) -> Option<VersionJson> {
let text = std::fs::read_to_string(env.game.version_json(version_id)).ok()?;
let manifest = VersionJson::from_json_str(&text).ok()?;
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(game_layout.version_json(id))
std::fs::read_to_string(env.game.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
@@ -90,9 +88,9 @@ fn capabilities(game_layout: &GameLayout, version_id: &str) -> Option<VersionJso
///
/// 判据是 core 的两件套(库表能力位 + 版本门槛),**UI 不复制**:
/// `graphics_backend_allowed(GameVersion, has_vulkan_backend)`。
pub fn backend_gate(game_layout: &GameLayout, version_id: &str) -> (bool, String) {
let mc_version = super::game::describe_version(game_layout, version_id).0;
let has_vulkan = capabilities(game_layout, version_id)
pub fn backend_gate(env: &HostEnv, version_id: &str) -> (bool, String) {
let mc_version = super::game::describe_version(env, version_id).0;
let has_vulkan = capabilities(env, version_id)
.map(|json| VersionCapabilities::from_json(&json).has_vulkan_backend)
.unwrap_or(false);
let allowed = graphics_backend_allowed(&GameVersion::parse(&mc_version), has_vulkan);
@@ -100,15 +98,10 @@ pub fn backend_gate(game_layout: &GameLayout, version_id: &str) -> (bool, String
}
/// 详情页「启动参数」区的读取视图。
pub fn view(
env: &HostEnv,
game_layout: &GameLayout,
instance_id: &str,
version_id: &str,
) -> LaunchArgsView {
pub fn view(env: &HostEnv, instance_id: &str, version_id: &str) -> LaunchArgsView {
let instance = instance_overrides(env, instance_id);
let effective = resolved(env, instance_id);
let (allowed, mc_version) = backend_gate(game_layout, version_id);
let (allowed, mc_version) = backend_gate(env, version_id);
LaunchArgsView {
instance_id: instance_id.to_string(),
jvm_args: instance.jvm_args.unwrap_or_default(),
@@ -179,14 +172,19 @@ pub fn min_version_hint() -> &'static str {
impl super::CoreHost {
/// 写实例启动参数(先按 id 找档案拿 `versionId`,再落盘)。
///
/// 外部实例(盘上有版本、还没有档案)**第一次**写启动参数时才懒创建档案并落盘
/// (`game::ensure_profile`)——在那之前,浏览列表不会在用户的盘上多出任何文件。
/// 实例必须已登记:没有档案时写 `config/instances/<id>.json` 会造出一个孤立的设置文件,
/// 启动链根本不会读它(它按 `profiles.json` 找实例)。
pub(super) fn apply_launch_args(
&mut self,
id: &str,
patch: &LaunchArgsPatch,
) -> Result<(), String> {
let profile = super::game::ensure_profile(&self.env, id)?;
let profiles = super::game::profiles(&self.env);
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == id)
.ok_or_else(|| format!("找不到实例 `{id}`"))?;
apply(&self.env, id, &profile.version_id, patch)
}
}
@@ -247,18 +245,18 @@ mod tests {
fn gate_uses_core_capability_and_version_threshold() {
let (env, _dir) = env();
write_version(&env, "26.2-snapshot-2", VULKAN_VERSION);
let (allowed, mc) = backend_gate(&env.game, "26.2-snapshot-2");
let (allowed, mc) = backend_gate(&env, "26.2-snapshot-2");
assert!(allowed, "26.2-snapshot-2 应当放行");
assert_eq!(mc, "26.2-snapshot-2");
// 26.1:版本不够(即使库表里硬塞了 vulkan 也不放行——精确门限优先)。
write_version(&env, "26.1", VULKAN_VERSION);
let (allowed, mc) = backend_gate(&env.game, "26.1");
let (allowed, mc) = backend_gate(&env, "26.1");
assert!(!allowed, "26.1 不该放行");
assert_eq!(mc, "26.1");
// 没有版本 JSON:能力位全假 ⇒ 不放行(不 panic)。
let (allowed, _) = backend_gate(&env.game, "1.20.4");
let (allowed, _) = backend_gate(&env, "1.20.4");
assert!(!allowed);
}
@@ -343,7 +341,7 @@ mod tests {
&LaunchArgsPatch::backend("vulkan"),
)
.expect("写回");
let view = view(&env, &env.game, "inst-1", "26.2-snapshot-2");
let view = view(&env, "inst-1", "26.2-snapshot-2");
assert_eq!(view.graphics_backend, "vulkan");
assert_eq!(view.graphics_backend_effective, "vulkan");
assert!(view.graphics_backend_allowed);
+10 -7
View File
@@ -5,7 +5,7 @@
//! | 纪律 | 落点 |
//! |---|---|
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_set_enabled`] 走 `job::spawn`,UI 只收快照 |
//! | 单根语义(D20,M274 起按**实例自己的根**) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生;`super::game::game_for` 先定根,再把那个根喂给派生 |
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
//! | 游戏在跑就不改名 | `running` 标记在 host 这一侧挡:Windows 上进程正持有 jar,rename 会失败,与其让用户读一句 OS 报错不如直说 |
//!
//! ## 为什么不复用启动路径的 `run_dir()`
@@ -184,15 +184,18 @@ fn toggle(env: &HostEnv, id: &str, file: &str, enabled: bool, running: bool) ->
}
}
/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。
fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
// **这个实例自己那个根**(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 global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id)
.map_err(|error| format!("读实例设置失败:{error}"))?;
let derivation = derive_game_dir(&game, &instance.version_id, base, config.as_ref(), None)
let derivation = derive_game_dir(
&env.game,
&instance.version_id,
global,
config.as_ref(),
None,
)
.map_err(|error| format!("派生运行目录失败:{error}"))?;
Ok(derivation.game_dir)
}
+10 -7
View File
@@ -5,7 +5,7 @@
//! | 纪律 | 落点 |
//! |---|---|
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] / [`spawn_rename`] / [`spawn_delete`] / [`spawn_backup`] 全走 `job::spawn`,UI 只收快照 |
//! | 单根语义(D20,M274 起按**实例自己的根**) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生;`super::game::game_for` 先定根,再把那个根喂给派生 |
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
//! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
//! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) |
//! | 游戏在跑就不写 | [`dispatch`] 的 `running` 为真时三条写命令**不 spawn、不碰盘**,直接回 [`crate::dto::SavesFailureKind::GameRunning`] |
@@ -240,15 +240,18 @@ pub(super) fn target(
Ok((instance.clone(), dir))
}
/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。
pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
// **这个实例自己那个根**(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 global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id)
.map_err(|error| format!("读实例设置失败:{error}"))?;
let derivation = derive_game_dir(&game, &instance.version_id, base, config.as_ref(), None)
let derivation = derive_game_dir(
&env.game,
&instance.version_id,
global,
config.as_ref(),
None,
)
.map_err(|error| format!("派生运行目录失败:{error}"))?;
Ok(derivation.game_dir)
}