feat(saves): 实例存档 / 截图的只读列表与打开文件夹(M235 STOREMVP)

用户诉求是 PCL 差距四项里的「存档截图 / 导入」,M230 审计确认三层全缺。塔裁决 U3
把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙(缩略图要引图片解码依赖,而
AGENTS §2 只允许 palette-extract 引 image 类依赖;截图/导入产物形态也没定型)。

core
- `game::saves`(新):只读列 `<run_dir>/saves`(世界目录)与 `<run_dir>/screenshots`
  (图片文件)。只调 symlink_metadata / read_dir / metadata,不建目录、不写文件、不读
  文件内容(`icon.png` 只判存在)。目录不存在 ⇒ 空列表不是错误;存在但不可用(符号
  链接 / 不是目录 / 读不了)⇒ 具名错误。条目级异常逐条登记原因,不静默丢。
- `platform::opener`(新):`open_dir` 是**目录**打开器的唯一收敛点(explorer / open /
  xdg-open),内部复用 `child::quiet`,cfg 分支只此一处。塔明确要求按 AGENTS §6 放
  platform 而不是业务模块,并要一条可执行判据防退化
  (`the_launcher_literals_live_here_only` 扫 `crates/core/src`:除本文件外不许出现
  `"explorer"` / `"xdg-open"` 字面量)。

presenter
- `dto::saves`:SavesCommand(List / OpenFolder)、SavesListView、SavesFailureView
  (结构 + OS 原文,不拼中文)、`SavesListView` 的计数助手。
- `host::saves`:走 `job::spawn`,UI 只收快照;运行目录仍是
  `isolation::derive_game_dir` 的同一个答案(与启动 / mod 列表同源,**不写盘**)。
  「打开文件夹」打开哪个目录由 host 定:`saves/` → `screenshots/` → 运行目录。
  失败分三类(InstanceUnavailable / DirUnusable / OpenFailed),可重试。
- `state::saves`:快照整体替换、迟到结果丢弃、失败保留绑定(重试才有意义)。
- 接线:CoreEvent::Saves / AppCommand::Saves / HostTask::Saves。

app-iced
- `pages::instances::saves`:详情页 SAVES 卡片(MODS 之后、启动参数之前;**不套第二层
  滚动条**,整列那一条是唯一入口)。世界行带 UTC 时间与「有图标」,截图行带大小与时间;
  第一行固定画「这一版只读」(U3 边界让用户看得见)。读盘失败画一条红字 + 「重试」;
  打开文件夹失败**不画**重试(那个动作的重试就是再点一次按钮)。
- 接线:Message::{ReloadSaves, OpenSavesFolder}、`sync_saves`(与 `sync_mods` 同一判据)、
  Model::saves、`saves_shot` 出图夹具(ULL_SAVES_FIXTURE=1)。

i18n
- `InstancesSaves*` 20 条中英成对,catalog 与金表在**同一条锚点**后追加、逐字节一致。

证据
- core 只读判据:`game::saves::tests` 在真盘建世界/截图,调用前后各拍一次
  (相对路径, 是否目录, 字节数, mtime)指纹并逐条比对;另有用例钉「目录缺失不建目录」。
- presenter:`host::saves::tests` 真盘端到端(custom 档位派生运行目录 → 快照)。
- GUI:`--software-renderer` 1180×760 浅/深各一张 + 1180×1280 两张「整块卡片可见」
  (1180×760 下 SAVES 卡片位于折叠线附近,只能看到卡头;第二对用 ULL_WINDOW_SIZE
  把窗口拉高,口径与 M98 的矩阵一致),落 `DevDocs/artifacts/M235/`。

没做的(U3 边界内,见 handoff):缩略图墙、导入、删除/重命名、`level.dat` 的
LevelName 解析(世界名暂时就是目录名)。
This commit is contained in:
AskaEth committed 2026-11-11 06:00:27 +08:00
1 parent 3789cb7e61
commit f217d83680
36 files changed
+2702 -17

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
//! 存档 / 截图的**只读**列举与「打开文件夹」(M235 · STOREMVP)。
//!
//! # 这一版做到哪、不做什么(塔裁决 U3)
//!
//! 用户原话是「存档截图 / 导入」,M230 审计确认三层全缺(core 没有读路径,`saves/` 只在
//! `isolation.rs` 的切换探测里被计数;presenter 没有 DTO;i18n 没有键;没有页面)。
//! U3 把本 mission 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条:
//!
//! 1. 缩略图要引图片解码依赖,而 `AGENTS.md` §2 只允许 `palette-extract` 之外不引 image 类依赖;
//! 2. 截图/导入的产物形态还没定型,先不进产品。
//!
//! 所以这里**只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、
//! 不改名字、不读文件内容(`icon.png` 只判存在——那是将来缩略图墙的输入,不是这一版的解码对象)。
//!
//! # 单根语义(D20)
//!
//! 入口是**已经派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果),
//! 本模块只往下取两层:`<run_dir>/saves/` 与 `<run_dir>/screenshots/`。
//! 不自己去猜游戏根,也不做「隔离侧 + 共享侧合并」——派生是启动、mod 列表、存档列表**同一个**
//! 答案的唯一来源。与 [`crate::addon::mods`] 同一条纪律。
//!
//! # 目录不存在不是错误
//!
//! 新实例两个目录都不存在,这**不是**异常(`exists = false` + 空列表)。只有「存在但不可用」
//! (符号链接 / 不是目录 / 读不了)才是 [`SavesError`];**并且照样不建目录**。
//!
//! # 收纳口径
//!
//! | 位置 | 收什么 | 不收什么(逐条登记原因,不静默丢) |
//! |---|---|---|
//! | `saves/` | 子目录 = 一个世界 | 符号链接(不跟随)、非目录(散落文件)、名字过不了路径规则、元数据读不出 |
//! | `screenshots/` | 扩展名是 [`IMAGE_EXTENSIONS`] 的普通文件 | 同上 + 不是图片的文件(`NotAnImage`) |
//!
//! 「不静默丢」是 M224 定下的口径:列表要能和磁盘对得上账,用户看到「跳过 3 项」时得知道
//! 那 3 项不是我们忘了。
//!
//! # 模块
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`error`] | 「目录存在但不可用」的失败形状(带 OS 原文) |
//! | [`list`] | 列 `<run_dir>/saves` 与 `<run_dir>/screenshots`(纯只读) |
//!
//! 「打开文件夹」不在本模块:平台差异收敛在 [`crate::platform::opener::open_dir`]
//! (`AGENTS.md` §6),本模块一个 `cfg` 都不写。
mod error;
mod list;
pub use error::SavesError;
pub use list::{
saves_dir, scan, screenshots_dir, Area, SavesScan, SavesSkip, ScreenshotEntry, SkipKind,
WorldEntry, IMAGE_EXTENSIONS, SAVES_DIR, SCREENSHOTS_DIR, WORLD_ICON,
};
#[cfg(test)]
mod tests;