feat(core): M112 整合包导出(mrpack 优先 + CF 清单,逐文件远程判定与内联兜底)

新增 addon::export(实例目录 → .mrpack / CF manifest.json):
- 逐文件三分:远程引用 / 内联进 overrides/ / 跳过(读不了、符号链接、路径不安全)。
  「能否被远程替代」= 目录能证明身份(Modrinth SHA-1、CF 指纹)**且**目标格式表达得了;
  证明不了的一律内联,绝不丢、绝不编,逐条进结构化报告(ExportReport)。
- game / dependencies 来自实例快照:versions/<id>/<id>.json 的继承链「叶子」+ 库坐标推加载器,
  不解析 <mc>-<loader>-<ver> 字符串(社区实例名不统一,那是猜)。
- .disabled 走路径后缀(不是 env.client=optional:后者装回来默认启用,跨启动器不保真)。
- CF files[] 只写能确定 projectID/fileID 的项;禁用文件 CF 表达不了,内联并记原因。
  CF modLoaders[].id 按官方写法:NeoForge 在 1.20.1 上写 neoforge-1.20.1-<ver>。
- 尺寸护栏:fileSize 超 u32 报 pack_limits;整包撞 ZIP64 阈值由 format::write 拒绝。

配套改动:
- pack/curseforge/fingerprint:MurmurHash2-32 + 0x09/0x0A/0x0D/0x20 过滤、seed=1、
  长度用过滤后长度、哨兵 811513880 短路;金标准由一份独立 Java 实现算出(双向验证)。
- pack/format/payload:PackPayload 加 with_sources(包内条目名 → 实例源文件),
  让「实例里的 mods/x.jar → 包里的 overrides/mods/x.jar」不用拼路径。
- pack/format:写盘时按未压缩总量守 ZIP64 阈值。
- pack/modrinth:禁用位改写路径后缀;fileSize 按 u32 校验。
- pack/manifest:PackLoader::curse_id_for 处理 NeoForge 的 MC 前缀特例。

测试:导出管线 10 条 + 往返保真 4 条(三种格式夹具;允许的差异逐条在测试文件头登记,
并在报告 notes 里给出)。M111 未合并,故本轮「导入」用 M110 的 read + 同形实例目录,
真 import_pack → export → import_pack 端到端留作欠账。
This commit is contained in:
AskaEth committed 2026-11-04 21:32:37 +08:00
1 parent 3ffb1c4fee
commit 90b233088f
20 files changed
+2491 -60

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
//! 整合包**导出**(M112):实例目录 → `.mrpack`(优先)/ CurseForge `manifest.json`(次之)。
//!
//! 设计依据:`DevDocs/PlanDoc/08-mod-and-recommend.md` §2(D10)、
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md` §4(导出八条),格式模型与安全闸
//! 是 M110 的 [`crate::addon::pack`],本模块**只消费,不重写**。
//!
//! # 一句话口径:不认识的 mod 不能丢、不能编
//!
//! 用户原话(M112 任务书):「要么能证明它可被远程替代,要么就必须内联进 `overrides/` 并在
//! 结果里逐条报告」。落到代码就是三分类,**没有第四类**:
//!
//! | 分类 | 判据 | 产物 |
//! |------|------|------|
//! | 远程引用([`FileClass::Remote`]) | 目标格式**能表达**它 + 某个目录([`RemoteCatalog`])**能证明**它的身份 | 清单 `files[]` 一条 |
//! | 内联([`FileClass::Inline`]) | 证明不了,或证明了但目标格式表达不了 | 进 `overrides/`,原字节照搬 |
//! | 跳过([`FileClass::Skipped`]) | 读不了 / 是符号链接 / 路径不安全 | 不写进包,**逐条**进报告 |
//!
//! 「能证明」用两条独立查询(契约 §4.4 的顺序):**Modrinth SHA-1** →
//! [`RemoteCatalog::modrinth_by_sha1`];**CurseForge 指纹**([`crate::addon::pack::curseforge::fingerprint`])
//! → [`RemoteCatalog::curseforge_by_fingerprint`]。两条都命中时两条直链都进 `downloads[]`
//! (HMCL 同款,兼容面更大)。**没有网络时**用 [`OfflineCatalog`]:所有文件都内联,包照样完整可导入
//! ——这不是降级失败,是"证明不了就内联"的正面行为。
//!
//! # 每条格式决策落在哪(tower 定案,别按任务书原话改回去)
//!
//! 1. **`.disabled` 走路径后缀**(`mods/x.jar.disabled`),不是 `env.client=optional`:后者只是
//! "安装时可取消勾选",装回来默认仍然启用 ⇒ 跨启动器不保真。官方 Modrinth 导出也是后缀。
//! 2. **覆盖目录一律 `overrides/`**(不用 HMCL 那套 `client-overrides/`):Modrinth 官方与
//! Prism 都用它,兼容面最大。分侧语义在本轮导出里不产生(实例目录本身不分侧)。
//! 3. **`game` + `dependencies` 从实例快照取**,绝不只写 `minecraft` 漏加载器:快照来自
//! `versions/<id>/<id>.json` 的继承链 + 库坐标([`snapshot`]),**不解析**
//! `<mc>-<loader>-<ver>` 这类实例 id 字符串(社区实例名不统一,那是猜)。
//! 4. **CF `files[]` 只写能确定 `projectID`/`fileID` 的项**:CF 没有"内联到 files[]"的概念,
//! 证明不了的(以及**禁用文件**:CF 根本没有禁用表达)一律进 `overrides/` 并进报告。
//! 5. **尺寸护栏**:`fileSize` 是 u32(超了报 `pack_limits`,不静默截断)、整包撞 ZIP64 阈值由
//! `format::write` 拒绝(`pack_limits`,不留半个包)。
//!
//! # 刻意不做的事(分工边界,别以为是漏做)
//!
//! - **MultiMC / Prism 导出**:不做(P1 只做导入)。它的包是"整实例快照",要先把所有远程文件
//! 下载下来再打包,成本极高;导入侧 M111 已覆盖。**这是有意省略。**
//! - **不装加载器 / 不补全清单**:加载器元数据解析与库下载是 `addon::loader` + `game::install`;
//! CF 的 `projectID/fileID → 文件名/URL` 补全需要 CF 客户端(D10 §5 规划中),本轮由调用方注入
//! [`RemoteCatalog`],本模块不发网络请求。
//! - **不碰 UI 类型**:core 零 UI 依赖(`core-no-ui` 门禁),报告只出现 `String` / `&'static str`。
//! - **不改实例**:导出只读实例目录。
//!
//! # 没验的部分(报告里同样登记)
//!
//! 真实 Modrinth / CurseForge API 查询(要网络与 key,`RemoteCatalog` 目前只有离线实现)、
//! 超大实例(GB 级、上千文件——指纹要对每个文件多读两遍)、Windows 之外的平台、
//! 以及「M111 合并后的 `import_pack → export → import_pack` 真端到端」。
pub mod catalog;
pub(crate) mod classify;
pub mod pipeline;
pub mod report;
pub mod scan;
pub mod snapshot;
pub use catalog::{OfflineCatalog, RemoteCatalog, RemoteHit, RemoteSource};
pub use pipeline::{export_instance, ExportRequest};
pub use report::{ExportFile, ExportReport, FileClass, FileReason};
pub use scan::{scan_instance, LocalFile, SkippedLocal, DEFAULT_CONTENT_ROOTS, OVERRIDES_DIR};
pub use snapshot::{read_instance_snapshot, InstanceSnapshot, SnapshotLoader};