mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
feat(assetbundle): 完善官方资源解析与双目录发布
This commit is contained in:
+6
-2
@@ -9,15 +9,19 @@
|
||||
### 新增
|
||||
- Addressables catalog 提取 `m_Crc`(bundle IEEE CRC-32):`ResourceEntry` 新增 `crc` 字段(compact/expanded 两种形态均解析),SQLite 持久化并对旧库幂等迁移补列;core 新增 `crc32_ieee` 与 `ResourceEntry::verify_downloaded_bytes`(按声明的 size/CRC 校验字节)(issue #2)
|
||||
- UnityFS 解析新增目录条目越界校验:directory 的 `offset+size` 必须落在解压数据区内,截断/损坏 bundle 的越界目录条目不再被静默接受(issue #3)
|
||||
- 官方资源下载回归顺序执行:manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17)
|
||||
- 官方资源下载回归顺序执行:manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17,按 wontfix 关闭多线程下载目标)
|
||||
- 新增 `cmd/bat-api` 资源分发 HTTP 服务(issue #19 / G-009 资源面):经 `bat.sock` RPC(`daemon.status` → `daemon.doctor` → catalog/manifest)发现已发布 release,按官方 CDN host/path 只读提供资源;管理面 `/healthz` `/v1/release` `/v1/resources`;可选 server-info 仅改写 Addressables root;`.env` 配置监听端口/RPC socket/预留数据库键。资源自动拉取仍由 Rust `bat` 负责
|
||||
- 官方同步新增 `official-parse-cache.json`:校验发布后从下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;URL、相对路径、size 和 BLAKE3 未变化时跳过重复解析
|
||||
- 官方原版资源和汉化产物目录分离:`BAT_OUTPUT`/`--output` 默认 `./bat-resources`,`BAT_LOCALIZED_OUTPUT`/`--localized-output` 默认 `./bat-localized`;同步报告新增 `localized_release_status=not_localized`,后续 Patch 发布完成后才切换为 `localized`
|
||||
|
||||
### 修复
|
||||
- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s)
|
||||
|
||||
### 计划
|
||||
- [ ] 实现 `bat-api`:仿 BlueArchive 官方 API 的 Go HTTP 服务(含鉴权/签名验签,issue #19)
|
||||
- [ ] `bat-api` 后续:launcher 链(若需要)、API 持久化层接入预留 database/redis 配置
|
||||
- [ ] 官方同步结果接入 CAS + ResourceRepository 的用户级工作流
|
||||
- [ ] 官方下载/导入路径接入 CRC/size 校验(复用 `verify_downloaded_bytes`)
|
||||
- [ ] 汉化 Patch 发布:维护 `localized-output/current`、`localized-version-state`、`localized` 状态切换和回滚
|
||||
- [ ] 实现翻译系统
|
||||
- [ ] 实现 Patch 引擎
|
||||
- [ ] 实现 Web 管理后台
|
||||
|
||||
+48
-50
@@ -1,16 +1,17 @@
|
||||
# BlueArchiveToolkit 当前工作区状态
|
||||
|
||||
- **更新时间**:2026-07-20
|
||||
- **更新时间**:2026-07-24
|
||||
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
||||
- **状态分支**:`experiment`
|
||||
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
||||
- **权威计划**:`PROJECT_PLAN.md`
|
||||
- **Go 进度权威**:`docs/reports/GO_STATUS.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 总体判断
|
||||
|
||||
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go `bat-api` 已有 Rust daemon RPC client 但 CLI/API/Web 仍未形成产品入口** 阶段。
|
||||
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go 侧以 `bat-api` 资源分发 MVP + `backendrpc` 为正式服务入口(同步/运维命令行仍为近乎全自动的 Rust `bat`)** 阶段。
|
||||
|
||||
Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
@@ -23,13 +24,13 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
7. 支持 curl 传输层本地代理:默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量(带凭据的代理推荐用环境变量配置),也可用 `--proxy <URL>` 显式指定或 `--no-proxy` 强制直连;代理决策会写入 progress log、daemon log 和 `bat doctor` 诊断输出。代理凭据不落世界可读位置:日志/`status` 脱敏,传给 curl 经 `ALL_PROXY` 环境变量而非 argv,`--daemon` 下经环境变量下传后台子进程、不进子进程 argv 或 `bat-status.json`,复用凭据存于 `bat-proxy.secret`(`0600`)且 `clean-stable` 会清除。
|
||||
8. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`。
|
||||
9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
|
||||
10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/<id>` 和 `.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
10. 默认官方原版资源目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`;官方资源目录是发布根目录,包含 `current` symlink、`versions/<id>` 和 `.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||
12. `<output>/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。
|
||||
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC 与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
|
||||
14. `bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。
|
||||
14. 非 dry-run 官方同步在校验完成并发布后,会在 active release 下刷新 `official-parse-cache.json`;每轮根据 `official-download-manifest.json` 的 URL、相对路径、size 和 BLAKE3 判断是否复用旧解析结果,本地文件未变化时跳过重复解析。解析入口覆盖下载 manifest 中的全部官方资源:直接 UnityFS bundle、zip 内 UnityFS 条目会进入解析,其它 catalog/hash/media 文件记录为不支持而不是失败。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;后续 Patch 发布完成后才应切换为 `localized`,表示原版和汉化两套资源都可发布。`bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。
|
||||
|
||||
仍需明确:这不是完整产品完成。Go 产品入口、完整 AssetBundle 引擎解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。
|
||||
仍需明确:这不是完整产品完成。完整 AssetBundle 引擎、Patch、翻译、Web、以及 `bat-api` 的服务器联调/可选业务扩展仍是后续工作;G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。
|
||||
|
||||
---
|
||||
|
||||
@@ -41,6 +42,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
|
||||
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
|
||||
- `docs/architecture/official-resource-backend.md`:官方资源后端设计和审核说明。
|
||||
- `docs/architecture/assetbundle.md`:解析补全路线图,覆盖 Addressables、UnityFS、Serialized 字段级解析、文本提取、CAS 接入和 Patch 发布前置。
|
||||
- `docs/reports/CURRENT_GAPS.md`:当前缺口和关闭顺序。
|
||||
|
||||
历史 Week 2/Week 3 报告只作追溯,不再代表当前状态。
|
||||
@@ -96,7 +98,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
待完成:
|
||||
|
||||
- `crates/bat-assetbundle` 仍是占位 crate,完整 UnityFS/对象表/TypeTree 引擎未实现。
|
||||
- `crates/bat-assetbundle` 已具备 UnityFS 容器基础解析,完整对象表/TypeTree/TextAsset/MonoBehaviour/ScriptableObject 引擎未实现。
|
||||
- Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
||||
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
|
||||
|
||||
@@ -144,20 +146,23 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
### `bat-assetbundle`
|
||||
|
||||
状态:**占位**
|
||||
状态:**UnityFS 解包和 TextAsset 提取已起步;MonoBehaviour/ScriptableObject 字段级解析未完成**
|
||||
|
||||
当前只有:
|
||||
当前已有:
|
||||
|
||||
- Parser trait 仍是占位。
|
||||
- AssetType 仍是占位。
|
||||
- 错误类型骨架可用,但没有完整解析引擎。
|
||||
- `UnityFsParser`、`UnityFsBundle`、`ParsedAssetBundle`、`RawAssetBundle` 等正式类型。
|
||||
- UnityFS header、block info、directory 解析。
|
||||
- block info at end、LZ4/LZMA block info 解压、LZ4/LZMA 数据 block 解压、directory 文件提取、压缩/解压数据区大小和 directory 越界诊断。
|
||||
- Unity serialized file header、type table、TypeTree node 元数据、object table 和 TextAsset bytes 提取。
|
||||
- `ResourceImportService` 的 UnityFS 摘要已包含解包文件数、serialized file 数、TextAsset 数量和名称。
|
||||
- `bat-adapters` 的 Unity 2021.3 adapter 已改为版本选择薄层,复用 `bat-assetbundle`,避免两套 UnityFS parser。
|
||||
|
||||
待完成:
|
||||
|
||||
- UnityFS header、block、directory、metadata、object table。
|
||||
- LZ4/LZMA 解压。
|
||||
- TypeTree 解析。
|
||||
- TextAsset、MonoBehaviour、ScriptableObject 解析入口。
|
||||
- MonoBehaviour、ScriptableObject 的 TypeTree 字段级反序列化。
|
||||
- 修改 TextAsset/字段值后的 AssetBundle 重打包或 Patch 生成。
|
||||
- 真实资源 fixture 覆盖对象级解析和文本提取。
|
||||
- 详细补全顺序见 `docs/architecture/assetbundle.md`。
|
||||
|
||||
### `bat-patch`
|
||||
|
||||
@@ -198,15 +203,19 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
### Go / API / Web
|
||||
|
||||
状态:**Go 产品入口仍未完成;`bat-api` 已有 typed Rust daemon RPC client、可选 CGO 兼容包装和试验性 `cmd/bat` 骨架**
|
||||
状态:**边界已冻结;资源分发 MVP 已落地。权威细节见 `docs/reports/GO_STATUS.md`。**
|
||||
|
||||
当前情况:
|
||||
| 角色 | 所有者 | 状态 |
|
||||
|---|---|---|
|
||||
| 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 |
|
||||
| 资源分发 HTTP | Go `cmd/bat-api` | CDN MVP |
|
||||
| daemon RPC client | `internal/backendrpc` | 完成 |
|
||||
| 试验 CLI | `cmd/bat` → `bin/bat-go` | 非产品 |
|
||||
| FFI | `internal/ffi` | 可选 |
|
||||
| 空目录 `api/` `pkg/` 等 | 占位 | 无实现 |
|
||||
| Web | `web/` | 空(G-010) |
|
||||
|
||||
- `internal/backendrpc` 已提供 Go typed Unix socket JSON-RPC client,作为 `bat-api` 调用 Rust daemon 的默认路径。
|
||||
- `internal/ffi/ffi.go` 已存在。
|
||||
- Go CLI 的稳定集成方向仍应优先通过 Rust daemon RPC 或 Rust `bat --json` one-shot 进程边界;`cmd/bat` 目前只是试验性骨架,不代表产品级 CLI 已完成。
|
||||
- `cmd/`、`pkg/`、`api/`、`web/` 仍无可用产品入口,`cmd/bat` 目前只覆盖 `doctor`、`manifest inspect`、`sync plan` 这类最小演示能力。
|
||||
- `go test ./...` 目前只有空测试包结果,`go vet ./...` 可作为基础门禁。
|
||||
默认 Go 门禁:`make test-go-api`、`make build-go-api`(无 FFI)。
|
||||
|
||||
---
|
||||
|
||||
@@ -216,27 +225,16 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
```bash
|
||||
cargo test --workspace --quiet
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build -o /tmp/bat-go-cli ./cmd/bat
|
||||
target/debug/bat --help
|
||||
git diff --check
|
||||
make test-go-api
|
||||
make build-go-api
|
||||
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
||||
```
|
||||
|
||||
同步确认:
|
||||
未执行 / 后置:
|
||||
|
||||
```bash
|
||||
git status --short --branch
|
||||
```
|
||||
|
||||
结果:工作区干净。
|
||||
|
||||
未执行:
|
||||
|
||||
- 本次状态更新未执行一次性真实官方网络全量下载 smoke;该流程已由 `docs/guides/official-full-pull-smoke.md` 和 `scripts/official-full-pull-smoke.sh` 固化并关闭(G-018),当前处于长期运行测试阶段,运行报告将在后续提供。
|
||||
- Go CLI 端到端测试,因为 Go 产品入口尚未完成;`internal/backendrpc` 已有 fake transport 单测覆盖。
|
||||
- Web/API 测试,因为 Web/API 尚未实现。
|
||||
- 真实官方全量 smoke 长期运行报告(G-018 命令已固化)。
|
||||
- `bat-api` 对服务器全量 release 的 SSH 联调(等连接信息)。
|
||||
- Web(G-010)。
|
||||
|
||||
---
|
||||
|
||||
@@ -265,15 +263,15 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
|
||||
## 6. 当前阻塞项
|
||||
|
||||
GitHub issue 状态:#1 已升为 P0;#17 的实现已合入 HEAD,但 issue 本身尚未关闭,需在验收后再同步关闭。其他 open issue 的实时标签以 GitHub 为准。
|
||||
GitHub issue 状态:#1 已关闭;#17 已按 wontfix 关闭(多线程下载入口已移除,下载回归顺序执行并保留指数退避与单调进度上报,子 issue #20–#23 均已关闭)。其他 open issue 的实时标签以 GitHub 为准。
|
||||
|
||||
下一阶段必须优先完成:
|
||||
|
||||
1. Issue #1(P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:`patch.*` / `unityfs.*`(被引擎阻塞)、`task.create`(按设计由语义方法创建)、`daemon.restart` / `daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做自重启或在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
|
||||
2. `bat-api` Go 侧:`internal/backendrpc` typed RPC client 已起步,`cmd/bat` 当前只实现 `doctor`、`manifest inspect` 和 `sync plan` 这类试验性入口,不能视作产品级 CLI;是否继续作为长期产品入口需要单独收敛。
|
||||
2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源分发 MVP 已落地;联调与 USERGUIDE 专章后置。
|
||||
3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。
|
||||
4. Issue #3(P2):AssetBundle UnityFS 基础解析校验。
|
||||
5. Issue #2(P2):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
|
||||
4. Issue #3(P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。
|
||||
5. Issue #2(P1):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
|
||||
6. Patch 和翻译系统仍应后置。
|
||||
|
||||
非阻塞跟踪项:官方同步长期运行测试正在进行,运行报告将在后续提供。
|
||||
@@ -285,12 +283,12 @@ GitHub issue 状态:#1 已升为 P0;#17 的实现已合入 HEAD,但 issue
|
||||
立即任务:
|
||||
|
||||
1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*`、`task.*`、`resource.repair`、任务持久化、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)以及 `task.create`、`daemon.restart`、`daemon.clean-stable` 的设计边界确认。
|
||||
2. 明确 Go 产品入口的边界:是继续推进独立 `bat` CLI,还是保留当前 Rust `bat` 为用户 CLI、Go 只做服务层与 `bat-api`。
|
||||
3. 跟进官方同步长期运行测试,收集并归档运行报告。
|
||||
4. 开始 AssetBundle parser 的 UnityFS header/block/directory(issue #3),并继续扩展 Addressables catalog 可校验字段(issue #2)。
|
||||
2. `bat-api` 与全量 release / 服务器 daemon 联调(issue #19 剩余)。
|
||||
3. 跟进官方同步长期运行测试报告。
|
||||
4. AssetBundle / Addressables(issue #3 / #2);CAS 用户级导入(G-011)。
|
||||
|
||||
---
|
||||
|
||||
- **当前总体完成度**:不再固定写单一百分比,以各模块状态和 issue 为准。
|
||||
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;Go `bat-api` 已有 Rust daemon RPC client,但产品级 CLI/API、CAS 用户级导入和完整 AssetBundle 引擎仍未完成。
|
||||
- **下一工程里程碑**:Rust Resource Backend RPC API 收尾、Go 产品入口收敛、官方同步结果接入 CAS/ResourceRepository、AssetBundle 解析起步。
|
||||
- **当前总体完成度**:不再固定写单一百分比,以各模块状态、`GO_STATUS.md` 和 issue 为准。
|
||||
- **当前基线状态**:Rust `bat` 同步闭环可用;Go `bat-api` 资源分发 MVP + `backendrpc` 可用;CAS 用户级导入与完整 AssetBundle 引擎未完成。
|
||||
- **下一工程里程碑**:bat-api 联调、CAS/ResourceRepository 用户流、AssetBundle 解析。
|
||||
|
||||
Generated
+11
-9
@@ -66,14 +66,13 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bat-adapters"
|
||||
version = "0.2.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"bat-assetbundle",
|
||||
"bat-core",
|
||||
"lz4",
|
||||
"lzma-rs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -83,10 +82,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bat-assetbundle"
|
||||
version = "0.2.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hex",
|
||||
"lz4",
|
||||
"lzma-rs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
@@ -95,7 +96,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bat-cas-engine"
|
||||
version = "0.2.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -112,7 +113,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bat-core"
|
||||
version = "0.2.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -125,7 +126,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bat-ffi"
|
||||
version = "0.2.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"bat-adapters",
|
||||
"bat-infrastructure",
|
||||
@@ -136,11 +137,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bat-infrastructure"
|
||||
version = "0.2.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bat-adapters",
|
||||
"bat-assetbundle",
|
||||
"bat-cas-engine",
|
||||
"bat-core",
|
||||
"blake3",
|
||||
@@ -157,7 +159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bat-patch"
|
||||
version = "0.2.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
authors = ["BlueArchive Toolkit Team"]
|
||||
license = "MIT"
|
||||
|
||||
+8
-2
@@ -11,9 +11,12 @@
|
||||
- `PROJECT_PLAN.md`:完整开发计划和最终目标路线图。
|
||||
- `CURRENT_STATUS.md`:当前工作区真实状态。
|
||||
- `docs/reports/CURRENT_GAPS.md`:当前实现缺口和关闭顺序。
|
||||
- `docs/reports/GO_STATUS.md`:Go 侧边界、约定与组件进度(权威)。
|
||||
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
|
||||
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
|
||||
- `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。
|
||||
- `docs/architecture/resource-release-layout.md`:release 布局、URL 映射、seed 规则、bat-api 分发契约(资源侧逆向权威)。
|
||||
- `docs/architecture/assetbundle.md`:AssetBundle、Addressables、Serialized File、文本提取和 Patch 前置解析路线图。
|
||||
- `docs/reference/rpc-backend-api.md`:Rust Resource Backend JSON-RPC 稳定 contract。
|
||||
- `CHANGELOG.md`:版本变更记录。
|
||||
- `AGENTS.md`:AI agent 和自动化开发助手长期规则。
|
||||
@@ -40,7 +43,6 @@
|
||||
后续建议新增:
|
||||
|
||||
- `docs/architecture/cas.md`:CAS 生产级设计。
|
||||
- `docs/architecture/assetbundle.md`:AssetBundle 解析设计。
|
||||
- `docs/architecture/translation.md`:翻译系统设计。
|
||||
|
||||
---
|
||||
@@ -101,14 +103,18 @@
|
||||
- CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发测试和损坏检测。
|
||||
- 文档整理和路线图重制。
|
||||
- Rust 官方资源同步闭环:`bat`、`--auto-discover`、`--watch`、`--daemon`、Unix socket JSON-RPC 后台控制、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor`、`clean-stable`、北京时间固定强制刷新、snapshot、manifest audit/repair、官方 seed `.hash` 校验。
|
||||
- 官方原版资源与汉化产物目录分离:`./bat-resources` 只承载原版 release,`./bat-localized` 承载后续汉化 release;当前官方同步报告 `not_localized`,Patch 发布完成后才进入 `localized`。
|
||||
- 官方 release 会维护 `official-parse-cache.json`,用于跳过未变化资源的重复解析。
|
||||
- `bat-api/internal/backendrpc` typed Unix socket JSON-RPC client。
|
||||
- `cmd/bat-api` 资源分发 HTTP MVP(进度见 `docs/reports/GO_STATUS.md`)。
|
||||
- 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。
|
||||
- `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。
|
||||
- Addressables 当前真实形态 fixture/golden 覆盖。
|
||||
- 解析补全路线图已固化到 `docs/architecture/assetbundle.md`:解析缓存、Addressables、UnityFS、Serialized 字段级解析、文本提取、CAS 接入和 Patch 发布前置。
|
||||
- SQLite Resource Repository 和可选无状态 `bat-ffi` JSON 兼容接口。
|
||||
|
||||
优先待办:
|
||||
|
||||
- 收敛 Go 产品入口的最终形态,避免把试验性 `cmd/bat` 误当作完成品。
|
||||
- `bat-api` 与全量 release / 服务器 daemon 联调(issue #19 剩余)。
|
||||
- 将官方同步结果接入 CAS + ResourceRepository 的用户级流程。
|
||||
- 推进 AssetBundle UnityFS 引擎级解析。
|
||||
|
||||
+45
-33
@@ -34,27 +34,29 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat`。
|
||||
7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list`、`catalog.*`、`task.*`);`restart` 和 `clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
||||
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
|
||||
9. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。
|
||||
9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布。
|
||||
10. 官方同步校验完成后会在 active release 下生成 `official-parse-cache.json`,未变化文件按 URL、相对路径、size 和 BLAKE3 复用解析结果。
|
||||
11. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。
|
||||
|
||||
### 仍是骨架或占位
|
||||
|
||||
1. `bat-assetbundle` 仍是占位 crate;完整 UnityFS、压缩块、TypeTree 或对象表解析未完成。
|
||||
1. `bat-assetbundle` 已具备 UnityFS 解包和 TextAsset 提取基础能力(header/block info/directory、LZ4/LZMA block info 与数据 block、directory 文件提取、serialized file object table、TypeTree node 元数据、TextAsset bytes),但 MonoBehaviour/ScriptableObject 字段级解析、重打包和 Patch 仍未完成。
|
||||
2. `bat-patch` 的 Binary/JSON 模块仍返回明确的未实现错误,不具备真实补丁能力。
|
||||
3. Go CLI/API/SDK 仍没有产品级入口;当前 `bat-api` module 只有 `internal/backendrpc` Rust daemon RPC client、`cmd/bat` 试验骨架与 `internal/ffi` 兼容包装。
|
||||
3. Go 侧边界已冻结(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发 = `cmd/bat-api` MVP;`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / Web / SDK 仍未完成。
|
||||
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
|
||||
5. 官方同步结果尚未作为用户级流程自动导入 CAS + ResourceRepository。
|
||||
6. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
|
||||
7. Web、数据库迁移、OpenAPI、插件加载机制尚未实现。
|
||||
8. 原 Git 历史未恢复;当前仓库以新初始化基线为准。
|
||||
6. 汉化 Patch 发布流程尚未完成;`localized` 发布状态、`localized-output/current` 切换、回滚和完整性校验仍需由 Patch 阶段落地。
|
||||
7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
|
||||
8. Web、数据库迁移、OpenAPI、插件加载机制尚未实现。
|
||||
9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。
|
||||
|
||||
### 已验证
|
||||
|
||||
1. `cargo test --workspace --quiet` 通过。
|
||||
2. `cargo clippy --workspace --all-targets -- -D warnings` 通过。
|
||||
3. `go test ./...` 通过,但目前没有 Go 产品级测试覆盖。
|
||||
4. `go vet ./...` 通过。
|
||||
5. `go build -o /tmp/bat-go-cli ./cmd/bat` 通过。
|
||||
6. `target/debug/bat --help` 可用。
|
||||
3. `make test-go-api` / `make build-go-api` 覆盖 `internal/api` 与 `internal/backendrpc`。
|
||||
4. `go vet` 覆盖 bat-api 相关包。
|
||||
5. `target/debug/bat --help`(Rust)可用。
|
||||
|
||||
---
|
||||
|
||||
@@ -141,7 +143,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
**目标**:完成可长期使用的 Content Addressable Storage。
|
||||
|
||||
**当前状态**:已完成 CAS V1。Go CLI/API 产品入口尚未完成;`bat-api` 已有 `internal/backendrpc` typed RPC client 和 `cmd/bat` 试验骨架;Rust 继续承载完整资源拉取与更新检查核心逻辑;`bat-ffi` 仅保留为可选兼容层。
|
||||
**当前状态**:已完成 CAS V1。Rust 承载完整资源拉取与更新检查;Go 以 `bat-api` 资源分发 MVP + `backendrpc` 为服务入口(`GO_STATUS.md`);`bat-ffi` 仅可选兼容层。
|
||||
|
||||
交付物:
|
||||
|
||||
@@ -174,11 +176,13 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;仍需冻结 Go CLI/API 可见模型。
|
||||
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验和 repair。
|
||||
4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch`、`--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。
|
||||
5. Go 产品入口:**未完成**。`internal/backendrpc` 已提供 Go 调 Rust daemon 的 typed RPC client;当前 `cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,尚不构成产品级 CLI;若要继续由 Go 承担用户入口,需要单独收敛命令集和调用边界。
|
||||
5. Go 入口边界:**已冻结**。同步命令行 = Rust `bat`(G-008 关闭);资源分发 = `bat-api` MVP(G-009 部分完成)。详见 `docs/reports/GO_STATUS.md`。
|
||||
6. 用户级 `sync`、`manifest inspect`、`cache status`:**未完成**。Rust `bat --json` 是当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
|
||||
7. 下载结果写入 CAS + ResourceRepository:**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果自动作为用户级流程导入。
|
||||
8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`。
|
||||
9. 真实官方网络全量下载 smoke test:**命令已固化(G-018 已关闭)**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
|
||||
10. 官方发布后的解析缓存:**已完成基础入口**。`official-parse-cache.json` 基于下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;本地文件未变化时跳过重复解析。
|
||||
11. 汉化发布状态:**已完成状态模型准备**。官方同步默认报告 `not_localized`,表示只发布原版资源;后续 Patch 阶段发布汉化资源后才切换为 `localized`。
|
||||
|
||||
验收标准:
|
||||
|
||||
@@ -190,6 +194,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
6. 自动更新入口必须做到无变化不下载,有变化下载成功后才写入新 snapshot。
|
||||
7. `--watch` 模式必须在 Rust 内部保持持久检查能力,外部 supervisor 只负责进程守护。
|
||||
8. 真实官方网络 smoke 必须记录输出目录、命令、结果摘要和未纳入仓库的大文件位置。
|
||||
9. 官方原版资源目录和汉化产物目录必须物理分离,不能相同或互相嵌套。
|
||||
10. 官方同步完成后必须能区分 `not_localized` 和 `localized`,不能把原版资源发布状态与汉化产物发布状态混为一谈。
|
||||
|
||||
---
|
||||
|
||||
@@ -199,19 +205,25 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
交付物:
|
||||
|
||||
1. 解析 UnityFS header、blocks、directory、metadata、objects。
|
||||
2. 支持 LZ4/LZMA 解压,记录压缩块校验。
|
||||
3. 实现 TypeTree/ObjectInfo 读取。
|
||||
4. 实现 TextAsset、MonoBehaviour、ScriptableObject 的可扩展解析入口。
|
||||
5. 增加解析器注册表和版本适配器。
|
||||
6. 编写 `bundle inspect`、`bundle extract`。
|
||||
1. **解析缓存闭环**:官方同步发布后生成 `official-parse-cache.json`,覆盖 manifest 全部条目、直接 bundle、zip 内 bundle、非候选资源和解析失败诊断;未变化文件按 URL、相对路径、size 和 BLAKE3 复用解析结果。
|
||||
2. **Addressables 完整化**:覆盖 Windows/Android JSON、compact JSON 和后续二进制 catalog 入口,解析 provider、internal id、primary key、dependency、bundle name、hash、size、CRC 和资源类型。
|
||||
3. **UnityFS 容器层**:继续完善 header、block info、directory、data block、压缩、alignment、边界错误、directory 文件提取和真实样本回归。
|
||||
4. **Serialized file 层**:稳定 Unity serialized file header、type table、TypeTree node、object table、path id、class id 和 raw object bytes 表示。
|
||||
5. **字段级解析层**:实现 TypeTree 字段 reader,支持 bool、integer、float、string、bytes、array、map、PPtr 和 managed reference 诊断占位。
|
||||
6. **文本对象入口**:实现 TextAsset、MonoBehaviour、ScriptableObject 的可扩展提取入口,输出可追溯到 bundle、serialized file、path id 和 field path 的文本定位。
|
||||
7. **工具与接口**:编写 `bundle inspect`、`bundle extract`、`text extract` 的最小稳定入口;CLI/RPC/API 使用解析器输出,不直接耦合解析内部结构。
|
||||
8. **汉化发布前置**:解析结果必须能作为 Patch 输入;Patch 发布阶段才写 `localized-output` 并切换 `localized` 状态。
|
||||
|
||||
验收标准:
|
||||
|
||||
1. 能解析真实样本或明确结构化测试样本。
|
||||
2. 错误报告包含 bundle 名称、偏移、字段和 Unity 版本。
|
||||
3. 解析器和业务流程解耦。
|
||||
4. 不支持的 Unity 版本返回明确错误,不做隐式猜测。
|
||||
1. 能解析结构化测试样本、离线回归 fixture 和隔离真实样本。
|
||||
2. 错误报告包含 URL/路径、archive entry、UnityFS directory、object path id、class id、field path、offset 和 Unity 版本。
|
||||
3. 解析器和业务流程解耦;解析器不直接写 `bat-resources` 或 `bat-localized`。
|
||||
4. 不支持的 Unity 版本或 TypeTree 结构返回明确错误,不做隐式猜测。
|
||||
5. `official-parse-cache.json` 能跳过未变化资源的重复解析,且不会影响官方原版资源发布。
|
||||
6. 文本提取结果能追溯到原始资源位置,并可作为后续 Patch manifest 输入。
|
||||
|
||||
详细分层路线图见 `docs/architecture/assetbundle.md`。
|
||||
|
||||
---
|
||||
|
||||
@@ -296,6 +308,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
2. 任一步失败都能回滚到补丁前状态。
|
||||
3. 不直接覆盖未经备份的客户端文件。
|
||||
4. Patch 生成与应用有端到端测试。
|
||||
5. 汉化产物写入独立 `localized-output`,保留官方相对目录结构;只有完整 Patch 发布并通过校验后才切换为 `localized`。
|
||||
|
||||
---
|
||||
|
||||
@@ -366,7 +379,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 5. 推荐执行顺序
|
||||
|
||||
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是 Go 产品入口边界、资源解析、同步结果进入 CAS/ResourceRepository,以及真实端到端验证。
|
||||
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是资源解析、同步结果进入 CAS/ResourceRepository、`bat-api` 与全量 release 联调,以及真实端到端验证。
|
||||
|
||||
建议顺序:
|
||||
|
||||
@@ -380,15 +393,14 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 6. 近期具体任务
|
||||
|
||||
优先完善 Rust `bat` 后端,并同步收敛 Go 产品入口边界。当前事实是 Rust `bat` 已承担可用的资源同步/运维入口,`bat-api/internal/backendrpc` 已提供 Go 到 Rust daemon 的本机 RPC client,Go `cmd/bat` 仍只是试验骨架,`bat-api` HTTP 服务仍是独立目标(issue #19 / G-009):
|
||||
优先完善 Rust 解析与资源库接入,并联调 Go 资源分发。边界见 `docs/reports/GO_STATUS.md`:
|
||||
|
||||
1. 对 issue #17 做验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open。
|
||||
2. 继续逆向 Addressables catalog,扩大 bundle hash/size/CRC 等可校验字段覆盖(issue #2)。
|
||||
3. 对 AssetBundle/UnityFS 做引擎级解析:header/block/directory/metadata/object table(issue #3 / G-005)。
|
||||
4. 将官方同步下载结果接入 CAS + `SqliteResourceRepository` 的用户级流程(G-011)。
|
||||
5. 收敛 Go 产品入口:明确继续推进最小 Go CLI,或把用户 CLI 固化为 Rust `bat` 并把 Go 侧集中到 `bat-api`。
|
||||
6. 实现 `bat-api`(仿官方 API 的 Go HTTP 服务,含鉴权/签名验签,issue #19 / G-009)。
|
||||
7. 为 CAS 增加 `doctor cas` 诊断入口。
|
||||
1. issue #17 已关闭(顺序下载 + 指数退避)。
|
||||
2. G-008 已决策关闭:同步/运维命令行 = 近乎全自动的 Rust `bat`。
|
||||
3. G-009 / issue #19:`bat-api` 资源分发 MVP 已落地;优先服务器联调;拉取仍在 Rust `bat`。
|
||||
4. 继续 Addressables(issue #2)与 UnityFS(issue #3 / G-005)。
|
||||
5. 官方同步结果接入 CAS + ResourceRepository 用户级流程(G-011)。
|
||||
6. 为 CAS 增加 `doctor cas` 诊断入口。
|
||||
|
||||
---
|
||||
|
||||
@@ -416,7 +428,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
处理策略:
|
||||
|
||||
1. Rust 提供稳定引擎能力,并在当前阶段承担可生产运行的官方资源同步 CLI、watch 和 daemon。
|
||||
2. Go 的长期职责包括用户命令、最小稳定 CLI、服务编排、网络和 Provider;当前 Go 产品入口尚未完成,不能把 `cmd/bat` 试验骨架视为完成。
|
||||
2. Go 的长期职责包括资源分发 HTTP(`bat-api`)、服务编排、网络和 Provider;同步/运维命令行由近乎全自动的 Rust `bat` 承担。不能把试验性 `cmd/bat` 视为产品 CLI。
|
||||
3. 跨边界优先进程或 SDK,FFI 只作为可选的粗粒度、无状态、安全、可测试兼容 API。
|
||||
4. Rust 不需要被强制写成 Go 调用库;当前 `bat --watch` / `bat --daemon` 是允许长期运行的 Rust 生产任务。
|
||||
|
||||
@@ -442,7 +454,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准。
|
||||
|
||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1 和 Rust 官方资源同步闭环,不是完整产品能力。下一阶段的关键不是继续堆目录,而是把 Go 产品入口边界、官方同步端到端验证、CAS/ResourceRepository 编排和 AssetBundle 解析链路做实。
|
||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环,以及 Go `bat-api` 资源分发 MVP。下一阶段的关键是官方同步结果进入 CAS/ResourceRepository、AssetBundle 解析,以及 bat-api 与全量 release 联调。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
|
||||
|
||||
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用 `--watch` 常驻定期检查更新。Go module 名为 `bat-api`,目前包含试验性的 `cmd/bat` 骨架、`internal/backendrpc` Rust daemon RPC client 和 `internal/ffi` 兼容包装;产品级 CLI、API Server、Web、完整 AssetBundle 解析、翻译系统和 Patch 系统仍在后续阶段。
|
||||
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源分发服务 `cmd/bat-api`(经 `bat.sock` RPC 发现 release,CDN path 只读提供);`internal/backendrpc` 为 RPC client;`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、Web、AssetBundle 引擎、翻译和 Patch 仍在后续阶段。
|
||||
|
||||
---
|
||||
|
||||
@@ -10,27 +10,30 @@
|
||||
|
||||
- Rust workspace 和 monorepo 结构。
|
||||
- `bat-core` 领域对象和仓储接口骨架。
|
||||
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 基础校验。
|
||||
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。
|
||||
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
||||
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
|
||||
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、顺序下载、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list`、`catalog.*`、`task.*`)。
|
||||
- `internal/backendrpc`:Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。
|
||||
- `cmd/bat-api`:资源分发 HTTP MVP(issue #19 / G-009);`.env` 配置端口/RPC socket;不负责自动拉取。
|
||||
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(G-008 已关闭:同步 CLI = Rust `bat`)。
|
||||
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||
- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。
|
||||
- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要、解包文件数、serialized file 数和 TextAsset 名称,TextAsset/Table/Media 会按类型分类索引。
|
||||
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
|
||||
- 文档路线图、当前状态、缺口清单、官方资源运行指南。
|
||||
|
||||
仍未完成:
|
||||
|
||||
- Go CLI 产品入口(当前仅有试验性 `cmd/bat` 骨架)。
|
||||
- 完整 UnityFS / AssetBundle 引擎解析。
|
||||
- `bat-api` 完整游戏业务 API / launcher 全链(资源 CDN MVP 已可用)。
|
||||
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据和 TextAsset bytes 已起步;MonoBehaviour/ScriptableObject 字段级解析、重打包和 Patch 仍未完成)。
|
||||
- 真实 Patch apply/diff。
|
||||
- Translation Memory、Glossary、AI Provider。
|
||||
- API Server、SDK、Web 管理后台。
|
||||
- SDK、Web 管理后台。
|
||||
|
||||
详细状态见:
|
||||
|
||||
- [当前状态](CURRENT_STATUS.md)
|
||||
- [Go 侧进度与边界](docs/reports/GO_STATUS.md)
|
||||
- [完整开发计划](PROJECT_PLAN.md)
|
||||
- [文档索引](DOCS_INDEX.md)
|
||||
- [当前缺口清单](docs/reports/CURRENT_GAPS.md)
|
||||
@@ -81,7 +84,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
--error-retry 60s
|
||||
```
|
||||
|
||||
后台自动运行可以把 `--watch` 换成 `--daemon`。默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。daemon 会在状态目录下创建 `bat.sock` 作为 Unix socket JSON-RPC 控制通道,同时写入 `bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 用于串行化 `status/stop/restart/reload/logs/refresh/repair` 等控制命令:
|
||||
后台自动运行可以把 `--watch` 换成 `--daemon`。默认官方原版资源目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`。daemon 会在状态目录下创建 `bat.sock` 作为 Unix socket JSON-RPC 控制通道,同时写入 `bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 用于串行化 `status/stop/restart/reload/logs/refresh/repair` 等控制命令:
|
||||
|
||||
```bash
|
||||
cargo run -p bat-infrastructure --bin bat -- \
|
||||
@@ -99,7 +102,7 @@ cargo run -p bat-infrastructure --bin bat -- stop
|
||||
|
||||
`bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||
|
||||
非 dry-run 同步不会把新文件直接写进生产可读目录。资源会先下载到 `<output>/.staging/<id>`,完成 manifest、BLAKE3、ZIP 和官方 `.hash` 校验后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应只读取 `<output>/current`。同步过程会更新 `<output>/official-version-state.json`:下载开始时写入 `in_progress_version`,发布成功后写入 `current_completed_version` 和 `previous_available_version`,失败或中断时写入 `failed_versions`。
|
||||
非 dry-run 同步不会把新文件直接写进生产可读目录。官方原版资源会先下载到 `<output>/.staging/<id>`,完成 manifest、BLAKE3、ZIP 和官方 `.hash` 校验后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应只读取 `<output>/current`。同步过程会更新 `<output>/official-version-state.json`:下载开始时写入 `in_progress_version`,发布成功后写入 `current_completed_version` 和 `previous_available_version`,失败或中断时写入 `failed_versions`。校验完成后会在 published release 下刷新 `official-parse-cache.json`;本地文件未变化时复用缓存并跳过重复解析。官方同步报告默认 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;后续 Patch/导出默认写入 `./bat-localized`,保留官方相对目录结构,完成后才应切换为 `localized`。
|
||||
|
||||
资源操作命令默认输出人类可读摘要,并在没有显式 metadata 参数时默认走官方自动发现。脚本或上层程序需要稳定结构化输出时加 `--json`:
|
||||
|
||||
@@ -129,7 +132,7 @@ make official-smoke
|
||||
|
||||
该 smoke 会执行 dry-run plan、首次全量拉取、二次 `up_to_date` 检查、本地文件破坏后的 `repair`、repair 后 `verify`,并在 `report/SMOKE_REPORT.md` 记录命令、输出目录、active release、文件数量、release 大小和被破坏文件。大型官方资源文件不纳入 Git。
|
||||
|
||||
生产资源输出目录必须使用独立目录,不要指向已有客户端目录,也不要指向 `/home/wanye/D/BlueArchive` 这类人工维护或开发资源目录。需要覆盖默认位置时,用 `--output <资源目录>`;需要覆盖后台状态目录时,用 `--state-dir <状态目录>`。
|
||||
生产官方资源输出目录和汉化产物目录都必须使用独立目录,不要指向已有客户端目录,也不要指向 `/home/wanye/D/BlueArchive` 这类人工维护或开发资源目录。需要覆盖官方原版资源位置时,用 `--output <资源目录>`;需要覆盖汉化产物位置时,用 `--localized-output <目录>` 或 `.env` 中的 `BAT_LOCALIZED_OUTPUT`;需要覆盖后台状态目录时,用 `--state-dir <状态目录>`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-2
@@ -6,6 +6,7 @@ authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bat-assetbundle = { path = "../crates/bat-assetbundle" }
|
||||
bat-core = { path = "../core" }
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -13,8 +14,6 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
async-trait.workspace = true
|
||||
tokio.workspace = true
|
||||
lz4 = "1.28"
|
||||
lzma-rs = "0.3"
|
||||
base64 = "0.22"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -428,74 +428,105 @@ impl AddressablesCatalogDriver {
|
||||
resources
|
||||
}
|
||||
|
||||
fn compact_entry_resources(json: &Value) -> Vec<ResourceEntry> {
|
||||
let Some(internal_ids) = Self::string_array(json, "m_InternalIds") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(provider_ids) = Self::string_array(json, "m_ProviderIds") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(key_bytes) = Self::blob_bytes(json, "m_KeyDataString") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(entry_records) = Self::compact_entry_records(json) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(buckets) = Self::compact_buckets(json) else {
|
||||
return Vec::new();
|
||||
};
|
||||
fn compact_entry_resources(json: &Value) -> Result<Vec<ResourceEntry>, String> {
|
||||
let internal_ids = Self::string_array(json, "m_InternalIds")
|
||||
.ok_or_else(|| "compact catalog missing string array m_InternalIds".to_string())?;
|
||||
let provider_ids = Self::string_array(json, "m_ProviderIds")
|
||||
.ok_or_else(|| "compact catalog missing string array m_ProviderIds".to_string())?;
|
||||
let key_bytes = Self::blob_bytes(json, "m_KeyDataString")
|
||||
.ok_or_else(|| "compact catalog missing decodable m_KeyDataString".to_string())?;
|
||||
let entry_records = Self::compact_entry_records(json)
|
||||
.ok_or_else(|| "failed to decode m_EntryDataString compact records".to_string())?;
|
||||
let buckets = Self::compact_buckets(json)
|
||||
.ok_or_else(|| "failed to decode m_BucketDataString compact buckets".to_string())?;
|
||||
|
||||
let keys = Self::compact_keys(&key_bytes, &buckets);
|
||||
if keys.is_empty() {
|
||||
return Vec::new();
|
||||
return Err("compact catalog contains no decodable key buckets".to_string());
|
||||
}
|
||||
|
||||
let internal_id_prefixes = Self::string_array(json, "m_InternalIdPrefixes")
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let extra_data = Self::blob_bytes(json, "m_ExtraDataString").unwrap_or_default();
|
||||
let extra_data = if json.get("m_ExtraDataString").is_some() {
|
||||
Self::blob_bytes(json, "m_ExtraDataString")
|
||||
.ok_or_else(|| "compact catalog has undecodable m_ExtraDataString".to_string())?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
entry_records
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, record)| {
|
||||
let internal_id = internal_ids.get(record.internal_id as usize)?;
|
||||
provider_ids.get(record.provider_index as usize)?;
|
||||
let primary_key = keys
|
||||
.get(record.primary_key_index as usize)
|
||||
.and_then(|key| key.as_ref())
|
||||
.and_then(AddressablesObject::key_string)
|
||||
.unwrap_or_else(|| format!("addressable_{}", index));
|
||||
let path = Self::normalize_internal_id(&internal_id_prefixes, internal_id);
|
||||
let extra = Self::extra_data_at(&extra_data, record.data_index);
|
||||
let resource_type_name = Self::resource_type_name(json, record.resource_type_index);
|
||||
let dependencies =
|
||||
Self::compact_dependencies(record, &entry_records, &buckets, &keys);
|
||||
let hash = extra
|
||||
.hash
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| extra.bundle_name.filter(|value| !value.is_empty()))
|
||||
.unwrap_or_else(|| format!("addressable_{}", index));
|
||||
let mut resources = Vec::with_capacity(entry_records.len());
|
||||
for (index, record) in entry_records.iter().enumerate() {
|
||||
if record.internal_id < 0 {
|
||||
return Err(format!("compact entry {index} has negative internal_id"));
|
||||
}
|
||||
if record.provider_index < 0 {
|
||||
return Err(format!("compact entry {index} has negative provider_index"));
|
||||
}
|
||||
if record.primary_key_index < 0 {
|
||||
return Err(format!(
|
||||
"compact entry {index} has negative primary_key_index"
|
||||
));
|
||||
}
|
||||
|
||||
Some(ResourceEntry {
|
||||
path: path.clone(),
|
||||
hash,
|
||||
size: extra.bundle_size.unwrap_or_default(),
|
||||
resource_type: Self::resource_type_for_compact_entry(
|
||||
&path,
|
||||
resource_type_name.as_deref(),
|
||||
),
|
||||
address: if primary_key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(primary_key)
|
||||
},
|
||||
dependencies,
|
||||
crc: extra.crc,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
let internal_id = internal_ids
|
||||
.get(record.internal_id as usize)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"compact entry {index} internal_id index {} out of range {}",
|
||||
record.internal_id,
|
||||
internal_ids.len()
|
||||
)
|
||||
})?;
|
||||
provider_ids
|
||||
.get(record.provider_index as usize)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"compact entry {index} provider_index {} out of range {}",
|
||||
record.provider_index,
|
||||
provider_ids.len()
|
||||
)
|
||||
})?;
|
||||
let primary_key = keys
|
||||
.get(record.primary_key_index as usize)
|
||||
.and_then(|key| key.as_ref())
|
||||
.and_then(AddressablesObject::key_string)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"compact entry {index} primary_key_index {} has no decodable key",
|
||||
record.primary_key_index
|
||||
)
|
||||
})?;
|
||||
let path = Self::normalize_internal_id(&internal_id_prefixes, internal_id);
|
||||
let extra = Self::extra_data_at(&extra_data, record.data_index);
|
||||
let resource_type_name = Self::resource_type_name(json, record.resource_type_index);
|
||||
let dependencies = Self::compact_dependencies(record, &entry_records, &buckets, &keys);
|
||||
let hash = extra
|
||||
.hash
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| extra.bundle_name.filter(|value| !value.is_empty()))
|
||||
.unwrap_or_else(|| format!("addressable_{}", index));
|
||||
|
||||
resources.push(ResourceEntry {
|
||||
path: path.clone(),
|
||||
hash,
|
||||
size: extra.bundle_size.unwrap_or_default(),
|
||||
resource_type: Self::resource_type_for_compact_entry(
|
||||
&path,
|
||||
resource_type_name.as_deref(),
|
||||
),
|
||||
address: if primary_key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(primary_key)
|
||||
},
|
||||
dependencies,
|
||||
crc: extra.crc,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
fn string_array(json: &Value, field: &str) -> Option<Vec<String>> {
|
||||
@@ -677,15 +708,30 @@ impl AddressablesCatalogDriver {
|
||||
format!("{prefix}{path}")
|
||||
}
|
||||
|
||||
fn resources(json: &Value) -> Vec<ResourceEntry> {
|
||||
fn has_compact_catalog_fields(json: &Value) -> bool {
|
||||
[
|
||||
"m_ProviderIds",
|
||||
"m_KeyDataString",
|
||||
"m_BucketDataString",
|
||||
"m_EntryDataString",
|
||||
"m_ExtraDataString",
|
||||
"m_resourceTypes",
|
||||
]
|
||||
.iter()
|
||||
.any(|field| json.get(field).is_some())
|
||||
}
|
||||
|
||||
fn resources(json: &Value) -> Result<Vec<ResourceEntry>, String> {
|
||||
let entry_resources = Self::entry_resources(json);
|
||||
if !entry_resources.is_empty() {
|
||||
return entry_resources;
|
||||
return Ok(entry_resources);
|
||||
}
|
||||
|
||||
let compact_resources = Self::compact_entry_resources(json);
|
||||
if !compact_resources.is_empty() {
|
||||
return compact_resources;
|
||||
if Self::has_compact_catalog_fields(json) {
|
||||
let compact_resources = Self::compact_entry_resources(json)?;
|
||||
if !compact_resources.is_empty() {
|
||||
return Ok(compact_resources);
|
||||
}
|
||||
}
|
||||
|
||||
let key_resources = Self::key_data_resources(json);
|
||||
@@ -694,13 +740,13 @@ impl AddressablesCatalogDriver {
|
||||
.map(|count| key_resources.len() >= count)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return key_resources;
|
||||
return Ok(key_resources);
|
||||
}
|
||||
|
||||
Self::internal_id_resources(json)
|
||||
Ok(Self::internal_id_resources(json))
|
||||
}
|
||||
|
||||
fn extra_metadata(json: &Value) -> HashMap<String, String> {
|
||||
fn extra_metadata(json: &Value, resources: &[ResourceEntry]) -> HashMap<String, String> {
|
||||
let mut extra = HashMap::new();
|
||||
Self::insert_array_len(&mut extra, json, "m_InternalIds", "internal_id_count");
|
||||
Self::insert_array_len(&mut extra, json, "m_Entries", "entry_count");
|
||||
@@ -732,7 +778,7 @@ impl AddressablesCatalogDriver {
|
||||
"m_ExtraDataString",
|
||||
"extra_data_string_len",
|
||||
);
|
||||
Self::insert_dependency_count(&mut extra, json);
|
||||
Self::insert_resource_summary(&mut extra, resources);
|
||||
extra
|
||||
}
|
||||
|
||||
@@ -772,13 +818,56 @@ impl AddressablesCatalogDriver {
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_dependency_count(extra: &mut HashMap<String, String>, json: &Value) {
|
||||
let count = Self::entry_resources(json)
|
||||
.into_iter()
|
||||
fn insert_resource_summary(extra: &mut HashMap<String, String>, resources: &[ResourceEntry]) {
|
||||
extra.insert("resource_count".to_string(), resources.len().to_string());
|
||||
|
||||
let asset_bundle_count = resources
|
||||
.iter()
|
||||
.filter(|entry| entry.resource_type == ResourceType::AssetBundle)
|
||||
.count();
|
||||
if asset_bundle_count > 0 {
|
||||
extra.insert(
|
||||
"asset_bundle_count".to_string(),
|
||||
asset_bundle_count.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let declared_size_count = resources.iter().filter(|entry| entry.size != 0).count();
|
||||
if declared_size_count > 0 {
|
||||
extra.insert(
|
||||
"declared_size_count".to_string(),
|
||||
declared_size_count.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let declared_crc_count = resources
|
||||
.iter()
|
||||
.filter(|entry| entry.declared_crc().is_some())
|
||||
.count();
|
||||
if declared_crc_count > 0 {
|
||||
extra.insert(
|
||||
"declared_crc_count".to_string(),
|
||||
declared_crc_count.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let dependency_count = resources
|
||||
.iter()
|
||||
.map(|entry| entry.dependencies.len())
|
||||
.sum::<usize>();
|
||||
if count > 0 {
|
||||
extra.insert("dependency_count".to_string(), count.to_string());
|
||||
if dependency_count > 0 {
|
||||
extra.insert("dependency_count".to_string(), dependency_count.to_string());
|
||||
}
|
||||
|
||||
let fallback_hash_count = resources
|
||||
.iter()
|
||||
.filter(|entry| entry.hash.starts_with("addressable_"))
|
||||
.count();
|
||||
if fallback_hash_count > 0 {
|
||||
extra.insert(
|
||||
"fallback_hash_count".to_string(),
|
||||
fallback_hash_count.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -886,16 +975,17 @@ impl ManifestDriver for AddressablesCatalogDriver {
|
||||
|
||||
async fn parse(&self, raw_data: &[u8]) -> Result<GenericManifest, String> {
|
||||
let json = Self::parse_json(raw_data)?;
|
||||
let resources = Self::resources(&json)?;
|
||||
|
||||
let metadata = ManifestMetadata {
|
||||
locator_id: Self::locator_id(&json),
|
||||
cdn_prefixes: Self::cdn_prefixes(&json),
|
||||
extra: Self::extra_metadata(&json),
|
||||
extra: Self::extra_metadata(&json, &resources),
|
||||
};
|
||||
|
||||
Ok(GenericManifest {
|
||||
format: ManifestFormat::AddressablesCatalog,
|
||||
resources: Self::resources(&json),
|
||||
resources,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
@@ -904,6 +994,91 @@ impl ManifestDriver for AddressablesCatalogDriver {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
|
||||
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_serialized_string(data: &mut Vec<u8>, value: &str) -> usize {
|
||||
let offset = data.len();
|
||||
data.push(0);
|
||||
push_u32_le(data, value.len() as u32);
|
||||
data.extend_from_slice(value.as_bytes());
|
||||
offset
|
||||
}
|
||||
|
||||
fn serialized_json_object(json_text: &str) -> Vec<u8> {
|
||||
let assembly_name =
|
||||
"Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
let class_name =
|
||||
"UnityEngine.ResourceManagement.ResourceProviders.AssetBundleRequestOptions";
|
||||
let mut json_bytes = Vec::new();
|
||||
for unit in json_text.encode_utf16() {
|
||||
json_bytes.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.push(7);
|
||||
data.push(assembly_name.len() as u8);
|
||||
data.extend_from_slice(assembly_name.as_bytes());
|
||||
data.push(class_name.len() as u8);
|
||||
data.extend_from_slice(class_name.as_bytes());
|
||||
push_u32_le(&mut data, json_bytes.len() as u32);
|
||||
data.extend_from_slice(&json_bytes);
|
||||
data
|
||||
}
|
||||
|
||||
fn compact_catalog_json(extra_json: &str) -> String {
|
||||
let mut key_data = Vec::new();
|
||||
push_u32_le(&mut key_data, 1);
|
||||
let key_offset = push_serialized_string(&mut key_data, "synthetic.bundle");
|
||||
|
||||
let mut bucket_data = Vec::new();
|
||||
push_u32_le(&mut bucket_data, 1);
|
||||
push_i32_le(&mut bucket_data, key_offset as i32);
|
||||
push_i32_le(&mut bucket_data, 1);
|
||||
push_i32_le(&mut bucket_data, 0);
|
||||
|
||||
let mut entry_data = Vec::new();
|
||||
push_u32_le(&mut entry_data, 1);
|
||||
push_i32_le(&mut entry_data, 0); // internal_id
|
||||
push_i32_le(&mut entry_data, 0); // provider_index
|
||||
push_i32_le(&mut entry_data, -1); // dependency_key_index
|
||||
push_i32_le(&mut entry_data, 0); // reserved/unused
|
||||
push_i32_le(&mut entry_data, 0); // data_index
|
||||
push_i32_le(&mut entry_data, 0); // primary_key_index
|
||||
push_i32_le(&mut entry_data, 0); // resource_type_index
|
||||
|
||||
let extra_data = serialized_json_object(extra_json);
|
||||
|
||||
serde_json::json!({
|
||||
"m_LocatorId": "AddressablesMainContentCatalog",
|
||||
"m_InternalIdPrefixes": [],
|
||||
"m_ProviderIds": [
|
||||
"UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider"
|
||||
],
|
||||
"m_InternalIds": [
|
||||
"{PlatformUtils.AddressableLoadPath}\\synthetic.bundle"
|
||||
],
|
||||
"m_resourceTypes": [
|
||||
{
|
||||
"m_AssemblyName": "Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
|
||||
"m_ClassName": "UnityEngine.ResourceManagement.ResourceProviders.IAssetBundleResource"
|
||||
}
|
||||
],
|
||||
"m_KeyDataString": STANDARD.encode(key_data),
|
||||
"m_BucketDataString": STANDARD.encode(bucket_data),
|
||||
"m_EntryDataString": STANDARD.encode(entry_data),
|
||||
"m_ExtraDataString": STANDARD.encode(extra_data)
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_parse_valid_catalog() {
|
||||
@@ -1048,6 +1223,65 @@ mod tests {
|
||||
assert!(error.contains("Invalid JSON at line"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_compact_catalog_extracts_verification_fields() {
|
||||
let driver = AddressablesCatalogDriver::new();
|
||||
let catalog_json = compact_catalog_json(
|
||||
r#"{
|
||||
"m_Hash":"hash-compact",
|
||||
"m_Crc":305419896,
|
||||
"m_BundleName":"synthetic-bundle-name",
|
||||
"m_BundleSize":42
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = driver.parse(catalog_json.as_bytes()).await.unwrap();
|
||||
|
||||
assert_eq!(manifest.resources.len(), 1);
|
||||
let resource = &manifest.resources[0];
|
||||
assert_eq!(resource.path, "synthetic.bundle");
|
||||
assert_eq!(resource.hash, "hash-compact");
|
||||
assert_eq!(resource.size, 42);
|
||||
assert_eq!(resource.crc, Some(0x1234_5678));
|
||||
assert_eq!(resource.resource_type, ResourceType::AssetBundle);
|
||||
assert_eq!(resource.address.as_deref(), Some("synthetic.bundle"));
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("resource_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("asset_bundle_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("declared_size_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("declared_crc_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_compact_catalog_reports_blob_decode_failure() {
|
||||
let driver = AddressablesCatalogDriver::new();
|
||||
let catalog_json = r#"{
|
||||
"m_LocatorId": "AddressablesMainContentCatalog",
|
||||
"m_ProviderIds": ["UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider"],
|
||||
"m_InternalIds": ["synthetic.bundle"],
|
||||
"m_KeyDataString": "not-base64",
|
||||
"m_BucketDataString": "not-base64",
|
||||
"m_EntryDataString": "not-base64",
|
||||
"m_ExtraDataString": "not-base64"
|
||||
}"#;
|
||||
|
||||
let error = driver.parse(catalog_json.as_bytes()).await.unwrap_err();
|
||||
|
||||
assert!(error.contains("compact catalog"), "{error}");
|
||||
assert!(error.contains("m_KeyDataString"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_table_bundle_resource_types() {
|
||||
let driver = AddressablesCatalogDriver::new();
|
||||
|
||||
@@ -38,13 +38,14 @@ pub struct YostarJpGameMainConfig {
|
||||
impl YostarJpGameMainConfig {
|
||||
/// Reads and decrypts `GameMainConfig` from a Unity serialized file.
|
||||
pub fn from_resources_assets(path: impl AsRef<Path>) -> Result<Self, String> {
|
||||
let serialized = UnitySerializedFile::from_path(path)?;
|
||||
let serialized = UnitySerializedFile::from_path(path).map_err(|error| error.to_string())?;
|
||||
Self::from_serialized_file(&serialized)
|
||||
}
|
||||
|
||||
/// Reads and decrypts `GameMainConfig` from serialized file bytes.
|
||||
pub fn from_resources_assets_bytes(bytes: &[u8]) -> Result<Self, String> {
|
||||
let serialized = UnitySerializedFile::from_slice(bytes)?;
|
||||
let serialized =
|
||||
UnitySerializedFile::from_slice(bytes).map_err(|error| error.to_string())?;
|
||||
Self::from_serialized_file(&serialized)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ pub mod unity_2021_3;
|
||||
|
||||
pub use adapter::{
|
||||
ParsedAssetBundle, RawAssetBundle, UnityAdapter, UnityFsBlockInfo, UnityFsCompression,
|
||||
UnityFsDirectoryInfo, UnityFsHeader, VersionRange,
|
||||
UnityFsDirectoryInfo, UnityFsFile, UnityFsHeader, UnitySerializedParseError, VersionRange,
|
||||
};
|
||||
pub use registry::UnityAdapterRegistry;
|
||||
pub use serialized_file::{UnitySerializedFile, UnitySerializedTextAsset};
|
||||
pub use serialized_file::{
|
||||
UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset, UnitySerializedType,
|
||||
UnityTypeTreeNode,
|
||||
};
|
||||
pub use unity_2021_3::Unity2021_3Adapter;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Unity Adapter 接口定义
|
||||
|
||||
use async_trait::async_trait;
|
||||
pub use bat_assetbundle::{
|
||||
ParsedAssetBundle, RawAssetBundle, UnityFsBlockInfo, UnityFsCompression, UnityFsDirectoryInfo,
|
||||
UnityFsFile, UnityFsHeader, UnitySerializedParseError,
|
||||
};
|
||||
|
||||
/// Unity 版本范围
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -56,92 +60,6 @@ fn parse_version_components(version: &str) -> Option<(u64, u64, u64)> {
|
||||
Some((major, minor, patch))
|
||||
}
|
||||
|
||||
/// 原始 AssetBundle 数据
|
||||
#[derive(Debug)]
|
||||
pub struct RawAssetBundle {
|
||||
/// 文件数据
|
||||
pub data: Vec<u8>,
|
||||
/// 文件路径(可选)
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// 解析后的 AssetBundle
|
||||
#[derive(Debug)]
|
||||
pub struct ParsedAssetBundle {
|
||||
/// Unity 版本
|
||||
pub unity_version: String,
|
||||
/// 资源列表(简化表示)
|
||||
pub assets: Vec<String>,
|
||||
/// 原始数据(保留用于序列化)
|
||||
pub raw_data: Vec<u8>,
|
||||
/// UnityFS 文件头信息。
|
||||
pub unityfs_header: Option<UnityFsHeader>,
|
||||
/// UnityFS 压缩块信息。
|
||||
pub blocks: Vec<UnityFsBlockInfo>,
|
||||
/// UnityFS 目录信息。
|
||||
pub directories: Vec<UnityFsDirectoryInfo>,
|
||||
}
|
||||
|
||||
/// UnityFS 文件头。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnityFsHeader {
|
||||
/// UnityFS 格式版本。
|
||||
pub format_version: u32,
|
||||
/// Bundle 目标版本字符串,例如 `5.x.x`。
|
||||
pub target_version: String,
|
||||
/// Unity 编辑器版本字符串。
|
||||
pub unity_version: String,
|
||||
/// 文件总大小。
|
||||
pub total_size: u64,
|
||||
/// 压缩后的 block info 大小。
|
||||
pub compressed_blocks_info_size: u32,
|
||||
/// 解压后的 block info 大小。
|
||||
pub uncompressed_blocks_info_size: u32,
|
||||
/// UnityFS flags 原始值。
|
||||
pub flags: u32,
|
||||
}
|
||||
|
||||
/// UnityFS 块压缩类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnityFsCompression {
|
||||
/// 未压缩。
|
||||
None,
|
||||
/// LZMA 压缩。
|
||||
Lzma,
|
||||
/// LZ4 压缩。
|
||||
Lz4,
|
||||
/// LZ4HC 压缩。
|
||||
Lz4Hc,
|
||||
/// 当前版本未识别的压缩类型。
|
||||
Unknown(u16),
|
||||
}
|
||||
|
||||
/// UnityFS 压缩块信息。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnityFsBlockInfo {
|
||||
/// 解压后大小。
|
||||
pub uncompressed_size: u32,
|
||||
/// 压缩后大小。
|
||||
pub compressed_size: u32,
|
||||
/// 块 flags 原始值。
|
||||
pub flags: u16,
|
||||
/// 解析出的压缩类型。
|
||||
pub compression: UnityFsCompression,
|
||||
}
|
||||
|
||||
/// UnityFS 目录条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnityFsDirectoryInfo {
|
||||
/// 条目在数据区中的偏移。
|
||||
pub offset: u64,
|
||||
/// 条目大小。
|
||||
pub size: u64,
|
||||
/// 条目 flags 原始值。
|
||||
pub flags: u32,
|
||||
/// 条目路径。
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Unity Adapter 接口
|
||||
///
|
||||
/// 用于解析不同 Unity 版本的 AssetBundle
|
||||
@@ -172,8 +90,7 @@ pub trait UnityAdapter: Send + Sync {
|
||||
/// - 成功:返回解析后的 AssetBundle
|
||||
/// - 失败:返回错误
|
||||
///
|
||||
/// # 注意
|
||||
/// Phase 1 中标记为 TODO,Phase 2 实现
|
||||
/// 当前 UnityFS 容器解析由具体适配器委托给 `bat-assetbundle`。
|
||||
async fn parse(&self, bundle: &RawAssetBundle) -> Result<ParsedAssetBundle, String>;
|
||||
|
||||
/// 序列化 AssetBundle
|
||||
@@ -185,8 +102,7 @@ pub trait UnityAdapter: Send + Sync {
|
||||
/// - 成功:返回序列化后的数据
|
||||
/// - 失败:返回错误
|
||||
///
|
||||
/// # 注意
|
||||
/// Phase 1 中标记为 TODO,Phase 2 实现
|
||||
/// 当前阶段只定义接口;具体序列化能力尚未进入实现范围。
|
||||
async fn serialize(&self, parsed: &ParsedAssetBundle) -> Result<Vec<u8>, String>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,521 +1,9 @@
|
||||
//! Unity serialized file reader.
|
||||
//! Compatibility exports for Unity serialized file parsing.
|
||||
//!
|
||||
//! This module is intentionally narrow: it extracts `TextAsset` payloads from
|
||||
//! Unity serialized files such as `resources.assets` and
|
||||
//! `globalgamemanagers.assets`.
|
||||
//! The implementation lives in `bat-assetbundle`; adapters keep this module so
|
||||
//! existing call sites can continue to import through `bat_adapters::unity`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// One extracted Unity `TextAsset`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnitySerializedTextAsset {
|
||||
/// Unity path ID of the object.
|
||||
pub path_id: i64,
|
||||
/// Asset name stored in the serialized object.
|
||||
pub name: String,
|
||||
/// Raw bytes stored by the `TextAsset`.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parsed Unity serialized file summary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnitySerializedFile {
|
||||
/// Serialized file format version.
|
||||
pub version: u32,
|
||||
/// Unity editor version stored in the file.
|
||||
pub unity_version: String,
|
||||
/// Target platform value from the file header.
|
||||
pub platform: i32,
|
||||
text_assets: Vec<UnitySerializedTextAsset>,
|
||||
}
|
||||
|
||||
impl UnitySerializedFile {
|
||||
/// Parses a serialized file from raw bytes.
|
||||
pub fn from_slice(data: &[u8]) -> Result<Self, String> {
|
||||
let mut reader = Reader::new(data);
|
||||
|
||||
let _metadata_size = reader.read_u32_be("metadata_size")?;
|
||||
let _file_size = reader.read_u32_be("file_size")?;
|
||||
let version = reader.read_u32_be("version")?;
|
||||
let _data_offset = reader.read_u32_be("data_offset")?;
|
||||
let endian_flag = reader.read_u8("endian_flag")?;
|
||||
reader.read_bytes(3, "reserved")?;
|
||||
|
||||
let (metadata_size, file_size, data_offset) = if version >= 22 {
|
||||
let metadata_size = reader.read_u32_be("metadata_size_2")?;
|
||||
let file_size = reader.read_u64_be("file_size_2")?;
|
||||
let data_offset = reader.read_u64_be("data_offset_2")? as usize;
|
||||
let _unknown = reader.read_u64_be("unknown_2")?;
|
||||
(metadata_size, file_size, data_offset)
|
||||
} else {
|
||||
(_metadata_size, _file_size as u64, _data_offset as usize)
|
||||
};
|
||||
let _ = metadata_size;
|
||||
let _ = file_size;
|
||||
|
||||
let endian = if endian_flag == 0 {
|
||||
Endian::Little
|
||||
} else {
|
||||
Endian::Big
|
||||
};
|
||||
reader.set_endian(endian);
|
||||
|
||||
let unity_version = reader.read_c_string("unity_version")?;
|
||||
let platform = reader.read_i32("platform")?;
|
||||
let enable_type_tree = reader.read_u8("enable_type_tree")?;
|
||||
let type_count = reader.read_i32("type_count")?;
|
||||
if type_count < 0 {
|
||||
return Err(format!("Invalid Unity type count: {}", type_count));
|
||||
}
|
||||
|
||||
let mut class_ids = Vec::with_capacity(type_count as usize);
|
||||
for _ in 0..type_count {
|
||||
class_ids.push(read_serialized_type(
|
||||
&mut reader,
|
||||
version,
|
||||
enable_type_tree,
|
||||
)?);
|
||||
}
|
||||
|
||||
let big_id_enabled = if (11..14).contains(&version) {
|
||||
reader.read_i32("big_id_enabled")?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let object_count = reader.read_i32("object_count")?;
|
||||
if object_count < 0 {
|
||||
return Err(format!("Invalid Unity object count: {}", object_count));
|
||||
}
|
||||
|
||||
let mut text_assets = Vec::new();
|
||||
for _ in 0..object_count {
|
||||
if version >= 14 {
|
||||
reader.align(4)?;
|
||||
}
|
||||
|
||||
let path_id = if big_id_enabled != 0 {
|
||||
reader.read_i64("path_id")?
|
||||
} else if version < 14 {
|
||||
reader.read_i32("path_id")? as i64
|
||||
} else {
|
||||
reader.read_i64("path_id")?
|
||||
};
|
||||
|
||||
let byte_start = if version >= 22 {
|
||||
reader.read_u64("byte_start")? as usize
|
||||
} else {
|
||||
reader.read_u32("byte_start")? as usize
|
||||
};
|
||||
let byte_size = reader.read_u32("byte_size")? as usize;
|
||||
let type_id = reader.read_i32("type_id")?;
|
||||
if version < 16 {
|
||||
reader.read_u16("class_id")?;
|
||||
}
|
||||
if version < 11 {
|
||||
reader.read_u16("is_destroyed")?;
|
||||
}
|
||||
if (11..17).contains(&version) {
|
||||
reader.read_i16("script_type_index")?;
|
||||
}
|
||||
if version == 15 || version == 16 {
|
||||
reader.read_u8("stripped")?;
|
||||
}
|
||||
|
||||
let class_id = class_ids
|
||||
.get(type_id as usize)
|
||||
.copied()
|
||||
.ok_or_else(|| format!("Invalid Unity type index: {}", type_id))?;
|
||||
if class_id == 49 {
|
||||
let object_start = data_offset
|
||||
.checked_add(byte_start)
|
||||
.ok_or_else(|| "Unity object offset overflow".to_string())?;
|
||||
let object_end = object_start
|
||||
.checked_add(byte_size)
|
||||
.ok_or_else(|| "Unity object size overflow".to_string())?;
|
||||
if object_end > data.len() {
|
||||
return Err(format!(
|
||||
"Unity object exceeds file size: start={}, size={}, file_size={}",
|
||||
object_start,
|
||||
byte_size,
|
||||
data.len()
|
||||
));
|
||||
}
|
||||
|
||||
let asset = parse_text_asset(path_id, &data[object_start..object_end], endian)?;
|
||||
text_assets.push(asset);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
version,
|
||||
unity_version,
|
||||
platform,
|
||||
text_assets,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a serialized file from disk.
|
||||
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, String> {
|
||||
let path = path.as_ref();
|
||||
let bytes = fs::read(path)
|
||||
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
|
||||
Self::from_slice(&bytes)
|
||||
}
|
||||
|
||||
/// Returns all extracted text assets.
|
||||
pub fn text_assets(&self) -> &[UnitySerializedTextAsset] {
|
||||
&self.text_assets
|
||||
}
|
||||
|
||||
/// Returns one extracted text asset by name.
|
||||
pub fn text_asset(&self, name: &str) -> Option<&UnitySerializedTextAsset> {
|
||||
self.text_assets.iter().find(|asset| asset.name == name)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_text_asset(
|
||||
path_id: i64,
|
||||
data: &[u8],
|
||||
endian: Endian,
|
||||
) -> Result<UnitySerializedTextAsset, String> {
|
||||
let mut reader = Reader::new(data);
|
||||
reader.set_endian(endian);
|
||||
let name = reader.read_len_prefixed_string("text_asset_name")?;
|
||||
reader.align(4)?;
|
||||
let bytes_len = reader.read_u32("text_asset_bytes_len")? as usize;
|
||||
let bytes = reader.read_bytes(bytes_len, "text_asset_bytes")?.to_vec();
|
||||
|
||||
Ok(UnitySerializedTextAsset {
|
||||
path_id,
|
||||
name,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_serialized_type(
|
||||
reader: &mut Reader<'_>,
|
||||
version: u32,
|
||||
enable_type_tree: u8,
|
||||
) -> Result<i32, String> {
|
||||
let class_id = reader.read_i32("type_class_id")?;
|
||||
|
||||
if version >= 16 {
|
||||
reader.read_u8("type_is_stripped")?;
|
||||
}
|
||||
if version >= 17 {
|
||||
reader.read_i16("type_script_index")?;
|
||||
}
|
||||
if version >= 13 {
|
||||
if (version < 16 && class_id < 0) || (version >= 16 && class_id == 114) {
|
||||
reader.read_bytes(16, "type_script_id")?;
|
||||
}
|
||||
reader.read_bytes(16, "type_hash")?;
|
||||
}
|
||||
|
||||
if enable_type_tree != 0 {
|
||||
if version >= 12 || version == 10 {
|
||||
let node_count = reader.read_i32("type_tree_node_count")?;
|
||||
if node_count < 0 {
|
||||
return Err(format!(
|
||||
"Invalid Unity type tree node count: {}",
|
||||
node_count
|
||||
));
|
||||
}
|
||||
let string_buffer_size = reader.read_i32("type_tree_string_buffer_size")?;
|
||||
if string_buffer_size < 0 {
|
||||
return Err(format!(
|
||||
"Invalid Unity type tree string buffer size: {}",
|
||||
string_buffer_size
|
||||
));
|
||||
}
|
||||
|
||||
let node_size = 2 + 1 + 1 + 4 + 4 + 4 + 4 + 4 + if version >= 19 { 8 } else { 0 };
|
||||
reader.read_bytes(node_count as usize * node_size, "type_tree_nodes")?;
|
||||
reader.read_bytes(string_buffer_size as usize, "type_tree_strings")?;
|
||||
}
|
||||
|
||||
if version >= 21 {
|
||||
let dependency_count = reader.read_i32("type_tree_dependency_count")?;
|
||||
if dependency_count < 0 {
|
||||
return Err(format!(
|
||||
"Invalid Unity type tree dependency count: {}",
|
||||
dependency_count
|
||||
));
|
||||
}
|
||||
reader.read_bytes(dependency_count as usize * 4, "type_tree_dependencies")?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(class_id)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Endian {
|
||||
Little,
|
||||
Big,
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
data: &'a [u8],
|
||||
offset: usize,
|
||||
endian: Endian,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self {
|
||||
data,
|
||||
offset: 0,
|
||||
endian: Endian::Big,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_endian(&mut self, endian: Endian) {
|
||||
self.endian = endian;
|
||||
}
|
||||
|
||||
fn read_bytes(&mut self, len: usize, field: &str) -> Result<&'a [u8], String> {
|
||||
let end = self
|
||||
.offset
|
||||
.checked_add(len)
|
||||
.ok_or_else(|| format!("{field} length overflow at offset {}", self.offset))?;
|
||||
if end > self.data.len() {
|
||||
return Err(format!(
|
||||
"Unexpected end while reading {field} at offset {}: need {}, have {}",
|
||||
self.offset,
|
||||
len,
|
||||
self.data.len().saturating_sub(self.offset)
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = &self.data[self.offset..end];
|
||||
self.offset = end;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn align(&mut self, alignment: usize) -> Result<(), String> {
|
||||
if alignment == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remainder = self.offset % alignment;
|
||||
if remainder == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let padding = alignment - remainder;
|
||||
self.read_bytes(padding, "alignment padding").map(|_| ())
|
||||
}
|
||||
|
||||
fn read_u8(&mut self, field: &str) -> Result<u8, String> {
|
||||
Ok(self.read_bytes(1, field)?[0])
|
||||
}
|
||||
|
||||
fn read_u16(&mut self, field: &str) -> Result<u16, String> {
|
||||
let bytes = self.read_bytes(2, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => u16::from_le_bytes([bytes[0], bytes[1]]),
|
||||
Endian::Big => u16::from_be_bytes([bytes[0], bytes[1]]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_i16(&mut self, field: &str) -> Result<i16, String> {
|
||||
let bytes = self.read_bytes(2, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => i16::from_le_bytes([bytes[0], bytes[1]]),
|
||||
Endian::Big => i16::from_be_bytes([bytes[0], bytes[1]]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u32(&mut self, field: &str) -> Result<u32, String> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
Endian::Big => u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u32_be(&mut self, field: &str) -> Result<u32, String> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||
}
|
||||
|
||||
fn read_i32(&mut self, field: &str) -> Result<i32, String> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
Endian::Big => i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u64(&mut self, field: &str) -> Result<u64, String> {
|
||||
let bytes = self.read_bytes(8, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => u64::from_le_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]),
|
||||
Endian::Big => u64::from_be_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u64_be(&mut self, field: &str) -> Result<u64, String> {
|
||||
let bytes = self.read_bytes(8, field)?;
|
||||
Ok(u64::from_be_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_i64(&mut self, field: &str) -> Result<i64, String> {
|
||||
let bytes = self.read_bytes(8, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => i64::from_le_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]),
|
||||
Endian::Big => i64::from_be_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_c_string(&mut self, field: &str) -> Result<String, String> {
|
||||
let remaining = &self.data[self.offset..];
|
||||
let Some(length) = remaining.iter().position(|&byte| byte == 0) else {
|
||||
return Err(format!(
|
||||
"Missing null terminator while reading {field} at offset {}",
|
||||
self.offset
|
||||
));
|
||||
};
|
||||
let bytes = self.read_bytes(length, field)?;
|
||||
self.offset += 1;
|
||||
std::str::from_utf8(bytes)
|
||||
.map(ToOwned::to_owned)
|
||||
.map_err(|error| format!("Invalid UTF-8 in {field}: {error}"))
|
||||
}
|
||||
|
||||
fn read_len_prefixed_string(&mut self, field: &str) -> Result<String, String> {
|
||||
let len = self.read_u32(field)? as usize;
|
||||
let bytes = self.read_bytes(len, field)?;
|
||||
std::str::from_utf8(bytes)
|
||||
.map(ToOwned::to_owned)
|
||||
.map_err(|error| format!("Invalid UTF-8 in {field}: {error}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_i64_le(data: &mut Vec<u8>, value: i64) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_u32_be(data: &mut Vec<u8>, value: u32) {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_u64_be(data: &mut Vec<u8>, value: u64) {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn align(data: &mut Vec<u8>, alignment: usize) {
|
||||
let remainder = data.len() % alignment;
|
||||
if remainder != 0 {
|
||||
data.resize(data.len() + alignment - remainder, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_serialized_file() -> Vec<u8> {
|
||||
let mut object_data = Vec::new();
|
||||
push_u32_le(&mut object_data, 14);
|
||||
object_data.extend_from_slice(b"GameMainConfig");
|
||||
align(&mut object_data, 4);
|
||||
push_u32_le(&mut object_data, 5);
|
||||
object_data.extend_from_slice(b"hello");
|
||||
|
||||
let mut metadata = Vec::new();
|
||||
metadata.extend_from_slice(b"2021.3.56f2\0");
|
||||
push_i32_le(&mut metadata, 19);
|
||||
metadata.push(0);
|
||||
push_i32_le(&mut metadata, 1);
|
||||
push_i32_le(&mut metadata, 49);
|
||||
metadata.push(0);
|
||||
push_i16_le(&mut metadata, 0);
|
||||
metadata.extend_from_slice(&[0; 16]);
|
||||
push_i32_le(&mut metadata, 1);
|
||||
align(&mut metadata, 4);
|
||||
push_i64_le(&mut metadata, 1);
|
||||
push_u64_le(&mut metadata, 0);
|
||||
push_u32_le(&mut metadata, object_data.len() as u32);
|
||||
push_i32_le(&mut metadata, 0);
|
||||
|
||||
let header_len = 48usize;
|
||||
let data_offset = header_len + metadata.len();
|
||||
let file_size = data_offset + object_data.len();
|
||||
|
||||
let mut file = Vec::new();
|
||||
push_u32_be(&mut file, metadata.len() as u32);
|
||||
push_u32_be(&mut file, file_size as u32);
|
||||
push_u32_be(&mut file, 22);
|
||||
push_u32_be(&mut file, 0);
|
||||
file.push(0);
|
||||
file.extend_from_slice(&[0, 0, 0]);
|
||||
push_u32_be(&mut file, metadata.len() as u32);
|
||||
push_u64_be(&mut file, file_size as u64);
|
||||
push_u64_be(&mut file, data_offset as u64);
|
||||
push_u64_be(&mut file, 0);
|
||||
file.extend_from_slice(&metadata);
|
||||
file.extend_from_slice(&object_data);
|
||||
file
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_synthetic_text_asset() {
|
||||
let file = synthetic_serialized_file();
|
||||
let parsed = UnitySerializedFile::from_slice(&file).unwrap();
|
||||
|
||||
assert_eq!(parsed.version, 22);
|
||||
assert_eq!(parsed.unity_version, "2021.3.56f2");
|
||||
assert_eq!(parsed.platform, 19);
|
||||
assert_eq!(parsed.text_assets.len(), 1);
|
||||
|
||||
let asset = parsed.text_asset("GameMainConfig").unwrap();
|
||||
assert_eq!(asset.name, "GameMainConfig");
|
||||
assert_eq!(asset.bytes, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires BAT_REAL_RESOURCES_ASSETS pointing at a local resources.assets"]
|
||||
fn reads_text_asset_from_real_resource_file() {
|
||||
let path = std::env::var("BAT_REAL_RESOURCES_ASSETS")
|
||||
.expect("BAT_REAL_RESOURCES_ASSETS must be set");
|
||||
|
||||
let parsed =
|
||||
UnitySerializedFile::from_path(Path::new(&path)).expect("parse local resources.assets");
|
||||
let asset = parsed
|
||||
.text_asset("GameMainConfig")
|
||||
.expect("GameMainConfig TextAsset present");
|
||||
|
||||
assert_eq!(asset.name, "GameMainConfig");
|
||||
assert!(!asset.bytes.is_empty());
|
||||
}
|
||||
}
|
||||
pub use bat_assetbundle::{
|
||||
UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset, UnitySerializedType,
|
||||
UnityTypeTreeNode,
|
||||
};
|
||||
|
||||
@@ -1,358 +1,24 @@
|
||||
//! Unity 2021.3 Adapter
|
||||
//! Unity 2021.3 adapter.
|
||||
//!
|
||||
//! 支持 Unity 2021.3.x 版本的 AssetBundle
|
||||
//! 该层只负责 Unity 版本选择;UnityFS 容器解析由 `bat-assetbundle` 引擎承担。
|
||||
|
||||
use super::adapter::{
|
||||
ParsedAssetBundle, RawAssetBundle, UnityAdapter, UnityFsBlockInfo, UnityFsCompression,
|
||||
UnityFsDirectoryInfo, UnityFsHeader, VersionRange,
|
||||
};
|
||||
use super::adapter::{ParsedAssetBundle, RawAssetBundle, UnityAdapter, VersionRange};
|
||||
use async_trait::async_trait;
|
||||
use std::io::Cursor;
|
||||
use bat_assetbundle::UnityFsParser;
|
||||
|
||||
const SERIALIZE_NOT_IMPLEMENTED: &str = "serialize() 将在 Phase 2 实现";
|
||||
const UNITYFS_COMPRESSION_MASK: u32 = 0x3f;
|
||||
const UNITYFS_BLOCK_INFO_AT_END_FLAG: u32 = 0x80;
|
||||
const UNITYFS_ALIGNMENT: usize = 16;
|
||||
const SERIALIZE_NOT_IMPLEMENTED: &str = "serialize() 尚未实现";
|
||||
|
||||
/// Unity 2021.3 Adapter
|
||||
/// Unity 2021.3 adapter.
|
||||
pub struct Unity2021_3Adapter;
|
||||
|
||||
impl Unity2021_3Adapter {
|
||||
/// 创建新的适配器实例
|
||||
/// Creates an adapter instance.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn unity_version_bytes(data: &[u8]) -> Option<&[u8]> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if &data[0..7] != b"UnityFS" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version_start = data.windows(7).position(|window| window == b"2021.3.")?;
|
||||
let version_bytes = &data[version_start..];
|
||||
let version_end = version_bytes
|
||||
.iter()
|
||||
.position(|&byte| byte == 0 || !byte.is_ascii())?;
|
||||
|
||||
Some(&version_bytes[..version_end])
|
||||
}
|
||||
|
||||
/// 检测 Unity 版本(从文件头)
|
||||
fn detect_unity_version(data: &[u8]) -> Option<String> {
|
||||
let version_bytes = Self::unity_version_bytes(data)?;
|
||||
std::str::from_utf8(version_bytes)
|
||||
.map(ToOwned::to_owned)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn parse_unityfs(data: &[u8]) -> Result<ParsedAssetBundle, String> {
|
||||
let mut reader = UnityFsReader::new(data);
|
||||
let signature = reader.read_c_string("signature")?;
|
||||
if signature != "UnityFS" {
|
||||
return Err(format!("Unsupported AssetBundle signature: {}", signature));
|
||||
}
|
||||
|
||||
let format_version = reader.read_u32("format_version")?;
|
||||
let target_version = reader.read_c_string("target_version")?;
|
||||
let unity_version = reader.read_c_string("unity_version")?;
|
||||
let total_size = reader.read_u64("total_size")?;
|
||||
let compressed_blocks_info_size = reader.read_u32("compressed_blocks_info_size")?;
|
||||
let uncompressed_blocks_info_size = reader.read_u32("uncompressed_blocks_info_size")?;
|
||||
let flags = reader.read_u32("flags")?;
|
||||
|
||||
let header = UnityFsHeader {
|
||||
format_version,
|
||||
target_version,
|
||||
unity_version,
|
||||
total_size,
|
||||
compressed_blocks_info_size,
|
||||
uncompressed_blocks_info_size,
|
||||
flags,
|
||||
};
|
||||
|
||||
if format_version >= 7 {
|
||||
reader.align(UNITYFS_ALIGNMENT)?;
|
||||
}
|
||||
|
||||
let blocks_info_bytes = read_blocks_info_bytes(data, &mut reader, &header)?;
|
||||
let block_info = decompress_blocks_info(
|
||||
blocks_info_bytes,
|
||||
compressed_blocks_info_size,
|
||||
uncompressed_blocks_info_size,
|
||||
flags,
|
||||
)?;
|
||||
let (blocks, directories) = Self::parse_blocks_info(&block_info)?;
|
||||
validate_directory_bounds(&blocks, &directories)?;
|
||||
|
||||
Ok(ParsedAssetBundle {
|
||||
unity_version: header.unity_version.clone(),
|
||||
assets: directories
|
||||
.iter()
|
||||
.map(|directory| directory.path.clone())
|
||||
.collect(),
|
||||
raw_data: data.to_vec(),
|
||||
unityfs_header: Some(header),
|
||||
blocks,
|
||||
directories,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_blocks_info(
|
||||
data: &[u8],
|
||||
) -> Result<(Vec<UnityFsBlockInfo>, Vec<UnityFsDirectoryInfo>), String> {
|
||||
let mut reader = UnityFsReader::new(data);
|
||||
let _hash = reader.read_bytes(16, "blocks_info_hash")?;
|
||||
let block_count = reader.read_i32("block_count")?;
|
||||
if block_count < 0 {
|
||||
return Err(format!("Invalid UnityFS block count: {}", block_count));
|
||||
}
|
||||
|
||||
let mut blocks = Vec::with_capacity(block_count as usize);
|
||||
for _ in 0..block_count {
|
||||
let uncompressed_size = reader.read_u32("block_uncompressed_size")?;
|
||||
let compressed_size = reader.read_u32("block_compressed_size")?;
|
||||
let flags = reader.read_u16("block_flags")?;
|
||||
blocks.push(UnityFsBlockInfo {
|
||||
uncompressed_size,
|
||||
compressed_size,
|
||||
flags,
|
||||
compression: compression_from_flags(flags),
|
||||
});
|
||||
}
|
||||
|
||||
let directory_count = reader.read_i32("directory_count")?;
|
||||
if directory_count < 0 {
|
||||
return Err(format!(
|
||||
"Invalid UnityFS directory count: {}",
|
||||
directory_count
|
||||
));
|
||||
}
|
||||
|
||||
let mut directories = Vec::with_capacity(directory_count as usize);
|
||||
for _ in 0..directory_count {
|
||||
directories.push(UnityFsDirectoryInfo {
|
||||
offset: reader.read_u64("directory_offset")?,
|
||||
size: reader.read_u64("directory_size")?,
|
||||
flags: reader.read_u32("directory_flags")?,
|
||||
path: reader.read_c_string("directory_path")?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok((blocks, directories))
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验目录条目落在解压数据区内。
|
||||
///
|
||||
/// UnityFS 的 directory 是解压后(所有 block 的 uncompressed 数据依次拼接而成的)
|
||||
/// 连续数据区上的 `[offset, offset + size)` 切片。解析阶段只按结构读取这些数值,
|
||||
/// 并不保证它们不越界;截断或损坏的 bundle 会给出指向数据区之外的目录条目,
|
||||
/// 若不校验就静默接受,后续按 offset/size 取数据时才会出错或读到错误内容。
|
||||
/// 这里把每个目录条目与「各 block 解压大小之和」比对,越界即报错并带上下文。
|
||||
fn validate_directory_bounds(
|
||||
blocks: &[UnityFsBlockInfo],
|
||||
directories: &[UnityFsDirectoryInfo],
|
||||
) -> Result<(), String> {
|
||||
let mut data_region_size: u64 = 0;
|
||||
for (index, block) in blocks.iter().enumerate() {
|
||||
data_region_size = data_region_size
|
||||
.checked_add(u64::from(block.uncompressed_size))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"UnityFS 解压数据区大小溢出:累加到第 {index} 个 block(uncompressed_size={})时超过 u64",
|
||||
block.uncompressed_size
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
for (index, directory) in directories.iter().enumerate() {
|
||||
let end = directory
|
||||
.offset
|
||||
.checked_add(directory.size)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"UnityFS 目录条目 {} 的 offset({}) + size({}) 溢出 u64",
|
||||
directory.path, directory.offset, directory.size
|
||||
)
|
||||
})?;
|
||||
if end > data_region_size {
|
||||
return Err(format!(
|
||||
"UnityFS 目录条目 {}(第 {index} 项)越界:offset({}) + size({}) = {} 超过解压数据区大小 {}",
|
||||
directory.path, directory.offset, directory.size, end, data_region_size
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_blocks_info_bytes<'a>(
|
||||
data: &'a [u8],
|
||||
reader: &mut UnityFsReader<'a>,
|
||||
header: &UnityFsHeader,
|
||||
) -> Result<&'a [u8], String> {
|
||||
let len = header.compressed_blocks_info_size as usize;
|
||||
if blocks_info_at_end(header.flags) {
|
||||
let start = data.len().checked_sub(len).ok_or_else(|| {
|
||||
format!(
|
||||
"UnityFS block info at end underflow: compressed size {}, file size {}",
|
||||
len,
|
||||
data.len()
|
||||
)
|
||||
})?;
|
||||
return Ok(&data[start..]);
|
||||
}
|
||||
|
||||
reader.read_bytes(len, "blocks_info")
|
||||
}
|
||||
|
||||
fn blocks_info_at_end(flags: u32) -> bool {
|
||||
flags & UNITYFS_BLOCK_INFO_AT_END_FLAG != 0
|
||||
}
|
||||
|
||||
fn decompress_blocks_info(
|
||||
data: &[u8],
|
||||
compressed_size: u32,
|
||||
uncompressed_size: u32,
|
||||
flags: u32,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if data.len() != compressed_size as usize {
|
||||
return Err(format!(
|
||||
"UnityFS block info size mismatch: header says {}, read {}",
|
||||
compressed_size,
|
||||
data.len()
|
||||
));
|
||||
}
|
||||
|
||||
let compression = compression_from_flags((flags & UNITYFS_COMPRESSION_MASK) as u16);
|
||||
match compression {
|
||||
UnityFsCompression::None => {
|
||||
if compressed_size != uncompressed_size {
|
||||
return Err(format!(
|
||||
"Uncompressed UnityFS block info size mismatch: compressed {} != uncompressed {}",
|
||||
compressed_size, uncompressed_size
|
||||
));
|
||||
}
|
||||
Ok(data.to_vec())
|
||||
}
|
||||
UnityFsCompression::Lz4 | UnityFsCompression::Lz4Hc => {
|
||||
lz4::block::decompress(data, Some(uncompressed_size as i32))
|
||||
.map_err(|error| format!("Failed to decompress UnityFS LZ4 block info: {}", error))
|
||||
}
|
||||
UnityFsCompression::Lzma => {
|
||||
let mut output = Vec::with_capacity(uncompressed_size as usize);
|
||||
lzma_rs::lzma_decompress(&mut Cursor::new(data), &mut output).map_err(|error| {
|
||||
format!("Failed to decompress UnityFS LZMA block info: {}", error)
|
||||
})?;
|
||||
if output.len() != uncompressed_size as usize {
|
||||
return Err(format!(
|
||||
"UnityFS LZMA block info size mismatch: expected {}, got {}",
|
||||
uncompressed_size,
|
||||
output.len()
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
UnityFsCompression::Unknown(value) => Err(format!(
|
||||
"Unsupported UnityFS block info compression flag: {}",
|
||||
value
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn compression_from_flags(flags: u16) -> UnityFsCompression {
|
||||
match flags & UNITYFS_COMPRESSION_MASK as u16 {
|
||||
0 => UnityFsCompression::None,
|
||||
1 => UnityFsCompression::Lzma,
|
||||
2 => UnityFsCompression::Lz4,
|
||||
3 | 4 => UnityFsCompression::Lz4Hc,
|
||||
value => UnityFsCompression::Unknown(value),
|
||||
}
|
||||
}
|
||||
|
||||
struct UnityFsReader<'a> {
|
||||
data: &'a [u8],
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> UnityFsReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self { data, offset: 0 }
|
||||
}
|
||||
|
||||
fn read_bytes(&mut self, len: usize, field: &str) -> Result<&'a [u8], String> {
|
||||
let end = self
|
||||
.offset
|
||||
.checked_add(len)
|
||||
.ok_or_else(|| format!("{} length overflow at offset {}", field, self.offset))?;
|
||||
if end > self.data.len() {
|
||||
return Err(format!(
|
||||
"Unexpected end while reading {} at offset {}: need {}, have {}",
|
||||
field,
|
||||
self.offset,
|
||||
len,
|
||||
self.data.len().saturating_sub(self.offset)
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = &self.data[self.offset..end];
|
||||
self.offset = end;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn align(&mut self, alignment: usize) -> Result<(), String> {
|
||||
if alignment == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remainder = self.offset % alignment;
|
||||
if remainder == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let padding = alignment - remainder;
|
||||
self.read_bytes(padding, "alignment padding").map(|_| ())
|
||||
}
|
||||
|
||||
fn read_u16(&mut self, field: &str) -> Result<u16, String> {
|
||||
let bytes = self.read_bytes(2, field)?;
|
||||
Ok(u16::from_be_bytes([bytes[0], bytes[1]]))
|
||||
}
|
||||
|
||||
fn read_u32(&mut self, field: &str) -> Result<u32, String> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||
}
|
||||
|
||||
fn read_i32(&mut self, field: &str) -> Result<i32, String> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||
}
|
||||
|
||||
fn read_u64(&mut self, field: &str) -> Result<u64, String> {
|
||||
let bytes = self.read_bytes(8, field)?;
|
||||
Ok(u64::from_be_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_c_string(&mut self, field: &str) -> Result<String, String> {
|
||||
let remaining = &self.data[self.offset..];
|
||||
let Some(length) = remaining.iter().position(|&byte| byte == 0) else {
|
||||
return Err(format!(
|
||||
"Missing null terminator while reading {} at offset {}",
|
||||
field, self.offset
|
||||
));
|
||||
};
|
||||
let bytes = self.read_bytes(length, field)?;
|
||||
self.offset += 1;
|
||||
std::str::from_utf8(bytes)
|
||||
.map(ToOwned::to_owned)
|
||||
.map_err(|error| format!("Invalid UTF-8 in {}: {}", field, error))
|
||||
UnityFsParser::detect_unity_version(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,16 +39,15 @@ impl UnityAdapter for Unity2021_3Adapter {
|
||||
}
|
||||
|
||||
fn can_handle(&self, bundle: &RawAssetBundle) -> bool {
|
||||
// 检测 Unity 版本
|
||||
if let Some(version) = Self::detect_unity_version(&bundle.data) {
|
||||
self.supported_versions().contains(&version)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
Self::detect_unity_version(&bundle.data)
|
||||
.map(|version| self.supported_versions().contains(&version))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn parse(&self, bundle: &RawAssetBundle) -> Result<ParsedAssetBundle, String> {
|
||||
let parsed = Self::parse_unityfs(&bundle.data)?;
|
||||
let parsed = UnityFsParser::new()
|
||||
.parse_asset_bundle(bundle)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !self.supported_versions().contains(&parsed.unity_version) {
|
||||
return Err(format!(
|
||||
"Unsupported Unity version for {}: {}",
|
||||
@@ -394,12 +59,6 @@ impl UnityAdapter for Unity2021_3Adapter {
|
||||
}
|
||||
|
||||
async fn serialize(&self, _parsed: &ParsedAssetBundle) -> Result<Vec<u8>, String> {
|
||||
// TODO: Phase 2 实现
|
||||
// 需要:
|
||||
// 1. 序列化 Asset 对象
|
||||
// 2. 重新构建 TypeTree
|
||||
// 3. 压缩数据块
|
||||
// 4. 写入 UnityFS 文件头
|
||||
Err(SERIALIZE_NOT_IMPLEMENTED.to_string())
|
||||
}
|
||||
}
|
||||
@@ -407,6 +66,9 @@ impl UnityAdapter for Unity2021_3Adapter {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::unity::UnityFsCompression;
|
||||
|
||||
const UNITYFS_ALIGNMENT: usize = 16;
|
||||
|
||||
fn push_c_string(data: &mut Vec<u8>, value: &str) {
|
||||
data.extend_from_slice(value.as_bytes());
|
||||
@@ -436,7 +98,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_minimal_unityfs_bundle() -> Vec<u8> {
|
||||
fn synthetic_minimal_unityfs_bundle(unity_version: &str) -> Vec<u8> {
|
||||
let mut blocks_info = Vec::new();
|
||||
blocks_info.extend_from_slice(&[0; 16]);
|
||||
push_i32(&mut blocks_info, 1);
|
||||
@@ -453,7 +115,7 @@ mod tests {
|
||||
push_c_string(&mut data, "UnityFS");
|
||||
push_u32(&mut data, 8);
|
||||
push_c_string(&mut data, "5.x.x");
|
||||
push_c_string(&mut data, "2021.3.56f2");
|
||||
push_c_string(&mut data, unity_version);
|
||||
push_u64(&mut data, 0);
|
||||
push_u32(&mut data, blocks_info.len() as u32);
|
||||
push_u32(&mut data, blocks_info.len() as u32);
|
||||
@@ -463,7 +125,7 @@ mod tests {
|
||||
data.extend_from_slice(b"data");
|
||||
|
||||
let total_size = data.len() as u64;
|
||||
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
|
||||
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + unity_version.len() + 1;
|
||||
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
||||
data
|
||||
}
|
||||
@@ -490,7 +152,7 @@ mod tests {
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
|
||||
let bundle = RawAssetBundle {
|
||||
data: synthetic_minimal_unityfs_bundle(),
|
||||
data: synthetic_minimal_unityfs_bundle("2021.3.56f2"),
|
||||
path: Some("synthetic-minimal.bundle".to_string()),
|
||||
};
|
||||
|
||||
@@ -514,7 +176,7 @@ mod tests {
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
|
||||
let bundle = RawAssetBundle {
|
||||
data: synthetic_minimal_unityfs_bundle(),
|
||||
data: synthetic_minimal_unityfs_bundle("2021.3.56f2"),
|
||||
path: Some("synthetic-minimal.bundle".to_string()),
|
||||
};
|
||||
|
||||
@@ -538,85 +200,34 @@ mod tests {
|
||||
path: None,
|
||||
};
|
||||
|
||||
let result = adapter.parse(&bundle).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("signature"));
|
||||
}
|
||||
|
||||
fn block(uncompressed_size: u32) -> UnityFsBlockInfo {
|
||||
UnityFsBlockInfo {
|
||||
uncompressed_size,
|
||||
compressed_size: uncompressed_size,
|
||||
flags: 0,
|
||||
compression: UnityFsCompression::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn directory(offset: u64, size: u64) -> UnityFsDirectoryInfo {
|
||||
UnityFsDirectoryInfo {
|
||||
offset,
|
||||
size,
|
||||
flags: 0,
|
||||
path: "CAB-test".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_within_data_region_is_accepted() {
|
||||
// 两个 block 共 12 字节解压数据区;目录条目正好覆盖尾部,合法。
|
||||
let result =
|
||||
validate_directory_bounds(&[block(8), block(4)], &[directory(0, 8), directory(8, 4)]);
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_past_data_region_is_rejected() {
|
||||
// 解压数据区仅 4 字节,目录声称 [0, 8) 越界,应被拒绝并带上下文。
|
||||
let error = validate_directory_bounds(&[block(4)], &[directory(0, 8)]).unwrap_err();
|
||||
assert!(error.contains("越界"), "{error}");
|
||||
assert!(error.contains("解压数据区大小 4"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_offset_size_overflow_is_rejected() {
|
||||
let error = validate_directory_bounds(&[block(4)], &[directory(u64::MAX, 1)]).unwrap_err();
|
||||
assert!(error.contains("溢出"), "{error}");
|
||||
let error = adapter.parse(&bundle).await.unwrap_err();
|
||||
assert!(error.contains("signature"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_rejects_out_of_bounds_directory() {
|
||||
// 构造一个 directory.size 超过 block 解压大小的 bundle,端到端验证被拒。
|
||||
let mut blocks_info = Vec::new();
|
||||
blocks_info.extend_from_slice(&[0; 16]);
|
||||
push_i32(&mut blocks_info, 1);
|
||||
push_u32(&mut blocks_info, 4); // block uncompressed_size = 4
|
||||
push_u32(&mut blocks_info, 4);
|
||||
push_u16(&mut blocks_info, 0);
|
||||
push_i32(&mut blocks_info, 1);
|
||||
push_u64(&mut blocks_info, 0);
|
||||
push_u64(&mut blocks_info, 99); // directory size 99 远超数据区
|
||||
push_u32(&mut blocks_info, 0);
|
||||
push_c_string(&mut blocks_info, "CAB-test");
|
||||
async fn test_parse_rejects_unsupported_unity_version() {
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
push_c_string(&mut data, "UnityFS");
|
||||
push_u32(&mut data, 8);
|
||||
push_c_string(&mut data, "5.x.x");
|
||||
push_c_string(&mut data, "2021.3.56f2");
|
||||
push_u64(&mut data, 0);
|
||||
push_u32(&mut data, blocks_info.len() as u32);
|
||||
push_u32(&mut data, blocks_info.len() as u32);
|
||||
push_u32(&mut data, 0);
|
||||
align(&mut data, UNITYFS_ALIGNMENT);
|
||||
data.extend_from_slice(&blocks_info);
|
||||
data.extend_from_slice(b"data");
|
||||
let bundle = RawAssetBundle {
|
||||
data: synthetic_minimal_unityfs_bundle("2022.3.1f1"),
|
||||
path: Some("unsupported.bundle".to_string()),
|
||||
};
|
||||
|
||||
let error = adapter.parse(&bundle).await.unwrap_err();
|
||||
assert!(error.contains("Unsupported Unity version"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serialize_returns_explicit_not_implemented_error() {
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
let bundle = RawAssetBundle {
|
||||
data,
|
||||
path: Some("out-of-bounds.bundle".to_string()),
|
||||
data: synthetic_minimal_unityfs_bundle("2021.3.56f2"),
|
||||
path: Some("synthetic-minimal.bundle".to_string()),
|
||||
};
|
||||
let error = adapter.parse(&bundle).await.unwrap_err();
|
||||
assert!(error.contains("越界"), "{error}");
|
||||
let parsed = adapter.parse(&bundle).await.unwrap();
|
||||
|
||||
let error = adapter.serialize(&parsed).await.unwrap_err();
|
||||
|
||||
assert_eq!(error, SERIALIZE_NOT_IMPLEMENTED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"asset_bundle_count": "3",
|
||||
"declared_size_count": "3",
|
||||
"dependency_count": "1",
|
||||
"internal_id_count": "3",
|
||||
"resource_count": "3",
|
||||
"resource_type_count": "1",
|
||||
"key_object_count": "4",
|
||||
"bucket_record_count": "4",
|
||||
|
||||
@@ -21,4 +21,6 @@ async fn parses_local_real_unityfs_bundle() {
|
||||
assert_eq!(parsed.unity_version, "2021.3.56f2");
|
||||
assert!(!parsed.blocks.is_empty());
|
||||
assert!(!parsed.directories.is_empty());
|
||||
assert_eq!(parsed.files.len(), parsed.directories.len());
|
||||
assert!(parsed.files.iter().all(|file| !file.data.is_empty()));
|
||||
}
|
||||
|
||||
@@ -11,9 +11,8 @@ thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
# 注意:byteorder、lz4、lzma-rs 等 UnityFS 解析/解压依赖待解析器真正实现时
|
||||
# 再按需引入,避免占位阶段白增编译负担。
|
||||
lz4 = "1.28"
|
||||
lzma-rs = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
hex = "0.4"
|
||||
|
||||
@@ -1,26 +1,52 @@
|
||||
//! AssetBundle 错误类型定义
|
||||
//! AssetBundle error types.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// AssetBundle 错误类型
|
||||
/// AssetBundle parser error.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AssetBundleError {
|
||||
/// IO 错误
|
||||
/// I/O error.
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// 解析错误
|
||||
/// Parser reached malformed data while reading a named field.
|
||||
#[error("Parse error at offset {offset} while reading {field}: {message}")]
|
||||
ParseField {
|
||||
/// Field or structure name being read.
|
||||
field: String,
|
||||
/// Byte offset where parsing failed.
|
||||
offset: usize,
|
||||
/// Human-readable diagnostic.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// General parser error.
|
||||
#[error("Parse error: {0}")]
|
||||
Parse(String),
|
||||
|
||||
/// 不支持的格式
|
||||
/// Unsupported format or compression mode.
|
||||
#[error("Unsupported format: {0}")]
|
||||
UnsupportedFormat(String),
|
||||
|
||||
/// 其他错误
|
||||
/// Other error.
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
/// AssetBundle Result 类型
|
||||
impl AssetBundleError {
|
||||
/// Creates a field-scoped parser error with byte offset context.
|
||||
pub fn parse_field(
|
||||
field: impl Into<String>,
|
||||
offset: usize,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::ParseField {
|
||||
field: field.into(),
|
||||
offset,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AssetBundle result type.
|
||||
pub type Result<T> = std::result::Result<T, AssetBundleError>;
|
||||
|
||||
@@ -9,9 +9,20 @@
|
||||
|
||||
pub mod error;
|
||||
pub mod parser;
|
||||
pub mod serialized;
|
||||
pub mod types;
|
||||
|
||||
pub use error::{AssetBundleError, Result};
|
||||
pub use parser::{compression_from_flags, Parser, UnityFsParser};
|
||||
pub use serialized::{
|
||||
UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset, UnitySerializedType,
|
||||
UnityTypeTreeNode,
|
||||
};
|
||||
pub use types::{
|
||||
AssetType, ParsedAssetBundle, RawAssetBundle, UnityFsBlockInfo, UnityFsBundle,
|
||||
UnityFsCompression, UnityFsDirectoryInfo, UnityFsFile, UnityFsHeader,
|
||||
UnitySerializedParseError,
|
||||
};
|
||||
|
||||
/// AssetBundle 解析器版本号
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,878 @@
|
||||
//! Unity serialized file parser.
|
||||
//!
|
||||
//! The parser is intentionally data-oriented: it reads the serialized file
|
||||
//! header, type metadata, object table and `TextAsset` payloads. Field-level
|
||||
//! deserialization for `MonoBehaviour` and `ScriptableObject` builds on the
|
||||
//! type tree structures exposed here.
|
||||
|
||||
use crate::error::{AssetBundleError, Result};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// One extracted Unity `TextAsset`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnitySerializedTextAsset {
|
||||
/// UnityFS directory path or standalone serialized file path.
|
||||
pub source_path: Option<String>,
|
||||
/// Unity path ID of the object.
|
||||
pub path_id: i64,
|
||||
/// Asset name stored in the serialized object.
|
||||
pub name: String,
|
||||
/// Raw bytes stored by the `TextAsset`.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parsed Unity serialized file summary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnitySerializedFile {
|
||||
/// UnityFS directory path or standalone path, when known.
|
||||
pub source_path: Option<String>,
|
||||
/// Serialized file format version.
|
||||
pub version: u32,
|
||||
/// Unity editor version stored in the file.
|
||||
pub unity_version: String,
|
||||
/// Target platform value from the file header.
|
||||
pub platform: i32,
|
||||
/// Type metadata entries declared by the file.
|
||||
pub types: Vec<UnitySerializedType>,
|
||||
/// Object table entries declared by the file.
|
||||
pub objects: Vec<UnitySerializedObject>,
|
||||
text_assets: Vec<UnitySerializedTextAsset>,
|
||||
}
|
||||
|
||||
impl UnitySerializedFile {
|
||||
/// Parses a serialized file from raw bytes.
|
||||
pub fn from_slice(data: &[u8]) -> Result<Self> {
|
||||
Self::from_named_slice(None::<String>, data)
|
||||
}
|
||||
|
||||
/// Parses a serialized file from raw bytes with a source path.
|
||||
pub fn from_named_slice(path: impl Into<Option<String>>, data: &[u8]) -> Result<Self> {
|
||||
let source_path = path.into();
|
||||
let mut reader = Reader::new(data);
|
||||
|
||||
let metadata_size_legacy = reader.read_u32_be("metadata_size")?;
|
||||
let file_size_legacy = reader.read_u32_be("file_size")?;
|
||||
let version = reader.read_u32_be("version")?;
|
||||
let data_offset_legacy = reader.read_u32_be("data_offset")?;
|
||||
let endian_flag = reader.read_u8("endian_flag")?;
|
||||
reader.read_bytes(3, "reserved")?;
|
||||
|
||||
let (metadata_size, file_size, data_offset) = if version >= 22 {
|
||||
let metadata_size = reader.read_u32_be("metadata_size_2")?;
|
||||
let file_size = reader.read_u64_be("file_size_2")?;
|
||||
let data_offset = reader.read_u64_be("data_offset_2")?;
|
||||
let _unknown = reader.read_u64_be("unknown_2")?;
|
||||
(u64::from(metadata_size), file_size, data_offset)
|
||||
} else {
|
||||
(
|
||||
u64::from(metadata_size_legacy),
|
||||
u64::from(file_size_legacy),
|
||||
u64::from(data_offset_legacy),
|
||||
)
|
||||
};
|
||||
|
||||
if file_size > data.len() as u64 {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Unity serialized file_size {} exceeds available bytes {}",
|
||||
file_size,
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
if data_offset > data.len() as u64 {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Unity serialized data_offset {} exceeds available bytes {}",
|
||||
data_offset,
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
let metadata_end = reader
|
||||
.offset()
|
||||
.checked_add(metadata_size as usize)
|
||||
.ok_or_else(|| {
|
||||
AssetBundleError::Parse("Unity serialized metadata end overflow".to_string())
|
||||
})?;
|
||||
if metadata_end > data.len() {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Unity serialized metadata exceeds file: end {}, file {}",
|
||||
metadata_end,
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let endian = if endian_flag == 0 {
|
||||
Endian::Little
|
||||
} else {
|
||||
Endian::Big
|
||||
};
|
||||
reader.set_endian(endian);
|
||||
|
||||
let unity_version = reader.read_c_string("unity_version")?;
|
||||
let platform = reader.read_i32("platform")?;
|
||||
let enable_type_tree = reader.read_u8("enable_type_tree")?;
|
||||
let type_count = reader.read_i32("type_count")?;
|
||||
if type_count < 0 {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Invalid Unity type count: {type_count}"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut types = Vec::with_capacity(type_count as usize);
|
||||
for index in 0..type_count {
|
||||
types.push(read_serialized_type(
|
||||
&mut reader,
|
||||
version,
|
||||
enable_type_tree,
|
||||
index as usize,
|
||||
)?);
|
||||
}
|
||||
|
||||
let big_id_enabled = if (11..14).contains(&version) {
|
||||
reader.read_i32("big_id_enabled")?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let object_count = reader.read_i32("object_count")?;
|
||||
if object_count < 0 {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Invalid Unity object count: {object_count}"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut objects = Vec::with_capacity(object_count as usize);
|
||||
let mut text_assets = Vec::new();
|
||||
for _ in 0..object_count {
|
||||
if version >= 14 {
|
||||
reader.align(4)?;
|
||||
}
|
||||
|
||||
let path_id = if big_id_enabled != 0 {
|
||||
reader.read_i64("path_id")?
|
||||
} else if version < 14 {
|
||||
i64::from(reader.read_i32("path_id")?)
|
||||
} else {
|
||||
reader.read_i64("path_id")?
|
||||
};
|
||||
|
||||
let byte_start = if version >= 22 {
|
||||
reader.read_u64("byte_start")?
|
||||
} else {
|
||||
u64::from(reader.read_u32("byte_start")?)
|
||||
};
|
||||
let byte_size = reader.read_u32("byte_size")?;
|
||||
let type_index_raw = reader.read_i32("type_id")?;
|
||||
if type_index_raw < 0 {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Invalid Unity type index: {type_index_raw}"
|
||||
)));
|
||||
}
|
||||
if version < 16 {
|
||||
reader.read_u16("class_id")?;
|
||||
}
|
||||
if version < 11 {
|
||||
reader.read_u16("is_destroyed")?;
|
||||
}
|
||||
if (11..17).contains(&version) {
|
||||
reader.read_i16("script_type_index")?;
|
||||
}
|
||||
if version == 15 || version == 16 {
|
||||
reader.read_u8("stripped")?;
|
||||
}
|
||||
|
||||
let type_index = type_index_raw as usize;
|
||||
let class_id = types.get(type_index).map(|ty| ty.class_id).ok_or_else(|| {
|
||||
AssetBundleError::Parse(format!(
|
||||
"Invalid Unity type index: {type_index_raw}, type_count {}",
|
||||
types.len()
|
||||
))
|
||||
})?;
|
||||
let object_start = data_offset.checked_add(byte_start).ok_or_else(|| {
|
||||
AssetBundleError::Parse("Unity object offset overflow".to_string())
|
||||
})?;
|
||||
let object_end = object_start
|
||||
.checked_add(u64::from(byte_size))
|
||||
.ok_or_else(|| AssetBundleError::Parse("Unity object size overflow".to_string()))?;
|
||||
if object_end > data.len() as u64 {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Unity object exceeds file size: start={}, size={}, file_size={}",
|
||||
object_start,
|
||||
byte_size,
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
objects.push(UnitySerializedObject {
|
||||
path_id,
|
||||
byte_start,
|
||||
byte_size,
|
||||
type_index,
|
||||
class_id,
|
||||
});
|
||||
|
||||
if class_id == 49 {
|
||||
let mut asset = parse_text_asset(
|
||||
path_id,
|
||||
&data[object_start as usize..object_end as usize],
|
||||
endian,
|
||||
)?;
|
||||
asset.source_path.clone_from(&source_path);
|
||||
text_assets.push(asset);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
source_path,
|
||||
version,
|
||||
unity_version,
|
||||
platform,
|
||||
types,
|
||||
objects,
|
||||
text_assets,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a serialized file from disk.
|
||||
pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let path = path.as_ref();
|
||||
let bytes = fs::read(path).map_err(AssetBundleError::Io)?;
|
||||
Self::from_named_slice(Some(path.display().to_string()), &bytes)
|
||||
}
|
||||
|
||||
/// Returns all extracted text assets.
|
||||
pub fn text_assets(&self) -> &[UnitySerializedTextAsset] {
|
||||
&self.text_assets
|
||||
}
|
||||
|
||||
/// Returns one extracted text asset by name.
|
||||
pub fn text_asset(&self, name: &str) -> Option<&UnitySerializedTextAsset> {
|
||||
self.text_assets.iter().find(|asset| asset.name == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Type metadata entry from a Unity serialized file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnitySerializedType {
|
||||
/// Index in the file type table.
|
||||
pub index: usize,
|
||||
/// Unity class ID, for example `49` for `TextAsset`.
|
||||
pub class_id: i32,
|
||||
/// Whether this type is stripped.
|
||||
pub is_stripped_type: bool,
|
||||
/// Script type index, when available.
|
||||
pub script_type_index: Option<i16>,
|
||||
/// Script hash bytes for MonoBehaviour-like types.
|
||||
pub script_id: Option<[u8; 16]>,
|
||||
/// Type hash bytes.
|
||||
pub old_type_hash: Option<[u8; 16]>,
|
||||
/// TypeTree nodes, if the file embeds a type tree.
|
||||
pub type_tree: Vec<UnityTypeTreeNode>,
|
||||
}
|
||||
|
||||
/// One TypeTree node.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnityTypeTreeNode {
|
||||
/// TypeTree format version for the node.
|
||||
pub version: u16,
|
||||
/// Nesting depth.
|
||||
pub level: u8,
|
||||
/// Whether this field is an array.
|
||||
pub is_array: bool,
|
||||
/// Field type name.
|
||||
pub type_name: String,
|
||||
/// Field name.
|
||||
pub name: String,
|
||||
/// Declared byte size.
|
||||
pub byte_size: i32,
|
||||
/// Node index.
|
||||
pub index: i32,
|
||||
/// Unity metadata flags.
|
||||
pub meta_flag: i32,
|
||||
/// Referenced type hash for newer Unity versions.
|
||||
pub ref_type_hash: Option<u64>,
|
||||
}
|
||||
|
||||
/// Object table entry from a Unity serialized file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnitySerializedObject {
|
||||
/// Unity path ID of the object.
|
||||
pub path_id: i64,
|
||||
/// Object byte offset relative to data section.
|
||||
pub byte_start: u64,
|
||||
/// Object byte size.
|
||||
pub byte_size: u32,
|
||||
/// Index into the file type table.
|
||||
pub type_index: usize,
|
||||
/// Unity class ID resolved from `type_index`.
|
||||
pub class_id: i32,
|
||||
}
|
||||
|
||||
fn parse_text_asset(path_id: i64, data: &[u8], endian: Endian) -> Result<UnitySerializedTextAsset> {
|
||||
let mut reader = Reader::new(data);
|
||||
reader.set_endian(endian);
|
||||
let name = reader.read_len_prefixed_string("text_asset_name")?;
|
||||
reader.align(4)?;
|
||||
let bytes_len = reader.read_u32("text_asset_bytes_len")? as usize;
|
||||
let bytes = reader.read_bytes(bytes_len, "text_asset_bytes")?.to_vec();
|
||||
|
||||
Ok(UnitySerializedTextAsset {
|
||||
source_path: None,
|
||||
path_id,
|
||||
name,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_serialized_type(
|
||||
reader: &mut Reader<'_>,
|
||||
version: u32,
|
||||
enable_type_tree: u8,
|
||||
index: usize,
|
||||
) -> Result<UnitySerializedType> {
|
||||
let class_id = reader.read_i32("type_class_id")?;
|
||||
|
||||
let is_stripped_type = if version >= 16 {
|
||||
reader.read_u8("type_is_stripped")? != 0
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let script_type_index = if version >= 17 {
|
||||
Some(reader.read_i16("type_script_index")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut script_id = None;
|
||||
let mut old_type_hash = None;
|
||||
if version >= 13 {
|
||||
if (version < 16 && class_id < 0) || (version >= 16 && class_id == 114) {
|
||||
script_id = Some(read_hash16(reader, "type_script_id")?);
|
||||
}
|
||||
old_type_hash = Some(read_hash16(reader, "type_hash")?);
|
||||
}
|
||||
|
||||
let type_tree = if enable_type_tree != 0 {
|
||||
read_type_tree(reader, version)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(UnitySerializedType {
|
||||
index,
|
||||
class_id,
|
||||
is_stripped_type,
|
||||
script_type_index,
|
||||
script_id,
|
||||
old_type_hash,
|
||||
type_tree,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_hash16(reader: &mut Reader<'_>, field: &str) -> Result<[u8; 16]> {
|
||||
reader
|
||||
.read_bytes(16, field)?
|
||||
.try_into()
|
||||
.map_err(|_| AssetBundleError::parse_field(field, reader.offset(), "expected 16 bytes"))
|
||||
}
|
||||
|
||||
fn read_type_tree(reader: &mut Reader<'_>, version: u32) -> Result<Vec<UnityTypeTreeNode>> {
|
||||
if version >= 12 || version == 10 {
|
||||
let node_count = reader.read_i32("type_tree_node_count")?;
|
||||
if node_count < 0 {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Invalid Unity type tree node count: {node_count}"
|
||||
)));
|
||||
}
|
||||
let string_buffer_size = reader.read_i32("type_tree_string_buffer_size")?;
|
||||
if string_buffer_size < 0 {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Invalid Unity type tree string buffer size: {string_buffer_size}"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut raw_nodes = Vec::with_capacity(node_count as usize);
|
||||
for _ in 0..node_count {
|
||||
raw_nodes.push(RawTypeTreeNode {
|
||||
version: reader.read_u16("type_tree_node_version")?,
|
||||
level: reader.read_u8("type_tree_node_level")?,
|
||||
is_array: reader.read_u8("type_tree_node_is_array")? != 0,
|
||||
type_offset: reader.read_i32("type_tree_node_type_offset")?,
|
||||
name_offset: reader.read_i32("type_tree_node_name_offset")?,
|
||||
byte_size: reader.read_i32("type_tree_node_byte_size")?,
|
||||
index: reader.read_i32("type_tree_node_index")?,
|
||||
meta_flag: reader.read_i32("type_tree_node_meta_flag")?,
|
||||
ref_type_hash: if version >= 19 {
|
||||
Some(reader.read_u64("type_tree_node_ref_type_hash")?)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let string_buffer =
|
||||
reader.read_bytes(string_buffer_size as usize, "type_tree_string_buffer")?;
|
||||
if version >= 21 {
|
||||
let dependency_count = reader.read_i32("type_tree_dependency_count")?;
|
||||
if dependency_count < 0 {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Invalid Unity type tree dependency count: {dependency_count}"
|
||||
)));
|
||||
}
|
||||
reader.read_bytes(dependency_count as usize * 4, "type_tree_dependencies")?;
|
||||
}
|
||||
|
||||
Ok(raw_nodes
|
||||
.into_iter()
|
||||
.map(|node| UnityTypeTreeNode {
|
||||
version: node.version,
|
||||
level: node.level,
|
||||
is_array: node.is_array,
|
||||
type_name: type_tree_string(string_buffer, node.type_offset),
|
||||
name: type_tree_string(string_buffer, node.name_offset),
|
||||
byte_size: node.byte_size,
|
||||
index: node.index,
|
||||
meta_flag: node.meta_flag,
|
||||
ref_type_hash: node.ref_type_hash,
|
||||
})
|
||||
.collect())
|
||||
} else {
|
||||
read_legacy_type_tree(reader, version)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_legacy_type_tree(reader: &mut Reader<'_>, version: u32) -> Result<Vec<UnityTypeTreeNode>> {
|
||||
let type_name = reader.read_c_string("legacy_type_tree_type")?;
|
||||
let name = reader.read_c_string("legacy_type_tree_name")?;
|
||||
let byte_size = reader.read_i32("legacy_type_tree_byte_size")?;
|
||||
let index = if version == 2 {
|
||||
0
|
||||
} else {
|
||||
reader.read_i32("legacy_type_tree_index")?
|
||||
};
|
||||
let is_array = reader.read_i32("legacy_type_tree_is_array")? != 0;
|
||||
let version_value = reader.read_i32("legacy_type_tree_version")?;
|
||||
let meta_flag = reader.read_i32("legacy_type_tree_meta_flag")?;
|
||||
let child_count = reader.read_i32("legacy_type_tree_child_count")?;
|
||||
if child_count < 0 {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Invalid legacy type tree child count: {child_count}"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut nodes = vec![UnityTypeTreeNode {
|
||||
version: version_value as u16,
|
||||
level: 0,
|
||||
is_array,
|
||||
type_name,
|
||||
name,
|
||||
byte_size,
|
||||
index,
|
||||
meta_flag,
|
||||
ref_type_hash: None,
|
||||
}];
|
||||
for _ in 0..child_count {
|
||||
nodes.extend(read_legacy_type_tree(reader, version)?);
|
||||
}
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
struct RawTypeTreeNode {
|
||||
version: u16,
|
||||
level: u8,
|
||||
is_array: bool,
|
||||
type_offset: i32,
|
||||
name_offset: i32,
|
||||
byte_size: i32,
|
||||
index: i32,
|
||||
meta_flag: i32,
|
||||
ref_type_hash: Option<u64>,
|
||||
}
|
||||
|
||||
fn type_tree_string(buffer: &[u8], offset: i32) -> String {
|
||||
let raw = offset as u32;
|
||||
if raw & 0x8000_0000 != 0 {
|
||||
let common_index = raw & 0x7fff_ffff;
|
||||
return common_type_tree_string(common_index)
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("unity_common_string_{common_index}"));
|
||||
}
|
||||
|
||||
let offset = raw as usize;
|
||||
if offset >= buffer.len() {
|
||||
return format!("invalid_string_offset_{offset}");
|
||||
}
|
||||
let bytes = &buffer[offset..];
|
||||
let len = bytes
|
||||
.iter()
|
||||
.position(|&byte| byte == 0)
|
||||
.unwrap_or(bytes.len());
|
||||
std::str::from_utf8(&bytes[..len])
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|_| format!("invalid_utf8_string_offset_{offset}"))
|
||||
}
|
||||
|
||||
fn common_type_tree_string(index: u32) -> Option<&'static str> {
|
||||
match index {
|
||||
0 => Some("AABB"),
|
||||
5 => Some("Array"),
|
||||
19 => Some("Base"),
|
||||
28 => Some("bool"),
|
||||
35 => Some("char"),
|
||||
41 => Some("ColorRGBA"),
|
||||
51 => Some("data"),
|
||||
56 => Some("deque"),
|
||||
62 => Some("double"),
|
||||
69 => Some("dynamic_array"),
|
||||
83 => Some("FastPropertyName"),
|
||||
100 => Some("first"),
|
||||
106 => Some("float"),
|
||||
112 => Some("Font"),
|
||||
117 => Some("GameObject"),
|
||||
128 => Some("Generic Mono"),
|
||||
141 => Some("GradientNEW"),
|
||||
153 => Some("GUID"),
|
||||
158 => Some("GUIStyle"),
|
||||
167 => Some("int"),
|
||||
171 => Some("list"),
|
||||
176 => Some("long long"),
|
||||
186 => Some("map"),
|
||||
190 => Some("Matrix4x4f"),
|
||||
200 => Some("m_ByteSize"),
|
||||
211 => Some("m_Curve"),
|
||||
219 => Some("m_EditorClassIdentifier"),
|
||||
243 => Some("m_EditorHideFlags"),
|
||||
261 => Some("m_Enabled"),
|
||||
271 => Some("m_ExtensionPtr"),
|
||||
286 => Some("m_GameObject"),
|
||||
299 => Some("m_Index"),
|
||||
307 => Some("m_IsArray"),
|
||||
317 => Some("m_IsStatic"),
|
||||
328 => Some("m_MetaFlag"),
|
||||
339 => Some("m_Name"),
|
||||
346 => Some("m_ObjectHideFlags"),
|
||||
364 => Some("m_PrefabInternal"),
|
||||
381 => Some("m_PrefabParentObject"),
|
||||
402 => Some("m_Script"),
|
||||
411 => Some("m_StaticEditorFlags"),
|
||||
431 => Some("m_Type"),
|
||||
438 => Some("m_Version"),
|
||||
448 => Some("Object"),
|
||||
455 => Some("pair"),
|
||||
460 => Some("PPtr<Component>"),
|
||||
476 => Some("PPtr<GameObject>"),
|
||||
493 => Some("PPtr<Material>"),
|
||||
508 => Some("PPtr<MonoBehaviour>"),
|
||||
528 => Some("PPtr<MonoScript>"),
|
||||
546 => Some("PPtr<Object>"),
|
||||
559 => Some("PPtr<Prefab>"),
|
||||
572 => Some("PPtr<Sprite>"),
|
||||
585 => Some("PPtr<TextAsset>"),
|
||||
601 => Some("PPtr<Texture>"),
|
||||
615 => Some("PPtr<Texture2D>"),
|
||||
631 => Some("PPtr<Transform>"),
|
||||
647 => Some("Quaternionf"),
|
||||
659 => Some("Rectf"),
|
||||
665 => Some("RectInt"),
|
||||
673 => Some("second"),
|
||||
680 => Some("set"),
|
||||
684 => Some("short"),
|
||||
690 => Some("size"),
|
||||
695 => Some("SInt16"),
|
||||
702 => Some("SInt32"),
|
||||
709 => Some("SInt64"),
|
||||
716 => Some("SInt8"),
|
||||
722 => Some("staticvector"),
|
||||
735 => Some("string"),
|
||||
742 => Some("TextAsset"),
|
||||
752 => Some("Texture2D"),
|
||||
762 => Some("Transform"),
|
||||
772 => Some("TypelessData"),
|
||||
785 => Some("UInt16"),
|
||||
792 => Some("UInt32"),
|
||||
799 => Some("UInt64"),
|
||||
806 => Some("UInt8"),
|
||||
812 => Some("unsigned int"),
|
||||
825 => Some("unsigned long long"),
|
||||
844 => Some("unsigned short"),
|
||||
859 => Some("vector"),
|
||||
866 => Some("Vector2f"),
|
||||
875 => Some("Vector3f"),
|
||||
884 => Some("Vector4f"),
|
||||
893 => Some("m_ScriptingClassIdentifier"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Endian {
|
||||
Little,
|
||||
Big,
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
data: &'a [u8],
|
||||
offset: usize,
|
||||
endian: Endian,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self {
|
||||
data,
|
||||
offset: 0,
|
||||
endian: Endian::Big,
|
||||
}
|
||||
}
|
||||
|
||||
fn offset(&self) -> usize {
|
||||
self.offset
|
||||
}
|
||||
|
||||
fn set_endian(&mut self, endian: Endian) {
|
||||
self.endian = endian;
|
||||
}
|
||||
|
||||
fn read_bytes(&mut self, len: usize, field: &str) -> Result<&'a [u8]> {
|
||||
let start = self.offset;
|
||||
let end = self.offset.checked_add(len).ok_or_else(|| {
|
||||
AssetBundleError::parse_field(field, start, "field length overflows usize")
|
||||
})?;
|
||||
if end > self.data.len() {
|
||||
return Err(AssetBundleError::parse_field(
|
||||
field,
|
||||
start,
|
||||
format!(
|
||||
"unexpected end: need {}, have {}",
|
||||
len,
|
||||
self.data.len().saturating_sub(start)
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = &self.data[self.offset..end];
|
||||
self.offset = end;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn align(&mut self, alignment: usize) -> Result<()> {
|
||||
if alignment == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remainder = self.offset % alignment;
|
||||
if remainder == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let padding = alignment - remainder;
|
||||
self.read_bytes(padding, "alignment_padding").map(|_| ())
|
||||
}
|
||||
|
||||
fn read_u8(&mut self, field: &str) -> Result<u8> {
|
||||
Ok(self.read_bytes(1, field)?[0])
|
||||
}
|
||||
|
||||
fn read_u16(&mut self, field: &str) -> Result<u16> {
|
||||
let bytes = self.read_bytes(2, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => u16::from_le_bytes([bytes[0], bytes[1]]),
|
||||
Endian::Big => u16::from_be_bytes([bytes[0], bytes[1]]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_i16(&mut self, field: &str) -> Result<i16> {
|
||||
let bytes = self.read_bytes(2, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => i16::from_le_bytes([bytes[0], bytes[1]]),
|
||||
Endian::Big => i16::from_be_bytes([bytes[0], bytes[1]]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u32(&mut self, field: &str) -> Result<u32> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
Endian::Big => u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u32_be(&mut self, field: &str) -> Result<u32> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||
}
|
||||
|
||||
fn read_i32(&mut self, field: &str) -> Result<i32> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
Endian::Big => i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u64(&mut self, field: &str) -> Result<u64> {
|
||||
let bytes = self.read_bytes(8, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => u64::from_le_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]),
|
||||
Endian::Big => u64::from_be_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u64_be(&mut self, field: &str) -> Result<u64> {
|
||||
let bytes = self.read_bytes(8, field)?;
|
||||
Ok(u64::from_be_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_i64(&mut self, field: &str) -> Result<i64> {
|
||||
let bytes = self.read_bytes(8, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => i64::from_le_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]),
|
||||
Endian::Big => i64::from_be_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_c_string(&mut self, field: &str) -> Result<String> {
|
||||
let start = self.offset;
|
||||
let remaining = &self.data[self.offset..];
|
||||
let Some(length) = remaining.iter().position(|&byte| byte == 0) else {
|
||||
return Err(AssetBundleError::parse_field(
|
||||
field,
|
||||
start,
|
||||
"missing null terminator",
|
||||
));
|
||||
};
|
||||
let bytes = self.read_bytes(length, field)?;
|
||||
self.offset += 1;
|
||||
std::str::from_utf8(bytes)
|
||||
.map(ToOwned::to_owned)
|
||||
.map_err(|error| {
|
||||
AssetBundleError::parse_field(field, start, format!("invalid UTF-8: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn read_len_prefixed_string(&mut self, field: &str) -> Result<String> {
|
||||
let len = self.read_u32(field)? as usize;
|
||||
let bytes = self.read_bytes(len, field)?;
|
||||
std::str::from_utf8(bytes)
|
||||
.map(ToOwned::to_owned)
|
||||
.map_err(|error| {
|
||||
AssetBundleError::parse_field(field, self.offset, format!("invalid UTF-8: {error}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_i64_le(data: &mut Vec<u8>, value: i64) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_u32_be(data: &mut Vec<u8>, value: u32) {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_u64_be(data: &mut Vec<u8>, value: u64) {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn align(data: &mut Vec<u8>, alignment: usize) {
|
||||
let remainder = data.len() % alignment;
|
||||
if remainder != 0 {
|
||||
data.resize(data.len() + alignment - remainder, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_serialized_file() -> Vec<u8> {
|
||||
let mut object_data = Vec::new();
|
||||
push_u32_le(&mut object_data, 14);
|
||||
object_data.extend_from_slice(b"GameMainConfig");
|
||||
align(&mut object_data, 4);
|
||||
push_u32_le(&mut object_data, 5);
|
||||
object_data.extend_from_slice(b"hello");
|
||||
|
||||
let mut metadata = Vec::new();
|
||||
metadata.extend_from_slice(b"2021.3.56f2\0");
|
||||
push_i32_le(&mut metadata, 19);
|
||||
metadata.push(0);
|
||||
push_i32_le(&mut metadata, 1);
|
||||
push_i32_le(&mut metadata, 49);
|
||||
metadata.push(0);
|
||||
push_i16_le(&mut metadata, 0);
|
||||
metadata.extend_from_slice(&[0; 16]);
|
||||
push_i32_le(&mut metadata, 1);
|
||||
align(&mut metadata, 4);
|
||||
push_i64_le(&mut metadata, 1);
|
||||
push_u64_le(&mut metadata, 0);
|
||||
push_u32_le(&mut metadata, object_data.len() as u32);
|
||||
push_i32_le(&mut metadata, 0);
|
||||
|
||||
let header_len = 48usize;
|
||||
let data_offset = header_len + metadata.len();
|
||||
let file_size = data_offset + object_data.len();
|
||||
|
||||
let mut file = Vec::new();
|
||||
push_u32_be(&mut file, metadata.len() as u32);
|
||||
push_u32_be(&mut file, file_size as u32);
|
||||
push_u32_be(&mut file, 22);
|
||||
push_u32_be(&mut file, 0);
|
||||
file.push(0);
|
||||
file.extend_from_slice(&[0, 0, 0]);
|
||||
push_u32_be(&mut file, metadata.len() as u32);
|
||||
push_u64_be(&mut file, file_size as u64);
|
||||
push_u64_be(&mut file, data_offset as u64);
|
||||
push_u64_be(&mut file, 0);
|
||||
file.extend_from_slice(&metadata);
|
||||
file.extend_from_slice(&object_data);
|
||||
file
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_synthetic_text_asset_and_object_table() {
|
||||
let file = synthetic_serialized_file();
|
||||
let parsed =
|
||||
UnitySerializedFile::from_named_slice(Some("CAB-test".to_string()), &file).unwrap();
|
||||
|
||||
assert_eq!(parsed.source_path.as_deref(), Some("CAB-test"));
|
||||
assert_eq!(parsed.version, 22);
|
||||
assert_eq!(parsed.unity_version, "2021.3.56f2");
|
||||
assert_eq!(parsed.platform, 19);
|
||||
assert_eq!(parsed.types.len(), 1);
|
||||
assert_eq!(parsed.types[0].class_id, 49);
|
||||
assert_eq!(parsed.objects.len(), 1);
|
||||
assert_eq!(parsed.objects[0].path_id, 1);
|
||||
assert_eq!(parsed.objects[0].class_id, 49);
|
||||
assert_eq!(parsed.text_assets.len(), 1);
|
||||
|
||||
let asset = parsed.text_asset("GameMainConfig").unwrap();
|
||||
assert_eq!(asset.source_path.as_deref(), Some("CAB-test"));
|
||||
assert_eq!(asset.name, "GameMainConfig");
|
||||
assert_eq!(asset.bytes, b"hello");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,184 @@
|
||||
//! AssetBundle 类型定义占位
|
||||
//! AssetBundle and UnityFS public types.
|
||||
|
||||
/// Asset 类型(待实现)
|
||||
use crate::serialized::{UnitySerializedFile, UnitySerializedTextAsset};
|
||||
|
||||
/// Asset type extracted from a Unity bundle.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum AssetType {
|
||||
/// 文本资源
|
||||
/// Unity TextAsset.
|
||||
TextAsset,
|
||||
}
|
||||
|
||||
/// Raw AssetBundle bytes with optional source path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RawAssetBundle {
|
||||
/// File bytes.
|
||||
pub data: Vec<u8>,
|
||||
/// Source path or logical name, when known.
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// Parsed AssetBundle summary used by higher layers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedAssetBundle {
|
||||
/// Unity editor version declared by the bundle.
|
||||
pub unity_version: String,
|
||||
/// Directory paths exposed by the UnityFS container.
|
||||
pub assets: Vec<String>,
|
||||
/// Original bytes retained for future serialization.
|
||||
pub raw_data: Vec<u8>,
|
||||
/// UnityFS header information.
|
||||
pub unityfs_header: Option<UnityFsHeader>,
|
||||
/// UnityFS compressed block entries.
|
||||
pub blocks: Vec<UnityFsBlockInfo>,
|
||||
/// UnityFS directory entries.
|
||||
pub directories: Vec<UnityFsDirectoryInfo>,
|
||||
/// Files extracted from the UnityFS uncompressed data region.
|
||||
pub files: Vec<UnityFsFile>,
|
||||
/// Serialized files parsed from UnityFS directory files.
|
||||
pub serialized_files: Vec<UnitySerializedFile>,
|
||||
/// TextAsset objects extracted from serialized files.
|
||||
pub text_assets: Vec<UnitySerializedTextAsset>,
|
||||
/// Non-fatal serialized-file parse diagnostics for extracted files.
|
||||
pub serialized_parse_errors: Vec<UnitySerializedParseError>,
|
||||
}
|
||||
|
||||
/// Parsed UnityFS container.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnityFsBundle {
|
||||
/// UnityFS header.
|
||||
pub header: UnityFsHeader,
|
||||
/// 16-byte block info hash stored before block entries.
|
||||
pub blocks_info_hash: [u8; 16],
|
||||
/// UnityFS compressed block entries.
|
||||
pub blocks: Vec<UnityFsBlockInfo>,
|
||||
/// UnityFS directory entries.
|
||||
pub directories: Vec<UnityFsDirectoryInfo>,
|
||||
/// Offset where compressed block payload bytes begin.
|
||||
pub data_start_offset: u64,
|
||||
/// Total compressed payload bytes declared by block entries.
|
||||
pub compressed_data_size: u64,
|
||||
/// Total uncompressed payload bytes declared by block entries.
|
||||
pub uncompressed_data_size: u64,
|
||||
/// Original bytes retained for future extraction/serialization.
|
||||
pub raw_data: Vec<u8>,
|
||||
/// Files extracted from the UnityFS uncompressed data region.
|
||||
pub files: Vec<UnityFsFile>,
|
||||
/// Serialized files parsed from UnityFS directory files.
|
||||
pub serialized_files: Vec<UnitySerializedFile>,
|
||||
/// TextAsset objects extracted from serialized files.
|
||||
pub text_assets: Vec<UnitySerializedTextAsset>,
|
||||
/// Non-fatal serialized-file parse diagnostics for extracted files.
|
||||
pub serialized_parse_errors: Vec<UnitySerializedParseError>,
|
||||
}
|
||||
|
||||
impl UnityFsBundle {
|
||||
/// Returns directory paths in stable order.
|
||||
pub fn asset_paths(&self) -> Vec<String> {
|
||||
self.directories
|
||||
.iter()
|
||||
.map(|directory| directory.path.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UnityFsBundle> for ParsedAssetBundle {
|
||||
fn from(bundle: UnityFsBundle) -> Self {
|
||||
Self {
|
||||
unity_version: bundle.header.unity_version.clone(),
|
||||
assets: bundle.asset_paths(),
|
||||
raw_data: bundle.raw_data,
|
||||
unityfs_header: Some(bundle.header),
|
||||
blocks: bundle.blocks,
|
||||
directories: bundle.directories,
|
||||
files: bundle.files,
|
||||
serialized_files: bundle.serialized_files,
|
||||
text_assets: bundle.text_assets,
|
||||
serialized_parse_errors: bundle.serialized_parse_errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UnityFS header.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UnityFsHeader {
|
||||
/// UnityFS format version.
|
||||
pub format_version: u32,
|
||||
/// Bundle target version, for example `5.x.x`.
|
||||
pub target_version: String,
|
||||
/// Unity editor version, for example `2021.3.56f2`.
|
||||
pub unity_version: String,
|
||||
/// Total file size declared by the header.
|
||||
pub total_size: u64,
|
||||
/// Compressed block info byte size.
|
||||
pub compressed_blocks_info_size: u32,
|
||||
/// Uncompressed block info byte size.
|
||||
pub uncompressed_blocks_info_size: u32,
|
||||
/// Raw UnityFS flags.
|
||||
pub flags: u32,
|
||||
}
|
||||
|
||||
/// UnityFS compression mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum UnityFsCompression {
|
||||
/// Uncompressed.
|
||||
None,
|
||||
/// LZMA compression.
|
||||
Lzma,
|
||||
/// LZ4 compression.
|
||||
Lz4,
|
||||
/// LZ4HC compression.
|
||||
Lz4Hc,
|
||||
/// Unknown compression mode.
|
||||
Unknown(u16),
|
||||
}
|
||||
|
||||
/// UnityFS compressed block entry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UnityFsBlockInfo {
|
||||
/// Uncompressed block size.
|
||||
pub uncompressed_size: u32,
|
||||
/// Compressed block size.
|
||||
pub compressed_size: u32,
|
||||
/// Raw block flags.
|
||||
pub flags: u16,
|
||||
/// Compression mode decoded from `flags`.
|
||||
pub compression: UnityFsCompression,
|
||||
}
|
||||
|
||||
/// UnityFS directory entry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UnityFsDirectoryInfo {
|
||||
/// Entry offset in the uncompressed data region.
|
||||
pub offset: u64,
|
||||
/// Entry byte size.
|
||||
pub size: u64,
|
||||
/// Raw directory flags.
|
||||
pub flags: u32,
|
||||
/// Entry path.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// File extracted from a UnityFS directory entry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnityFsFile {
|
||||
/// Entry path from the UnityFS directory table.
|
||||
pub path: String,
|
||||
/// Offset in the uncompressed UnityFS data region.
|
||||
pub offset: u64,
|
||||
/// File byte size.
|
||||
pub size: u64,
|
||||
/// Raw directory flags.
|
||||
pub flags: u32,
|
||||
/// Extracted file bytes.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Non-fatal parse error for an extracted UnityFS file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UnitySerializedParseError {
|
||||
/// UnityFS directory path that failed serialized-file parsing.
|
||||
pub path: String,
|
||||
/// Human-readable parser error.
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ User=bat
|
||||
Group=bat
|
||||
WorkingDirectory=/var/lib/bluearchive-toolkit
|
||||
Environment=BAT_OUTPUT_ROOT=/var/lib/bluearchive-toolkit/official
|
||||
Environment=BAT_LOCALIZED_OUTPUT_ROOT=/var/lib/bluearchive-toolkit/localized
|
||||
Environment=BAT_INTERVAL=1h
|
||||
Environment=BAT_ERROR_RETRY=60s
|
||||
EnvironmentFile=-/etc/bluearchive-toolkit/official-sync.env
|
||||
ExecStart=/opt/bluearchive-toolkit/bin/bat --auto-discover --output ${BAT_OUTPUT_ROOT} --watch --interval ${BAT_INTERVAL} --error-retry ${BAT_ERROR_RETRY} --no-banner
|
||||
ExecStart=/opt/bluearchive-toolkit/bin/bat --auto-discover --output ${BAT_OUTPUT_ROOT} --localized-output ${BAT_LOCALIZED_OUTPUT_ROOT} --watch --interval ${BAT_INTERVAL} --error-retry ${BAT_ERROR_RETRY} --no-banner
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
TimeoutStopSec=60
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
# sudo install -o root -g root -m 0644 deployments/systemd/official-sync.env.example /etc/bluearchive-toolkit/official-sync.env
|
||||
#
|
||||
# Paths are intentionally independent from any official launcher or game client
|
||||
# install directory. Do not point BAT_OUTPUT_ROOT at an existing game directory.
|
||||
# install directory. Do not point either root at an existing game directory, and
|
||||
# keep the official and localized roots separate.
|
||||
|
||||
BAT_OUTPUT_ROOT=/var/lib/bluearchive-toolkit/official
|
||||
BAT_LOCALIZED_OUTPUT_ROOT=/var/lib/bluearchive-toolkit/localized
|
||||
BAT_INTERVAL=1h
|
||||
BAT_ERROR_RETRY=60s
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# AssetBundle 与资源解析路线图
|
||||
|
||||
- **更新时间**:2026-07-25
|
||||
- **适用范围**:Rust 解析引擎、官方同步后的解析缓存、CAS/ResourceRepository 接入、后续文本提取和 Patch 发布。
|
||||
- **权威关联**:`PROJECT_PLAN.md` Milestone 3/4/5/8,`docs/reports/CURRENT_GAPS.md` G-005/G-007/G-011/G-011D。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标边界
|
||||
|
||||
解析系统的目标不是把下载流程写成一次性脚本,而是建立可长期维护的资源理解层:
|
||||
|
||||
1. 官方资源同步负责拉取、校验和发布原版资源。
|
||||
2. 解析器只读取已发布或 staging 中已校验的资源,不修改原始文件。
|
||||
3. 解析结果写入派生缓存、CAS 索引或后续文本提取索引。
|
||||
4. 汉化产物只能由 Patch/发布阶段写入 `localized-output`,不能写回官方资源目录。
|
||||
5. 解析器必须与 CLI、daemon、Go API、Patch 业务流程解耦。
|
||||
|
||||
当前官方同步完成后会维护 `official-parse-cache.json`。它是官方 release 的派生索引,不是汉化产物;本地 URL、相对路径、size 和 BLAKE3 未变化时应复用旧解析结果并跳过重复解析。
|
||||
|
||||
---
|
||||
|
||||
## 2. 分层模型
|
||||
|
||||
解析能力按从外到内分层:
|
||||
|
||||
| 层级 | 输入 | 输出 | 当前状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 官方 seed manifest | `TableCatalog.bytes`、`BundlePackingInfo.bytes`、`MediaCatalog.bytes` | 完整下载 URL、相对路径、hash 校验边界 | 已用于下载计划,仍需沉淀更多结构化字段 |
|
||||
| Addressables catalog | `catalog_*.zip` 内 JSON/bin catalog、`catalog_*.hash` | asset path、provider、dependencies、size、CRC、bundle name | JSON/compact 当前样本已覆盖,仍需更多真实结构变体 |
|
||||
| UnityFS container | `.bundle`、zip 内 bundle | header、block、directory、解压文件、基础摘要 | 已支持基础解包、LZ4/LZMA、边界校验 |
|
||||
| Serialized file | UnityFS directory 文件 | header、type table、TypeTree node、object table、TextAsset bytes | 已支持基础表结构和 TextAsset bytes |
|
||||
| Unity 对象字段 | TextAsset、MonoBehaviour、ScriptableObject | 可翻译文本单元、上下文、资源定位 | TextAsset bytes 已有入口,字段级反序列化未完成 |
|
||||
| Patch 发布前解析 | 已翻译文本、中间格式、原版资源 | 可验证 patch manifest、汉化 release 目录 | 未完成 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前已落地能力
|
||||
|
||||
`crates/bat-assetbundle` 已经承担解析核心:
|
||||
|
||||
1. `UnityFsParser` 解析 UnityFS header、block info、directory。
|
||||
2. 支持 LZ4/LZMA block info 和数据 block 解压。
|
||||
3. 支持 block info at end 和官方样本中出现的 block data alignment。
|
||||
4. 能从 UnityFS directory 提取文件 bytes。
|
||||
5. `serialized` 模块能读取 Unity serialized file header、type table、TypeTree node 元数据、object table。
|
||||
6. 能提取 TextAsset 的 name 和原始 bytes。
|
||||
7. `ResourceImportService` 能把 AssetBundle 摘要、TextAsset/Table/Media 分类写入导入报告。
|
||||
8. 官方同步后 `OfficialParseCacheService` 能从 `official-download-manifest.json` 遍历所有资源,解析直接 bundle 和 zip 内条目,非候选资源记录为 unsupported。
|
||||
|
||||
当前还不能宣称完整:
|
||||
|
||||
1. MonoBehaviour/ScriptableObject 的 TypeTree 字段级反序列化未完成。
|
||||
2. Unity managed reference、PPtr、array/map、string alignment 等复杂字段未完整覆盖。
|
||||
3. Addressables bin/compact catalog 结构变体仍需真实样本驱动补齐。
|
||||
4. 解析结果尚未自动进入用户级 CAS + ResourceRepository 查询流程。
|
||||
5. 不能重写 AssetBundle,也不能生成可发布汉化 patch。
|
||||
|
||||
---
|
||||
|
||||
## 4. 补全顺序
|
||||
|
||||
### P0:解析缓存和样本闭环
|
||||
|
||||
目标:让官方同步后的解析结果可复用、可诊断、可回归。
|
||||
|
||||
交付:
|
||||
|
||||
1. `official-parse-cache.json` 记录 manifest entry、zip entry、解析状态、Unity 版本、文件数、TextAsset 数、错误摘要和缓存复用状态。
|
||||
2. 解析直接 `.bundle` / `.unity3d` 和 zip 内全部文件条目,不能只假设 `FullPatch_*.zip`。
|
||||
3. 非候选资源记录为 unsupported,不影响官方同步发布。
|
||||
4. 缺失、损坏或无法解析的 bundle 记录 failed,但不回滚已经完成校验的官方原版 release。
|
||||
5. 用合成 fixture、隔离真实样本和回归 fixture 覆盖缓存复用、zip 内条目、非候选资源、解析失败。
|
||||
|
||||
验收:
|
||||
|
||||
1. 第二次运行同一 release 时能看到解析缓存复用计数。
|
||||
2. 修改任意 manifest entry 的 size/BLAKE3 后,只重新解析对应资源。
|
||||
3. 解析缓存不会写入 `localized-output`。
|
||||
|
||||
### P1:Addressables catalog 完整化
|
||||
|
||||
目标:把“能列出资源”推进到“能稳定定位 bundle、依赖、校验字段和资源类型”。
|
||||
|
||||
交付:
|
||||
|
||||
1. 覆盖 JSON catalog、compact JSON、可能的二进制 catalog 入口。
|
||||
2. 解析 provider id、internal id、primary key、dependency key、resource type、bundle name、hash、size、CRC。
|
||||
3. 明确 `catalog_*.hash` 只作为 Addressables remote catalog marker,不套用 seed `.hash` 的 xxHash32 规则。
|
||||
4. 将 Windows/Android catalog 样本拆成可复现 fixture,不把大文件纳入 Git。
|
||||
5. 对未知结构返回明确错误或保真 raw metadata,不静默丢字段。
|
||||
|
||||
验收:
|
||||
|
||||
1. 当前目标版本 Windows/Android catalog 样本集合解析通过。
|
||||
2. 解析结果能反查 bundle 文件和依赖链。
|
||||
3. size/CRC/hash 字段能参与本地文件验证或至少进入诊断报告。
|
||||
|
||||
### P2:Unity Serialized 字段级解析
|
||||
|
||||
目标:把 Unity object table 推进到可提取文本字段。
|
||||
|
||||
交付:
|
||||
|
||||
1. TypeTree schema 内部表示稳定化:node path、type、name、size、flags、array 信息。
|
||||
2. 实现基础字段 reader:bool、integer、float、string、bytes、array、map、PPtr、managed reference 占位。
|
||||
3. 支持 TextAsset script/name 的结构化读取,而不只保留 raw bytes。
|
||||
4. 支持 MonoBehaviour 和 ScriptableObject 的 TypeTree 字段遍历。
|
||||
5. 对缺 TypeTree 或 stripped 类型返回可诊断错误,保留 raw object bytes 作为后备。
|
||||
|
||||
验收:
|
||||
|
||||
1. 合成 fixture 覆盖标量、数组、嵌套结构、string alignment。
|
||||
2. 隔离真实样本能输出稳定 JSON field tree。
|
||||
3. 解析错误包含 file path、object path id、class id、字段路径和偏移。
|
||||
|
||||
### P3:文本提取中间层
|
||||
|
||||
目标:为日语汉化提供稳定、可回写定位的文本单元。
|
||||
|
||||
交付:
|
||||
|
||||
1. 定义 `TextUnit`:source text、resource path、archive entry、object path id、field path、语言、版本、上下文。
|
||||
2. TextAsset 支持 JSON/CSV/TSV/plain text 的可配置探测。
|
||||
3. MonoBehaviour/ScriptableObject 按字段路径和类型策略提取字符串。
|
||||
4. 保留重复文本和上下文,不在解析阶段做会丢定位的合并。
|
||||
5. 导出 JSONL 作为第一稳定格式,CSV/XLIFF 可后置。
|
||||
|
||||
验收:
|
||||
|
||||
1. 提取不会修改官方资源。
|
||||
2. 每条文本能追溯回原 bundle、serialized file、path id 和字段路径。
|
||||
3. 同一文本在不同上下文中保持可区分。
|
||||
|
||||
### P4:CAS/Repository 用户级接入
|
||||
|
||||
目标:让解析结果进入可查询资源库,而不是只停留在文件系统缓存。
|
||||
|
||||
交付:
|
||||
|
||||
1. 官方同步完成后可配置触发导入 CAS + ResourceRepository。
|
||||
2. ResourceRepository 保存版本、平台、资源类型、bundle path、TextAsset 名称、TextUnit 索引摘要。
|
||||
3. 支持 CLI/RPC 查询资源、bundle、TextAsset、解析错误和缓存状态。
|
||||
4. schema 迁移版本化,旧库可重复升级。
|
||||
|
||||
验收:
|
||||
|
||||
1. 可以按版本、路径、类型、hash、TextAsset 名称查询。
|
||||
2. 解析缓存和 repository 数据能从同一 manifest fingerprint 追溯。
|
||||
3. CAS 对象跨版本复用,不重复存储相同文件。
|
||||
|
||||
### P5:Patch 发布前置解析
|
||||
|
||||
目标:让解析结果成为可生成汉化 patch 的输入。
|
||||
|
||||
交付:
|
||||
|
||||
1. 定义 patch manifest:目标官方版本、输入 TextUnit 版本、输出文件、hash、回滚信息。
|
||||
2. 支持 TextAsset raw bytes 替换的最小 patch 路径。
|
||||
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果。
|
||||
4. Patch 产物写入 `localized-output/.staging/<id>`,校验通过后发布到 `localized-output/versions/<id>` 并切换 `current`。
|
||||
5. 成功后发布状态从 `not_localized` 切到 `localized`。
|
||||
|
||||
验收:
|
||||
|
||||
1. Patch 失败不影响 `bat-resources/current`。
|
||||
2. 汉化 release 保留官方相对目录结构。
|
||||
3. `localized` 状态能证明原版和汉化两套资源都已发布。
|
||||
|
||||
---
|
||||
|
||||
## 5. 解析器接口原则
|
||||
|
||||
1. 解析器输入只接受 bytes、逻辑路径和可选上下文,不直接访问下载器状态。
|
||||
2. 解析器输出必须可序列化,供 CLI/RPC/API、缓存和测试 golden 使用。
|
||||
3. 错误必须带位置:URL 或路径、archive entry、UnityFS directory、object path id、field path、offset。
|
||||
4. 未识别结构优先保留 raw metadata,不做低保真猜测。
|
||||
5. 解析器不写 `bat-resources` 和 `bat-localized`,写文件由上层缓存、导入或 Patch 发布流程负责。
|
||||
|
||||
---
|
||||
|
||||
## 6. Fixture 策略
|
||||
|
||||
1. 合成 fixture 放入代码仓库,覆盖边界和回归。
|
||||
2. 真实小样本可放入仓库前必须确认体积、许可和可复现性。
|
||||
3. 大型真实官方资源只允许放在 `/tmp`、隔离测试目录或用户显式提供的远端测试目录,不纳入 Git。
|
||||
4. 每个新增 fixture 必须说明覆盖的真实风险:字段变体、压缩模式、越界、hash mismatch、zip 内路径、TypeTree 结构等。
|
||||
|
||||
---
|
||||
|
||||
## 7. 近期关闭路径
|
||||
|
||||
优先顺序:
|
||||
|
||||
1. 完成 Addressables Windows/Android 当前版本 catalog 样本集合,关闭 G-007 当前阶段。
|
||||
2. 完成 TypeTree 字段 reader 和 MonoBehaviour/ScriptableObject 遍历,推进 G-005。
|
||||
3. 将 `official-parse-cache.json` 摘要接入 CAS/ResourceRepository 用户级导入,推进 G-011。
|
||||
4. 定义 TextUnit JSONL 和最小 TextAsset 提取命令,衔接 Milestone 5。
|
||||
5. 在 Patch 引擎落地后实现 `localized` 发布状态切换,关闭 G-011D 的核心发布缺口。
|
||||
@@ -6,6 +6,12 @@
|
||||
|
||||
这个后端只处理 **日服官方资源**,只接受官方 `.jp/.com` 域名下的资源链路。
|
||||
|
||||
**Release 布局、URL→磁盘映射、seed 模板与 bat-api 分发 path 的冻结契约**见:
|
||||
|
||||
- `docs/architecture/resource-release-layout.md`
|
||||
|
||||
---
|
||||
|
||||
明确排除:
|
||||
|
||||
- `bluearchive.cafe`
|
||||
@@ -207,8 +213,10 @@
|
||||
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。
|
||||
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot。
|
||||
12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/current` symlink 指向该 versioned 目录。
|
||||
13. 发布完成后刷新 active release 下的 `official-parse-cache.json`;未变化文件按 manifest 的 URL、相对路径、size 和 BLAKE3 复用旧解析结果。
|
||||
14. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;后续 Patch 发布阶段完成后才切换为 `localized`,表示原版和汉化两套资源都已发布。
|
||||
|
||||
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status`、`stop`、`restart`、`reload`、`logs`、`refresh`、`verify`、`repair`、`doctor`、`clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane;`bat.pid`、`bat-status.json` 和 `bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`bat-status.json` 和 `status` 子命令包含最后成功时间、下次检查时间、最后错误摘要、当前阶段和当前下载 URL 进度。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,默认 `repair` 会通过 `resource.repair` 入队本地 manifest 审计+修复任务,`restart` 才负责重启进程或替换启动参数;显式 `--proxy` / `--no-proxy` 会作为启动参数保存并在后台重启时复用。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新,默认形态 `repair` 会通过 RPC 入队任务。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output` 和 `--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
|
||||
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status`、`stop`、`restart`、`reload`、`logs`、`refresh`、`verify`、`repair`、`doctor`、`clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane;`bat.pid`、`bat-status.json` 和 `bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`bat-status.json` 和 `status` 子命令包含最后成功时间、下次检查时间、最后错误摘要、当前阶段和当前下载 URL 进度。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,默认 `repair` 会通过 `resource.repair` 入队本地 manifest 审计+修复任务,`restart` 才负责重启进程或替换启动参数;显式 `--proxy` / `--no-proxy` 会作为启动参数保存并在后台重启时复用。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新,默认形态 `repair` 会通过 RPC 入队任务。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。默认官方原版资源输出目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`,三者分别通过 `--output`、`--localized-output` 和 `--state-dir` 配置;官方目录和汉化目录不能相同或互相嵌套。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
|
||||
|
||||
对应实现主要在:
|
||||
|
||||
@@ -304,14 +312,22 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
||||
|
||||
### 7.2 Go 层职责边界
|
||||
|
||||
- Go 层负责:BlueArchive 客户端请求处理、HTTP API、鉴权、内容分发,
|
||||
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon
|
||||
(连接 `bat.sock`,每行一个 JSON-RPC 请求/响应)。当前 Go 产品入口
|
||||
尚未完成,`cmd/bat` 仍是试验骨架。
|
||||
- Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查、
|
||||
- Go 层负责:资源内容分发(`cmd/bat-api`)、HTTP API 进程配置、以及通过
|
||||
`internal/backendrpc` 作为 RPC client 调用本机 daemon(连接 `bat.sock`,
|
||||
每行一个 JSON-RPC 请求/响应)。`cmd/bat` 仍是试验骨架,不是产品级用户 CLI。
|
||||
- **`bat-api`(资源分发,issue #19)**:
|
||||
- 只读提供 Rust `bat` 已发布 release 中的资源字节(官方 CDN host/path 形态)。
|
||||
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再
|
||||
`catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。
|
||||
- 支持 `.env` / 环境变量配置监听端口、public base URL、RPC socket,并预留
|
||||
database/redis 键供后续 API 持久化;**不**负责资源自动拉取。
|
||||
- 可选改写 server-info 中的 `AddressablesCatalogUrlRoot` 指向自身;不伪装
|
||||
完整游戏业务 API,launcher 全链非本服务关闭条件。
|
||||
- Rust `bat` / daemon 负责:官方资源自动发现与拉取、校验、catalog 更新检查、
|
||||
版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。
|
||||
- Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源
|
||||
目录内部结构;跨语言交互只经 RPC 契约。
|
||||
- Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件;跨语言控制面
|
||||
只经 RPC 契约。文件字节从 RPC 给出的 `resource_root`(或显式
|
||||
`--resource-root`)读取,与 daemon 同机或共享文件系统部署。
|
||||
|
||||
### 7.3 FFI 的定位(降级说明)
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
# 官方资源 Release 布局与资源侧契约
|
||||
|
||||
- **更新时间**:2026-07-24
|
||||
- **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则,以及 `bat-api` 分发 path 的 1:1 对应关系。
|
||||
- **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。
|
||||
- **权威代码**:
|
||||
- URL / 平台 / seed:`adapters/src/official/yostar_jp.rs`
|
||||
- inventory 抽取:`adapters/src/official/inventory.rs`
|
||||
- 落盘与 manifest:`infrastructure/src/official_download.rs`(`destination_for_url`)
|
||||
- 发布布局:`infrastructure/src/official_update.rs`
|
||||
- 分发:`cmd/bat-api` + `internal/api`(见 `docs/reports/GO_STATUS.md`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 产品边界(资源侧)
|
||||
|
||||
| 角色 | 组件 | 职责 |
|
||||
|---|---|---|
|
||||
| 同步 / 运维(近乎全自动) | Rust `bat` | auto-discover、拉取、校验、发布、watch/daemon、RPC 后端 |
|
||||
| 资源只读分发 | Go `bat-api` | 按官方 CDN path 提供已发布字节;经 `bat.sock` 发现版本 |
|
||||
| 试验 CLI | Go `cmd/bat` → `bin/bat-go` | 非产品;禁止与 Rust `bat` 重名 |
|
||||
|
||||
**禁止**:把已安装客户端目录或 `/home/wanye/D/BlueArchive` 当作生产输入;真实全量样本优先服务器 release 或 `/tmp` 隔离目录。
|
||||
|
||||
---
|
||||
|
||||
## 2. 发布根布局(L1)
|
||||
|
||||
```text
|
||||
<output>/ # 官方原版资源发布根(--output / BAT_OUTPUT)
|
||||
current -> versions/<id> # 原子 symlink,生产读侧
|
||||
versions/<id>/ # 已发布 versioned release(= resource_root)
|
||||
official-download-manifest.json
|
||||
official-parse-cache.json # 校验后派生解析缓存,不是汉化产物
|
||||
official-sync-snapshot.json # 常在 active root / current 下
|
||||
prod-clientpatch.bluearchiveyostar.com/
|
||||
<root_token>/
|
||||
TableBundles/
|
||||
TableCatalog.bytes
|
||||
TableCatalog.hash
|
||||
<table files...> # e.g. ExcelDB.db, Excel.zip
|
||||
Windows_PatchPack/
|
||||
BundlePackingInfo.bytes
|
||||
BundlePackingInfo.hash
|
||||
catalog_StandaloneWindows64.zip
|
||||
catalog_StandaloneWindows64.hash
|
||||
FullPatch_NNN.zip
|
||||
Android_PatchPack/
|
||||
BundlePackingInfo.bytes
|
||||
BundlePackingInfo.hash
|
||||
catalog_Android.zip
|
||||
catalog_Android.hash
|
||||
FullPatch_NNN.zip
|
||||
MediaResources-Windows/
|
||||
Catalog/MediaCatalog.bytes
|
||||
Catalog/MediaCatalog.hash
|
||||
GameData/...
|
||||
Prologue/...
|
||||
MediaResources/ # Android
|
||||
Catalog/MediaCatalog.bytes
|
||||
Catalog/MediaCatalog.hash
|
||||
...
|
||||
yostar-serverinfo.bluearchiveyostar.com/ # 若曾下载 server-info
|
||||
<name>.json
|
||||
.staging/<id>/ # 未发布写侧(失败可复用)
|
||||
official-version-state.json # 发布根级版本状态
|
||||
official-bootstrap-cache.json # auto-discover 缓存
|
||||
|
||||
<localized-output>/ # 汉化产物发布根(--localized-output / BAT_LOCALIZED_OUTPUT)
|
||||
current -> versions/<id> # 已汉化后才切换;未汉化状态不发布
|
||||
versions/<id>/ # 与官方相对路径一致的汉化资源
|
||||
localized-version-state.json # 预留:后续 Patch 发布阶段维护,官方同步阶段不写入
|
||||
```
|
||||
|
||||
官方资源发布和汉化发布是两个独立状态:
|
||||
|
||||
- `not_localized`:官方原版资源已经完成下载、校验和发布,汉化资源尚未发布;这是官方同步完成后的默认状态。
|
||||
- `localized`:同一官方版本的原版资源和汉化资源都已发布,生产侧可以同时提供两套资源。
|
||||
|
||||
### 2.1 读侧 vs 写侧
|
||||
|
||||
| 阶段 | 根目录 |
|
||||
|---|---|
|
||||
| 下载写入 | `<output>/.staging/<id>` |
|
||||
| 发布完成 | rename 到 `versions/<id>`,再切换 `current` |
|
||||
| 生产读取 / bat-api | `current` 解析后的 versioned 目录,或 RPC 给出的 `version.resource_root` |
|
||||
|
||||
---
|
||||
|
||||
## 3. URL → 磁盘映射(核心不变量)
|
||||
|
||||
实现:`OfficialResourcePullService::destination_for_url`。
|
||||
|
||||
```text
|
||||
https://{host}/{path...} → <resource_root>/{host}/{path...}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
1. 仅 `https://`
|
||||
2. host 必须是官方 JP 资源 host(见下节)
|
||||
3. path 分段不得为 `.` / `..`
|
||||
4. **禁止** query / fragment(否则直接拒绝,避免同路径覆盖)
|
||||
5. 分段经 sanitize 后 join;结果必须在 `resource_root` 内
|
||||
|
||||
### 3.1 官方 host
|
||||
|
||||
| Host | 用途 |
|
||||
|---|---|
|
||||
| `prod-clientpatch.bluearchiveyostar.com` | Addressables / Table / Media / PatchPack 内容 |
|
||||
| `yostar-serverinfo.bluearchiveyostar.com` | server-info JSON |
|
||||
|
||||
(launcher 包 CDN 属于启动器链,**不是**默认资源 release 主体。)
|
||||
|
||||
### 3.2 bat-api 对外 path(1:1)
|
||||
|
||||
```text
|
||||
GET {public-base-url}/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
|
||||
≡ 磁盘 <resource_root>/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
|
||||
```
|
||||
|
||||
默认仅服务 **download manifest 索引内且 Present + size 匹配** 的文件。
|
||||
|
||||
---
|
||||
|
||||
## 4. `official-download-manifest.json`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `version` | u32 | 当前为 `1` |
|
||||
| `entries` | map URL → entry | 按完整官方 URL 为键(有序 BTreeMap) |
|
||||
|
||||
每条 entry:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `url` | 官方 https URL |
|
||||
| `destination` | 相对 resource_root 的路径(`host/path...`) |
|
||||
| `bytes` | 文件大小 |
|
||||
| `blake3` | 本地 BLAKE3 hex |
|
||||
|
||||
**权威清单**:拉取闭环写入的 manifest;`bat-api` / RPC `resource.manifest` 以此为应有集合,再以磁盘校验 Present。
|
||||
|
||||
---
|
||||
|
||||
## 5. 发现与 seed URL 规则(L2)
|
||||
|
||||
常量根:
|
||||
|
||||
- server-info:`https://yostar-serverinfo.bluearchiveyostar.com`
|
||||
- client-patch:`https://prod-clientpatch.bluearchiveyostar.com`
|
||||
|
||||
`AddressablesCatalogUrlRoot` 形如:
|
||||
|
||||
```text
|
||||
https://prod-clientpatch.bluearchiveyostar.com/<root_token>
|
||||
```
|
||||
|
||||
默认平台:`Windows` + `Android`。
|
||||
|
||||
### 5.1 平台目录名
|
||||
|
||||
| 平台 | Patch 目录 | Media 目录 | Addressables catalog zip |
|
||||
|---|---|---|---|
|
||||
| Windows | `Windows_PatchPack` | `MediaResources-Windows` | `catalog_StandaloneWindows64.zip` |
|
||||
| Android | `Android_PatchPack` | `MediaResources` | `catalog_Android.zip` |
|
||||
|
||||
### 5.2 Seed 端点模板
|
||||
|
||||
共享(非平台):
|
||||
|
||||
```text
|
||||
{CLIENT_PATCH}/{token}/TableBundles/TableCatalog.bytes
|
||||
{CLIENT_PATCH}/{token}/TableBundles/TableCatalog.hash
|
||||
```
|
||||
|
||||
每平台:
|
||||
|
||||
```text
|
||||
{CLIENT_PATCH}/{token}/{PatchDir}/BundlePackingInfo.bytes
|
||||
{CLIENT_PATCH}/{token}/{PatchDir}/BundlePackingInfo.hash
|
||||
{CLIENT_PATCH}/{token}/{PatchDir}/{catalog_zip}
|
||||
{CLIENT_PATCH}/{token}/{PatchDir}/{catalog_base}.hash
|
||||
{CLIENT_PATCH}/{token}/{MediaDir}/Catalog/MediaCatalog.bytes
|
||||
{CLIENT_PATCH}/{token}/{MediaDir}/Catalog/MediaCatalog.hash
|
||||
```
|
||||
|
||||
### 5.3 Content URL 模板
|
||||
|
||||
| 类型 | 模板 |
|
||||
|---|---|
|
||||
| Table 文件 | `{CLIENT_PATCH}/{token}/TableBundles/{Name}` |
|
||||
| Patch pack | `{CLIENT_PATCH}/{token}/{PatchDir}/{FullPatch_NNN.zip}` |
|
||||
| Media 文件 | `{CLIENT_PATCH}/{token}/{MediaDir}/{relative_path}` |
|
||||
|
||||
`relative_path` 示例:`GameData/Audio/VOC_JP/JP_Airi.zip`、`Prologue/Scenario/Event/10000_Title_Sound.ogg`。
|
||||
|
||||
### 5.4 校验分层
|
||||
|
||||
| 对象 | 算法 / 规则 |
|
||||
|---|---|
|
||||
| seed `.bytes` + `.hash` | 官方 `.hash` 为 **xxHash32(seed=0)** 的十进制文本;强校验 |
|
||||
| 一般已下载文件 | 本地 manifest **size + BLAKE3** |
|
||||
| `.zip` | 另加 ZIP central/local 结构校验 |
|
||||
| `catalog_*.hash` | Addressables/SBP **Hash128 文本标记**,**不是** seed 的 xxHash32 规则 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Inventory 抽取规则(L3,当前实现)
|
||||
|
||||
实现:`adapters/src/official/inventory.rs`(**可打印串启发式**,非完整 schema 反序列化)。
|
||||
|
||||
| Catalog | 抽取逻辑 | 风险 |
|
||||
|---|---|---|
|
||||
| `BundlePackingInfo.bytes` | 可打印串中扩展名为 `zip` 且匹配 `FullPatch_NNN.zip`(总长 17,中间 3 位数字) | 漏抽非 FullPatch 包名(当前有意只 FullPatch) |
|
||||
| `TableCatalog.bytes` | 可打印串中 `.db`/`.zip` 文件名;**出现次数 ≥ 2** 才收录 | 依赖「双份列表」启发式;形态变化会漏/多 |
|
||||
| `MediaCatalog.bytes` | 可打印串中相对路径,扩展名 zip/mp4/png/jpg/jpeg/ogg/wav | 路径须 `is_plausible_relative_path` |
|
||||
|
||||
**R2 待真机核对**:用服务器全量 seed 字节跑抽取,与 manifest 中 content URL 集合 diff;有未解释差异再改 inventory + fixture。
|
||||
|
||||
仓库内已有:`adapters/tests/fixtures`、`infrastructure/tests/fixtures/official_regression`;**不能替代**全量 release 实勘。
|
||||
|
||||
---
|
||||
|
||||
## 7. 客户端资源请求假设(R3,服务 bat-api)
|
||||
|
||||
| 面 | 假设(当前工程) | bat-api 行为 |
|
||||
|---|---|---|
|
||||
| client-patch 内容 | GET 官方 path;无业务鉴权头(资源 CDN) | `GET/HEAD /prod-clientpatch.../...` 原样字节 |
|
||||
| server-info | GET JSON;字段 PascalCase(`ConnectionGroups` 等) | 可选加载并**只改** `AddressablesCatalogUrlRoot` 指向 `{public-base}/prod-clientpatch.../{token}` |
|
||||
| seed `.hash` | 纯文本十进制(可含空白) | 原样分发 |
|
||||
| Range / 断点 | 官方客户端下载器用 Range;bat 用 curl `.part` | **本轮 bat-api 可不实现 Range**;记入后续 |
|
||||
| 业务 ApiUrl/Gateway | 游戏协议 | **不改写、不仿造** |
|
||||
|
||||
Addressables 改写后客户端拼接:
|
||||
|
||||
```text
|
||||
{rewritten_root}/TableBundles/TableCatalog.bytes
|
||||
≡ {public-base}/prod-clientpatch.../{token}/TableBundles/TableCatalog.bytes
|
||||
```
|
||||
|
||||
与磁盘映射一致。
|
||||
|
||||
---
|
||||
|
||||
## 8. RPC 与分发发现顺序
|
||||
|
||||
`bat-api`(及任何 Go 服务层)发现当前 release:
|
||||
|
||||
1. `daemon.status`
|
||||
2. `daemon.doctor`
|
||||
3. `catalog.status`(`version.resource_root`、`addressables_root`、app/bundle)
|
||||
4. `resource.manifest` 分页(url / destination / bytes / blake3)
|
||||
5. 在 `resource_root` 上 Lstat 校验 Present / size
|
||||
|
||||
**不读** `bat-status.json` / `bat-tasks.json` 作为常规路径。
|
||||
|
||||
配置:`--socket` / `BAT_API_SOCKET`;应急 `--resource-root`。见 `cmd/bat-api/.env.example`。
|
||||
|
||||
---
|
||||
|
||||
## 9. issue #2 / #3 样本索引(R4/R5 预置)
|
||||
|
||||
在服务器 release 上优先采集到 `/tmp` 隔离目录(**不入库大文件**):
|
||||
|
||||
| 用途 | 建议路径模式 |
|
||||
|---|---|
|
||||
| Addressables(#2) | `{PatchDir}/catalog_*.zip` 解压后的 JSON/bin + 旁路 `.hash` |
|
||||
| UnityFS(#3) | `FullPatch_*.zip` 内抽样 `.bundle`,或已解包 bundle |
|
||||
| seed 加固(R2) | 各平台 `TableCatalog` / `BundlePackingInfo` / `MediaCatalog` 的 `.bytes`+`.hash` |
|
||||
|
||||
字段目标(#2,已有 `m_Crc` 部分):继续扩大 hash/size/CRC/依赖等可校验字段覆盖。
|
||||
结构目标(#3):header / block / directory / metadata / object table 引擎级解析。
|
||||
|
||||
---
|
||||
|
||||
## 10. 服务器实勘清单(R1,等 SSH)
|
||||
|
||||
连接信息到位后只读执行:
|
||||
|
||||
1. `readlink current` → version id
|
||||
2. 顶层是否仅有官方 host 目录 + manifest/snapshot
|
||||
3. manifest 条目数 vs 磁盘抽样 size
|
||||
4. RPC 四步(status → doctor → catalog.status → manifest 首页)
|
||||
5. 将结论写入 `docs/reports/resource-server-survey-YYYYMMDD.md`(无凭据)
|
||||
|
||||
所需:
|
||||
|
||||
```text
|
||||
SSH: user@host -p PORT
|
||||
资源目录: .../official
|
||||
bat.sock 或 state-dir: ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 相关文档
|
||||
|
||||
- `docs/reports/GO_STATUS.md` — Go 边界与进度
|
||||
- `docs/architecture/official-resource-backend.md` — 拉取后端总览
|
||||
- `docs/reference/rpc-backend-api.md` — RPC 契约
|
||||
- `docs/guides/official-resource-test-pull.md` — 用户向运行说明
|
||||
- `docs/reports/CURRENT_GAPS.md` — G-009 / #2 / #3
|
||||
|
||||
---
|
||||
|
||||
## 12. 变更纪律
|
||||
|
||||
1. 改 URL 模板或落盘规则 → **必须**同步本文 + 相关单测。
|
||||
2. 改 inventory 启发式 → 说明覆盖的真实风险并补 fixture。
|
||||
3. 真机实勘若发现与本文冲突 → **以真机为准** 修代码与本文,禁止静默分叉。
|
||||
@@ -227,10 +227,11 @@ systemctl status bluearchive-toolkit-official-sync.service
|
||||
journalctl -u bluearchive-toolkit-official-sync.service -f
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat doctor \
|
||||
--output /var/lib/bluearchive-toolkit/official \
|
||||
--localized-output /var/lib/bluearchive-toolkit/localized \
|
||||
--state-dir /run/bluearchive-toolkit
|
||||
```
|
||||
|
||||
`--watch` 是 Rust 内部持久检查模式,正常情况下默认每 1 小时执行一次检查,并且每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会强制执行一次自动刷新。固定时间刷新会中断普通 interval 的 sleep,该轮注入 `force=true`;如果失败,会按失败重试周期继续重试。远端和本地一致时默认静默;有远端变化或本地文件损坏时自动下载或 repair,并输出人类可读摘要。下载、发现或校验失败时默认 60 秒后重试,可显式加 `BAT_ERROR_RETRY=60s` 或调整 service `ExecStart`。默认平台是 `Windows,Android`,无需显式传 `--platforms`;需要覆盖时用 systemd drop-in 重写 `ExecStart`。默认资源目录是 `./bat-resources`,生产 service 显式使用 `/var/lib/bluearchive-toolkit/official`。生产读取方应读取 `/var/lib/bluearchive-toolkit/official/current`;同步中的文件只会进入 `.staging/<id>`,校验完成后才发布为 `versions/<id>` 并切换 `current`。
|
||||
`--watch` 是 Rust 内部持久检查模式,正常情况下默认每 1 小时执行一次检查,并且每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会强制执行一次自动刷新。固定时间刷新会中断普通 interval 的 sleep,该轮注入 `force=true`;如果失败,会按失败重试周期继续重试。远端和本地一致时默认静默;有远端变化或本地文件损坏时自动下载或 repair,并输出人类可读摘要。下载、发现或校验失败时默认 60 秒后重试,可显式加 `BAT_ERROR_RETRY=60s` 或调整 service `ExecStart`。默认平台是 `Windows,Android`,无需显式传 `--platforms`;需要覆盖时用 systemd drop-in 重写 `ExecStart`。默认官方原版资源目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`;生产 service 显式使用 `/var/lib/bluearchive-toolkit/official` 和 `/var/lib/bluearchive-toolkit/localized`,两者不能相同或互相嵌套。生产读取方应读取 `/var/lib/bluearchive-toolkit/official/current`;同步中的原版文件只会进入 `.staging/<id>`,校验完成后才发布为 `versions/<id>` 并切换 `current`。官方同步报告 `localized_release_status=not_localized` 表示汉化资源尚未发布;后续 Patch 发布才切换 `/var/lib/bluearchive-toolkit/localized/current`。
|
||||
|
||||
### 可选模式:CLI 自托管 `--daemon`
|
||||
|
||||
@@ -240,6 +241,7 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat doctor \
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat \
|
||||
--auto-discover \
|
||||
--output /var/lib/bluearchive-toolkit/official \
|
||||
--localized-output /var/lib/bluearchive-toolkit/localized \
|
||||
--state-dir /var/lib/bluearchive-toolkit/daemon-state \
|
||||
--daemon
|
||||
|
||||
@@ -262,9 +264,11 @@ systemd 模式:
|
||||
- 当前可读 release:`/var/lib/bluearchive-toolkit/official/current`
|
||||
- 资源状态:`/var/lib/bluearchive-toolkit/official/current/official-sync-snapshot.json`
|
||||
- 下载 manifest:`/var/lib/bluearchive-toolkit/official/current/official-download-manifest.json`
|
||||
- 解析缓存:`/var/lib/bluearchive-toolkit/official/current/official-parse-cache.json`
|
||||
- 历史 release:`/var/lib/bluearchive-toolkit/official/versions/<id>`
|
||||
- 同步 staging:`/var/lib/bluearchive-toolkit/official/.staging/<id>`
|
||||
- 资源写锁:`/var/lib/bluearchive-toolkit/official/.official-sync.lock`
|
||||
- 汉化 release(Patch 发布后):`/var/lib/bluearchive-toolkit/localized/current`
|
||||
- 运行期目录:`/run/bluearchive-toolkit/`
|
||||
|
||||
standalone `--daemon` 模式:
|
||||
@@ -279,10 +283,10 @@ standalone `--daemon` 模式:
|
||||
### 生产维护命令
|
||||
|
||||
```bash
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat refresh --output /var/lib/bluearchive-toolkit/official
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat refresh --force --output /var/lib/bluearchive-toolkit/official
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat verify --output /var/lib/bluearchive-toolkit/official
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat repair --output /var/lib/bluearchive-toolkit/official
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat refresh --output /var/lib/bluearchive-toolkit/official --localized-output /var/lib/bluearchive-toolkit/localized
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat refresh --force --output /var/lib/bluearchive-toolkit/official --localized-output /var/lib/bluearchive-toolkit/localized
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat verify --output /var/lib/bluearchive-toolkit/official --localized-output /var/lib/bluearchive-toolkit/localized
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat repair --output /var/lib/bluearchive-toolkit/official --localized-output /var/lib/bluearchive-toolkit/localized
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat doctor --output /var/lib/bluearchive-toolkit/official --state-dir /run/bluearchive-toolkit
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat clean-stable --output /var/lib/bluearchive-toolkit/official --state-dir /run/bluearchive-toolkit
|
||||
```
|
||||
|
||||
@@ -141,11 +141,18 @@ git push origin feature/your-feature-name
|
||||
cargo fmt --check
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
go test ./...
|
||||
go vet ./...
|
||||
make test-go-api
|
||||
make build-go-api
|
||||
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
||||
```
|
||||
|
||||
Go 产品入口尚未完成,但仓库已有 `internal/backendrpc` Rust daemon RPC client、`cmd/bat` 试验骨架与 `internal/ffi` 兼容包装。提交前应运行 `go test ./...` 和 `go vet ./...`;`internal/backendrpc` 使用 fake transport 覆盖 JSON-RPC envelope、错误和 typed helper,不能把这些测试误认为 Go 产品级 CLI/API 已完成。
|
||||
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
|
||||
|
||||
- **同步/运维命令行** = Rust `bat`(近乎全自动)
|
||||
- **资源分发服务** = `cmd/bat-api`(`make build-go-api`)
|
||||
- **默认 Go 门禁** = `make test-go-api`(无 FFI)
|
||||
- 试验 CLI 产物为 `bin/bat-go`(`make build-go-cli`),**禁止**与 Rust `bat` 重名
|
||||
- 修改 FFI 时再跑 `make test-go-ffi`
|
||||
|
||||
### 常用聚焦命令
|
||||
|
||||
@@ -160,7 +167,7 @@ cargo clippy -p bat-core -p bat-adapters -p bat-infrastructure --all-targets --
|
||||
|
||||
官方资源同步、下载、daemon、status、verify 或 repair 相关改动必须至少覆盖 `bat-infrastructure` 和 `bat` 二进制测试。
|
||||
|
||||
`bat-ffi` 只是可选无状态 C ABI 兼容层。修改 FFI 导出、JSON schema、错误返回或 `internal/ffi` CGO 包装时必须运行 `cargo test -p bat-ffi -- --nocapture`;未来 Go 产品入口和生产同步默认应通过 `internal/backendrpc` 调用 daemon RPC,或在 one-shot/fallback 场景使用 Rust `bat --json` 进程边界。
|
||||
`bat-ffi` 只是可选无状态 C ABI 兼容层。修改 FFI 时必须运行 `cargo test -p bat-ffi -- --nocapture`。Go 服务层默认经 `internal/backendrpc` 调 daemon;同步任务由 Rust `bat` 执行,不由 Go 试验 CLI 承担。
|
||||
|
||||
### 集成测试
|
||||
|
||||
@@ -186,7 +193,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
开发环境真实下载默认写入 `./bat-resources`;如果要覆盖,必须使用 `/tmp` 或其他隔离目录,不要写入现有资源目录。
|
||||
开发环境真实官方资源下载默认写入 `./bat-resources`;汉化产物默认写入独立的 `./bat-localized`。如果要覆盖,官方原版资源使用 `--output` / `BAT_OUTPUT`,汉化产物使用 `--localized-output` / `BAT_LOCALIZED_OUTPUT`。两者都必须使用 `/tmp` 或其他隔离目录,不要写入现有资源目录,也不要把汉化输出覆盖到官方原版资源目录。
|
||||
|
||||
生产或 CI 环境不得依赖安装官方启动器。需要启动器信息时,只能分析启动器资源、官方 manifest 或公开更新数据,并将解析结果固化为可验证流程。
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ target/release/bat \
|
||||
--watch
|
||||
```
|
||||
|
||||
默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。资源输出目录是发布根目录:非 dry-run 同步先写 `<output>/.staging/<id>`,校验完成后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应读取 `current`。后台状态目录会保存 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`;其中 `bat.sock` 是 live daemon 的 Unix socket JSON-RPC 控制通道,`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 串行化 `status/stop/restart/reload/logs/refresh/repair` 等控制命令。生产资源输出目录必须是独立目录;需要覆盖时用 `--output <资源目录>`,不要使用已有游戏客户端目录、官方启动器安装目录、人工维护资源目录,或开发机上的 `/home/wanye/D/BlueArchive`。
|
||||
默认官方原版资源输出目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`。官方资源输出目录是发布根目录:非 dry-run 同步先写 `<output>/.staging/<id>`,校验完成后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应读取 `current`。后台状态目录会保存 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`;其中 `bat.sock` 是 live daemon 的 Unix socket JSON-RPC 控制通道,`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 串行化 `status/stop/restart/reload/logs/refresh/repair` 等控制命令。生产官方资源目录和汉化产物目录都必须是独立目录;需要覆盖官方目录时用 `--output <资源目录>`,需要覆盖汉化目录时用 `--localized-output <目录>` 或 `BAT_LOCALIZED_OUTPUT`,不要使用已有游戏客户端目录、官方启动器安装目录、人工维护资源目录,或开发机上的 `/home/wanye/D/BlueArchive`。
|
||||
|
||||
同步流程会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||
|
||||
@@ -210,6 +210,8 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
||||
- 真实更新会输出 `downloaded_count`、`resumed_count`、`skipped_count`、`transferred_bytes`、`official_seed_hash_verified_count`。
|
||||
- 校验报告分层输出 `official_seed_hash_verified_count`、`local_manifest_verified_count`、`addressables_marker_checked_count`、`unverified_marker_count`。
|
||||
- 下载阶段复用同一套本地清单、ZIP 结构校验和 `.part` 续传逻辑;没有清单或校验不匹配的文件会重新下载。
|
||||
- 校验和发布完成后会刷新 `<output>/current/official-parse-cache.json`。解析缓存从 `official-download-manifest.json` 的全部条目出发,处理直接 UnityFS bundle 和 zip 内 UnityFS 条目;catalog、hash、媒体等非 UnityFS 文件记录为不支持,不视为同步失败。本地 URL、相对路径、size 和 BLAKE3 未变化时复用缓存并跳过重复解析。
|
||||
- 官方同步报告中的 `localized_release_status=not_localized` 表示原版资源已发布、汉化资源未发布,这是当前官方同步阶段的正常完成状态;后续 Patch 发布完成后才应切换为 `localized`,表示原版和汉化两套资源都已发布。
|
||||
|
||||
资源同步状态文件默认分布如下:
|
||||
|
||||
@@ -217,6 +219,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
||||
- `<output>/official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 未变时复用缓存;metadata 变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
|
||||
- `<output>/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。
|
||||
- `<output>/current/official-parse-cache.json`:官方资源发布后的派生解析缓存,记录 bundle/zip 条目解析摘要和缓存复用情况;它不是汉化产物。
|
||||
- `<output>/current/official-download-quarantine.json` 或当前 staging 下同名文件:下载最终失败的 URL 诊断记录,包含失败类型、HTTP 状态、是否可重试、尝试次数和最后错误。
|
||||
|
||||
先 dry-run:
|
||||
@@ -251,7 +254,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
--watch
|
||||
```
|
||||
|
||||
后台自动运行使用 `--daemon`。它会启动一个脱离终端的 watch 子进程,资源默认写入 `./bat-resources`,后台控制和状态默认写入 `/tmp/bat-pid`:
|
||||
后台自动运行使用 `--daemon`。它会启动一个脱离终端的 watch 子进程,官方原版资源默认写入 `./bat-resources`,汉化产物默认写入 `./bat-localized`,后台控制和状态默认写入 `/tmp/bat-pid`:
|
||||
|
||||
```bash
|
||||
cargo run -p bat-infrastructure --bin bat -- \
|
||||
@@ -310,7 +313,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
|
||||
默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,下载执行保持顺序处理,已完成计数保持单调不倒退,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。
|
||||
|
||||
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产官方资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取原版资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。汉化 Patch/导出应写入 `--localized-output`,并保留官方相对目录结构,不能写回 `--output/current`。发布状态分两档:`not_localized` 只发布原版资源、不发布汉化资源;`localized` 发布原版和汉化两套资源。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一官方资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
|
||||
需要只做探测时可以加 `--dry-run`。需要关闭本地 audit 或 repair 时可以显式使用 `--no-audit-local` 或 `--no-repair`,但生产同步默认应保持开启。
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 当前实现缺口清单
|
||||
|
||||
- **更新时间**:2026-07-20
|
||||
- **更新时间**:2026-07-24
|
||||
- **Go 进度权威**:`GO_STATUS.md`
|
||||
- **资源布局 / 逆向契约**:`../architecture/resource-release-layout.md`
|
||||
- **用途**:集中跟踪当前代码中的占位实现、设计缺口和下一步验收项。
|
||||
- **权威计划**:`../../PROJECT_PLAN.md`
|
||||
|
||||
@@ -109,24 +111,40 @@
|
||||
|
||||
### G-005:AssetBundle 引擎解析器仍未完成
|
||||
|
||||
状态:**部分完成**
|
||||
|
||||
现象:
|
||||
|
||||
- `crates/bat-assetbundle/src/parser.rs` 只有 `Parser::name`。
|
||||
- `types.rs` 只有 `AssetType::TextAsset`。
|
||||
- `adapters/src/unity/unity_2021_3.rs` 已能解析 UnityFS header、block info、directory,并校验 directory `offset+size` 不越界;这属于 adapter 层基础摘要能力,不等于 `bat-assetbundle` 引擎已完成。
|
||||
- 仍没有对象表、TypeTree、TextAsset、MonoBehaviour、ScriptableObject 或可扩展提取入口。
|
||||
- `crates/bat-assetbundle` 已接管 UnityFS 解析,提供 `UnityFsParser`、`UnityFsBundle`、header、block info、directory、压缩模式、block info at end、LZ4/LZMA block info 解压、数据 block 解压、directory 文件提取和边界诊断。
|
||||
- `crates/bat-assetbundle::serialized` 已提供 Unity serialized file header、type table、TypeTree node 元数据、object table 和 `TextAsset` bytes 提取。
|
||||
- `adapters/src/unity/unity_2021_3.rs` 已降为 Unity 版本选择薄层,复用 `bat-assetbundle`,不再维护第二套 UnityFS parser。
|
||||
- `ResourceImportService` 的 UnityFS 摘要已经能暴露解包文件数、serialized file 数、TextAsset 数量和名称。
|
||||
- 仍没有 `MonoBehaviour`、`ScriptableObject` 的 TypeTree 字段级反序列化和可编辑重打包入口。
|
||||
|
||||
影响:
|
||||
|
||||
- 可以对部分 UnityFS 样本做基础结构校验,但无法完成真实资源对象解析和文本提取。
|
||||
- 无法提取 TextAsset 或配置文本。
|
||||
- 可以对 UnityFS 容器做结构校验、解包 directory 文件,并提取 serialized file 中的 TextAsset 原始 bytes。
|
||||
- 对日语汉化最关键的 TextAsset 索引和 bytes 提取已有基础入口,但还不能直接解析 `MonoBehaviour`/`ScriptableObject` 自定义字段或完成修改后重打包。
|
||||
|
||||
验收:
|
||||
当前验收证据:
|
||||
|
||||
- `crates/bat-assetbundle` 能解析结构化测试样本和隔离真实样本。
|
||||
- 支持 UnityFS header、blocks、directory、metadata、object table。
|
||||
- 支持 UnityFS header、blocks、directory、metadata 摘要、directory 文件提取。
|
||||
- 支持 Unity serialized file object table、TypeTree node 元数据和 TextAsset 提取的合成 fixture。
|
||||
- 错误包含偏移和字段上下文。
|
||||
|
||||
关闭前仍需:
|
||||
|
||||
- 完成 TypeTree 字段 reader:bool、integer、float、string、bytes、array、map、PPtr 和 managed reference 诊断占位。
|
||||
- 支持 MonoBehaviour、ScriptableObject 字段级遍历,形成可扩展文本提取入口。
|
||||
- 输出可追溯文本定位:bundle path、archive entry、serialized file、path id、class id、field path。
|
||||
- 用真实资源 fixture 覆盖对象级解析、TextAsset 提取和字段级文本提取。
|
||||
- 将解析结果作为 Patch 输入;真正的重打包、Patch 生成和 `localized` 发布切换归 G-006/G-011D。
|
||||
|
||||
解析补全路线图:
|
||||
|
||||
- 见 `docs/architecture/assetbundle.md` 的 P2/P3/P5。
|
||||
|
||||
### G-006:Patch 引擎仍是占位
|
||||
|
||||
现象:
|
||||
@@ -153,8 +171,9 @@
|
||||
|
||||
- `AddressablesCatalogDriver` 已能解析当前真实形态 JSON catalog fixture/golden。
|
||||
- 已输出 path、hash、size、resource_type、address、dependencies、metadata,并已提取 `m_Crc` 到 `crc` 字段。
|
||||
- compact catalog 解析已补充 hash/size/CRC 的非 0 回归、资源计数 metadata,以及 blob 解码失败时的明确错误;不再在 compact 字段损坏时静默退回低保真 `m_InternalIds`。
|
||||
- `bat-core` 已提供 `crc32_ieee` 和 `ResourceEntry::verify_downloaded_bytes`,SQLite `ResourceRepository` 已有 `crc` 列迁移。
|
||||
- 仍需覆盖更多官方 catalog 结构变体、二进制/压缩字段组合和更明确的失败诊断。
|
||||
- 仍需覆盖更多官方 catalog 结构变体、provider/bundle name 持久化字段,以及真实 Windows/Android catalog 样本集合。
|
||||
|
||||
影响:
|
||||
|
||||
@@ -165,54 +184,53 @@
|
||||
- 能解析项目目标版本的真实 Catalog 样本集合。
|
||||
- 解析结果包含资源 key、provider、dependency、hash、size、path、CRC。
|
||||
- 对不支持的 catalog 结构返回明确错误,而不是静默丢字段。
|
||||
- 解析结果能反查 bundle 文件、依赖链和本地下载 manifest 条目。
|
||||
- Windows/Android 样本集合需要覆盖 JSON、compact JSON 和后续二进制 catalog 入口。
|
||||
|
||||
解析补全路线图:
|
||||
|
||||
- 见 `docs/architecture/assetbundle.md` 的 P1。
|
||||
|
||||
---
|
||||
|
||||
## 3. 应用层缺口
|
||||
|
||||
### G-008:Go CLI 产品入口尚未完成
|
||||
### G-008:Go 同步/运维 CLI 产品入口
|
||||
|
||||
状态:**未完成(此前“并入 G-009”只是短期跟踪调整,不代表能力完成)**
|
||||
状态:**已决策关闭(wontfix)**
|
||||
|
||||
现象:
|
||||
决策(2026-07-24,见 `GO_STATUS.md`):
|
||||
|
||||
- 当前可用的用户同步/运维入口是 Rust `bat` binary。
|
||||
- `internal/backendrpc` 已提供 Go 到 Rust daemon 的 typed JSON-RPC client;`cmd/bat` 已存在,但仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力;`doctor` 只输出固定 `ok`,`manifest`/`sync` 依赖可选 CGO/FFI helper。
|
||||
- Go 侧尚未实现通过 Rust `bat --json` 或 daemon RPC 包装官方同步命令、稳定 human/json 输出、真实 doctor 检查和端到端测试。
|
||||
- 如果项目决策改为“用户 CLI 永久由 Rust `bat` 承担,Go 只做 `bat-api`/服务层”,必须同步更新 `AGENTS.md`、`PROJECT_PLAN.md` 和 issue 跟踪;在完成该决策前,不能把 Go CLI 写成已完成。
|
||||
- **正式同步/运维命令行 = Rust `bat`**(近乎全自动:auto-discover + watch/daemon,无需持久手操维护)。
|
||||
- **不另做**产品级 Go 同步 CLI,避免与 Rust `bat` 双轨。
|
||||
- Go 试验入口 `cmd/bat` 可保留为 experimental,产物必须为 `bin/bat-go`,**禁止**再构建为 `bin/bat`。
|
||||
- Go 正式产品入口集中在 **`bat-api` 资源分发服务** + `internal/backendrpc`(G-009)。
|
||||
|
||||
验收:
|
||||
原验收(真实 doctor / Go sync 包装)**不再作为当前里程碑**。
|
||||
|
||||
- `cmd/bat doctor` 做真实环境诊断,而不是固定字符串。
|
||||
- `cmd/bat sync` 能通过 `internal/backendrpc` daemon RPC 或 Rust `bat --json` one-shot/fallback 触发/查询官方同步,不走 FFI 控制下载器或 daemon。
|
||||
- human/json 输出、退出码和错误码与 Rust `bat` 契约一致。
|
||||
- `go test ./...`、`go vet ./...` 覆盖命令解析、错误输出和至少一个 mocked Rust 边界。
|
||||
### G-009:API Server(`bat-api`,资源分发)部分完成
|
||||
|
||||
### G-009:API Server(`bat-api`,仿官方 API)尚未实现
|
||||
状态:**资源 CDN MVP 已落地;非完整官方游戏 API**
|
||||
|
||||
现象:
|
||||
目标(对应 issue #19,**按资源面收窄**):
|
||||
|
||||
- `api/` 只有目录结构,无 handler、service、路由。
|
||||
- Go 侧尚无对接 daemon RPC / `current/` 发布布局的服务端入口。
|
||||
- `cmd/bat-api`:只读分发 Rust `bat` 已发布 release(官方 CDN host/path 形态)。
|
||||
- **拉取归属 Rust `bat`**;`bat-api` 不做下载器。
|
||||
- 发现经 `bat.sock`:先 `daemon.status`,再 `daemon.doctor`,再 `catalog.status` / `resource.manifest`。
|
||||
- `.env` 配置端口 / public base / RPC socket;预留 database/redis。
|
||||
- launcher 全链、完整业务 API **非关闭条件**;USERGUIDE bat-api 专章延后。
|
||||
|
||||
目标(对应 issue #19):
|
||||
已完成:
|
||||
|
||||
- 新建 `cmd/bat-api`:**完全仿照 BlueArchive 官方 API** 的 Go HTTP 服务,把 Rust `bat` 后端发布的 `current/` release 按官方接口形态对外提供,使真实客户端/工具可将其当作官方服务端。
|
||||
- 仿真面:资源 CDN 面(TableCatalog/MediaCatalog/BundlePackingInfo、bundle、seed `.hash`)、server-info 面、launcher API 面。
|
||||
- 鉴权/签名**完全仿照**官方实现,服务端做验签(对齐 `adapters/src/official/launcher.rs` 的签名逻辑)。
|
||||
- 版本/状态经 daemon RPC(`catalog.*` / `resource.manifest`)发现,资源字节从 `current/` 读取;不读写 daemon 状态文件内部。
|
||||
- `cmd/bat-api`、`internal/api`、fixture 单测、`make build-go-api` / `test-go-api`
|
||||
- 进度权威:`docs/reports/GO_STATUS.md`
|
||||
|
||||
影响:
|
||||
验收(剩余):
|
||||
|
||||
- 客户端、工具和第三方集成无服务端入口。
|
||||
- 与全量 release / 服务器 daemon 联调(SSH 实勘可后置)
|
||||
- 文档与 GO_STATUS 持续一致
|
||||
|
||||
验收:
|
||||
|
||||
- Go 单测覆盖路由、签名验签、错误响应形态。
|
||||
- 真机 e2e:daemon 发布 fixture release → 启动 `bat-api` → 按官方 URL 与鉴权头请求 server-info / catalog / bundle / launcher 链,断言字节与形态正确、验签生效(缺签/错签被拒)。
|
||||
- 统一错误结构与官方响应 envelope 对齐;Makefile 增加 Go 构建/测试目标。
|
||||
|
||||
排期:P2,排在 issue #17 验收收口以及 issue #2 / #3 的解析能力继续推进之后启动;可与 G-008 的 Go 产品入口边界收敛并行。
|
||||
排期:P2 主体可联调;持久化 API 层与 launcher 另议。
|
||||
|
||||
### G-010:Web 管理后台尚未实现
|
||||
|
||||
@@ -248,6 +266,11 @@
|
||||
- schema 和迁移可重复执行。
|
||||
- 可按版本、类型、hash、路径查询资源。
|
||||
- 官方同步后的资源可通过 CLI 查询并能追溯到 CAS 对象。
|
||||
- `official-parse-cache.json` 的 bundle、zip entry、TextAsset 摘要能进入 ResourceRepository 查询面。
|
||||
|
||||
解析补全路线图:
|
||||
|
||||
- 见 `docs/architecture/assetbundle.md` 的 P4。
|
||||
|
||||
### G-011A:资源导入链路基础能力不足
|
||||
|
||||
@@ -294,6 +317,30 @@
|
||||
- `cargo test -p bat-infrastructure version_state`
|
||||
- `cargo test -p bat-infrastructure --test official_game_main_config_bootstrap`
|
||||
|
||||
### G-011D:汉化发布状态与 Patch 发布流程未完成
|
||||
|
||||
状态:**新建,未关闭**
|
||||
|
||||
当前已完成:
|
||||
|
||||
- 官方原版资源发布根为 `./bat-resources`,汉化产物发布根为 `./bat-localized`。
|
||||
- CLI 支持 `--localized-output` / `BAT_LOCALIZED_OUTPUT`,并拒绝官方目录和汉化目录相同或互相嵌套。
|
||||
- 官方同步报告新增 `localized_release_status=not_localized`,明确表示原版资源已发布、汉化资源未发布。
|
||||
|
||||
仍未完成:
|
||||
|
||||
- Patch 发布阶段尚未生成 `localized-output/versions/<id>`。
|
||||
- 尚未维护 `localized-output/current` 原子指针和汉化版本状态文件。
|
||||
- 尚未实现 `localized` 状态切换、回滚和汉化产物完整性校验。
|
||||
- 尚未实现原版资源与汉化资源双发布后的查询、分发和清理策略。
|
||||
|
||||
验收:
|
||||
|
||||
- 原版资源同步成功后保持 `not_localized`,不发布半成品汉化资源。
|
||||
- Patch 构建和校验成功后,汉化产物按官方相对路径写入 `localized-output/versions/<id>`。
|
||||
- 汉化发布必须原子切换 `localized-output/current`,失败时不影响已发布原版资源。
|
||||
- `localized` 状态能证明原版和汉化两套资源都可发布,并能被 CLI/RPC/API 查询。
|
||||
|
||||
### G-011C:真实 fixture 与回归样本不足
|
||||
|
||||
状态:**已关闭当前阶段**
|
||||
@@ -470,13 +517,15 @@
|
||||
|
||||
## 6. 当前关闭顺序建议
|
||||
|
||||
1. issue #24:失败 staging 复用已补离线回归;继续核对 issue 口径、状态与后续是否仍有真实场景无法复现的残余。
|
||||
2. issue #1:Rust daemon/backend API 边界收口;`resource.repair`、`resource.list`、`daemon.doctor`、`internal/backendrpc` Go client 和稳定 RPC reference 已补齐,剩余确认 `patch.*` / `unityfs.*`(待引擎)、`task.create`(暂不开放通用入口)以及 `daemon.restart` / `daemon.clean-stable`(CLI 生命周期入口)的 issue 验收口径。
|
||||
3. issue #17/#20/#21/#22:多线程下载入口已按最新决定移除,下载回归顺序执行并保留指数退避与单调进度上报;daemon 子进程不再透传并发参数,但 GitHub issue 仍 open。
|
||||
4. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。
|
||||
5. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。
|
||||
6. G-011:官方同步结果接入 CAS + ResourceRepository 用户级工作流。
|
||||
7. G-008 / G-009:收敛 Go 产品入口边界,并实现 `bat-api`(issue #19)。
|
||||
8. G-012 / G-006:翻译系统、Patch 引擎。
|
||||
1. issue #24:失败 staging 复用回归已补;核对残余场景。
|
||||
2. issue #1:RPC 主体已落地;剩余 `patch.*` / `unityfs.*`、设计边界确认。
|
||||
3. issue #17 及子 issue:已按 wontfix 关闭多线程下载(顺序下载 + 指数退避)。
|
||||
4. **G-008:已决策关闭**(同步 CLI = Rust `bat`;见 `GO_STATUS.md`)。
|
||||
5. **G-009 / issue #19**:资源分发 MVP 已编码;优先服务器联调与索引实勘,非「从零实现」。
|
||||
6. issue #2 / G-007(P1):Addressables 可校验字段。
|
||||
7. issue #3 / G-005(P1):UnityFS 容器基础解析已落地;对象级引擎解析继续跟踪 G-005。
|
||||
8. G-011:官方同步结果接入 CAS + ResourceRepository 用户级工作流。
|
||||
9. G-011D / G-006:汉化发布状态持久化、Patch 发布和回滚。
|
||||
10. G-012 / G-006:翻译系统、Patch 引擎。
|
||||
|
||||
这个顺序优先把 Rust `bat` 后端做扎实(解析能力 + 下载性能),再把官方同步结果进入可查询资源库,之后收敛 Go 入口和仿官方 API 服务端,最后推进翻译和补丁。G-018 已固化为可重复 smoke 命令并关闭;G-017 已按“不引入托管 CI”决策关闭。
|
||||
Go 进度以 `docs/reports/GO_STATUS.md` 为准。G-018 / G-017 已关闭。
|
||||
|
||||
@@ -12,6 +12,7 @@ path = "src/bin/bat_official_sync.rs"
|
||||
[dependencies]
|
||||
bat-core = { path = "../core" }
|
||||
bat-adapters = { path = "../adapters" }
|
||||
bat-assetbundle = { path = "../crates/bat-assetbundle" }
|
||||
bat-cas-engine = { path = "../crates/bat-cas-engine" }
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -52,7 +52,7 @@ const BEIJING_UTC_OFFSET_SECONDS: u64 = 8 * 60 * 60;
|
||||
const DAILY_FORCED_REFRESH_LOCAL_SECONDS: [u64; 3] = [3 * 60 * 60, 16 * 60 * 60, 18 * 60 * 60];
|
||||
const DAILY_FORCED_REFRESH_LABEL: &str = "UTC+8 03:00, 16:00, 18:00";
|
||||
const STARTUP_BANNER: &str = r#"
|
||||
============================================================
|
||||
=====================================================================================
|
||||
____ _ _ _ _ _____ _ _ _ _
|
||||
| __ )| |_ _ ___ / \ _ __ ___| |__ (_)_ _____|_ _|__ ___ | | | _(_) |_
|
||||
| _ \| | | | |/ _ \/ _ \ | '__/ __| '_ \| \ \ / / _ \ | |/ _ \ / _ \| | |/ / | __|
|
||||
@@ -61,7 +61,7 @@ const STARTUP_BANNER: &str = r#"
|
||||
|
||||
BlueArchiveToolkit
|
||||
Official Resource Sync
|
||||
============================================================
|
||||
=====================================================================================
|
||||
"#;
|
||||
|
||||
fn main() {
|
||||
@@ -3104,6 +3104,7 @@ fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<&
|
||||
&& options.config.launcher_version == defaults.launcher_version
|
||||
&& options.config.platforms.is_none()
|
||||
&& options.config.output_root == defaults.output_root
|
||||
&& options.config.localized_output_root == defaults.localized_output_root
|
||||
&& options.config.snapshot_path.is_none()
|
||||
&& options.config.curl_command == defaults.curl_command
|
||||
&& options.config.curl_proxy == defaults.curl_proxy
|
||||
@@ -3122,6 +3123,7 @@ fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<&
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool {
|
||||
sync_command_rpc_method(options, command_name) == Some(RPC_METHOD_REFRESH)
|
||||
}
|
||||
@@ -3456,7 +3458,14 @@ impl HumanReport for OfficialUpdateReport {
|
||||
print_field("本地审计", format_bool(self.audit_local));
|
||||
print_field("自动修复", format_bool(self.repair));
|
||||
print_field("dry-run", format_bool(self.dry_run));
|
||||
print_path_field("输出根目录", &self.output_root);
|
||||
print_path_field("官方资源目录", &self.output_root);
|
||||
print_path_field("汉化输出目录", &self.localized_output_root);
|
||||
print_field("汉化发布状态", self.localized_release_status.as_str());
|
||||
print_path_field("汉化 current", &self.localized_current_path);
|
||||
print_optional_path_field(
|
||||
"汉化 published",
|
||||
self.localized_published_version_path.as_ref(),
|
||||
);
|
||||
print_path_field("active release", &self.active_resource_root);
|
||||
print_path_field("current", &self.current_path);
|
||||
print_path_field("version state", &self.version_state_path);
|
||||
@@ -3464,6 +3473,7 @@ impl HumanReport for OfficialUpdateReport {
|
||||
print_optional_path_field("published", self.published_version_path.as_ref());
|
||||
print_path_field("snapshot", &self.snapshot_path);
|
||||
print_path_field("manifest", &self.download_manifest);
|
||||
print_optional_path_field("解析缓存", self.parse_cache_path.as_ref());
|
||||
print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref());
|
||||
print_optional_path_field("bootstrap cache", self.bootstrap_cache_path.as_ref());
|
||||
print_optional_field("bootstrap 命中", self.bootstrap_cache_hit.map(format_bool));
|
||||
@@ -3478,6 +3488,14 @@ impl HumanReport for OfficialUpdateReport {
|
||||
print_field("需修复", self.local_manifest_repair_needed_count);
|
||||
print_field("官方 hash 校验", self.official_seed_hash_verified_count);
|
||||
print_verification_summary(&self.verification_summary);
|
||||
if let Some(summary) = self.parse_summary.as_ref() {
|
||||
print_field("解析缓存条目", summary.cache_entry_count);
|
||||
print_field("解析成功 bundle", summary.parsed_bundle_count);
|
||||
print_field("解析复用", summary.skipped_unchanged_count);
|
||||
print_field("解析不支持", summary.unsupported_count);
|
||||
print_field("解析失败", summary.failed_count);
|
||||
print_field("TextAsset", summary.text_asset_count);
|
||||
}
|
||||
print_field("catalog marker", self.addressables_marker_checked_count);
|
||||
print_list("变更 endpoint", &self.changed_endpoint_urls, 8);
|
||||
print_list("计划 URL", &self.download_urls, 8);
|
||||
@@ -3868,11 +3886,21 @@ fn build_doctor_report(
|
||||
let mut checks = vec![
|
||||
path_check("state_dir", state_dir, "后台状态目录可用"),
|
||||
path_check("output_root", &config.output_root, "资源输出目录可用"),
|
||||
path_check(
|
||||
"localized_output_root",
|
||||
&config.localized_output_root,
|
||||
"汉化输出目录可用",
|
||||
),
|
||||
safety_check(
|
||||
"output_root_safety",
|
||||
validate_output_root(&config.output_root),
|
||||
"资源输出目录安全边界通过",
|
||||
),
|
||||
safety_check(
|
||||
"localized_output_root_safety",
|
||||
validate_output_root(&config.localized_output_root),
|
||||
"汉化输出目录安全边界通过",
|
||||
),
|
||||
safety_check(
|
||||
"state_dir_safety",
|
||||
validate_runtime_state_dir(state_dir),
|
||||
@@ -4524,6 +4552,8 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
|
||||
}
|
||||
args.push("--output".to_string());
|
||||
args.push(config.output_root.to_string_lossy().to_string());
|
||||
args.push("--localized-output".to_string());
|
||||
args.push(config.localized_output_root.to_string_lossy().to_string());
|
||||
if let Some(snapshot_path) = config.snapshot_path.as_ref() {
|
||||
args.push("--snapshot".to_string());
|
||||
args.push(snapshot_path.to_string_lossy().to_string());
|
||||
@@ -4937,6 +4967,7 @@ fn stage_to_static(stage: &str) -> &'static str {
|
||||
"download" => "download",
|
||||
"snapshot" => "snapshot",
|
||||
"publish" => "publish",
|
||||
"parse" => "parse",
|
||||
"finish" => "finish",
|
||||
"watch" => "watch",
|
||||
"daemon" => "daemon",
|
||||
@@ -4968,6 +4999,7 @@ fn localized_stage(stage: &str) -> &str {
|
||||
"dry-run" => "试运行",
|
||||
"download" => "下载",
|
||||
"publish" => "发布",
|
||||
"parse" => "解析",
|
||||
"resource" => "资源",
|
||||
"finish" => "完成",
|
||||
"watch" => "常驻",
|
||||
@@ -5003,8 +5035,10 @@ const ENV_TEMPLATE: &str = r#"# BlueArchive Toolkit 配置文件(bat 首次启
|
||||
# 设 BAT_SKIP_ENV_FILE=1 可让 bat 完全忽略本文件。
|
||||
|
||||
# ---- 基本配置 ----
|
||||
# 资源发布根目录(默认 ./bat-resources,相对当前工作目录)
|
||||
# 官方原版资源发布根目录(默认 ./bat-resources,相对当前工作目录)
|
||||
BAT_OUTPUT=./bat-resources
|
||||
# 汉化产物输出根目录(默认 ./bat-localized,与官方原版资源分离)
|
||||
BAT_LOCALIZED_OUTPUT=./bat-localized
|
||||
# 自动发现 app-version / connection-group / server-info(无参启动建议保持 1)
|
||||
BAT_AUTO_DISCOVER=1
|
||||
# 后台状态目录(bat.sock / 日志 / 任务历史等;默认 /tmp/bat-pid)
|
||||
@@ -5175,6 +5209,9 @@ fn apply_bat_env_overrides(
|
||||
if let Some(v) = value("BAT_OUTPUT") {
|
||||
options.config.output_root = PathBuf::from(v);
|
||||
}
|
||||
if let Some(v) = value("BAT_LOCALIZED_OUTPUT") {
|
||||
options.config.localized_output_root = PathBuf::from(v);
|
||||
}
|
||||
if let Some(v) = value("BAT_STATE_DIR") {
|
||||
options.state_dir = PathBuf::from(v);
|
||||
}
|
||||
@@ -5343,6 +5380,11 @@ fn parse_args_with_env(
|
||||
options.config.output_root = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||||
options.output_explicit = true;
|
||||
}
|
||||
"--localized-output" => {
|
||||
options.config.localized_output_root =
|
||||
PathBuf::from(next_option_value(&mut args, &flag)?);
|
||||
options.output_explicit = true;
|
||||
}
|
||||
"--state-dir" | "--pid-dir" => {
|
||||
options.state_dir = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||||
}
|
||||
@@ -5707,7 +5749,10 @@ fn print_usage(binary: &str) {
|
||||
eprintln!("Sync:");
|
||||
eprintln!(" --platforms <LIST> Platforms, e.g. Windows,Android");
|
||||
eprintln!(
|
||||
" --output <DIR> Resource publish root (default: ./bat-resources)"
|
||||
" --output <DIR> Official resource publish root (default: ./bat-resources)"
|
||||
);
|
||||
eprintln!(
|
||||
" --localized-output <DIR> Localized output root (default: ./bat-localized)"
|
||||
);
|
||||
eprintln!(" --snapshot <PATH> Override snapshot path (default: <output>/current/official-sync-snapshot.json)");
|
||||
eprintln!(" --curl <PATH> curl executable (default: curl)");
|
||||
@@ -5739,7 +5784,10 @@ fn print_usage(binary: &str) {
|
||||
eprintln!();
|
||||
eprintln!("Defaults:");
|
||||
eprintln!(" platforms: Windows,Android");
|
||||
eprintln!(" resource output: ./bat-resources (current -> versions/<id>, .staging/<id>)");
|
||||
eprintln!(
|
||||
" official resource output: ./bat-resources (current -> versions/<id>, .staging/<id>)"
|
||||
);
|
||||
eprintln!(" localized output: ./bat-localized (separate patch/export target)");
|
||||
eprintln!(" daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log, bat-events.jsonl)");
|
||||
eprintln!(" forced refresh: {DAILY_FORCED_REFRESH_LABEL}");
|
||||
}
|
||||
@@ -5820,6 +5868,7 @@ mod tests {
|
||||
&["bat"],
|
||||
&[
|
||||
("BAT_OUTPUT", "/srv/bat"),
|
||||
("BAT_LOCALIZED_OUTPUT", "/srv/bat-localized"),
|
||||
("BAT_AUTO_DISCOVER", "1"),
|
||||
("BAT_STATE_DIR", "/srv/state"),
|
||||
("BAT_INTERVAL_SECONDS", "120"),
|
||||
@@ -5827,6 +5876,10 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(options.config.output_root, PathBuf::from("/srv/bat"));
|
||||
assert_eq!(
|
||||
options.config.localized_output_root,
|
||||
PathBuf::from("/srv/bat-localized")
|
||||
);
|
||||
assert!(options.config.auto_discover);
|
||||
assert_eq!(options.state_dir, PathBuf::from("/srv/state"));
|
||||
assert_eq!(options.interval, Duration::from_secs(120));
|
||||
@@ -5946,6 +5999,7 @@ mod tests {
|
||||
keys.push(key);
|
||||
}
|
||||
assert!(keys.contains(&"BAT_OUTPUT".to_string()));
|
||||
assert!(keys.contains(&"BAT_LOCALIZED_OUTPUT".to_string()));
|
||||
assert!(keys.contains(&"BAT_AUTO_DISCOVER".to_string()));
|
||||
}
|
||||
|
||||
@@ -6118,6 +6172,10 @@ mod tests {
|
||||
assert!(options.banner);
|
||||
assert_eq!(options.output_format, OutputFormat::Human);
|
||||
assert_eq!(config.output_root, PathBuf::from("./bat-resources"));
|
||||
assert_eq!(
|
||||
config.localized_output_root,
|
||||
PathBuf::from("./bat-localized")
|
||||
);
|
||||
assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR));
|
||||
}
|
||||
|
||||
@@ -6210,6 +6268,8 @@ mod tests {
|
||||
"--daemon",
|
||||
"--output",
|
||||
"/tmp/daemon-output",
|
||||
"--localized-output",
|
||||
"/tmp/daemon-localized",
|
||||
"--interval",
|
||||
"30m",
|
||||
])
|
||||
@@ -6222,6 +6282,10 @@ mod tests {
|
||||
options.config.output_root,
|
||||
PathBuf::from("/tmp/daemon-output")
|
||||
);
|
||||
assert_eq!(
|
||||
options.config.localized_output_root,
|
||||
PathBuf::from("/tmp/daemon-localized")
|
||||
);
|
||||
assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR));
|
||||
assert_eq!(options.interval, Duration::from_secs(30 * 60));
|
||||
assert!(options.quiet_up_to_date);
|
||||
@@ -6246,11 +6310,19 @@ mod tests {
|
||||
assert!(!status.progress);
|
||||
assert!(!status.banner);
|
||||
assert_eq!(status.config.output_root, PathBuf::from("./bat-resources"));
|
||||
assert_eq!(
|
||||
status.config.localized_output_root,
|
||||
PathBuf::from("./bat-localized")
|
||||
);
|
||||
assert_eq!(status.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR));
|
||||
|
||||
let stop = parse(&["bat", "stop", "--state-dir", "/tmp/custom-bat-pid"]).unwrap();
|
||||
assert_eq!(stop.command, CliCommand::Stop);
|
||||
assert_eq!(stop.config.output_root, PathBuf::from("./bat-resources"));
|
||||
assert_eq!(
|
||||
stop.config.localized_output_root,
|
||||
PathBuf::from("./bat-localized")
|
||||
);
|
||||
assert_eq!(stop.state_dir, PathBuf::from("/tmp/custom-bat-pid"));
|
||||
}
|
||||
|
||||
@@ -6312,6 +6384,8 @@ mod tests {
|
||||
"Windows,Android",
|
||||
"--output",
|
||||
"/tmp/daemon-output",
|
||||
"--localized-output",
|
||||
"/tmp/daemon-localized",
|
||||
"--state-dir",
|
||||
"/tmp/daemon-state",
|
||||
"--curl",
|
||||
@@ -6336,6 +6410,9 @@ mod tests {
|
||||
assert!(args
|
||||
.windows(2)
|
||||
.any(|pair| pair == ["--output", "/tmp/daemon-output"]));
|
||||
assert!(args
|
||||
.windows(2)
|
||||
.any(|pair| pair == ["--localized-output", "/tmp/daemon-localized"]));
|
||||
assert!(args
|
||||
.windows(2)
|
||||
.any(|pair| pair == ["--state-dir", "/tmp/daemon-state"]));
|
||||
@@ -6579,8 +6656,10 @@ mod tests {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("output");
|
||||
let mut base_config = OfficialUpdateConfig::default();
|
||||
base_config.output_root = output_root;
|
||||
let base_config = OfficialUpdateConfig {
|
||||
output_root,
|
||||
..Default::default()
|
||||
};
|
||||
let (queue, _rx) = mpsc::channel::<TaskJob>();
|
||||
let context = DaemonTaskContext {
|
||||
registry: TaskRegistry::new(),
|
||||
@@ -6662,11 +6741,13 @@ mod tests {
|
||||
let control = new_daemon_control();
|
||||
// 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。
|
||||
let (queue, rx) = mpsc::channel::<TaskJob>();
|
||||
let mut base_config = OfficialUpdateConfig::default();
|
||||
base_config.force = true;
|
||||
base_config.dry_run = true;
|
||||
base_config.audit_local = false;
|
||||
base_config.repair = false;
|
||||
let base_config = OfficialUpdateConfig {
|
||||
force: true,
|
||||
dry_run: true,
|
||||
audit_local: false,
|
||||
repair: false,
|
||||
..Default::default()
|
||||
};
|
||||
let context = DaemonTaskContext {
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
|
||||
@@ -87,6 +87,16 @@ pub struct UnityFsImportSummary {
|
||||
pub directory_count: usize,
|
||||
/// UnityFS directory 路径。
|
||||
pub directories: Vec<String>,
|
||||
/// UnityFS directory 解出的文件数量。
|
||||
pub file_count: usize,
|
||||
/// 成功解析出的 Unity serialized file 数量。
|
||||
pub serialized_file_count: usize,
|
||||
/// 成功解析出的 TextAsset 数量。
|
||||
pub text_asset_count: usize,
|
||||
/// TextAsset 名称列表。
|
||||
pub text_assets: Vec<String>,
|
||||
/// 非致命 serialized-file 解析诊断数量。
|
||||
pub serialized_parse_error_count: usize,
|
||||
}
|
||||
|
||||
/// Manifest 导入报告。
|
||||
@@ -266,6 +276,15 @@ impl<'a> ResourceImportService<'a> {
|
||||
.into_iter()
|
||||
.map(|directory| directory.path)
|
||||
.collect(),
|
||||
file_count: parsed.files.len(),
|
||||
serialized_file_count: parsed.serialized_files.len(),
|
||||
text_asset_count: parsed.text_assets.len(),
|
||||
text_assets: parsed
|
||||
.text_assets
|
||||
.into_iter()
|
||||
.map(|asset| asset.name)
|
||||
.collect(),
|
||||
serialized_parse_error_count: parsed.serialized_parse_errors.len(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -375,6 +394,26 @@ mod tests {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_i64_le(data: &mut Vec<u8>, value: i64) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn align(data: &mut Vec<u8>, alignment: usize) {
|
||||
let remainder = data.len() % alignment;
|
||||
if remainder != 0 {
|
||||
@@ -414,6 +453,83 @@ mod tests {
|
||||
data
|
||||
}
|
||||
|
||||
fn synthetic_text_asset_unityfs_bundle() -> Vec<u8> {
|
||||
let serialized_file = synthetic_serialized_text_asset();
|
||||
let mut blocks_info = Vec::new();
|
||||
blocks_info.extend_from_slice(&[1; 16]);
|
||||
push_i32(&mut blocks_info, 1);
|
||||
push_u32(&mut blocks_info, serialized_file.len() as u32);
|
||||
push_u32(&mut blocks_info, serialized_file.len() as u32);
|
||||
push_u16(&mut blocks_info, 0);
|
||||
push_i32(&mut blocks_info, 1);
|
||||
push_u64(&mut blocks_info, 0);
|
||||
push_u64(&mut blocks_info, serialized_file.len() as u64);
|
||||
push_u32(&mut blocks_info, 0);
|
||||
push_c_string(&mut blocks_info, "CAB-scenario");
|
||||
|
||||
let mut data = Vec::new();
|
||||
push_c_string(&mut data, "UnityFS");
|
||||
push_u32(&mut data, 8);
|
||||
push_c_string(&mut data, "5.x.x");
|
||||
push_c_string(&mut data, "2021.3.56f2");
|
||||
push_u64(&mut data, 0);
|
||||
push_u32(&mut data, blocks_info.len() as u32);
|
||||
push_u32(&mut data, blocks_info.len() as u32);
|
||||
push_u32(&mut data, 0);
|
||||
align(&mut data, 16);
|
||||
data.extend_from_slice(&blocks_info);
|
||||
data.extend_from_slice(&serialized_file);
|
||||
|
||||
let total_size = data.len() as u64;
|
||||
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
|
||||
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
fn synthetic_serialized_text_asset() -> Vec<u8> {
|
||||
let mut object_data = Vec::new();
|
||||
push_u32_le(&mut object_data, 8);
|
||||
object_data.extend_from_slice(b"Scenario");
|
||||
align(&mut object_data, 4);
|
||||
push_u32_le(&mut object_data, 15);
|
||||
object_data.extend_from_slice("こんにちは".as_bytes());
|
||||
|
||||
let mut metadata = Vec::new();
|
||||
metadata.extend_from_slice(b"2021.3.56f2\0");
|
||||
push_i32_le(&mut metadata, 19);
|
||||
metadata.push(0);
|
||||
push_i32_le(&mut metadata, 1);
|
||||
push_i32_le(&mut metadata, 49);
|
||||
metadata.push(0);
|
||||
push_i16_le(&mut metadata, 0);
|
||||
metadata.extend_from_slice(&[0; 16]);
|
||||
push_i32_le(&mut metadata, 1);
|
||||
align(&mut metadata, 4);
|
||||
push_i64_le(&mut metadata, 1);
|
||||
push_u64_le(&mut metadata, 0);
|
||||
push_u32_le(&mut metadata, object_data.len() as u32);
|
||||
push_i32_le(&mut metadata, 0);
|
||||
|
||||
let header_len = 48usize;
|
||||
let data_offset = header_len + metadata.len();
|
||||
let file_size = data_offset + object_data.len();
|
||||
|
||||
let mut file = Vec::new();
|
||||
push_u32(&mut file, metadata.len() as u32);
|
||||
push_u32(&mut file, file_size as u32);
|
||||
push_u32(&mut file, 22);
|
||||
push_u32(&mut file, 0);
|
||||
file.push(0);
|
||||
file.extend_from_slice(&[0, 0, 0]);
|
||||
push_u32(&mut file, metadata.len() as u32);
|
||||
push_u64(&mut file, file_size as u64);
|
||||
push_u64(&mut file, data_offset as u64);
|
||||
push_u64(&mut file, 0);
|
||||
file.extend_from_slice(&metadata);
|
||||
file.extend_from_slice(&object_data);
|
||||
file
|
||||
}
|
||||
|
||||
fn synthetic_manifest() -> GenericManifest {
|
||||
GenericManifest {
|
||||
format: ManifestFormat::AddressablesCatalog,
|
||||
@@ -594,6 +710,11 @@ mod tests {
|
||||
assert_eq!(unityfs.block_count, 1);
|
||||
assert_eq!(unityfs.directory_count, 1);
|
||||
assert_eq!(unityfs.directories, vec!["SYNTHETIC-CAB".to_string()]);
|
||||
assert_eq!(unityfs.file_count, 1);
|
||||
assert_eq!(unityfs.serialized_file_count, 0);
|
||||
assert_eq!(unityfs.text_asset_count, 0);
|
||||
assert!(unityfs.text_assets.is_empty());
|
||||
assert_eq!(unityfs.serialized_parse_error_count, 0);
|
||||
assert_eq!(
|
||||
report.imported[1].category,
|
||||
ResourceImportCategory::TextAsset
|
||||
@@ -645,6 +766,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_summary_reports_text_assets_inside_assetbundle() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
|
||||
let resources = InMemoryResourceRepository::new();
|
||||
let service = ResourceImportService::new(&cas, &resources);
|
||||
let manifest = manifest_with(vec![ResourceEntry {
|
||||
path: "synthetic/scenario.bundle".to_string(),
|
||||
hash: "synthetic-scenario-hash".to_string(),
|
||||
size: 1,
|
||||
resource_type: ResourceType::AssetBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
}]);
|
||||
|
||||
let report = service
|
||||
.import_manifest_bundles(
|
||||
&manifest,
|
||||
&[BundleSource::new(
|
||||
"scenario.bundle",
|
||||
synthetic_text_asset_unityfs_bundle(),
|
||||
)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let unityfs = report.imported[0].unityfs.as_ref().unwrap();
|
||||
assert_eq!(unityfs.file_count, 1);
|
||||
assert_eq!(unityfs.serialized_file_count, 1);
|
||||
assert_eq!(unityfs.text_asset_count, 1);
|
||||
assert_eq!(unityfs.text_assets, vec!["Scenario".to_string()]);
|
||||
assert_eq!(unityfs.serialized_parse_error_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_error_when_bundle_data_is_missing() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod import;
|
||||
pub mod official_download;
|
||||
pub mod official_game_main_config;
|
||||
pub mod official_launcher;
|
||||
pub mod official_parse;
|
||||
pub mod official_pull;
|
||||
pub mod official_sync;
|
||||
pub mod official_update;
|
||||
@@ -45,6 +46,12 @@ pub use official_launcher::{
|
||||
OfficialLauncherBootstrapService, YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig,
|
||||
YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
|
||||
};
|
||||
pub use official_parse::{
|
||||
read_parse_cache_at, write_parse_cache_at, OfficialParseCache, OfficialParseCacheEntry,
|
||||
OfficialParseCacheService, OfficialParseConfig, OfficialParseReport,
|
||||
OfficialParseSourceFingerprint, OfficialParseSourceKind, OfficialParseStatus,
|
||||
OfficialParseSummary, OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_PARSE_CACHE_VERSION,
|
||||
};
|
||||
pub use official_pull::{
|
||||
build_official_pull_plan, build_official_pull_plan_for_platform_inventory,
|
||||
build_official_pull_plan_for_platforms, build_official_pull_plan_from_platform_inventory,
|
||||
@@ -58,11 +65,11 @@ pub use official_update::{
|
||||
cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging,
|
||||
read_bootstrap_cache, read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot,
|
||||
write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
|
||||
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot,
|
||||
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
|
||||
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
|
||||
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState,
|
||||
ResolvedBootstrap,
|
||||
LocalizedReleaseStatus, OfficialBootstrapCache, OfficialEndpointMarkerRole,
|
||||
OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource,
|
||||
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
|
||||
OfficialVersionRecord, OfficialVersionState, ResolvedBootstrap,
|
||||
};
|
||||
pub use path_security::{
|
||||
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
|
||||
|
||||
@@ -0,0 +1,950 @@
|
||||
//! Official resource post-sync parse cache.
|
||||
//!
|
||||
//! The parser runs after official download verification has completed. It reads
|
||||
//! the immutable published resource tree, writes a small derived cache next to
|
||||
//! `official-download-manifest.json`, and leaves localized output generation to
|
||||
//! later patch/export stages.
|
||||
|
||||
use crate::official_download::{read_download_manifest_at, OfficialDownloadManifestEntry};
|
||||
use crate::path_security::{
|
||||
ensure_path_within_root, ensure_safe_file_target, read_file_no_symlink, write_file_atomic,
|
||||
STATE_FILE_MODE,
|
||||
};
|
||||
use bat_assetbundle::{Parser, UnityFsParser};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Parse-cache file name stored under a published official resource root.
|
||||
pub const OFFICIAL_PARSE_CACHE_FILE: &str = "official-parse-cache.json";
|
||||
/// Current parse-cache schema version.
|
||||
pub const OFFICIAL_PARSE_CACHE_VERSION: u32 = 1;
|
||||
|
||||
/// Configuration for one official resource parse-cache refresh.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OfficialParseConfig {
|
||||
/// Published official resource root containing `official-download-manifest.json`.
|
||||
pub resource_root: PathBuf,
|
||||
/// `unzip` executable used to inspect zip archives without extracting them.
|
||||
pub unzip_command: PathBuf,
|
||||
}
|
||||
|
||||
impl OfficialParseConfig {
|
||||
/// Creates parse-cache configuration for a published official resource root.
|
||||
pub fn new(resource_root: impl Into<PathBuf>, unzip_command: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
resource_root: resource_root.into(),
|
||||
unzip_command: unzip_command.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the parse-cache path for this resource root.
|
||||
pub fn cache_path(&self) -> PathBuf {
|
||||
self.resource_root.join(OFFICIAL_PARSE_CACHE_FILE)
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured report returned by a parse-cache refresh.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OfficialParseReport {
|
||||
/// Official resource root that was inspected.
|
||||
pub resource_root: PathBuf,
|
||||
/// Parse-cache path written by the refresh.
|
||||
pub cache_path: PathBuf,
|
||||
/// Aggregate parse-cache summary.
|
||||
pub summary: OfficialParseSummary,
|
||||
}
|
||||
|
||||
/// Aggregate counters for a parse-cache refresh.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OfficialParseSummary {
|
||||
/// Number of entries in `official-download-manifest.json`.
|
||||
pub manifest_entry_count: usize,
|
||||
/// Number of entries stored in `official-parse-cache.json`.
|
||||
pub cache_entry_count: usize,
|
||||
/// Number of direct bundle files or zip inner files inspected as parser candidates.
|
||||
pub candidate_file_count: usize,
|
||||
/// Number of zip inner file entries inspected.
|
||||
pub zip_entry_count: usize,
|
||||
/// Number of cached entries reused because source URL/path/size/BLAKE3 did not change.
|
||||
pub skipped_unchanged_count: usize,
|
||||
/// Number of bundles parsed successfully.
|
||||
pub parsed_bundle_count: usize,
|
||||
/// Number of files intentionally skipped because they are not UnityFS bundles.
|
||||
pub unsupported_count: usize,
|
||||
/// Number of files or archives that failed parser/cache inspection.
|
||||
pub failed_count: usize,
|
||||
/// Total TextAsset objects found in parsed Unity serialized files.
|
||||
pub text_asset_count: usize,
|
||||
}
|
||||
|
||||
/// Persistent parse cache for one official resource root.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OfficialParseCache {
|
||||
/// Cache schema version.
|
||||
#[serde(default = "default_parse_cache_version")]
|
||||
pub version: u32,
|
||||
/// Cache generation time as Unix seconds.
|
||||
pub generated_unix_seconds: u64,
|
||||
/// Aggregate summary for this cache.
|
||||
pub summary: OfficialParseSummary,
|
||||
/// Cache entries keyed by source URL and optional archive entry.
|
||||
#[serde(default)]
|
||||
pub entries: BTreeMap<String, OfficialParseCacheEntry>,
|
||||
}
|
||||
|
||||
/// One parsed or skipped official resource file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OfficialParseCacheEntry {
|
||||
/// Stable cache key.
|
||||
pub key: String,
|
||||
/// Official URL from the download manifest.
|
||||
pub source_url: String,
|
||||
/// Relative destination path under the official resource root.
|
||||
pub destination: String,
|
||||
/// Inner archive path when the source is a zip file.
|
||||
pub archive_entry: Option<String>,
|
||||
/// Source classification used by the parser.
|
||||
pub source_kind: OfficialParseSourceKind,
|
||||
/// Fingerprint derived from the download manifest.
|
||||
pub fingerprint: OfficialParseSourceFingerprint,
|
||||
/// Parse status for this cache entry.
|
||||
pub status: OfficialParseStatus,
|
||||
/// Whether this entry was reused from the previous parse cache in this run.
|
||||
#[serde(default)]
|
||||
pub reused_from_previous_cache: bool,
|
||||
/// Unity editor version when a UnityFS bundle was parsed.
|
||||
pub unity_version: Option<String>,
|
||||
/// Number of files extracted from the UnityFS directory table.
|
||||
pub file_count: usize,
|
||||
/// Number of Unity serialized files parsed from extracted files.
|
||||
pub serialized_file_count: usize,
|
||||
/// Number of TextAsset objects found.
|
||||
pub text_asset_count: usize,
|
||||
/// TextAsset names found in stable order.
|
||||
pub text_assets: Vec<String>,
|
||||
/// Non-fatal serialized-file parse diagnostic count.
|
||||
pub serialized_parse_error_count: usize,
|
||||
/// Human-readable error or skip reason.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Source kind for a parse-cache entry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OfficialParseSourceKind {
|
||||
/// A direct official resource file that should be a UnityFS bundle.
|
||||
DirectBundle,
|
||||
/// A file entry streamed from an official zip archive.
|
||||
ZipEntry,
|
||||
/// A manifest entry that is not expected to contain a UnityFS bundle.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// Stable fingerprint copied from the official download manifest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OfficialParseSourceFingerprint {
|
||||
/// Official URL from the download manifest.
|
||||
pub source_url: String,
|
||||
/// Relative destination path under the official resource root.
|
||||
pub destination: String,
|
||||
/// File byte count from the verified download manifest.
|
||||
pub bytes: u64,
|
||||
/// BLAKE3 digest from the verified download manifest.
|
||||
pub blake3: String,
|
||||
}
|
||||
|
||||
/// Parse status for one official resource cache entry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OfficialParseStatus {
|
||||
/// UnityFS parsing succeeded.
|
||||
Parsed,
|
||||
/// The source was inspected and is not currently parsed as UnityFS.
|
||||
SkippedUnsupported,
|
||||
/// The source was expected to be parseable but failed inspection.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Service that refreshes official parse caches.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct OfficialParseCacheService;
|
||||
|
||||
impl OfficialParseCacheService {
|
||||
/// Creates a parse-cache service.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Refreshes the parse cache for the configured official resource root.
|
||||
pub fn run(&self, config: &OfficialParseConfig) -> Result<OfficialParseReport, String> {
|
||||
let manifest = read_download_manifest_at(&config.resource_root)?.ok_or_else(|| {
|
||||
format!(
|
||||
"缺少官方下载 manifest,无法更新解析缓存:{}",
|
||||
config.resource_root.display()
|
||||
)
|
||||
})?;
|
||||
let previous_cache = read_parse_cache_at(&config.resource_root)?;
|
||||
let mut summary = OfficialParseSummary {
|
||||
manifest_entry_count: manifest.entries.len(),
|
||||
..OfficialParseSummary::default()
|
||||
};
|
||||
let mut entries = BTreeMap::new();
|
||||
|
||||
for manifest_entry in manifest.entries.values() {
|
||||
let produced = process_manifest_entry(config, manifest_entry, previous_cache.as_ref());
|
||||
for entry in produced {
|
||||
summary.record_entry(&entry);
|
||||
entries.insert(entry.key.clone(), entry);
|
||||
}
|
||||
}
|
||||
|
||||
summary.cache_entry_count = entries.len();
|
||||
let cache = OfficialParseCache {
|
||||
version: OFFICIAL_PARSE_CACHE_VERSION,
|
||||
generated_unix_seconds: unix_seconds_now(),
|
||||
summary: summary.clone(),
|
||||
entries,
|
||||
};
|
||||
write_parse_cache_at(&config.resource_root, &cache)?;
|
||||
|
||||
Ok(OfficialParseReport {
|
||||
resource_root: config.resource_root.clone(),
|
||||
cache_path: config.cache_path(),
|
||||
summary,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl OfficialParseSummary {
|
||||
fn record_entry(&mut self, entry: &OfficialParseCacheEntry) {
|
||||
match entry.source_kind {
|
||||
OfficialParseSourceKind::DirectBundle => {
|
||||
self.candidate_file_count += 1;
|
||||
}
|
||||
OfficialParseSourceKind::ZipEntry => {
|
||||
self.candidate_file_count += 1;
|
||||
self.zip_entry_count += 1;
|
||||
}
|
||||
OfficialParseSourceKind::Unsupported => {}
|
||||
}
|
||||
match entry.status {
|
||||
OfficialParseStatus::Parsed => {
|
||||
self.parsed_bundle_count += 1;
|
||||
self.text_asset_count += entry.text_asset_count;
|
||||
}
|
||||
OfficialParseStatus::SkippedUnsupported => {
|
||||
self.unsupported_count += 1;
|
||||
}
|
||||
OfficialParseStatus::Failed => {
|
||||
self.failed_count += 1;
|
||||
}
|
||||
}
|
||||
if entry.reused_from_previous_cache {
|
||||
self.skipped_unchanged_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the parse cache under a published official resource root.
|
||||
///
|
||||
/// Missing, corrupt, or unsupported-version caches return `Ok(None)` so a new
|
||||
/// cache can be regenerated without blocking official resource publication.
|
||||
pub fn read_parse_cache_at(resource_root: &Path) -> Result<Option<OfficialParseCache>, String> {
|
||||
let path = resource_root.join(OFFICIAL_PARSE_CACHE_FILE);
|
||||
let Some(bytes) = read_file_no_symlink(&path, "官方解析缓存")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(cache) = serde_json::from_slice::<OfficialParseCache>(&bytes) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if cache.version != OFFICIAL_PARSE_CACHE_VERSION {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(cache))
|
||||
}
|
||||
|
||||
/// Writes the parse cache under a published official resource root.
|
||||
pub fn write_parse_cache_at(
|
||||
resource_root: &Path,
|
||||
cache: &OfficialParseCache,
|
||||
) -> Result<(), String> {
|
||||
let path = resource_root.join(OFFICIAL_PARSE_CACHE_FILE);
|
||||
ensure_path_within_root(resource_root, &path)?;
|
||||
ensure_safe_file_target(resource_root, &path, "官方解析缓存")?;
|
||||
let bytes = serde_json::to_vec_pretty(cache)
|
||||
.map_err(|error| format!("序列化官方解析缓存失败:{error}"))?;
|
||||
write_file_atomic(&path, &bytes, STATE_FILE_MODE, "官方解析缓存")
|
||||
}
|
||||
|
||||
fn process_manifest_entry(
|
||||
config: &OfficialParseConfig,
|
||||
manifest_entry: &OfficialDownloadManifestEntry,
|
||||
previous_cache: Option<&OfficialParseCache>,
|
||||
) -> Vec<OfficialParseCacheEntry> {
|
||||
let fingerprint = fingerprint_for(manifest_entry);
|
||||
if looks_like_zip_source(manifest_entry) {
|
||||
return process_zip_entry(config, manifest_entry, previous_cache, fingerprint);
|
||||
}
|
||||
|
||||
if looks_like_direct_bundle_source(manifest_entry) {
|
||||
let key = direct_key(&manifest_entry.url);
|
||||
if let Some(mut cached) = reusable_entry(previous_cache, &key, &fingerprint) {
|
||||
cached.reused_from_previous_cache = true;
|
||||
return vec![cached];
|
||||
}
|
||||
return vec![parse_direct_bundle(
|
||||
config,
|
||||
manifest_entry,
|
||||
key,
|
||||
fingerprint,
|
||||
)];
|
||||
}
|
||||
|
||||
let key = unsupported_key(&manifest_entry.url);
|
||||
if let Some(mut cached) = reusable_entry(previous_cache, &key, &fingerprint) {
|
||||
cached.reused_from_previous_cache = true;
|
||||
return vec![cached];
|
||||
}
|
||||
vec![unsupported_entry(
|
||||
manifest_entry,
|
||||
None,
|
||||
OfficialParseSourceKind::Unsupported,
|
||||
fingerprint,
|
||||
key,
|
||||
"非 UnityFS 候选资源",
|
||||
)]
|
||||
}
|
||||
|
||||
fn process_zip_entry(
|
||||
config: &OfficialParseConfig,
|
||||
manifest_entry: &OfficialDownloadManifestEntry,
|
||||
previous_cache: Option<&OfficialParseCache>,
|
||||
fingerprint: OfficialParseSourceFingerprint,
|
||||
) -> Vec<OfficialParseCacheEntry> {
|
||||
let cached_entries = reusable_archive_entries(previous_cache, manifest_entry, &fingerprint);
|
||||
if !cached_entries.is_empty() {
|
||||
return cached_entries
|
||||
.into_iter()
|
||||
.map(|mut entry| {
|
||||
entry.reused_from_previous_cache = true;
|
||||
entry
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let archive_path = match resource_path_for(&config.resource_root, manifest_entry) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
return vec![failed_entry(
|
||||
manifest_entry,
|
||||
None,
|
||||
OfficialParseSourceKind::ZipEntry,
|
||||
fingerprint,
|
||||
zip_list_key(&manifest_entry.url),
|
||||
error,
|
||||
)]
|
||||
}
|
||||
};
|
||||
let archive_entries = match list_zip_entries(&config.unzip_command, &archive_path) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
return vec![failed_entry(
|
||||
manifest_entry,
|
||||
None,
|
||||
OfficialParseSourceKind::ZipEntry,
|
||||
fingerprint,
|
||||
zip_list_key(&manifest_entry.url),
|
||||
error,
|
||||
)]
|
||||
}
|
||||
};
|
||||
|
||||
let mut produced = Vec::new();
|
||||
for archive_entry in archive_entries {
|
||||
let key = zip_key(&manifest_entry.url, &archive_entry);
|
||||
let bytes = match read_zip_entry_bytes(&config.unzip_command, &archive_path, &archive_entry)
|
||||
{
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
produced.push(failed_entry(
|
||||
manifest_entry,
|
||||
Some(archive_entry),
|
||||
OfficialParseSourceKind::ZipEntry,
|
||||
fingerprint.clone(),
|
||||
key,
|
||||
error,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
produced.push(parse_zip_inner_file(
|
||||
manifest_entry,
|
||||
archive_entry,
|
||||
fingerprint.clone(),
|
||||
key,
|
||||
&bytes,
|
||||
));
|
||||
}
|
||||
|
||||
if produced.is_empty() {
|
||||
produced.push(unsupported_entry(
|
||||
manifest_entry,
|
||||
None,
|
||||
OfficialParseSourceKind::Unsupported,
|
||||
fingerprint,
|
||||
zip_list_key(&manifest_entry.url),
|
||||
"ZIP 内没有可检查文件条目",
|
||||
));
|
||||
}
|
||||
produced
|
||||
}
|
||||
|
||||
fn parse_direct_bundle(
|
||||
config: &OfficialParseConfig,
|
||||
manifest_entry: &OfficialDownloadManifestEntry,
|
||||
key: String,
|
||||
fingerprint: OfficialParseSourceFingerprint,
|
||||
) -> OfficialParseCacheEntry {
|
||||
let path = match resource_path_for(&config.resource_root, manifest_entry) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
return failed_entry(
|
||||
manifest_entry,
|
||||
None,
|
||||
OfficialParseSourceKind::DirectBundle,
|
||||
fingerprint,
|
||||
key,
|
||||
error,
|
||||
)
|
||||
}
|
||||
};
|
||||
let bytes = match read_resource_file(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
return failed_entry(
|
||||
manifest_entry,
|
||||
None,
|
||||
OfficialParseSourceKind::DirectBundle,
|
||||
fingerprint,
|
||||
key,
|
||||
error,
|
||||
)
|
||||
}
|
||||
};
|
||||
if !UnityFsParser::has_unityfs_signature(&bytes) {
|
||||
return unsupported_entry(
|
||||
manifest_entry,
|
||||
None,
|
||||
OfficialParseSourceKind::DirectBundle,
|
||||
fingerprint,
|
||||
key,
|
||||
"文件不是 UnityFS bundle",
|
||||
);
|
||||
}
|
||||
parsed_bundle_entry(
|
||||
manifest_entry,
|
||||
None,
|
||||
OfficialParseSourceKind::DirectBundle,
|
||||
fingerprint,
|
||||
key,
|
||||
&bytes,
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_zip_inner_file(
|
||||
manifest_entry: &OfficialDownloadManifestEntry,
|
||||
archive_entry: String,
|
||||
fingerprint: OfficialParseSourceFingerprint,
|
||||
key: String,
|
||||
bytes: &[u8],
|
||||
) -> OfficialParseCacheEntry {
|
||||
if !UnityFsParser::has_unityfs_signature(bytes) {
|
||||
return unsupported_entry(
|
||||
manifest_entry,
|
||||
Some(archive_entry),
|
||||
OfficialParseSourceKind::ZipEntry,
|
||||
fingerprint,
|
||||
key,
|
||||
"ZIP 条目不是 UnityFS bundle",
|
||||
);
|
||||
}
|
||||
parsed_bundle_entry(
|
||||
manifest_entry,
|
||||
Some(archive_entry),
|
||||
OfficialParseSourceKind::ZipEntry,
|
||||
fingerprint,
|
||||
key,
|
||||
bytes,
|
||||
)
|
||||
}
|
||||
|
||||
fn parsed_bundle_entry(
|
||||
manifest_entry: &OfficialDownloadManifestEntry,
|
||||
archive_entry: Option<String>,
|
||||
source_kind: OfficialParseSourceKind,
|
||||
fingerprint: OfficialParseSourceFingerprint,
|
||||
key: String,
|
||||
bytes: &[u8],
|
||||
) -> OfficialParseCacheEntry {
|
||||
let parser = UnityFsParser::new();
|
||||
match parser.parse(bytes) {
|
||||
Ok(parsed) => OfficialParseCacheEntry {
|
||||
key,
|
||||
source_url: manifest_entry.url.clone(),
|
||||
destination: manifest_entry.destination.clone(),
|
||||
archive_entry,
|
||||
source_kind,
|
||||
fingerprint,
|
||||
status: OfficialParseStatus::Parsed,
|
||||
reused_from_previous_cache: false,
|
||||
unity_version: Some(parsed.unity_version),
|
||||
file_count: parsed.files.len(),
|
||||
serialized_file_count: parsed.serialized_files.len(),
|
||||
text_asset_count: parsed.text_assets.len(),
|
||||
text_assets: parsed
|
||||
.text_assets
|
||||
.iter()
|
||||
.map(|asset| asset.name.clone())
|
||||
.collect(),
|
||||
serialized_parse_error_count: parsed.serialized_parse_errors.len(),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => failed_entry(
|
||||
manifest_entry,
|
||||
archive_entry,
|
||||
source_kind,
|
||||
fingerprint,
|
||||
key,
|
||||
error.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_entry(
|
||||
manifest_entry: &OfficialDownloadManifestEntry,
|
||||
archive_entry: Option<String>,
|
||||
source_kind: OfficialParseSourceKind,
|
||||
fingerprint: OfficialParseSourceFingerprint,
|
||||
key: String,
|
||||
error: impl Into<String>,
|
||||
) -> OfficialParseCacheEntry {
|
||||
status_entry(
|
||||
manifest_entry,
|
||||
archive_entry,
|
||||
source_kind,
|
||||
fingerprint,
|
||||
key,
|
||||
OfficialParseStatus::Failed,
|
||||
error.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn unsupported_entry(
|
||||
manifest_entry: &OfficialDownloadManifestEntry,
|
||||
archive_entry: Option<String>,
|
||||
source_kind: OfficialParseSourceKind,
|
||||
fingerprint: OfficialParseSourceFingerprint,
|
||||
key: String,
|
||||
reason: impl Into<String>,
|
||||
) -> OfficialParseCacheEntry {
|
||||
status_entry(
|
||||
manifest_entry,
|
||||
archive_entry,
|
||||
source_kind,
|
||||
fingerprint,
|
||||
key,
|
||||
OfficialParseStatus::SkippedUnsupported,
|
||||
reason.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn status_entry(
|
||||
manifest_entry: &OfficialDownloadManifestEntry,
|
||||
archive_entry: Option<String>,
|
||||
source_kind: OfficialParseSourceKind,
|
||||
fingerprint: OfficialParseSourceFingerprint,
|
||||
key: String,
|
||||
status: OfficialParseStatus,
|
||||
reason: String,
|
||||
) -> OfficialParseCacheEntry {
|
||||
OfficialParseCacheEntry {
|
||||
key,
|
||||
source_url: manifest_entry.url.clone(),
|
||||
destination: manifest_entry.destination.clone(),
|
||||
archive_entry,
|
||||
source_kind,
|
||||
fingerprint,
|
||||
status,
|
||||
reused_from_previous_cache: false,
|
||||
unity_version: None,
|
||||
file_count: 0,
|
||||
serialized_file_count: 0,
|
||||
text_asset_count: 0,
|
||||
text_assets: Vec::new(),
|
||||
serialized_parse_error_count: 0,
|
||||
error: Some(reason),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_resource_file(path: &Path) -> Result<Vec<u8>, String> {
|
||||
read_file_no_symlink(path, "官方解析输入")?
|
||||
.ok_or_else(|| format!("官方解析输入不存在:{}", path.display()))
|
||||
}
|
||||
|
||||
fn resource_path_for(
|
||||
resource_root: &Path,
|
||||
manifest_entry: &OfficialDownloadManifestEntry,
|
||||
) -> Result<PathBuf, String> {
|
||||
let path = resource_root.join(Path::new(&manifest_entry.destination));
|
||||
ensure_path_within_root(resource_root, &path)?;
|
||||
ensure_safe_file_target(resource_root, &path, "官方解析输入")?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn list_zip_entries(unzip_command: &Path, archive_path: &Path) -> Result<Vec<String>, String> {
|
||||
let output = Command::new(unzip_command)
|
||||
.arg("-Z1")
|
||||
.arg(archive_path)
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"启动 unzip 列出 ZIP 条目失败 {}:{error}",
|
||||
archive_path.display()
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"列出 ZIP 条目失败 {}:{}",
|
||||
archive_path.display(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(|line| line.trim_end_matches('\r').trim().to_string())
|
||||
.filter(|line| !line.is_empty() && !line.ends_with('/'))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn read_zip_entry_bytes(
|
||||
unzip_command: &Path,
|
||||
archive_path: &Path,
|
||||
archive_entry: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let output = Command::new(unzip_command)
|
||||
.arg("-p")
|
||||
.arg(archive_path)
|
||||
.arg(archive_entry)
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"启动 unzip 读取 ZIP 条目失败 {}!{}:{error}",
|
||||
archive_path.display(),
|
||||
archive_entry
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"读取 ZIP 条目失败 {}!{}:{}",
|
||||
archive_path.display(),
|
||||
archive_entry,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(output.stdout)
|
||||
}
|
||||
|
||||
fn reusable_entry(
|
||||
previous_cache: Option<&OfficialParseCache>,
|
||||
key: &str,
|
||||
fingerprint: &OfficialParseSourceFingerprint,
|
||||
) -> Option<OfficialParseCacheEntry> {
|
||||
previous_cache
|
||||
.and_then(|cache| cache.entries.get(key))
|
||||
.filter(|entry| &entry.fingerprint == fingerprint)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn reusable_archive_entries(
|
||||
previous_cache: Option<&OfficialParseCache>,
|
||||
manifest_entry: &OfficialDownloadManifestEntry,
|
||||
fingerprint: &OfficialParseSourceFingerprint,
|
||||
) -> Vec<OfficialParseCacheEntry> {
|
||||
previous_cache
|
||||
.into_iter()
|
||||
.flat_map(|cache| cache.entries.values())
|
||||
.filter(|entry| {
|
||||
entry.source_url == manifest_entry.url
|
||||
&& entry.destination == manifest_entry.destination
|
||||
&& &entry.fingerprint == fingerprint
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fingerprint_for(
|
||||
manifest_entry: &OfficialDownloadManifestEntry,
|
||||
) -> OfficialParseSourceFingerprint {
|
||||
OfficialParseSourceFingerprint {
|
||||
source_url: manifest_entry.url.clone(),
|
||||
destination: manifest_entry.destination.clone(),
|
||||
bytes: manifest_entry.bytes,
|
||||
blake3: manifest_entry.blake3.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_zip_source(manifest_entry: &OfficialDownloadManifestEntry) -> bool {
|
||||
has_case_insensitive_suffix(&manifest_entry.url, ".zip")
|
||||
|| has_case_insensitive_suffix(&manifest_entry.destination, ".zip")
|
||||
}
|
||||
|
||||
fn looks_like_direct_bundle_source(manifest_entry: &OfficialDownloadManifestEntry) -> bool {
|
||||
[".bundle", ".unity3d"].iter().any(|suffix| {
|
||||
has_case_insensitive_suffix(&manifest_entry.url, suffix)
|
||||
|| has_case_insensitive_suffix(&manifest_entry.destination, suffix)
|
||||
})
|
||||
}
|
||||
|
||||
fn has_case_insensitive_suffix(value: &str, suffix: &str) -> bool {
|
||||
value
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.is_some_and(|name| name.to_ascii_lowercase().ends_with(suffix))
|
||||
}
|
||||
|
||||
fn direct_key(url: &str) -> String {
|
||||
format!("direct:{url}")
|
||||
}
|
||||
|
||||
fn zip_key(url: &str, archive_entry: &str) -> String {
|
||||
format!("zip:{url}!{archive_entry}")
|
||||
}
|
||||
|
||||
fn zip_list_key(url: &str) -> String {
|
||||
format!("zip-list:{url}")
|
||||
}
|
||||
|
||||
fn unsupported_key(url: &str) -> String {
|
||||
format!("unsupported:{url}")
|
||||
}
|
||||
|
||||
fn default_parse_cache_version() -> u32 {
|
||||
OFFICIAL_PARSE_CACHE_VERSION
|
||||
}
|
||||
|
||||
fn unix_seconds_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn push_c_string(data: &mut Vec<u8>, value: &str) {
|
||||
data.extend_from_slice(value.as_bytes());
|
||||
data.push(0);
|
||||
}
|
||||
|
||||
fn push_u16(data: &mut Vec<u8>, value: u16) {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_u32(data: &mut Vec<u8>, value: u32) {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_i32(data: &mut Vec<u8>, value: i32) {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_u64(data: &mut Vec<u8>, value: u64) {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn align(data: &mut Vec<u8>, alignment: usize) {
|
||||
let remainder = data.len() % alignment;
|
||||
if remainder != 0 {
|
||||
data.resize(data.len() + alignment - remainder, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_unityfs_bundle(directory_path: &str, payload: &[u8]) -> Vec<u8> {
|
||||
let mut blocks_info = Vec::new();
|
||||
blocks_info.extend_from_slice(&[0xAB; 16]);
|
||||
push_i32(&mut blocks_info, 1);
|
||||
push_u32(&mut blocks_info, payload.len() as u32);
|
||||
push_u32(&mut blocks_info, payload.len() as u32);
|
||||
push_u16(&mut blocks_info, 0);
|
||||
push_i32(&mut blocks_info, 1);
|
||||
push_u64(&mut blocks_info, 0);
|
||||
push_u64(&mut blocks_info, payload.len() as u64);
|
||||
push_u32(&mut blocks_info, 0);
|
||||
push_c_string(&mut blocks_info, directory_path);
|
||||
|
||||
let mut data = Vec::new();
|
||||
push_c_string(&mut data, "UnityFS");
|
||||
push_u32(&mut data, 8);
|
||||
push_c_string(&mut data, "5.x.x");
|
||||
push_c_string(&mut data, "2021.3.56f2");
|
||||
push_u64(&mut data, 0);
|
||||
push_u32(&mut data, blocks_info.len() as u32);
|
||||
push_u32(&mut data, blocks_info.len() as u32);
|
||||
push_u32(&mut data, 0);
|
||||
align(&mut data, 16);
|
||||
data.extend_from_slice(&blocks_info);
|
||||
data.extend_from_slice(payload);
|
||||
|
||||
let total_size = data.len() as u64;
|
||||
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
|
||||
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
fn write_download_manifest(
|
||||
root: &Path,
|
||||
entries: &[(&str, &str, &[u8])],
|
||||
) -> Vec<OfficialDownloadManifestEntry> {
|
||||
let mut manifest_entries = BTreeMap::new();
|
||||
let mut written = Vec::new();
|
||||
for (url, destination, bytes) in entries {
|
||||
let path = root.join(destination);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, bytes).unwrap();
|
||||
let entry = OfficialDownloadManifestEntry {
|
||||
url: (*url).to_string(),
|
||||
destination: (*destination).to_string(),
|
||||
bytes: bytes.len() as u64,
|
||||
blake3: blake3::hash(bytes).to_hex().to_string(),
|
||||
};
|
||||
manifest_entries.insert((*url).to_string(), entry.clone());
|
||||
written.push(entry);
|
||||
}
|
||||
let manifest = serde_json::json!({
|
||||
"version": 1,
|
||||
"entries": manifest_entries,
|
||||
});
|
||||
fs::write(
|
||||
root.join("official-download-manifest.json"),
|
||||
serde_json::to_vec_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
written
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_direct_bundle_and_reuses_unchanged_cache() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let root = temp.path();
|
||||
let bundle = synthetic_unityfs_bundle("CAB-fixture", b"data");
|
||||
write_download_manifest(
|
||||
root,
|
||||
&[(
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/r93/Bundle/test.bundle",
|
||||
"Bundle/test.bundle",
|
||||
&bundle,
|
||||
)],
|
||||
);
|
||||
let config = OfficialParseConfig::new(root, "unzip");
|
||||
|
||||
let first = OfficialParseCacheService::new().run(&config).unwrap();
|
||||
assert_eq!(first.summary.manifest_entry_count, 1);
|
||||
assert_eq!(first.summary.parsed_bundle_count, 1);
|
||||
assert_eq!(first.summary.skipped_unchanged_count, 0);
|
||||
assert!(first.cache_path.exists());
|
||||
|
||||
let second = OfficialParseCacheService::new().run(&config).unwrap();
|
||||
assert_eq!(second.summary.parsed_bundle_count, 1);
|
||||
assert_eq!(second.summary.skipped_unchanged_count, 1);
|
||||
let cache = read_parse_cache_at(root).unwrap().unwrap();
|
||||
let entry = cache.entries.values().next().unwrap();
|
||||
assert_eq!(entry.status, OfficialParseStatus::Parsed);
|
||||
assert_eq!(entry.file_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_non_candidate_resources_as_unsupported() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let root = temp.path();
|
||||
write_download_manifest(
|
||||
root,
|
||||
&[(
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/TableCatalog.bytes",
|
||||
"TableBundles/TableCatalog.bytes",
|
||||
b"catalog",
|
||||
)],
|
||||
);
|
||||
let config = OfficialParseConfig::new(root, "unzip");
|
||||
|
||||
let report = OfficialParseCacheService::new().run(&config).unwrap();
|
||||
|
||||
assert_eq!(report.summary.parsed_bundle_count, 0);
|
||||
assert_eq!(report.summary.unsupported_count, 1);
|
||||
assert_eq!(report.summary.failed_count, 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn parses_zip_entries_without_extracting_archive_tree() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let root = temp.path();
|
||||
let bundle = synthetic_unityfs_bundle("CAB-zipped", b"zipdata");
|
||||
let bundle_path = temp.path().join("inner.bundle");
|
||||
fs::write(&bundle_path, &bundle).unwrap();
|
||||
let archive_bytes = b"zip-placeholder";
|
||||
write_download_manifest(
|
||||
root,
|
||||
&[(
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/r93/Windows_PatchPack/FullPatch_000.zip",
|
||||
"Windows_PatchPack/FullPatch_000.zip",
|
||||
archive_bytes,
|
||||
)],
|
||||
);
|
||||
let unzip_script = temp.path().join("fake-unzip");
|
||||
fs::write(
|
||||
&unzip_script,
|
||||
format!(
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "${{1:-}}" == "-Z1" ]]; then
|
||||
printf '%s\n' 'assets/scenario.bundle' 'assets/readme.txt'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${{1:-}}" == "-p" && "${{3:-}}" == "assets/scenario.bundle" ]]; then
|
||||
cat '{}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${{1:-}}" == "-p" ]]; then
|
||||
printf 'plain text'
|
||||
exit 0
|
||||
fi
|
||||
exit 2
|
||||
"#,
|
||||
bundle_path.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut permissions = fs::metadata(&unzip_script).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&unzip_script, permissions).unwrap();
|
||||
let config = OfficialParseConfig::new(root, unzip_script);
|
||||
|
||||
let report = OfficialParseCacheService::new().run(&config).unwrap();
|
||||
|
||||
assert_eq!(report.summary.manifest_entry_count, 1);
|
||||
assert_eq!(report.summary.zip_entry_count, 2);
|
||||
assert_eq!(report.summary.parsed_bundle_count, 1);
|
||||
assert_eq!(report.summary.unsupported_count, 1);
|
||||
assert_eq!(report.summary.failed_count, 0);
|
||||
assert!(!root.join("assets").exists());
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use crate::curl_transfer::{resolve_curl_proxy, CurlProxyConfig};
|
||||
use crate::path_security::{
|
||||
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target,
|
||||
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
|
||||
read_file_no_symlink, validate_output_root, write_file_atomic, STATE_FILE_MODE,
|
||||
};
|
||||
use crate::{
|
||||
@@ -18,6 +18,7 @@ use crate::{
|
||||
OfficialResourcePullService, YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl,
|
||||
YostarJpLauncherRemoteManifest,
|
||||
};
|
||||
use crate::{OfficialParseCacheService, OfficialParseConfig, OfficialParseSummary};
|
||||
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
|
||||
use bat_adapters::official::inventory::{
|
||||
YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
|
||||
@@ -78,6 +79,8 @@ pub struct OfficialUpdateConfig {
|
||||
pub platforms: Option<Vec<PatchPlatform>>,
|
||||
/// Output root for resources and state files.
|
||||
pub output_root: PathBuf,
|
||||
/// Output root reserved for localized resources generated from official data.
|
||||
pub localized_output_root: PathBuf,
|
||||
/// Optional explicit sync snapshot path.
|
||||
pub snapshot_path: Option<PathBuf>,
|
||||
/// Curl command used by the current infrastructure downloader.
|
||||
@@ -108,6 +111,7 @@ impl Default for OfficialUpdateConfig {
|
||||
auto_discover: false,
|
||||
platforms: None,
|
||||
output_root: PathBuf::from("./bat-resources"),
|
||||
localized_output_root: PathBuf::from("./bat-localized"),
|
||||
snapshot_path: None,
|
||||
curl_command: PathBuf::from("curl"),
|
||||
curl_proxy: CurlProxyConfig::default(),
|
||||
@@ -168,6 +172,26 @@ impl OfficialUpdateStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publication state for localized resources associated with an official release.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LocalizedReleaseStatus {
|
||||
/// Official resources are published, but localized resources are not.
|
||||
NotLocalized,
|
||||
/// Official resources and localized resources are both published.
|
||||
Localized,
|
||||
}
|
||||
|
||||
impl LocalizedReleaseStatus {
|
||||
/// Returns a stable string label for CLI and JSON callers.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotLocalized => "not_localized",
|
||||
Self::Localized => "localized",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of the official JP update state observed at a point in time.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OfficialUpdateSnapshot {
|
||||
@@ -477,6 +501,14 @@ pub struct OfficialUpdateReport {
|
||||
pub platforms: Vec<PatchPlatform>,
|
||||
/// Managed publish root containing `current`, `versions`, and staging.
|
||||
pub output_root: PathBuf,
|
||||
/// Separate root reserved for localized resources.
|
||||
pub localized_output_root: PathBuf,
|
||||
/// Localized publication state for this official resource version.
|
||||
pub localized_release_status: LocalizedReleaseStatus,
|
||||
/// Future localized `current` pointer path under `localized_output_root`.
|
||||
pub localized_current_path: PathBuf,
|
||||
/// Versioned localized release directory when localized resources are published.
|
||||
pub localized_published_version_path: Option<PathBuf>,
|
||||
/// Active resource root used for local audit before this run.
|
||||
pub active_resource_root: PathBuf,
|
||||
/// Atomic `current` pointer path.
|
||||
@@ -543,6 +575,10 @@ pub struct OfficialUpdateReport {
|
||||
pub verification_summary: OfficialVerificationSummary,
|
||||
/// Download manifest path.
|
||||
pub download_manifest: PathBuf,
|
||||
/// Parse-cache path written or refreshed after successful verification.
|
||||
pub parse_cache_path: Option<PathBuf>,
|
||||
/// Post-sync parse-cache summary.
|
||||
pub parse_summary: Option<OfficialParseSummary>,
|
||||
/// Snapshot path written after success.
|
||||
pub snapshot_written: Option<PathBuf>,
|
||||
}
|
||||
@@ -1175,6 +1211,10 @@ impl OfficialUpdateService {
|
||||
addressables_root: current_snapshot.addressables_root.clone(),
|
||||
platforms: platforms.to_vec(),
|
||||
output_root: config.output_root.clone(),
|
||||
localized_output_root: config.localized_output_root.clone(),
|
||||
localized_release_status: LocalizedReleaseStatus::NotLocalized,
|
||||
localized_current_path: config.localized_output_root.join(OFFICIAL_CURRENT_LINK),
|
||||
localized_published_version_path: None,
|
||||
active_resource_root: active_resource_root.clone(),
|
||||
current_path: publish_layout.current_path.clone(),
|
||||
version_state_path: version_state_path.clone(),
|
||||
@@ -1214,6 +1254,8 @@ impl OfficialUpdateService {
|
||||
local_zip_structure_verified_count,
|
||||
),
|
||||
download_manifest: fetcher.download_manifest_path(),
|
||||
parse_cache_path: None,
|
||||
parse_summary: None,
|
||||
snapshot_written: None,
|
||||
};
|
||||
|
||||
@@ -1242,6 +1284,13 @@ impl OfficialUpdateService {
|
||||
&active_resource_root,
|
||||
&snapshot_path,
|
||||
)?;
|
||||
run_post_sync_parse_cache(
|
||||
config,
|
||||
&active_resource_root,
|
||||
&mut report,
|
||||
&mut progress,
|
||||
&mut should_cancel,
|
||||
)?;
|
||||
}
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"finish",
|
||||
@@ -1454,6 +1503,13 @@ impl OfficialUpdateService {
|
||||
format!("资源已发布,但写入版本状态失败(下轮可重试):{error}"),
|
||||
));
|
||||
}
|
||||
run_post_sync_parse_cache(
|
||||
config,
|
||||
&published_version_path,
|
||||
&mut report,
|
||||
&mut progress,
|
||||
&mut should_cancel,
|
||||
)?;
|
||||
|
||||
// 清理未被最新版本状态引用的孤儿 staging 目录(GC 失败仅告警,不影响发布结果)。
|
||||
match read_version_state(&version_state_path) {
|
||||
@@ -1487,6 +1543,46 @@ impl OfficialUpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_post_sync_parse_cache(
|
||||
config: &OfficialUpdateConfig,
|
||||
resource_root: &Path,
|
||||
report: &mut OfficialUpdateReport,
|
||||
progress: &mut dyn FnMut(OfficialUpdateProgress),
|
||||
should_cancel: &mut dyn FnMut() -> bool,
|
||||
) -> anyhow::Result<()> {
|
||||
check_shutdown_requested(should_cancel)?;
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"parse",
|
||||
format!("更新官方资源解析缓存 {}", resource_root.display()),
|
||||
));
|
||||
let parse_config = OfficialParseConfig::new(resource_root, &config.unzip_command);
|
||||
match OfficialParseCacheService::new().run(&parse_config) {
|
||||
Ok(parse_report) => {
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"parse",
|
||||
format!(
|
||||
"解析缓存完成:条目={} 已解析={} 复用={} 不支持={} 失败={} TextAsset={}",
|
||||
parse_report.summary.cache_entry_count,
|
||||
parse_report.summary.parsed_bundle_count,
|
||||
parse_report.summary.skipped_unchanged_count,
|
||||
parse_report.summary.unsupported_count,
|
||||
parse_report.summary.failed_count,
|
||||
parse_report.summary.text_asset_count
|
||||
),
|
||||
));
|
||||
report.parse_cache_path = Some(parse_report.cache_path);
|
||||
report.parse_summary = Some(parse_report.summary);
|
||||
}
|
||||
Err(error) => {
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"parse",
|
||||
format!("解析缓存更新失败(不影响已校验官方资源):{error}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_pull_plan(
|
||||
server_info: &YostarJpServerInfo,
|
||||
connection_group: &str,
|
||||
@@ -1751,7 +1847,10 @@ pub fn diff_extended_snapshot(
|
||||
|
||||
fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
|
||||
validate_output_root(&config.output_root)?;
|
||||
validate_output_root(&config.localized_output_root)?;
|
||||
validate_separate_output_roots(&config.output_root, &config.localized_output_root)?;
|
||||
ensure_safe_directory_path(&config.output_root, "资源输出目录")?;
|
||||
ensure_safe_directory_path(&config.localized_output_root, "汉化输出目录")?;
|
||||
if let Some(snapshot_path) = config.snapshot_path.as_ref() {
|
||||
ensure_path_within_root(&config.output_root, snapshot_path)?;
|
||||
ensure_safe_file_target(&config.output_root, snapshot_path, "官方更新快照")?;
|
||||
@@ -1770,6 +1869,28 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_separate_output_roots(
|
||||
output_root: &Path,
|
||||
localized_output_root: &Path,
|
||||
) -> Result<(), String> {
|
||||
let official = lexical_absolute(output_root)?;
|
||||
let localized = lexical_absolute(localized_output_root)?;
|
||||
if official == localized {
|
||||
return Err(format!(
|
||||
"官方资源目录和汉化输出目录不能相同:{}",
|
||||
official.display()
|
||||
));
|
||||
}
|
||||
if localized.starts_with(&official) || official.starts_with(&localized) {
|
||||
return Err(format!(
|
||||
"官方资源目录和汉化输出目录不能互相嵌套:官方={} 汉化={}",
|
||||
official.display(),
|
||||
localized.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_path_for(config: &OfficialUpdateConfig, resource_root: &Path) -> PathBuf {
|
||||
config
|
||||
.snapshot_path
|
||||
@@ -3412,6 +3533,35 @@ mod tests {
|
||||
assert!(error.to_string().contains("危险路径"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rejects_same_official_and_localized_output_root() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let root = temp.path().join("resources");
|
||||
let config = OfficialUpdateConfig {
|
||||
output_root: root.clone(),
|
||||
localized_output_root: root,
|
||||
dry_run: true,
|
||||
..OfficialUpdateConfig::default()
|
||||
};
|
||||
|
||||
let error = OfficialUpdateService::new().run(&config).unwrap_err();
|
||||
assert!(error.to_string().contains("不能相同"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rejects_nested_official_and_localized_output_roots() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let config = OfficialUpdateConfig {
|
||||
output_root: temp.path().join("resources"),
|
||||
localized_output_root: temp.path().join("resources/localized"),
|
||||
dry_run: true,
|
||||
..OfficialUpdateConfig::default()
|
||||
};
|
||||
|
||||
let error = OfficialUpdateService::new().run(&config).unwrap_err();
|
||||
assert!(error.to_string().contains("不能互相嵌套"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rejects_snapshot_path_escape() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user