Compare commits

...
57 Commits
Author SHA1 Message Date
nyaKazuha 93f4bc69b3 docs: update current document status
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-04 20:36:49 +08:00
nyaKazuha d21c01a697 docs: 校正文档分类与当前边界
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-04 19:58:07 +08:00
nyaKazuha 34e2f0d907 refactor(cli): 模块化 Rust 前台输出
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-02 17:42:16 +08:00
nyaKazuha bd1e1a06f2 feat(resource): 增加历史 release 与 CAS 复用
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
Closes #47
2026-09-02 01:26:53 +08:00
nyaKazuha fdd4075e7e feat(resource): 增加 CAS 诊断与索引过滤优化
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
补充 doctor cas 只读诊断、校验 CAS 对象分片布局,并将常用 ResourceRepository metadata 查询下推到 SQLite。

Refs G-011
2026-09-01 00:41:41 +08:00
nyaKazuha 4ed81f0030 feat(bat-api): 实现内嵌 dashboard
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
Closes #46
2026-08-31 23:17:23 +08:00
nyaKazuha ab21344773 feat(i18n): 完成 localized patch 发布回滚闭环
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
Closes #45
2026-08-31 00:02:55 +08:00
nyaKazuha f441f1810e feat(i18n): 接入翻译 provider worker
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
Closes #44
2026-08-30 21:13:31 +08:00
nyaKazuha 7f465523e1 fix(bat-api): 完成 issue #19 同机 live 联调
bat-rust / Build and test Go API (push) Canceled after 0s
bat-rust / Build and test Rust (push) Canceled after 0s
2026-08-29 22:58:01 +08:00
nyaKazuha 90083302a2 fix(unityfs): 加固基础容器解析校验
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
补充 UnityFS 总大小、block/directory 计数、目录路径、重复项和边界校验,覆盖 LZMA 数据块与损坏输入回归。新增隔离真实 UnityPy bundle 测试入口,并用 /tmp 中的 char_118_yuki.ab 完成实际回归。复杂对象解析与发布级重打包继续保留在 G-005 后续范围。

Fixes #3
2026-08-20 12:13:04 +08:00
nyaKazuha 9d4f8d903c feat(addressables): 补全 catalog 可校验字段
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
补充 JSON/compact catalog 的 provider ID、bundle name、资源类型、hash、size、CRC 和依赖字段,并贯通 ResourceEntry、SQLite 资源索引和回归 golden。对 compact extra data 与 resource type index 的损坏返回明确错误,不再把 bundle name 当作 hash fallback。\n\n验证:cargo fmt --check;cargo test -p bat-core --locked;cargo test -p bat-adapters --locked;cargo test -p bat-infrastructure --locked;git diff --check。\n\nClippy 仍受既有 core/src/domain/game_client.rs:141 的 needless-question-mark 和 items-after-test-module 基线问题影响,未混入本 issue 修复。\n\nFixes #2
2026-08-19 23:10:16 +08:00
nyaKazuha 72c1a879a3 feat(bat): 补全工作流命令与人工校对状态
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
Refs #43
2026-08-19 21:33:33 +08:00
nyaKazuha 550ee7fd9a feat(bat): 补全翻译工作台编辑命令
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
Refs #43
2026-08-12 09:33:14 +08:00
nyaKazuha 974a6e18c3 feat(bat): 暴露翻译任务状态控制
Refs #43
2026-08-12 09:11:09 +08:00
nyaKazuha 1933d6acb0 feat(bat): 补全工作流校验与调度过滤
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
新增 parse clear-cache 和 i18n validate,补齐 schedule 的作用域过滤与单轮执行上限,并将列表过滤参数暴露给 bat-api dashboard。同步 RPC、OpenAPI、用户文档和回归测试。

Refs #43
2026-08-03 23:47:59 +08:00
nyaKazuha 0784d5b532 feat(bat): 完善工作流调度与 dashboard RPC
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
补全资源拉取、解析、翻译、重打包和本地化发布命令,支持单次、限定次数与周期调度。移除 TUI 计划并通过 schedule.* RPC 暴露给 bat-api dashboard。

Closes #43
2026-08-03 22:18:52 +08:00
nyaKazuha 3b103be8a9 docs: 对齐全部文档状态
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-08-03 12:19:06 +08:00
nyaKazuha a2e2ae8ac5 docs: 对齐 RPC 接口文档
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-08-03 11:47:51 +08:00
nyaKazuha 7c863d10d1 fix: 稳定并发 worker 回归测试
bat-rust / Build and test Go API (push) Canceled after 0s
bat-rust / Build and test Rust (push) Canceled after 0s
2026-08-03 00:57:29 +08:00
nyaKazuha 9a5b3ba39b fix: 拆分 bat 控制面 2026-08-03 00:43:11 +08:00
nyaKazuha 2b053e247d fix: 抽象官方库存与 sidecar hash 策略 2026-08-02 23:12:25 +08:00
nyaKazuha ba28e067c9 fix: 明确并发下载报告按计划顺序输出 2026-08-02 22:47:21 +08:00
nyaKazuha 6442c7661d fix: 保持官方资源 adapter URL 错误契约 2026-08-02 22:44:09 +08:00
nyaKazuha 8b64cc94f3 fix: 完成下载并发与翻译交接链路 2026-08-02 22:34:23 +08:00
nyaKazuha d533c88108 chore: 忽略本地 CHECK.md 2026-08-01 19:17:36 +08:00
nyaKazuha 12c5d365ab feat(translation): 增加离线任务状态查询 2026-08-01 19:04:54 +08:00
nyaKazuha a05d3ee6af fix(repository): 扩展资源索引查询面 2026-08-01 12:11:35 +08:00
nyaKazuha 8d930bf4d7 chore(docs): 增加状态一致性门禁 2026-08-01 10:55:43 +08:00
nyaKazuha 80e6718e8a fix(sync): 对齐 localized 状态契约 2026-07-31 18:28:02 +08:00
nyaKazuha df361cff28 test(api): 固化 Rust-Go RPC 契约 2026-07-31 18:20:37 +08:00
nyaKazuha 1e466d3374 fix(sync): 规范 release flow 状态码解析 2026-07-31 17:45:50 +08:00
nyaKazuha 6af7706190 fix(api): 补齐 bat-api 控制与后端 RPC 2026-07-31 17:01:03 +08:00
nyaKazuha 20ddd67947 fix(api): 统一 catalog GameMainConfig 字段 2026-07-31 16:05:17 +08:00
nyaKazuha 4cc143f66d fix(sync): 固化 release flow 状态码 2026-07-31 15:57:07 +08:00
nyaKazuha 99e6b3a23a fix(sync): 立即校验官方 hash sidecar 2026-07-31 13:12:21 +08:00
nyaKazuha 16e73327b4 fix(sync): 补齐发布清理与校验进度 2026-07-31 13:00:53 +08:00
nyaKazuha 8ec0e12795 ci(api): 增加 Go API 门禁 2026-07-31 12:55:43 +08:00
nyaKazuha 03021ad649 docs(project): 同步维护冻结与运行文档
bat-rust / Build and test Rust (push) Canceled after 0s
补齐解析模块维护冻结规则,并同步 CURRENT_STATUS、CURRENT_GAPS、PROJECT_PLAN、RPC 参考、部署指南、用户指南和官方资源运行说明。

文档同时反映 bat-api 资源 bootstrap/分发边界、官方同步解析缓存、TextUnit 队列、双目录发布和 patch 入口的当前状态。

验证:未运行新命令;本轮已按要求停止重复构建/测试。
2026-07-31 00:45:46 +08:00
nyaKazuha 3f78f8f880 feat(api): 补齐资源分发服务入口
新增 bat-api 资源 bootstrap/分发 HTTP 服务、RPC release 发现、CDN path 分发、launcher 资源引导兼容、控制面中间件、OpenAPI 和 systemd 模板。

同步 Go 边界文档,明确 Rust bat 是资源生产者和同步运维入口,Go bat-api 是只读 bootstrap/分发服务,试验 Go CLI 产物为 bin/bat-go。

验证:未运行新命令;本轮已按要求停止重复构建/测试。
2026-07-31 00:41:48 +08:00
nyaKazuha 2079c6a307 feat(sync): 接入解析缓存与汉化发布前置
补齐官方 release 解析缓存、TextUnit 明细索引、资源变更集、Crowdin handoff 预留、ResourceRepository 导入元数据和 localized release patch 前置链路。

同时开放文件级 patch.apply 与 UnityFS TextAsset/string/semantic field patch CLI/RPC 入口,并保留官方原版资源与汉化产物双目录发布状态。

验证:cargo test -p bat-assetbundle --locked;cargo clippy -p bat-assetbundle --all-targets --locked -- -D warnings;cargo test -p bat-infrastructure --locked。
2026-07-31 00:38:45 +08:00
nyaKazuha f4880a71bd feat(patch): 补齐通用补丁引擎基础
实现 Binary、JSON、Text patch 与通用 manifest 校验基础。

验证:cargo test -p bat-patch --locked;cargo clippy -p bat-patch --all-targets --locked -- -D warnings。
2026-07-31 00:21:50 +08:00
nyaKazuha 3e9bb20d79 feat(assetbundle): 完善官方资源解析与双目录发布 2026-07-25 20:51:01 +08:00
nyaKazuha 102b49b666 feat(rpc): 完成 issue #1 Go 调用边界
bat-rust / Build and test Rust (push) Failing after 3m17s
2026-07-24 17:58:34 +08:00
nyaKazuha ecda08ed97 feat(rpc): 补齐 issue #1 资源后端接口 2026-07-24 15:55:37 +08:00
nyaKazuha a729615a48 fix(sync): 移除多线程下载并补齐staging复用回归 2026-07-24 10:34:58 +08:00
nyaKazuha 3f5d2a8da7 fix(sync): 修复官方资源并发下载与媒体路径
bat-rust / Build and test Rust (push) Successful in 3m47s
daemon 子进程现会透传下载并发配置,下载进度按已完成数量单调上报。MediaCatalog 改为使用官方相对路径生成全局媒体 URL,覆盖 GameData、Prologue 等目录并补齐 jpg 资源,避免叶子文件名误拼媒体根目录导致 403。同步更新测试、smoke 断言和运行文档。
2026-07-23 11:56:34 +08:00
nyaKazuha d694100d6c fix(ci)
bat-rust / Build and test Rust (push) Successful in 3m44s
2026-07-21 22:52:39 +08:00
nyaKazuha 3add5f7327 fix(ci): 产物构建与上传 2026-07-21 22:24:17 +08:00
nyaKazuha 40bd82e227 fix(ci):
bat-rust / Build and test Rust (push) Successful in 1m45s
2026-07-21 22:05:25 +08:00
nyaKazuha 84047fbacb fix(ci): 新增同步环境
bat-rust / Build and test Rust (push) Failing after 6s
2026-07-21 21:54:51 +08:00
nyaKazuha 43e1a33b88 fix(ci): 移除 Gitea workflow 外部 action 依赖
bat-rust / Build and test Rust (push) Failing after 1s
改为在 linux runner 中手动 fetch 当前提交,避免自托管 runner 准备阶段通过 gh-proxy 克隆 actions/checkout 和 dtolnay/rust-toolchain 时被 403 拦截,并同步开发文档与缺口说明。
2026-07-21 11:53:39 +08:00
nyaKazuha 924cff5f51 ci(rust): 切换 Gitea runner 标签为 linux
bat-rust / Build and test Rust (push) Canceled after 14m5s
保留 .gitea/workflows/bat.yml 作为实际工作流入口,删除冗余副本,并同步项目计划与当前缺口文档。
2026-07-21 11:35:59 +08:00
nyaKazuha d76f6f1c88 ci(rust): 添加 Gitea host runner 工作流
补充自托管 Gitea host-runner workflow,并将项目状态、缺口、开发指南与路线图同步到当前实际情况。
2026-07-21 10:39:11 +08:00
nyaKazuhaandClaude Fable 5 0ab3f3b953 feat(download): 多线程下载 + 重试指数退避(issue #17)
下载主循环由串行改为「并发下载 + 串行簿记」三段式:
- Phase A:无网络前置校验(官方性/目标路径/建目录)+ 判定跳过/需下载,
  非官方 URL 在任何下载前 fail-fast
- Phase B:need-download 项经 scoped 线程池并发下载(默认并发 4,
  可配 1..=256)。worker 只做只读 &self 的 pull_one(各 URL 独立
  .part/目标文件),经 mpsc 把结果送回主线程;manifest/quarantine
  簿记与进度回调全在主线程串行执行,无需加锁。首个失败或 should_cancel
  置 cancel 标志,其余 worker 在任务边界停止
- Phase C:按 plan 顺序串行收尾——seed .hash 校验(顺序相关、可
  fail-fast)+ 构建有序结果

fail-fast 与「不发布不完整资源」不变量保留;进度事件按 URL 配对但
顺序不再单调(并发下天然如此)。

curl 重试加指数退避(网络类 200ms→400ms→800ms…上限 5s;ETXTBSY 仍走
极短退避),并发下对官方 CDN 更礼貌;退避基值 cfg(test) 下为 0 不拖慢
单测。

并发度经 OfficialUpdateConfig.download_concurrency 贯通,CLI
--download-concurrency 与 BAT_DOWNLOAD_CONCURRENCY 可配,.env 模板
与 USERGUIDE/CURRENT_STATUS/CHANGELOG 同步。

验证:新增并发正确性测试(并发 8:每 URL 恰一次 started+finished、
全部落盘)、并发度钳制、退避时长计算、CLI/env 解析单测;progress
排序测试改为顺序无关不变量;workspace 全测试(20 套件) + fmt +
clippy --all-targets -D warnings 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 03:25:04 -07:00
nyaKazuhaandClaude Fable 5 8efd8f36b4 feat(addressables): 提取 m_Crc 并提供 size/CRC 校验 API(issue #2)
Addressables catalog 里的 m_Crc(bundle IEEE CRC-32)此前从未解析;
声明的 size 也只作元数据、无校验能力。本次:

- ResourceEntry 新增 crc: Option<u32>(serde default 向后兼容),
  compact 与 expanded 两种 catalog 形态均解析 m_Crc/m_Crc→crc
- core 新增 crc32_ieee(IEEE CRC-32,等价 zlib/Unity m_Crc)与
  ResourceEntry::{declared_crc, verify_downloaded_bytes}:按声明的
  size/crc 校验字节,0 视为「无 CRC」跳过
- SqliteResourceRepository 持久化 crc 列,旧库经幂等 ensure_column
  迁移补列(pragma_table_info 判断后 ALTER)
- golden 投影与 fixture 补 crc 字段,验证真实形态 catalog 提取贯通

校验 API 暂不接入 import 覆盖路径(该路径按 CAS id 重写 hash/size 是
既定语义,且合成测试的声明值不匹配实际字节);接入下载/导入校验留
待 G-011。

验证:core crc32 标准向量 + verify 分支单测、expanded 形态非零 crc
提取单测、golden 端到端;core/adapters/infrastructure 全测试 + fmt +
clippy --all-targets -D warnings 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 02:41:41 -07:00
nyaKazuhaandClaude Fable 5 a150407a14 feat(unity): UnityFS 目录条目越界校验(issue #3)
解析阶段已读取 directory 的 offset/size,但从不校验其是否落在解压
数据区(各 block uncompressed_size 之和)内;截断或损坏的 bundle
会给出越界目录条目并被静默接受。新增 validate_directory_bounds:
累加数据区大小、逐条校验 offset+size 不越界且不溢出 u64,越界即
报错并带上下文,接在 parse_blocks_info 之后。

验证:新增 4 个测试(合法覆盖尾部/越界拒绝/offset+size 溢出/
端到端越界 bundle 被拒);bat-adapters 73 测试全绿,fmt + clippy
-p bat-adapters --all-targets -D warnings 通过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:07:40 -07:00
nyaKazuhaandClaude Fable 5 d9332299ef docs: G-008 并入 G-009(bat-api 仿官方 API),调整关闭顺序
- G-008(Go CLI)关闭:用户命令入口由功能完整的 Rust bat 承担,
  Go 侧转向仿官方 API 的 bat-api(issue #19)
- G-009 重定义为 bat-api:仿 BlueArchive 官方 API 的 Go HTTP 服务,
  含鉴权/签名验签,经 daemon RPC + current/ 发布布局对接
- 关闭顺序改为先完善 Rust bat 后端(issue #2/#3/#17)再做 bat-api
- PROJECT_PLAN 近期任务同步为 Rust 后端优先

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:55:03 -07:00
172 changed files with 61682 additions and 10177 deletions
+247
View File
@@ -0,0 +1,247 @@
# Gitea Actions workflow for the Rust workspace.
# Self-hosted runner friendly.
# Does not use external GitHub Actions.
name: bat-rust
on:
push:
branches:
- "**"
pull_request:
workflow_dispatch:
jobs:
rust:
name: Build and test Rust
runs-on: linux
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
BAT_SKIP_ENV_FILE: "1"
steps:
- name: Checkout
shell: bash
run: |
set -euo pipefail
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
: "${GITHUB_SHA:?GITHUB_SHA is required}"
repo_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
if [ -d .git ]; then
git remote set-url origin "${repo_url}"
else
git init .
git remote add origin "${repo_url}"
fi
ref="${GITHUB_REF:-${GITHUB_SHA}}"
git fetch \
--no-tags \
--depth=1 \
origin \
"${ref}" \
|| git fetch \
--no-tags \
--depth=1 \
origin \
"${GITHUB_SHA}"
git checkout --force --detach FETCH_HEAD
git submodule update --init --recursive
- name: Show tool versions
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
command -v git
command -v rustc
command -v cargo
rustc --version
cargo --version
rustfmt --version
cargo clippy --version
- name: Check formatting
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo fmt --all -- --check
- name: Check workspace
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo check --workspace --locked
- name: Build workspace
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo build \
--workspace \
--release \
--locked
- name: Run clippy
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo clippy \
--workspace \
--all-targets \
--locked \
-- -D warnings
- name: Run tests
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo test \
--workspace \
--locked
- name: Package binary
shell: bash
run: |
set -euo pipefail
mkdir -p artifact
cp target/release/bat artifact/
tar \
-czf \
bat-linux-x86_64.tar.gz \
-C artifact \
bat
sha256sum \
bat-linux-x86_64.tar.gz \
> bat-linux-x86_64.sha256
- name: Prepare artifact
shell: bash
run: |
set -euo pipefail
mkdir upload
cp bat-linux-x86_64.tar.gz upload/
cp bat-linux-x86_64.sha256 upload/
cd upload
zip -q \
../bat-linux-x86_64.zip \
*
cd ..
ls -lh bat-linux-x86_64.zip
go-api:
name: Build and test Go API
runs-on: linux
env:
GOCACHE: /tmp/bat-go-cache
steps:
- name: Checkout
shell: bash
run: |
set -euo pipefail
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
: "${GITHUB_SHA:?GITHUB_SHA is required}"
repo_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
if [ -d .git ]; then
git remote set-url origin "${repo_url}"
else
git init .
git remote add origin "${repo_url}"
fi
ref="${GITHUB_REF:-${GITHUB_SHA}}"
git fetch \
--no-tags \
--depth=1 \
origin \
"${ref}" \
|| git fetch \
--no-tags \
--depth=1 \
origin \
"${GITHUB_SHA}"
git checkout --force --detach FETCH_HEAD
git submodule update --init --recursive
- name: Show Go tool version
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
command -v go
go version
- name: Run Go API tests
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
make test-go-api
- name: Run Go API vet
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
- name: Build Go API
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
go build -o /tmp/bat-api ./cmd/bat-api
- name: Run documentation status gate
shell: bash
run: |
set -euo pipefail
make check-docs
+2 -1
View File
@@ -5,7 +5,7 @@
*.dll
*.so
*.dylib
bat
/bat
# Test binary, built with `go test -c`
*.test
@@ -53,6 +53,7 @@ pg_log/
/docs/reports/fuck-u-code-*.md
/docs/reports/*-current.generated.md
/docs/reports/**/SMOKE_REPORT.md
CHECK.md
# Backups
/deployments/backups/
+141 -53
View File
@@ -1,72 +1,160 @@
# Agent 开发规则
# AGENTS.md
本文件 BlueArchive Toolkit 中 AI agent、自动化开发助手和长期维护脚本的权威入口。它替代旧 `CLAUDE.md` 中真正长期有效的工程规则
本文件用于约束在 BlueArchiveToolkit 中工作的 AI Agent。
## 语言和表达
具体开发进度看 `CURRENT_STATUS.md`,开发计划看 `PROJECT_PLAN.md`,当前缺口看 `docs/reports/CURRENT_GAPS.md`。这里不记录具体任务和阶段待办。
1. 默认使用简体中文交流、写文档、写提交说明、写开发日志和写 API 说明。
2. 代码中的包名、类型名、函数名、变量名、数据库字段、协议字段和命令参数保持英文命名规范。
3. 代码注释只在能降低理解成本时添加;不要写复述代码行为的空泛注释。
4. 如果任务、上游规范或第三方 API 明确要求英文,可以在对应位置使用英文。
## 基本要求
## 项目定位
默认使用简体中文交流、写文档和提交说明。代码标识符、协议字段、数据库字段、命令参数等保持英文。
BlueArchive Toolkit 是长期维护的开源工具链,不是 demo、一次性脚本或临时试验项目
BlueArchiveToolkit 是长期维护项目。不要为了尽快完成当前任务引入明显的临时实现,也不要把未来计划描述成已经存在的能力
长期目标包括但不限于:
修改代码前先读相关实现。涉及跨模块改动时,至少确认当前状态、相关架构文档、测试和已有接口,不要只看一个文件就重新设计整个模块。
1. 官方资源同步、版本管理、增量同步、断点续传、重试、限速、缓存和校验
2. Content Addressable StorageCAS)、引用计数、垃圾回收、多版本共享和完整性校验。
3. UnityFS / AssetBundle / Addressables / Manifest 解析框架,并保持解析器与业务逻辑解耦。
4. 文本提取、翻译记忆、术语库、AI Provider 抽象、Patch、CLI、Web、API、SDK 和插件系统。
5. 支持未来扩展到其他区域、语言或 Unity 游戏;不要把架构写死到单一版本。
如果发现用户提出的方案、现有代码或文档本身有问题,直接指出。不要为了迎合要求保留明显不合理的设计
## 工程边界
## 以什么为准
1. 默认工作于用户本地环境。不要把生产环境当作开发环境
2. 真实资源下载、smoke run 和手动验证必须写入隔离目录,例如 `/tmp` 或显式指定的测试目录。
3. 不要默认读取、修改或污染现有客户端目录、生产资源目录或 `/home/wanye/D/BlueArchive` 这类本地资源目录。
4. 不要要求安装官方启动器作为生产运行前提。可以分析启动器资源或官方公开数据,但生产链路必须能在 Linux 环境中独立运行。
5. 涉及官方资源时,优先使用官方 `.hash`、catalog、manifest 和可复现 fixture 做校验依据。
仓库里有不少历史文档,不能混着看
## 架构原则
判断**当前实现**时,优先参考:
1. 仓库采用 monorepo;模块必须边界清晰、高内聚、低耦合。
2. 公共接口应稳定、可测试、可维护,并为未来扩展保留合理空间。
3. Rust 侧优先承担二进制解析、AssetBundle、Patch、CAS 和官方资源后端能力;Go 侧优先承担 CLI、运维入口和面向用户的命令编排。边界调整必须先说明理由。
4. Rust/Go 默认集成路径优先进程边界(当前为 `bat --json`)或未来稳定 SDK`bat-ffi` 仅作为可选无状态 C ABI 兼容层,不能扩展成 daemon、下载器、CAS handle 或主控制面
5. SDK 不得与 CLI 耦合;解析器不得与业务流程耦合;Provider、存储后端、Patch 算法和解析器应保留插件化扩展点。
6. 不引入 God Object、God Class、超长函数、超长文件、硬编码、魔法数字、重复代码、临时实现或只为当前测试通过的伪实现。
7. 不使用 `TODO``FIXME` 掩盖未完成设计。确实无法完成时,应在当前缺口文档中说明边界、风险和后续工作。
* 当前源码和测试;
* `CURRENT_STATUS.md`
* 对应模块的专项状态文档,例如 `docs/reports/GO_STATUS.md`
* 已冻结的 RPC、release、schema 等契约
## 开发流程
`PROJECT_PLAN.md``CURRENT_GAPS.md` 描述的是计划和缺口,不代表功能已经实现。
1. 动手前先读相关文档和代码,确认当前真实状态
2. 对跨模块、架构、数据格式或用户工作流有影响的改动,先给出设计判断或简短计划。
3. 实现后必须同步验证。验证范围要覆盖改动实际影响面,而不是只跑最窄的命令。
4. 涉及用户可见行为、运行方式、架构边界或缺口状态时,必须同步更新文档。
5. 保持改动范围和任务目标一致;不要顺手做无关重构或格式化 churn。
6. 如果需求、技术路线或设计存在明显风险,应直接指出并给出可执行替代方案。
7. 不确定的事实必须查证或询问;不要凭空调用不存在的接口、命令、路径或线上资源。
`docs/archive/``docs/reports/historical/` 只用于追溯历史,不应作为当前实现依据
## 质量要求
如果文档之间冲突,先核对源码和测试,再判断哪份文档已经过时。修代码时顺手修正相关权威文档,不要让冲突继续留在仓库里。
1. 所有错误必须显式处理,并给出可诊断信息
2. 日志应结构化或至少足够定位阶段、路径、版本、URL、重试、校验和失败原因。
3. 下载、写文件、状态切换和发布操作必须考虑原子性、断点续传、并发锁、失败恢复和清理策略。
4. 本地状态文件和索引必须有版本字段或兼容策略。
5. 新增 fixture、golden 或回归样本时,应说明它覆盖的真实风险。
6. 默认验证命令见 `docs/guides/development.md`;稳定工程基线见 `docs/guides/baseline.md`
ADR 记录架构决策,但旧 ADR 中已经被后续实现明确替代的部分不能机械照搬
## 文档职责
## 现有边界
长期规则的权威位置如下:
当前正式的资源同步和运维入口是 Rust `bat`
1. `AGENTS.md`:agent 行为、工程边界、架构原则和质量要求
2. `CONTRIBUTING.md`:贡献者工作流、提交规范、验证和 PR 要求。
3. `docs/guides/development.md`:环境准备、开发命令、测试、调试和真实资源验证方式。
4. `PROJECT_PLAN.md`:产品目标、阶段路线图和长期能力规划。
5. `CURRENT_STATUS.md`:当前实现状态。
6. `docs/reports/CURRENT_GAPS.md`:当前缺口、优先级和关闭顺序。
官方资源发现、下载、校验、版本状态、staging、release 发布、watch/daemon、任务和相关长期状态都由 Rust 侧负责。不要在 Go、Web 或其他模块再实现一套相同状态机
`CLAUDE.md` 只保留兼容入口,不应继续新增长期规则
Go `bat-api` 是资源 bootstrap、只读分发和管理入口。它通过 `bat.sock` RPC 消费 Rust 状态,并读取 Rust 已经发布的资源。不要让它直接管理官方同步状态,也不要在 Go 里重新实现 CAS、AssetBundle 解析或 Patch 核心算法
`bat-ffi` 只是兼容接口,不是主集成方式。不要把 daemon、下载器、CAS 长生命周期状态或新的主控制面塞进 FFI。
官方原版 release 和 localized release 是两套独立生命周期。已经发布的官方 release 应当视为不可变输入,汉化和 Patch 必须走独立 staging、校验、发布和 rollback 流程。
前端、API 和 CLI 不应维护第二套业务状态。状态以真正拥有它的后端模块为准。
## 模块和接口
优先使用仓库已经存在的抽象,例如 Repository、Adapter、Driver、Registry、Provider、RPC 和现有状态模型。
不要因为新增一个功能就平行实现第二套下载、存储、解析、Patch、翻译任务或 release 系统。
容易随着 Blue Archive、Unity、Addressables 或外部服务变化的逻辑应尽量留在 adapter/driver/provider 一侧,不要散进整个业务代码。
同时不要为了“以后可能会扩展”提前创建大量无实际用途的接口。只有已经存在多实现需求,或确定属于高变化边界的部分,才值得进一步抽象。
公共或持久化接口修改时要考虑兼容性。特别注意:
* RPC method 和 schema
* `status` / `status_code`
* `BAT-ERR-*` 错误码;
* CLI JSON 输出;
* release layout
* manifest/state 文件;
* SQLite/PostgreSQL schema
* Patch manifest
* HTTP API。
不要静默改变已有字段的含义。确实需要破坏性修改时,先考虑版本号、迁移或兼容读取。
## 代码修改
先弄清楚代码为什么放在当前位置,再决定是继续修改还是拆模块。
仓库里已经存在一些较大的文件。不要因为“文件太长”机械拆分,但也不要继续往一个已经承担过多职责的文件里塞新的独立功能。按职责拆,不按行数拆。
避免:
* 重复实现已有能力;
* 大范围无关重构;
* 为测试专门加入生产逻辑;
* 静默吞错;
* 无说明的硬编码;
* 魔法数字;
* 假实现、空实现冒充完成功能;
*`TODO` / `FIXME` 代替正式的缺口记录。
如果当前任务确实无法完成某一部分,应明确限制实现范围,并把剩余问题记录到对应的状态、缺口或 Issue 中。
## 文件、网络和发布安全
资源处理代码不能绕过现有的路径和完整性检查。
涉及文件写入、下载、CAS、Patch、release 或客户端文件时,应继续遵守仓库现有做法,包括路径归属检查、symlink 防护、临时文件、原子写入、hash/size 校验、失败不发布不完整结果等。
官方资源链路只使用项目当前允许的官方来源。不要为了绕过失败偷偷加入镜像或来源不明的 fallback。
密钥、Token、代理凭据等不能进入 Git,也不能无必要地出现在日志、状态文件或进程参数中。
## 测试
根据改动范围运行仓库已有的测试和检查,不要自己发明另一套质量流程。
Rust 修改通常至少考虑:
```bash
cargo fmt --all -- --check
cargo check --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
```
Go / bat-api 修改使用仓库现有 Makefile 和对应 `go test` / `go vet` 门禁。
涉及文档状态时运行:
```bash
make check-docs
```
涉及真实资源格式、RPC contract、release 或网络流程时,优先补已有 fixture、contract test、integration test 或 smoke,而不是只写一个理想化单元测试。
不能只根据合成样本宣称支持新的官方格式。
不方便运行某项重要测试时,在结果里说明没有运行什么以及原因。
## 文档
改动如果影响用户或其他模块能够观察到的行为,就同步对应文档。
尤其是:
* CLI
* RPC
* HTTP API
* 配置项;
* release 布局;
* schema
* 错误码和状态码;
* 模块职责;
* 当前实现状态。
不要把具体任务、临时优先级或某次实现方案写进本文件。
新的长期架构决策应该进入 ADR 或对应架构文档;开发路线进入 `PROJECT_PLAN.md`;实际进度进入 `CURRENT_STATUS.md`;未完成内容进入 `CURRENT_GAPS.md` 或 Issue。
## 工作方式
局部且模式明确的修改可以直接做。
涉及公共契约、新子系统、持久化格式、跨语言边界、大范围重构或安全边界时,先把现有实现和影响范围弄清楚,再动代码。
完成后检查三件事:
1. 有没有重复仓库已经存在的能力;
2. 有没有无意改变稳定接口或状态所有权;
3. 代码、测试和权威文档是否仍然一致。
+29 -9
View File
@@ -6,15 +6,35 @@
## [未发布]
### 新增
- 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
- 官方资源下载使用默认 8 个独立 worker;每个 worker 完成当前 URL 后立即从共享计划队列领取下一个任务,manifest/quarantine 簿记与 seed `.hash` 校验逐项保持一致,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17 的历史决定不代表当前并发实现)
- 新增 `cmd/bat-api` 资源 bootstrap/分发 HTTP 服务(issue #19 / G-009 资源面):与 Rust `bat` 同环境运行,经 `bat.sock` RPC`daemon.status``daemon.doctor` → catalog/manifest)发现已发布 release 和 `resource_root`,按官方 CDN host/path 只读提供资源;管理面 `/healthz` `/v1/release` `/v1/resources`;可选 server-info 仅改写 Addressables root`.env` 配置监听端口/RPC socket/刷新周期/预留数据库键。资源自动拉取仍由 Rust `bat` 负责
- `cmd/bat-api` 新增 `/v1/bootstrap`:把 Rust `bat` 的 RPC 健康、已发布 release 摘要、server-info URL、client-patch base 和改写后的 Addressables root 组织成启动前资源发现响应,固定 `bat` 是资源生产者、`bat-api` 是只读 bootstrap/分发层的关系
- `bat-api` CDN 分发补齐 Range / HEAD / 条件请求语义:基于 manifest BLAKE3 生成 ETag,返回 Last-Modified、Accept-Ranges 和长期缓存头,`.hash``text/plain` 返回
- `bat-api` 增加 RPC 周期刷新(`BAT_API_REFRESH_INTERVAL` / `--refresh-interval`),用于跟随同机 Rust `bat` 发布新 release;生产不应写死 `BAT_API_RESOURCE_ROOT`
- `bat-api` 增加同机 live smoke`make bat-api-local-live-smoke`),在隔离 `/tmp` 目录验证真实 `bat.sock`、release 切换、未 ready、恢复和 CDN 读路径;失效 resource root、manifest 不完整和编码 dot-segment 不会继续分发旧索引
- `bat-api` 增加 refresh 诊断和 `/readyz``/healthz` 暴露最近一次 RPC refresh 的时间、耗时、warning 和错误,`/readyz` 在无可分发 release 时返回 `503`
- `bat-api` 增加 launcher 资源引导兼容:`/v1/launcher/bootstrap``/api/launcher/game/config``/api/launcher/game/config/json``/api/launcher/advanced/game/download/cdn``/api-launcher-jp.yo-star.com/...` host 形状入口,响应来自 Rust `bat` 已发布 snapshot/RPC,明确不提供登录、网关、鉴权或完整 package update manifest
- `bat-api` 增加玩家-facing HTTP 控制面:token 鉴权、进程内限流、访问日志、反代 IP 适配、安全响应头、动态 JSON `Cache-Control: no-store``/v1/resources` 分页上限、统一 JSON error、OpenAPI (`/openapi.yaml`) 和 `/admin/` 管理面板预留
- 新增 `deployments/systemd/bluearchive-toolkit-bat-api.service``deployments/systemd/bat-api.env.example`,固定 bat-api 通过本机 `bat.sock` 获取资源根的部署契约
- USERGUIDE 补充 `bat-api` 资源 bootstrap / 分发章节,说明与 Rust `bat` 的运行关系、接口、CDN 响应语义和不仿造业务 API 的边界
- 官方同步新增 `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`
- 官方资源同步支持从已发布历史 release 或 CAS 复用已校验文件:复用前检查 size、BLAKE3 和 ZIP 结构,失败保留诊断并回退网络;release 级 CAS 引用写入 `official-cas-reuse-references.json`,清理流程同步回收引用
- Rust `bat` 的报告渲染与前台终端输出分别模块化到 `report_output.rs``terminal_output.rs`,保持 JSON、人类摘要、帮助、进度和错误输出契约不变
### 修复
- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s
### 计划
- [ ] 实现 Go CLI 最小可用入口(默认经 daemon RPC 或 `bat --json` 进程边界)
- [ ] 官方同步结果接入 CAS + ResourceRepository 的用户级工作流
- [ ] 实现 AssetBundle 解析器(UnityFS header/block/directory 起步
- [ ] 继续逆向 Addressables catalog 可校验字段
- [ ] 实现翻译系统
- [ ] 实现 Patch 引擎
- [ ] 实现 API Server
- [ ] 实现 Web 管理后台
- [ ] `bat-api` 后续:refresh mtime/size 增量缓存、完整 launcher 安装包更新链(若需要,新 issue)、API 持久化层接入预留 database/redis 配置;Rust/Go snapshot contract fixture 与同机 live smoke 已完成
- [ ] 扩展 CAS + ResourceRepository 的用户级查询、翻译记忆和通用 Patch 发布资源视图
- [ ] 官方下载/导入路径接入 CRC/size 校验(复用 `verify_downloaded_bytes`
- [ ] 完成通用 manifest 驱动的 Patch build/rollback、复杂 AssetBundle 重打包和完整汉化文件集合发布
- [ ] 扩展 provider 编排、翻译记忆和人工协作工作流
- [ ] 完成 Web 协作后台的持久化、权限和长期任务能力
## [0.2.0] - 2026-07-17
@@ -38,7 +58,7 @@
- 新增官方同步路径安全边界:拒绝危险输出目录和 snapshot 路径逃逸,下载目标、manifest、daemon PID/status/log/control 文件不跟随 symlinkdaemon 状态文件默认使用 `0600` 权限
- 新增官方资源原子发布布局:非 dry-run 下载先进入 `.staging/<id>`,校验和 manifest/snapshot 写入完成后发布到 `versions/<id>`,再原子切换 `current` symlink
- 新增 daemon 可观测性:`bat-events.jsonl` 结构化 JSONL 日志、日志轮转、status 中的当前下载进度、最后成功时间、下次检查时间和最后错误摘要
- 新增运行时下载与校验 progress log总体下载进度、单文件开始/完成状态、官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验摘要
- 新增运行时下载与校验 progress log下载已完成计数、单文件开始/完成状态、官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验摘要
- 新增官方资源同步生产部署模板:release binary symlink 路径、systemd unit、运行用户、日志位置、升级和回滚流程
- 新增真实官方网络全量拉取 smoke:`scripts/official-full-pull-smoke.sh``make official-smoke``docs/guides/official-full-pull-smoke.md`
- 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验
+7 -2
View File
@@ -37,9 +37,14 @@
基础验证命令见 `docs/guides/development.md`。常用最低门禁:
```bash
cargo fmt --check
cargo fmt --all -- --check
cargo check --workspace
cargo test --workspace
cargo clippy --workspace -- -D warnings
cargo clippy --workspace --all-targets -- -D warnings
make test-go-api
make build-go-api
go vet ./...
make check-docs
```
如果改动只影响部分 crate,可以先跑更窄的测试,但合并前必须确保影响面被覆盖。官方资源同步、下载、daemon、status、verify 或 repair 相关改动还应运行:
+97 -78
View File
@@ -1,35 +1,41 @@
# BlueArchiveToolkit 当前工作区状态
- **更新时间**2026-07-15
- **更新时间**2026-09-04
- **状态来源**:本地工作区盘点、代码验证和最新提交
- **状态分支**`experiment`
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
- **权威计划**`PROJECT_PLAN.md`
- **Go 进度权威**`docs/reports/GO_STATUS.md`
---
## 1. 总体判断
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备最小生产运行形态、Go CLI/API/Web 仍未落地** 阶段。
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go 侧以 `bat-api` 资源 bootstrap/分发 MVP + `backendrpc` 为正式服务入口(同步/运维命令行仍为近乎全自动的 Rust `bat`)** 阶段。
Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
1. 首次运行可以通过 `--auto-discover` 从官方 HTTP metadata 解析 `GameMainConfig`,自动获得 `app-version``connection-group``server-info`;解密出的 `GameMainConfig` JSON 会校验已知字段,避免把错误解密结果当成成功。
1. 首次运行可以通过 `--auto-discover` 从官方 HTTP metadata 解析 `GameMainConfig`,自动获得 `app-version``connection-group``server-info`;解密出的 `GameMainConfig` JSON 会校验已知字段,避免把错误解密结果当成成功。自动发现会记录 launcher metadata、launcher CDN config、remote manifest 文件列表 digest、选中的 `resources.assets` 来源和 `GameMainConfig` 摘要。
2. 不安装、不启动、不依赖已安装官方启动器。
3. 默认平台为 `Windows + Android`
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。启动器/server-info 先行更新但 client-patch seed marker 或必需 seed catalog 尚未开放时,会进入 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging,也不写入失败版本循环;启用 `--auto-discover` 的非 dry-run 会写入 `<output>/official-launcher-bootstrap.pending.json` 作为维护期证据。下载默认使用 8 个独立 worker,范围为 `1..=256`;每个 worker 完成当前 URL 后立即从共享计划队列领取下一个任务,进度按实际完成顺序即时上报,最终 report 资源列表仍按计划顺序输出。manifest/quarantine 簿记与 seed `.hash` 校验仍逐项执行,`fail-fast` 与「不发布不完整资源」不变量不变。下载进度按已完成数量单调上报,不再使用 plan 序号计算百分比。新 staging 还会按规范化 destination 查找已发布历史 release,重新校验 size、BLAKE3 和 ZIP 结构后用硬链接或跨文件系统复制复用;历史文件不满足条件时再验证配置的 CAS 对象,最后才回退网络,并把 `release_reused``cas_reused``downloaded` 和复用诊断写入报告。
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`
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 秒快速重试,官方资源端尚未开放时状态为 `waiting` 并同样按错误重试间隔探测;`resource.state` / `catalog.status` / `parse.status` / `localized.status` 会返回 `status` 与稳定 `status_code`(如 `official.up_to_date``official.published``parse.completed``translation.queued_offline``localized.published``distribution.ready`),供 `bat-api` 等读侧判断阶段、终态和重试属性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`启用 `--auto-discover` 的 release 会包含 `official-launcher-bootstrap.json`up-to-date 轮询会为旧 release 补写该产物;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;从 CAS 复用的 release 会在自身目录保存版本化 `official-cas-reuse-references.json`,孤儿 staging 清理或 release 清理时按清单递减 CAS 引用,避免 CAS GC 误删仍被 release 使用的对象;后台状态目录包含 `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、403/404、hash mismatch 均有离线回归 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`)。
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root``--import-resource-db``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,release、平台、bundle path 和常用数组 metadata 过滤已下推到 SQLite,数据库不存在时返回 `available=false` 且不会创建空库;`bat doctor cas` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计,不会因诊断创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units``parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixtureup-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published``status_code=localized.published``localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY``BAT_TRANSLATION_*` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。官方同步报告还分别统计当前 manifest 复用、历史 release 复用、CAS 复用、网络传输字节和复用回退诊断,下载事件状态使用 `release_reused``cas_reused``downloaded` 等稳定值。
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存`/tmp` 隔离目录,不纳入 Git。
15. Rust `bat` 已提供 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。`translation.worker.run` 已接入 provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。`bat-api` 已提供内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该工作流只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作真实官方网络全量拉取 smoke 已固化真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`
当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker,完成后动态领取
任务,最终资源报告按 pull plan 顺序输出。
---
@@ -40,7 +46,9 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
- `DOCS_INDEX.md`:文档阅读顺序和索引。
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
- `docs/guides/bat-workflows.md`Rust `bat``res` / `parse` / `i18n` 工作流、调度计划和 `bat-api` 调度接口。
- `docs/architecture/official-resource-backend.md`:官方资源后端设计和审核说明。
- `docs/architecture/assetbundle.md`:解析补全路线图,覆盖 Addressables、UnityFS、Serialized 字段级解析、文本提取、CAS 接入和 Patch 发布前置。
- `docs/reports/CURRENT_GAPS.md`:当前缺口和关闭顺序。
历史 Week 2/Week 3 报告只作追溯,不再代表当前状态。
@@ -87,17 +95,17 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
已包含:
- Unity adapter trait、注册表、Unity 2021.3 adapter 骨架
- Unity adapter trait、注册表、Unity 2021.3 adapter 基础解析与校验
- Manifest driver trait、Addressables driver、注册表。
- Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。
- Addressables JSON/compact catalog 的 path、hash、size、address、dependencies、provider ID、bundle name、CRC、metadata 解析。
- 真实形态 Addressables fixture/golden 测试。
- 当前 catalog、上一个版本 catalog、结构变化 catalog 的离线回归 fixture。
- 官方日服 `server-info`、URL 规则、平台 discovery 和 inventory 枚举。
- 官方日服 `server-info`、URL 规则、平台 discovery 和 inventory 枚举`MediaCatalog.bytes` 使用官方相对路径生成媒体 URL,覆盖 `GameData/``Prologue/` 下的 zip/mp4/png/jpg/ogg/wav 等媒体资源,避免把叶子文件名误拼到媒体根目录
待完成:
- Unity bundle serialize 仍是后续阶段能力
- Addressables parser 仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
- `crates/bat-assetbundle` 已具备 UnityFS 容器、对象表、TypeTree 元数据、基础字段读取、TextAsset 和 TextUnit 提取;UnityFS 容器已补充总大小、计数、路径、重复 directory、LZMA 和边界校验,并通过 UnityPy 真实 bundle 隔离回归;已有 TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 发布闭环,真实复杂版本差异、整体 AssetBundle 重打包和通用 Patch 仍未实现
- Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
### `bat-cas-engine`
@@ -135,43 +143,50 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
- `OfficialResourcePullService`:官方 URL 拒绝策略、目标路径映射、下载 manifest、下载 quarantine、`.part` 续传、curl 代理配置、403/404/5xx 分类重试、ZIP 结构校验、官方 seed `.hash` 校验、本地全量 verify。
- `OfficialUpdateService`:官方 metadata auto-discover、bootstrap cache、snapshot diff、marker diff、本地 audit/repair、失败 staging 恢复。
- `bat`:正式 CLI binary,支持 one-shot、`--proxy` / `--no-proxy``--watch``--daemon``status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`
- `report_output.rs``terminal_output.rs`:分别负责结果报告渲染和前台终端诊断、帮助、进度及结构化日志输出。
待完成:
- 将官方同步下载结果作为用户级流程自动导入 CAS + ResourceRepository
- 基于已接入的 `translation.worker.run` 继续推进翻译记忆、完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit 查询和通用 Patch 发布资源视图
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`;实际运行报告由脚本写入隔离输出目录。
- 增加更多权限和极端文件系统场景测试。
### `bat-assetbundle`
状态:**占位**
状态:**UnityFS 解包、TypeTree 字段读取、TextUnit 提取和受支持 localized patch 发布已可用;复杂结构覆盖与整体 AssetBundle 重打包仍待继续补齐**
当前只有:
解析扩展当前按路线图和真实 fixture 验收推进。
- 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 提取。
- TypeTree 基础字段 reader 支持标量、string、bytes、array、vector/staticvector 嵌套 `Array` 形态、`List<T>` / `HashSet<T>` 集合 alias、map、PPtr、enum `value__` backing field、`LayerMask` / `BitField``m_Bits` backing field、嵌套对象、常见固定 Unity 值类型(`Vector2f/3f/4f``Quaternionf``ColorRGBA``Rectf``AABB/Bounds/Ray``Matrix4x4f``Vector2Int/Vector3Int``RectInt``BoundsInt``RangeInt``GUID``Hash128`)的 leaf 和 direct child TypeTree 形态、unknown fixed-size raw bytes 保留、TypeTree-covered managed reference / `SerializedReference` alias、TypeTree-covered managed reference registry 记录、常见 registry 命名别名(含 `m_ManagedReferences` / `RefIds` / verbose type 字段 / `managedReference*``serializedReference*` prefixed metadata)、managed-reference payload 命名别名(含 `data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData`)、managed-reference full typename 拆解和 offset/size 诊断;array/vector/List/HashSet/map 元素与 registry payload 字段会保留独立 field path、offset 和 byte size,字符串元素可作为 patch 输入定位,enum 会暴露为语义化 `{type_name, storage_type, value}`bit field 会暴露为语义化 `{type_name, storage_type, bits}`object 字段组合、固定 Unity 值类型、enum、bit_field、unknown fixed-size raw bytes 同长度替换与 TypeTree schema 支撑的 array/vector/List/HashSet/map 已支持整体替换、长度变化和空容器扩容,map entry 的 `first/second``key/value` 字段命名已有重建回归覆盖。
- `TextUnitExtractor` 支持 JSON/CSV/TSV/plain TextAsset 探测、TypeTree 字段字符串提取和 JSONL 输出;TextUnit 明细包含 serialized file、path id、class id、field path、字段 offset/byte size、format、asset name 和上下文。managed-reference registry 的类型名、namespace、assembly 等元数据不会进入翻译文本队列,而是写入 payload TextUnit context;未能聚合成结构化 `references` 的 fallback registry 字段也会按 `RefIds[n]` 等记录前缀或子字段推导 managed-reference metadata 并写入 payload context,避免多条 fallback record 混用类型上下文。
- `ResourceImportService``official-parse-cache.json` 已包含 TextUnit 数量、格式和诊断摘要。
- `official-textunit-index.json` 已持久化单条 TextUnit 与解析错误;`parse.text_units` / `parse.errors` RPC 和 CLI 可分页过滤查询。
- `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 版本差异、复杂容器结构调整、unknown 字段结构语义和未见样本驱动的完整 managed reference registry / map entry 变体覆盖;TypeTree-covered managed reference 字段与 registry 记录已可结构化解码,常见 full typename 可拆解为 assembly/namespace/class,不做低保真猜测
- 复杂对象整体结构修改后的发布级 AssetBundle 重打包;UnityFS TextAsset、TypeTree string 字段、managed-reference registry payload 字符串、基础语义字段、enum、bit_field、object 字段组合和 TypeTree schema 支撑的 array/vector/map 整体替换的文件级链路已具备重建后校验,受支持 localized patch 已有独立 staging、manifest、current、状态校验和显式 rollback;整体 AssetBundle 发布仍未完成
- 真实资源 fixture 覆盖对象级解析和文本提取
- 详细补全顺序见 `docs/architecture/assetbundle.md`
### `bat-patch`
状态:**占位**
状态:**通用 Binary/JSON/Text Patch 基础可用;受支持 localized patch 发布/rollback 已完成,通用 Patch 发布仍未完成**
当前 Binary Patch 和 JSON Patch 函数返回空结果,不具备真实补丁能力
当前已有确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据。`patch.apply` RPC 与 `patch-apply` CLI 已可对显式 source/patch/target 文件执行 Binary/JSON/Text patch,并返回 size/BLAKE3 报告;`unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` RPC 和 `unityfs-patch-text-asset` / `unityfs-patch-string-field` / `unityfs-patch-field` CLI 已可对显式 UnityFS bundle 输出目标文件。`unityfs.patch_field` 支持 bool、signed/unsigned integer、float raw bits、string、bytes、enum、bit_field、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 同长度替换、PPtr、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换语义 JSON 值;array/vector/List/HashSet/map 扩容会复用当前首个元素或 TypeTree data node 的编码 schema,空容器扩容已用合成 fixture 覆盖,嵌套 vector `Array``List<T>``HashSet<T>` 形态、enum、bit_field、unknown fixed-size raw bytes、managed-reference registry `data``managedReferenceData` payload 字符串已有重建后重解析 fixture。`bat-assetbundle` + `LocalizedPatchService` 已能对 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 执行替换,写入带 TextUnit/provider/review/rollback trace 的 localized patch manifest,在独立 staging 校验后发布汉化 release,并通过 `localized.publish` / `localized.rollback` RPC、`i18n publish` / `i18n rollback` CLI 和 bat-api 控制面暴露;`LocalizedPatchManifest` 可转换为通用 `bat_patch::PatchManifest`,通用 manifest 驱动发布仍未迁移
待完成:
- Binary diff/apply
- JSON Patch apply/validate
- Patch manifest
- Integrity check。
- Rollback。
- 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包
- 通用 manifest 驱动的跨类型 patch build/apply/diff 发布;当前 localized 发布仅接受已验证 TextUnit 对应的受支持 UnityFS 文本字段,并不等价于整体 AssetBundle 重打包
- `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放
### `bat-ffi`
@@ -188,7 +203,8 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
- `bat-ffi` 只暴露粗粒度、无状态、一次调用一次 JSON 输入输出的 C ABI helper。
- 它不持有 downloader、daemon、CAS handle、资源目录锁或长生命周期状态。
- Go CLI 和生产运维默认应调用 `bat --json` 进程边界;未来稳定 SDK 也优先于 FFI。
- 新的 Go 集成和生产运维读侧默认应调用 `bat.sock` RPC`bat --json` 仅是
Rust CLI 的机器输出形态,`bat-ffi` 仍是可选兼容层。
- FFI 仅用于需要嵌入 C ABI 的兼容场景,不能作为官方同步控制面或主集成边界。
待完成:
@@ -198,49 +214,54 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
### Go / API / Web
状态:**CLI/API/Web 仍未实现,仅有可选 CGO 兼容包装**
状态:**边界已确定;资源分发 MVP 已落地。权威细节见 `docs/reports/GO_STATUS.md`**
当前情况:
| 角色 | 所有者 | 状态 |
|---|---|---|
| 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 |
| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + CDN MVP + RPC 周期刷新/诊断 + readiness + 内嵌 dashboard |
| daemon RPC client | `internal/backendrpc` | 完成 |
| 试验 CLI | `cmd/bat``bin/bat-go` | 非产品 |
| FFI | `internal/ffi` | 可选 |
| 空目录 `api/` `pkg/` 等 | 占位 | 无实现 |
| Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 |
- `internal/ffi/ffi.go` 已存在
- Go CLI 默认集成方向是调用 Rust `bat --json` 并转发结构化 report,而不是依赖 FFI。
- `cmd/``pkg/``api/``web/` 仍无可用产品入口。
- `go test ./...` 在没有 Go package 时可能无测试可运行;Makefile 会清晰跳过空 Go 阶段。
默认 Go/docs 门禁:`make test-go-api``make build-go-api``make check-docs`(无 FFI
---
## 4. 已验证结果
最新功能提交前已运行并通过:
以下命令已于 2026-09-04 在本地工作区执行并通过:
```bash
cargo test -p bat-adapters -- --nocapture
cargo test -p bat-ffi -- --nocapture
cargo test -p bat-infrastructure -- --nocapture
cargo test -p bat-infrastructure --bin bat -- --nocapture
cargo run -p bat-infrastructure --bin bat -- --help
git diff --cached --check
cargo fmt --all -- --check
cargo check --workspace --locked
cargo test --workspace --locked
cargo clippy --workspace --all-targets --locked -- -D warnings
```
提交后确认
Go 与文档门禁
```bash
git status --short
GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache make test-go-api
GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache make build-go-api
GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache go test ./...
GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache go vet ./...
make check-docs
```
结果:工作区干净。
当前仍未作为本地事实确认的项目包括:
未执行:
- 本次状态更新未执行一次性真实官方网络全量下载 smoke;该流程已由 `docs/guides/official-full-pull-smoke.md``scripts/official-full-pull-smoke.sh` 固化并关闭(G-018),当前处于长期运行测试阶段,运行报告将在后续提供
- Go CLI 端到端测试,因为 Go CLI 尚未实现。
- Web/API 测试,因为 Web/API 尚未实现。
- 真实官方全量 smoke 长期运行报告;命令已固化为 `make official-smoke`
- `bat-api` 同机 live 联调:已由 `make bat-api-local-live-smoke``/tmp` 隔离目录完成;真实官方网络全量下载仍由 `make official-smoke` 独立跟踪。
- 完整 Web 协作后台
---
## 5. 当前生产运行边界
当前唯一可作为 Linux 生产资源同步任务运行的入口是 Rust binary
当前唯一可作为 Linux 生产资源同步任务运行的入口是 Rust binary
```bash
cargo run -p bat-infrastructure --bin bat -- \
@@ -249,6 +270,8 @@ cargo run -p bat-infrastructure --bin bat -- \
--watch
```
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status``resource.manifest``official-sync-snapshot.json` contract fixtureGo mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh``schedule.*``task.*` 查询/取消、`daemon.logs``parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update``translation.worker.run``translation.proofread``localized.publish``localized.rollback` 可经 dashboard 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
生产要求:
1. 使用独立输出目录,例如 `/var/lib/bluearchive-toolkit/official`
@@ -261,34 +284,30 @@ cargo run -p bat-infrastructure --bin bat -- \
---
## 6. 当前阻塞项
## 6. 当前开发基础与后续工作
GitHub issue 状态:#4#16 已全部关闭(#16 为 daemon status 版本失败输出与重复堆积 bug,已由失败版本去重和状态输出优化修复),当前 open 的是 #1P1)、#2P2)、#3P2
Issue 状态不作为本地实现状态的权威来源;本次复核未把远端 Issue 列表作为已验证事实
当前实现以源码、测试、稳定契约和本文件的模块状态为准。
下一阶段必须优先完成
当前非阻塞验证跟踪
1. Issue #1P1,主体已实现):`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)、`resource.*`state/sync/verify/manifest)、`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.*`(被引擎阻塞)、`resource.repair`(待引擎独立修复模式)、`task.create`(按设计由语义方法创建)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7
2. Go CLI 最小可用入口:`bat doctor`、稳定的 `bat --help` 命令结构,默认通过上述 RPC 或 `bat --json` 进程边界获取同步 report。
3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。
4. Issue #3P2):AssetBundle UnityFS 基础解析校验。
5. Issue #2P2):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
6. Patch 和翻译系统仍应后置。
- 使用 `make official-smoke` 执行真实官方网络长期运行测试,并将报告留在隔离目录
非阻塞跟踪项:官方同步长期运行测试正在进行,运行报告将在后续提供。
后续工程顺序:
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
2. 继续通用 Patchmanifest 驱动、双 release 查询和清理策略。
3. 继续资源查询和翻译基础设施:更丰富的查询、Translation Memory、Glossary 和 Provider
扩展体系。
4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
---
## 7. 下一步建议
立即任务:
1. Issue #1 收尾:协议基础设施、最小方法集及 `catalog.*`/`task.*` 全量、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)与任务持久化按后续里程碑推进。
2. 实现 Go CLI 最小框架和 `doctor`,通过 RPC 或 `bat --json` 边界对接 Rust backend
3. 跟进官方同步长期运行测试,收集并归档运行报告。
4. 开始 AssetBundle parser 的 UnityFS header/block/directoryissue #3),并继续扩展 Addressables catalog 可校验字段(issue #2
---
- **当前总体完成度**:约 22%
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;产品级 CLI/API/Web 仍未完成。
- **下一工程里程碑**Rust Resource Backend RPC API 最小方法集(issue #1)+ Go CLI 最小可用 + 官方同步结果接入 CAS/ResourceRepository + AssetBundle 解析起步。
- **当前总体完成度**:不固定写单一百分比,以各模块状态、源码、测试和契约为准。
- **当前基线状态**Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP、
HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard
`backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、
通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用;
完整 AssetBundle 重打包、完整 Web 协作后台、翻译记忆和通用 manifest 发布未完成
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、翻译记忆、通用 manifest Patch
构建,以及真实官方资源长期运行验证
Generated
+13 -9
View File
@@ -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,13 @@ dependencies = [
[[package]]
name = "bat-assetbundle"
version = "0.2.0"
version = "1.0.0"
dependencies = [
"anyhow",
"hex",
"lz4",
"lzma-rs",
"md-5",
"serde",
"serde_json",
"thiserror",
@@ -95,7 +97,7 @@ dependencies = [
[[package]]
name = "bat-cas-engine"
version = "0.2.0"
version = "1.0.0"
dependencies = [
"anyhow",
"async-trait",
@@ -112,7 +114,7 @@ dependencies = [
[[package]]
name = "bat-core"
version = "0.2.0"
version = "1.0.0"
dependencies = [
"anyhow",
"async-trait",
@@ -125,7 +127,7 @@ dependencies = [
[[package]]
name = "bat-ffi"
version = "0.2.0"
version = "1.0.0"
dependencies = [
"bat-adapters",
"bat-infrastructure",
@@ -136,13 +138,15 @@ 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",
"bat-patch",
"blake3",
"hex",
"libc",
@@ -157,7 +161,7 @@ dependencies = [
[[package]]
name = "bat-patch"
version = "0.2.0"
version = "1.0.0"
dependencies = [
"anyhow",
"blake3",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
]
[workspace.package]
version = "0.2.0"
version = "1.0.0"
edition = "2021"
authors = ["BlueArchive Toolkit Team"]
license = "MIT"
+115 -89
View File
@@ -1,110 +1,136 @@
# BlueArchiveToolkit 文档索引
# BlueArchive Toolkit 文档分类索引
- **更新时间**2026-07-15
- **说明**本索引用于快速定位当前权威文档和历史资料
- **更新时间**2026-09-04
- **用途**按用途、时效性和权威级别定位文档
- **原则**:目录是物理归档方式,不能单独代表文档权威性;当前源码、测试和下列当前文档优先于历史报告。
---
## 1. 项目入口与协作规则
## 1. 权威入口
这些文件位于仓库根目录,是项目级入口或协作规则:
- `README.md`:项目概览、当前可用能力和快速开始。
- `PROJECT_PLAN.md`:完整开发计划和最终目标路线图
- `CURRENT_STATUS.md`:当前工作区真实状态
- `docs/reports/CURRENT_GAPS.md`:当前实现缺口和关闭顺序
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令
- `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明
- `CHANGELOG.md`:版本变更记录
- `AGENTS.md`:AI agent 和自动化开发助手长期规则。
- `CONTRIBUTING.md`:贡献者协作、提交和验证要求。
- `CLAUDE.md`Claude Code 等旧工具的兼容入口。
- `README.md`:项目概览、当前能力和快速开始。
- `USERGUIDE.md``bat` 用户指南、命令、配置、错误码和常用 RPC 说明
- `CURRENT_STATUS.md`:当前实现状态,使用源码和测试复核后维护
- `PROJECT_PLAN.md`:长期目标、里程碑和后续路线图
- `CONTRIBUTING.md`:贡献流程、提交规范和验证要求
- `CHANGELOG.md`:版本变更记录,不作为当前实现的唯一依据
- `CLAUDE.md`:旧工具兼容入口,不承载独立规则
- `AGENTS.md`AI agent 长期协作规则
---
## 2. 当前状态、计划与缺口
## 2. 架构与指南
这些文件描述当前项目,不应写入未经源码或测试证明的完成状态:
- `docs/architecture/README.md`:总体架构设计
- `docs/api/README.md`API 设计入口
- `docs/guides/development.md`:开发指南
- `docs/guides/deployment.md`:部署指南
- `deployments/systemd/`官方资源同步生产 systemd unit 和环境文件示例
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
- `docs/guides/baseline.md`:稳定工程基线指南。
- `docs/architecture/adr/0001-engine-and-application-boundaries.md`Rust/Go 边界决策。
- `docs/architecture/adr/0002-cas-v1-design-boundary.md`CAS V1 边界决策。
- `docs/architecture/adr/0003-cas-core-interface-and-error-boundary.md`CAS 核心接口和错误边界冻结。
- `CURRENT_STATUS.md`:全项目当前状态总览
- `docs/reports/GO_STATUS.md`Go `bat-api` 边界和组件进度的权威文档
- `docs/reports/CURRENT_GAPS.md`:当前缺口、影响和推进顺序
- `PROJECT_PLAN.md`:目标和路线图;其中的计划项不等于已实现
- `docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md`Rust 输出、Go contract fixture 和联调的当前交接说明
后续建议新增:
## 3. 架构、决策与稳定契约
- `docs/architecture/cas.md`CAS 生产级设计。
- `docs/architecture/assetbundle.md`AssetBundle 解析设计。
- `docs/architecture/translation.md`:翻译系统设计。
### 3.1 架构总览和专题
---
- `docs/architecture/README.md`:总体架构和目标边界;当前实现以 `CURRENT_STATUS.md` 为准。
- `docs/architecture/official-resource-backend.md`:官方资源发现、清单、下载、发布和导入边界。
- `docs/architecture/resource-release-layout.md`release 目录、URL 映射、seed 和 `bat-api` 分发契约。
- `docs/architecture/assetbundle.md`Addressables、UnityFS、Serialized File、TextUnit、CAS 和 Patch 的解析路线图。
## 3. 分析资料
### 3.2 架构决策记录
- `docs/assetbundle_analysis.json`AssetBundle 分析资料
- `docs/textassets_analysis.json`TextAsset 分析资料
- `docs/archive/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md`:历史技术分析
- `docs/archive/ARCHITECTURE_REVIEW.md`:历史架构审查
- `docs/archive/ARCHITECTURE_REVIEW_SUMMARY.md`:历史架构审查摘要。
- `docs/archive/READY_FOR_PHASE_1.md`:历史 Phase 1 准备文档。
- `docs/archive/REFACTOR_CHECKLIST.md`:历史重构清单。
- `docs/architecture/adr/0001-engine-and-application-boundaries.md`Rust 引擎与 Go 应用层边界
- `docs/architecture/adr/0002-cas-v1-design-boundary.md`CAS V1 设计边界
- `docs/architecture/adr/0003-cas-core-interface-and-error-boundary.md`CAS 核心接口和错误边界
- `docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md`:当前 Rust `bat` 与 Go `bat-api` 资源控制面边界
---
### 3.3 对外接口规范
## 4. 历史报告
- `docs/reference/rpc-backend-api.md`Rust Resource Backend JSON-RPC 稳定 contract。
- `api/openapi/bat-api.yaml``bat-api` HTTP OpenAPI 静态规范。
- `docs/api/README.md`API 文档入口及规范索引。
历史报告已按来源和主题归档,供追溯使用,不再代表当前状态
契约文档涉及字段、状态码、错误码、release layout 或路径语义时,必须与源码测试和 `internal/api/testdata/contract/` 一起复核
## 4. 用户、开发与运维指南
这些文件描述如何使用或验证已经存在的能力:
- `docs/guides/development.md`:本地开发、测试、调试和代码质量流程。
- `docs/guides/deployment.md`:部署、systemd、Docker 和运维说明。
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新运行指南。
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook。
- `docs/guides/bat-api-local-live-smoke.md`Rust `bat` 与 Go `bat-api` 同机 live 联调。
- `docs/guides/bat-workflows.md``res``parse``i18n` 工作流和调度接口。
- `docs/guides/baseline.md`:稳定工程基线和合并前检查。
- `scripts/check-doc-status.sh`:当前状态、占位目录和关键契约文字门禁。
- `scripts/check-doc-links.sh`:全仓库 Markdown 本地链接门禁。
`deployments/` 下的 systemd、Docker、环境文件和数据库配置是部署材料,不作为独立架构文档;其行为说明以本节指南和当前源码为准。
## 5. 分析资料和机器产物
以下资料用于分析或测试,不是当前能力声明:
- `docs/assetbundle_analysis.json`AssetBundle 分析数据。
- `docs/textassets_analysis.json`TextAsset 分析数据。
- `adapters/tests/fixtures/``adapters/tests/golden/`Manifest / Addressables fixture 和 golden。
- `infrastructure/tests/fixtures/``infrastructure/tests/golden/`:官方同步和导入 fixture。
- `internal/api/testdata/contract/`Rust-Go contract mirror 和 Go contract tests 输入。
- `internal/api/testdata/release/``bat-api` 本地 release fixture。
测试 fixture 可以证明特定行为,但不能单独证明对所有真实官方格式的完整支持;真实样本和运行 smoke 仍需单独标注。
## 6. 模块状态说明
以下 README 是模块占位或边界说明,不是完整实现文档:
- `api/README.md`
- `api/proto/README.md`
- `api/openapi/README.md`
- `internal/config/README.md`
- `internal/downloader/README.md`
- `internal/extractor/README.md`
- `internal/manifest/README.md`
- `internal/storage/README.md`
- `pkg/README.md`
- `pkg/cas/README.md`
- `pkg/translator/README.md`
- `pkg/types/README.md`
- `web/README.md`
- `web/admin/README.md`
- `web/shared/README.md`
这些目录的实现状态以 `docs/reports/GO_STATUS.md`、对应源码和测试为准;不能因为目录或 README 存在就视为模块已完成。
## 7. 历史归档
以下内容只用于追溯,不能作为当前实现、当前优先级或当前测试结果的证据:
- `docs/archive/`:早期架构审查、技术分析、重构清单和 Phase 1 准备材料。
- `docs/reports/historical/root/`:原根目录阶段报告。
- `docs/reports/historical/current-stage/`:已被 `CURRENT_STATUS.md`当前指南取代的阶段交接、推送前核查报告。
- `docs/reports/historical/week2/`Week 2 相关报告。
- `docs/reports/historical/week3/`Week 3 相关报告。注意:这些报告中存在“完成”和“回滚”的冲突描述。
- `docs/reports/historical/build-logs/`:历史构建、测试、Clippy 输出
- `docs/reports/historical/current-stage/`:已被当前状态和指南取代的阶段交接报告。
- `docs/reports/historical/week2/`Week 2 报告和当时的构建/测试输出
- `docs/reports/historical/week3/`Week 3 报告;其中存在互相冲突的完成描述。
- `docs/reports/historical/PARSER_FREEZE.md`:已解除的解析模块维护冻结历史记录,不构成当前开发约束
- `docs/reports/historical/build-logs/`:历史构建、测试和 Clippy 输出。
- `docs/reports/historical/quality/`:历史质量报告。
- `docs/reports/historical/nested-docs/`:从误嵌套 `docs/docs` 移出的报告。
- `docs/reports/historical/nested-docs/`:从旧目录结构迁移出来的历史报告。
---
## 8. 推荐阅读顺序
## 5. 当前阅读顺序
1. `README.md`
2. `CURRENT_STATUS.md`
3. `PROJECT_PLAN.md`
4. `docs/reports/CURRENT_GAPS.md`
5. `docs/reports/GO_STATUS.md`
6. `docs/architecture/official-resource-backend.md`
7. `docs/architecture/resource-release-layout.md`
8. `docs/reference/rpc-backend-api.md`
9. `docs/architecture/assetbundle.md`
10. `docs/guides/official-resource-test-pull.md`
11. `docs/guides/bat-workflows.md`
12. `docs/guides/development.md`
13. `CONTRIBUTING.md`
14. `AGENTS.md`
新开发者或新会话建议按以下顺序阅读:
1. `CURRENT_STATUS.md`
2. `PROJECT_PLAN.md`
3. `docs/guides/official-resource-test-pull.md`
4. `docs/guides/official-full-pull-smoke.md`
5. `docs/architecture/official-resource-backend.md`
6. `docs/reports/CURRENT_GAPS.md`
7. `docs/guides/baseline.md`
8. `docs/architecture/README.md`
9. `docs/guides/development.md`
10. `CONTRIBUTING.md`
11. `AGENTS.md`
---
## 6. 状态摘要
当前总体完成度约 **22%**
已完成:
- Rust 领域模型和仓储接口骨架。
- Unity/Manifest/Client 适配器框架。
- 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` 校验。
- 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。
- `bat` 运行时 progress log 已覆盖总体下载进度、单文件下载进度和校验结果摘要。
- Addressables 当前真实形态 fixture/golden 覆盖。
- SQLite Resource Repository 和可选无状态 `bat-ffi` JSON 兼容接口。
优先待办:
- 落地 Go CLI 最小可用入口。
- 将官方同步结果接入 CAS + ResourceRepository 的用户级流程。
- 开始 AssetBundle UnityFS 解析。
阅读顺序中的状态和契约结论必须回到当前源码、测试和实际命令验证;历史报告只用于解释演进过程。
+39 -36
View File
@@ -1,4 +1,4 @@
.PHONY: help build build-ffi test clean check fmt lint install dev docker-build docker-up docker-down official-smoke
.PHONY: help build build-ffi test clean check check-docs fmt lint install dev docker-build docker-up docker-down official-smoke bat-api-local-live-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
# 默认目标
.DEFAULT_GOAL := help
@@ -28,21 +28,25 @@ build-ffi: ## 构建 bat-ffi release 库(cgo 链接依赖)
@echo "$(BLUE)Building bat-ffi (release)...$(NC)"
cargo build --release -p bat-ffi
build-go: build-ffi ## 构建 Go 组件
@echo "$(BLUE)Building Go CLI...$(NC)"
build-go: build-go-api ## 构建 Go 默认产物(bat-api bootstrap/分发;同步 CLI 请用 Rust bat
build-go-api: ## 构建 bat-api(资源 bootstrap/分发 HTTP,无 FFI
@echo "$(BLUE)Building bat-api (resource bootstrap + distribution)...$(NC)"
@mkdir -p bin
go build -o bin/bat-api ./cmd/bat-api
build-go-cli: build-ffi ## 构建试验性 Go CLI → bin/bat-go(禁止命名为 bat
@echo "$(BLUE)Building experimental Go CLI as bin/bat-go...$(NC)"
@mkdir -p bin
@if [ -f cmd/bat/main.go ]; then \
go build -o bin/bat ./cmd/bat; \
go build -o bin/bat-go ./cmd/bat; \
else \
echo "$(YELLOW)Go CLI entrypoint not implemented yet, skipping...$(NC)"; \
echo "$(YELLOW)experimental cmd/bat missing, skipping...$(NC)"; \
fi
install: ## 安装到本地
@echo "$(BLUE)Installing bat CLI...$(NC)"
@if [ -f cmd/bat/main.go ]; then \
go install ./cmd/bat; \
else \
echo "$(YELLOW)Go CLI entrypoint not implemented yet, skipping...$(NC)"; \
fi
install: build-go-api ## 安装 bat-api 到 GOPATH/bin(不安装名为 bat 的 Go 二进制)
@echo "$(BLUE)Installing bat-api...$(NC)"
go install ./cmd/bat-api
# ============================================================================
# 测试相关
@@ -54,32 +58,35 @@ test-rust: ## 运行 Rust 测试
@echo "$(BLUE)Running Rust tests...$(NC)"
cargo test --workspace
test-go: build-ffi ## 运行 Go 测试
@echo "$(BLUE)Running Go tests...$(NC)"
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
go test -v ./...; \
else \
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
fi
test-go: test-go-api ## 默认 Go 门禁(无 FFI;见 GO_STATUS.md
test-go-api: ## 纯 Go 测试:internal/api + backendrpc
@echo "$(BLUE)Running pure Go tests (api + backendrpc)...$(NC)"
go test ./internal/api/... ./internal/backendrpc/...
test-go-ffi: build-ffi ## 含 FFI/试验 CLI 的 Go 测试
@echo "$(BLUE)Running Go tests including FFI packages...$(NC)"
go test ./...
test-go-all: test-go-api test-go-ffi ## 全部 Go 测试
bench: ## 运行性能基准测试
@echo "$(BLUE)Running benchmarks...$(NC)"
cargo bench --workspace
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
go test -bench=. -benchmem ./...; \
else \
echo "$(YELLOW)No Go packages yet, skipping Go benchmarks...$(NC)"; \
fi
go test -bench=. -benchmem ./...
official-smoke: ## 运行真实官方全量拉取 smoke(默认写入 /tmp 隔离目录)
@echo "$(BLUE)Running official full pull smoke...$(NC)"
./scripts/official-full-pull-smoke.sh
bat-api-local-live-smoke: ## 在同一临时主机环境联调 Rust bat.sock 与 Go bat-api
@echo "$(BLUE)Running local bat/bat-api live smoke...$(NC)"
./scripts/bat-api-local-live-smoke.sh
# ============================================================================
# 代码质量
# ============================================================================
check: check-rust check-go ## 检查代码(不编译)
check: check-rust check-go check-docs ## 检查代码和状态文档(不编译)
check-rust: ## 检查 Rust 代码
@echo "$(BLUE)Checking Rust code...$(NC)"
@@ -87,11 +94,11 @@ check-rust: ## 检查 Rust 代码
check-go: ## 检查 Go 代码
@echo "$(BLUE)Checking Go code...$(NC)"
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
go vet ./...; \
else \
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
fi
go vet ./...
check-docs: ## 检查权威状态文档与占位目录声明
@echo "$(BLUE)Checking documentation status claims...$(NC)"
bash scripts/check-doc-status.sh
fmt: fmt-rust fmt-go ## 格式化所有代码
@@ -101,17 +108,13 @@ fmt-rust: ## 格式化 Rust 代码
fmt-go: ## 格式化 Go 代码
@echo "$(BLUE)Formatting Go code...$(NC)"
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
go fmt ./...; \
else \
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
fi
go fmt ./...
lint: lint-rust lint-go ## 运行所有 Linter
lint-rust: ## Rust Clippy 检查
@echo "$(BLUE)Running Clippy...$(NC)"
cargo clippy --workspace -- -D warnings
cargo clippy --workspace --all-targets -- -D warnings
lint-go: ## Go Linter 检查
@echo "$(BLUE)Running golangci-lint...$(NC)"
+90 -69
View File
@@ -1,8 +1,8 @@
# BlueArchiveToolkit 完整开发计划
- **项目名称**BlueArchiveToolkit
- **文档版本**2026-07-06 状态收口
- **权威状态**:以本文档和 `CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。
- **文档版本**2026-09-04 状态复核
- **文档角色**:长期目标、里程碑和路线图;当前实现以源码、测试、稳定契约`CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。
- **最终目标**:构建一个可长期维护、可扩展、可审计的 Blue Archive 资源管理、文本提取、翻译和补丁平台。
---
@@ -13,7 +13,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
1. **CLI 工具**:面向本地用户和自动化任务,覆盖 `doctor``sync``manifest``bundle``extract``translate``patch``verify``cache``serve` 等命令。
2. **Rust 核心引擎**:负责 CAS、AssetBundle 解析、Patch、二进制安全处理和性能敏感逻辑。
3. **Go 服务层**:负责 CLI 编排、资源同步、下载器、API Server、任务调度和外部集成
3. **Go 服务层**:负责资源分发 API、服务编排、任务调度和外部集成;官方资源同步/运维命令行当前由 Rust `bat` 承担,Go 通过 RPC 调用
4. **Web 管理后台**:负责翻译审核、术语管理、全文搜索、历史版本、Diff 和 Dashboard。
5. **SDK/API**:提供稳定的 Go SDK、进程边界和 REST/OpenAPI 接口,方便其他工具复用;FFI 仅保留为可选兼容层。
6. **插件系统**:允许新增解析器、翻译 Provider、存储后端、Patch 算法,而不修改核心代码。
@@ -22,7 +22,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 2. 当前真实状态
本节来自 2026-07-06 的工作区盘点、本地验证和最新功能提交。
本节来自 2026-09-04 的工作区盘点、本地验证和最新功能提交。
### 已具备
@@ -32,29 +32,32 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。
5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。
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 的 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable` 运维命令
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/restart/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``localized.status``catalog.*``task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI 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`,表示原版资源已发布、汉化资源未发布;`translation.proofread` 可把汉化 workflow 标记为人工校对中,但不会覆盖已发布的汉化 release
10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json``crowdin-translation-handoff.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。
11. Rust `bat` 已提供 `res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD 与作用域过滤、解析缓存清理、翻译工作台校验、离线翻译工作台、人工文本查看/修改/清空、翻译任务状态回写、人工校对状态标记、既有 patch 能力的批量重打包和独立汉化 release 发布;`translation.worker.run` 已接入 provider worker,默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果;`bat-api` 已提供内嵌 dashboard MVP,直接调用已有鉴权接口控制资源、调度、任务、日志、parse、翻译和 localized 发布/回滚;schedule CRUD、`translation.tasks` / `translation.handoff``translation.task.update``translation.worker.run``translation.proofread` 已经通过 `bat.sock``bat-api` 管理接口暴露,dashboard 不维护第二套状态;新解析覆盖仍需真实 fixture 和回归验收。
12. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。
### 仍是骨架或占位
1. AssetBundle 解析器仍是占位 trait,未解析 UnityFS、压缩块、TypeTree 或对象表
2. Patch 的 Binary/JSON 模块仍返回空结果,不具备真实补丁能力
3. Go CLI/API/SDK 仍没有产品级入口;只有 `internal/ffi` 的可选兼容包装骨架
1. `bat-assetbundle` 已具备 UnityFS 解包和 TextAsset 提取基础能力(header/block info/directory、LZ4/LZMA block info 与数据 block、directory 文件提取、serialized file object table、TypeTree node 元数据、TextAsset bytes、TypeTree-covered managed reference payload TextUnit 上下文),并已有受支持 localized patch 发布能力;MonoBehaviour/ScriptableObject 复杂字段级解析、整体重打包和通用 Patch 仍未完成
2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService` 已完成受支持 TextUnit 到 localized patch manifest、独立 staging、发布和 rollback 闭环,通用 manifest 发布与整体 AssetBundle 重打包仍后置
3. Go 侧边界已确定(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发和内嵌 dashboard = `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 历史未恢复;当前仓库以新初始化基线为准
5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract`translation.worker.run` 已提供真实 provider worker 触发、lease/retry 和结果落库 contract`translation.proofread` 已提供汉化 workflow 人工校对标记 contract
6. 受支持汉化 Patch 发布已具备 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 manifest/apply/rollback/完整性校验和 `localized.status` 严格校验;真实 provider worker 已接入,翻译记忆到完整汉化文件集合的构建仍未完成
7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook;真实运行记录处于长期运行测试阶段,报告待后续提供
8. 内嵌 dashboard MVP 已实现;完整 Web 协作后台、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发/OpenAPI/管理控制面已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成
9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。
### 已验证
1. `cargo test -p bat-adapters -- --nocapture` 通过。
2. `cargo test -p bat-ffi -- --nocapture` 通过。
3. `cargo test -p bat-infrastructure -- --nocapture` 通过
4. `cargo test -p bat-infrastructure --bin bat -- --nocapture` 通过
5. `cargo run -p bat-infrastructure --bin bat -- --help` 可用。
6. `go test ./...` 当前无 Go 产品 package`Makefile` 已调整为在 Go 未实现阶段明确跳过。
1. `cargo test --workspace --quiet` 通过。
2. `cargo clippy --workspace --all-targets -- -D warnings` 通过。
3. `make test-go-api` / `make build-go-api` 覆盖 `internal/api``internal/backendrpc`
4. `go vet` 覆盖 bat-api 相关包
5. `target/debug/bat --help`Rust可用。
---
@@ -70,8 +73,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
### 3.2 技术决策
1. **Rust**:保留为核心引擎语言,用于 CAS、AssetBundle、Patch、完整资源拉取和更新检查核心逻辑;`bat --json` 进程边界是当前主集成路径FFI 仅作为可选兼容层。
2. **Go**用于最小稳定 CLI、服务编排、API Server、任务编排Provider 集成不强制要求 Rust 核心能力必须写成库供 Go 调用。
1. **Rust**:保留为核心引擎语言,用于 CAS、AssetBundle、Patch、完整资源拉取和更新检查核心逻辑;`bat.sock` RPC 是 Go `bat-api` 的当前主集成边界,`bat --json` 是 Rust CLI 的机器输出形态FFI 仅作为可选兼容层。
2. **Go**当前用于 `bat-api` 资源 bootstrap/分发和 Rust RPC 管理入口;完整服务编排、API Server、任务编排Provider 集成仍是目标能力,不强制要求 Rust 核心能力必须写成库供 Go 调用。
3. **PostgreSQL**:作为服务端主数据库,承载翻译记忆库、术语库、任务、审核和用户权限。
4. **SQLite**:仅作为本地 CLI 可选元数据后端,必须通过仓储抽象隔离,不能绑定业务逻辑。
5. **Redis**:用于服务端缓存、任务状态、限流和短期锁。
@@ -84,8 +87,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
1. 无占位返回、无静默吞错、无未说明的 `TODO`
2. 公共接口具备文档、错误语义和兼容性说明。
3. 单元测试覆盖核心分支;跨模块能力补集成测试。
4. `cargo fmt``cargo clippy --workspace -- -D warnings``cargo test --workspace` 通过。
5. Go 模块落地后,`go test ./...``go vet ./...` 通过
4. `cargo fmt``cargo clippy --workspace --all-targets -- -D warnings``cargo test --workspace` 通过。
5. Go 当前门禁通过 `make test-go-api``make build-go-api``go vet ./...`
6. 用户可见命令必须有 `doctor` 检查和失败恢复建议。
---
@@ -141,7 +144,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
**目标**:完成可长期使用的 Content Addressable Storage。
**当前状态**:已完成 CAS V1。Go CLI 以最小稳定入口优先,Rust 继续承载完整资源拉取与更新检查核心逻辑`bat-ffi`保留为可选兼容层。
**当前状态**:已完成 CAS V1。Rust 承载完整资源拉取与更新检查Go 以 `bat-api` 资源分发 MVP + `backendrpc` 为服务入口(`GO_STATUS.md``bat-ffi` 仅可选兼容层。
交付物:
@@ -166,19 +169,23 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
**目标**:能够获取、解析和同步 Blue Archive 资源清单。
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;Go CLI、完整解析覆盖、CAS 导入编排和真实线上 smoke 仍待完成。
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;`bat-api` 资源 bootstrap/分发入口已落地,CAS + ResourceRepository 导入、历史 release/CAS 复用已可用,但完整解析覆盖、丰富查询/翻译记忆和真实线上 smoke 仍待完成。
交付物:
1. Addressables Catalog 真实字段解析:**部分完成**。当前已覆盖 path、hash、size、address、dependencies、metadata 和真实形态 fixture/golden;仍需继续覆盖更多官方 catalog 结构变体
2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;仍需冻结 Go CLI/API 可见模型。
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验repair。
1. Addressables Catalog 目标字段解析:**当前目标完成**。JSON/compact 已覆盖 path、hash、size、address、dependencies、provider ID、bundle name、CRC、metadata,并通过 fixture/golden 与 SQLite 迁移回归;独立二进制 catalog 仍明确拒绝
2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;Go CLI/API 可见模型仍需在稳定 contract 中继续收敛
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验repair、已发布历史 release/CAS 复用,以及默认 8、范围 `1..=256` 的有界并发 scheduler;worker 动态领取任务,进度按完成数单调上报,report 保持 plan 顺序,复用和网络传输分别统计
4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch``--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。
5. Go CLI**未完成**。需要实现 `bat doctor``bat sync --help`、Rust 官方同步命令包装和 JSON/human 输出
6. 用户级 `sync``manifest inspect``cache status`**未完成**。Rust `bat --json` 是 Go CLI 默认进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
7. 下载结果写入 CAS + ResourceRepository**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果作为用户级流程自动导入。
5. Go 入口边界:**已确定**。同步命令行 = Rust `bat`;资源分发 = `bat-api` MVP。详见 `docs/reports/GO_STATUS.md`
6. Go 用户级 `sync``manifest inspect``cache status`**未完成**。Rust `bat`
是当前正式资源同步 CLI`bat --json` 是其机器输出形态;`bat-ffi` 只提供可选
兼容用的 Manifest inspect 和 sync plan JSON helper。
7. 下载结果写入 CAS + ResourceRepository**基础能力可用,查询面仍部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后导入;`resource.index` 可按资源级 release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 查询现有索引和资源 metadata,常用 metadata 过滤已下推到 SQLite`bat doctor cas` 可只读诊断既有 CAS 根目录、对象目录、元数据库文件和对象统计;`parse.text_units` / `parse.errors` 可查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` 可查询离线 TextUnit 翻译任务状态、跳过/失败原因和 worker 结果。剩余工作是更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。
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 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
9. 真实官方网络全量下载 smoke test:**命令已固化**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
10. 官方发布后的增量 handoff 与解析缓存:**已完成基础入口**。新 release 发布后先生成 `official-resource-changes.json``crowdin-translation-handoff.json`,新增+变更资源进入解析/翻译候选;`official-parse-cache.json` 基于下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;随后生成 `official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`,本地文件未变化且缓存/索引有效时跳过重复解析。
11. 汉化发布状态:**受支持范围已完成闭环**。官方同步默认报告 `not_localized`,表示只发布原版资源;TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 和 rollback 校验后才切换为 `localized`,可通过 `localized.publish` / `localized.rollback` 与 bat-api 管理接口控制。
验收标准:
@@ -190,28 +197,39 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
6. 自动更新入口必须做到无变化不下载,有变化下载成功后才写入新 snapshot。
7. `--watch` 模式必须在 Rust 内部保持持久检查能力,外部 supervisor 只负责进程守护。
8. 真实官方网络 smoke 必须记录输出目录、命令、结果摘要和未纳入仓库的大文件位置。
9. 官方原版资源目录和汉化产物目录必须物理分离,不能相同或互相嵌套。
10. 官方同步完成后必须能区分 `not_localized``localized`,不能把原版资源发布状态与汉化产物发布状态混为一谈。
11. 新 release 发布后必须能产出可审计的资源变更集,新增+变更资源进入解析/翻译 handoffCrowdin 调用由后续翻译 worker 消费本地 handoff 决定。
---
### Milestone 4Unity AssetBundle 解析
当前解析扩展按路线图和真实回归继续推进。
**目标**:建立可扩展 AssetBundle 解析框架,并首先支持文本相关资源。
交付物:
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、LZ4/LZMA、alignment、总大小/计数/路径/边界错误、directory 文件提取和 UnityPy 真实样本回归;复杂版本差异和发布级重打包另行推进
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、vector/staticvector 嵌套 `Array``List<T>` / `HashSet<T>` 集合 alias、map、PPtr、enum `value__` backing field、`LayerMask` / `BitField``m_Bits` backing field、常见固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 保留和同长度替换、TypeTree-covered managed reference / `SerializedReference` alias 和 TypeTree-covered managed reference registry 记录;managed-reference full typename 可拆为 assembly/namespace/class,常见 `m_ManagedReferences` / `RefIds` / verbose type 字段命名、`managedReference*` / `serializedReference*` metadata 和 `data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData` payload 命名已有回归覆盖,TextUnit 只提取 payload 字符串并按结构化 record、`RefIds[n]` 等记录前缀或子字段保留类型上下文;array/vector/List/HashSet/map 元素与 registry payload 字段保留独立 field path、offset 和 byte size,可支撑字符串元素、managed-reference registry payload 字段、基础语义字段 patch、enum/bit_field 语义 patch、固定值类型 patch、unknown fixed-size bytes patch、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体变长替换,`first/second``key/value` map entry schema 已有回归覆盖;解析模块当前仍有真实版本差异、未见样本驱动的完整 managed reference registry / map entry 变体和 unknown 字段结构语义需要继续推进
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` / `BAT_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`
---
@@ -284,11 +302,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
交付物:
1. 实现 Binary Patch、JSON Patch、Text Patch。
2. 定义 Patch manifest:目标版本、文件列表、Hash、签名、回滚信息
1. 实现确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply 和 UTF-8 Text Patch。
2. 定义 Patch manifest 基础:目标版本、文件列表、BLAKE3、size 和 rollback 元数据;签名后置
3. 实现客户端发现、路径校验、备份、应用、回滚。
4. 实现 `patch build``patch apply``patch rollback``verify`
5. 实现 dry-run 和安全检查。
6. 将通用 Patch manifest 与汉化发布流程进一步统一。
验收标准:
@@ -296,6 +315,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
2. 任一步失败都能回滚到补丁前状态。
3. 不直接覆盖未经备份的客户端文件。
4. Patch 生成与应用有端到端测试。
5. 汉化产物写入 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立目录,保留官方相对目录结构;只有完整 Patch 发布并通过校验后才切换为 `localized`
---
@@ -305,12 +325,14 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
交付物:
1. Go CLI 主入口和命令体系。
1. 正式 Rust `bat` CLI 和命令体系Go 侧面向 `bat-api`、SDK 和服务集成发展
2. 配置系统:项目级、用户级、环境变量、密钥管理。
3. Go SDKManifest、Sync、CAS、Extract、Translate、Patch。
4. REST API Server:认证、权限、统一错误码、OpenAPI。
5. 后台任务系统:同步、提取、翻译、补丁构建。
当前边界:正式同步与运维 CLI 继续由 Rust `bat` 承担;Go `cmd/bat` 仅为试验入口,Go 产品化工作集中在 `bat-api`、SDK 和服务集成。
验收标准:
1. CLI 命令风格统一,支持 JSON 输出和人类可读输出。
@@ -322,12 +344,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
### Milestone 10Web 管理后台
**目标**:为翻译协作和资源管理提供可用后台。
**目标**在已落地的 `bat-api` 内嵌 dashboard MVP 之上,为翻译协作和资源管理提供完整后台。
交付物:
1. 登录、权限、用户角色。
2. Dashboard:同步状态、翻译进度、质量问题、队列状态。
2. Dashboard:同步状态、翻译进度、质量问题、队列状态;当前 MVP 已覆盖资源、调度、任务、日志、parse、翻译和 localized 控制
3. 翻译审核:列表、详情、Diff、批量操作。
4. 术语管理:搜索、冲突提示、审核。
5. 资源浏览:版本、资源、Bundle、文本定位。
@@ -348,7 +370,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
交付物:
1. 发布验证:format、lint、test、build、security audit、release artifact 由本地可重复命令与脚本承担(决策:不引入 GitHub Workflows 等托管 CI,见 `docs/reports/CURRENT_GAPS.md` G-017
1. 发布验证:format、lint、test、build、security audit、release artifact 由本地可重复命令、自托管 Gitea linux-runner workflow 与脚本承担;当前不引入托管 CI
2. Docker Compose:本地开发、服务端部署。
3. 数据备份与恢复文档。
4. 用户文档、开发文档、故障排查文档。
@@ -366,7 +388,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 5. 推荐执行顺序
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是 Go CLI 入口、资源解析、同步结果进入 CAS/ResourceRepository,以及真实端到端验证。
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是翻译记忆、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证。
建议顺序:
@@ -374,20 +396,19 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
2. 完成 Milestone 5,再开始翻译系统。
3. 完成 Milestone 6 和 7,建立可审计翻译流程。
4. 完成 Milestone 8,形成可交付补丁。
5. 最后补齐 CLI/API/Web/发布工程。
5. 最后补齐完整 CLI/API/Web 协作后台和发布工程。
---
## 6. 近期具体任务
## 6. 当前开发入口
1. 落地 Go CLI 的最小生产入口:`bat doctor``bat sync --help``bat official sync --help`
2. 让 Go CLI 默认调用 Rust `bat --json` 官方同步入口,并稳定转发结构化 report;除非有明确兼容需求,不走 FFI。
3. 记录一次真实官方网络 smoke:dry-run、首次下载、二次 up-to-date、本地损坏 repair。
4. 将官方同步下载结果接入 CAS + `SqliteResourceRepository` 的用户级流程
5. 继续扩展 Addressables parser 的真实 catalog 变体覆盖和错误诊断
6. 开始 AssetBundle UnityFS header/block/directory 解析
7. 为 CLI 和 CAS 增加 `doctor cas` 诊断入口
8.`bat --watch` / `bat --daemon` 持续补充发布型构建、systemd service 示例和运维检查清单;后台 live control plane 已改为 Unix socket JSON-RPC;基础生产部署模板、日志路径、权限用户、升级/回滚流程已补齐。
当前优先推进 Rust 解析、资源库查询和翻译发布能力。边界见
`docs/reports/GO_STATUS.md`
1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力
2. 基于 `translation.worker.run` 继续推进翻译记忆和 Patch 构建
3. 继续扩展资源库剩余查询面:更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图
4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告
---
@@ -395,7 +416,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
### SQLite 权限问题
旧 Week 3 报告提到 SQLite 文件权限导致测试失败。处理策略:
本地 SQLite 元数据后端的权限和恢复风险需要通过显式测试覆盖。处理策略:
1. 本地元数据后端必须使用临时目录和明确权限测试。
2. SQLite 只作为 adapter,不进入领域层。
@@ -414,8 +435,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
处理策略:
1. Rust 提供稳定引擎能力,不承担 CLI 编排,但负责完整资源拉取和更新检查的核心逻辑
2. Go 负责用户命令、最小稳定 CLI、服务编排、网络和 Provider
1. Rust 提供稳定引擎能力,并在当前阶段承担可生产运行的官方资源同步 CLI、watch 和 daemon
2. Go 的目标职责包括资源分发 HTTP(当前为 `bat-api`)、服务编排、网络和 Provider;同步/运维命令行由近乎全自动的 Rust `bat` 承担。不能把试验性 `cmd/bat` 视为产品 CLI
3. 跨边界优先进程或 SDK,FFI 只作为可选的粗粒度、无状态、安全、可测试兼容 API。
4. Rust 不需要被强制写成 Go 调用库;当前 `bat --watch` / `bat --daemon` 是允许长期运行的 Rust 生产任务。
@@ -428,22 +449,22 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
3. smoke test 只记录命令、状态和摘要,不把大体积官方资源纳入 Git。
4. 下载成功后必须通过 `official-download-manifest.json` audit 和官方 seed `.hash` 校验报告确认。
### 过早做 Web
### Web 范围控制
处理策略:
1. Web 依赖可用 API 和数据库,不应早于核心同步、提取、翻译模型
2. 先完成 CLI 和 API,再构建 Web
1. 当前内嵌 dashboard 只编排已有 API,不维护第二套业务状态
2. 完整协作后台应在权限、翻译模型和持久化 API 明确后继续建设
---
## 8. 当前完成度评估
按最终目标计算,当前总体完成度约为 **22%**
按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准
已完成的是稳定基线、架构骨架、部分接口、CAS V1Rust 官方资源同步闭环,不是完整产品能力。下一阶段的关键不是继续堆目录,而是把 Go CLI 最小入口、官方同步端到端验证、CAS/ResourceRepository 编排和 AssetBundle 解析链路做实
已完成的是稳定基线、架构骨架、部分接口、CAS V1Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是翻译记忆、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告
---
- **下一份应更新文档**:真实官方网络 smoke 记录
- **下一项工程任务**Go CLI 最小可用入口和官方同步端到端 smoke。
- **下一份应补充的验证材料**:真实官方网络 smoke 运行记录
- **下一项工程任务**推进翻译记忆、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
+44 -30
View File
@@ -2,7 +2,7 @@
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用 `--watch` 常驻定期检查更新。Go CLI、API Server、Web、完整 AssetBundle 解析、翻译系统和 Patch 系统仍在后续阶段。
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、CDN path 只读分发和内嵌管理 dashboard);`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,31 +10,40 @@
- Rust workspace 和 monorepo 结构。
- `bat-core` 领域对象和仓储接口骨架。
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖。
- `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 后台控制命令 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable`
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、默认并发 8(可配置 `1..=256`report 按 plan 顺序、进度按完成数单调上报)、已发布历史 release 与 CAS 复用、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json``--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.*``resource.*``parse.*``translation.tasks/handoff/task.update``localized.status``catalog.*``task.*``patch.apply``unityfs.patch_*`
- `internal/backendrpc`Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。
- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVPG-009);`/v1/bootstrap``/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readinessCDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(同步 CLI = Rust `bat`)。
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询索引,常用 metadata 过滤会下推到 SQLite;历史 release 复用会重新校验 size、BLAKE3 和 ZIP 结构,失败时按历史 release、CAS、网络顺序回退,CAS 引用记录在 `official-cas-reuse-references.json` 中;`bat doctor cas` 可只读诊断既有 CAS 目录、对象数、对象字节数和元数据库文件状态
- 新 release 发布后会生成 `official-resource-changes.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-translation-handoff.json``crowdin-textunit-queue.json``translation-tasks.sqlite``translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接。
- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试和 TextUnit 译文结果落库;尚缺 Translation Memory、Glossary 和完整 Provider 扩展体系。
- `LocalizedPatchService` 已具备受支持的 UnityFS localized patch 发布/回滚能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset、TypeTree string field 或 managed-reference string field patch、写入带 TextUnit/provider/review/rollback trace 的 `localized-patch-manifest.json`,校验后发布到 `versions/<id>` 并切换 `current`,也可显式 rollback。
- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,受支持 localized 发布通过独立 manifest/staging/current 流程完成。
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
- 文档路线图、当前状态、缺口清单、官方资源运行指南。
仍未完成:
- Go CLI 最小可用入口
- 完整 UnityFS / AssetBundle 解析
- 真实 Patch apply/diff
- Translation Memory、Glossary、AI Provider。
- API Server、SDK、Web 管理后台。
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段
- Translation Memory、Glossary 和完整 Provider 扩展体系仍未实现
- SDK、完整 Web 协作后台。
详细状态见:
- [当前状态](CURRENT_STATUS.md)
- [Go 侧进度与边界](docs/reports/GO_STATUS.md)
- [完整开发计划](PROJECT_PLAN.md)
- [文档索引](DOCS_INDEX.md)
- [当前缺口清单](docs/reports/CURRENT_GAPS.md)
- [官方资源拉取与自动更新指南](docs/guides/official-resource-test-pull.md)
- [官方全量拉取 Smoke Runbook](docs/guides/official-full-pull-smoke.md)
- [bat-api 同机 Live Smoke Runbook](docs/guides/bat-api-local-live-smoke.md)
- [官方资源后端说明](docs/architecture/official-resource-backend.md)
---
@@ -44,17 +53,21 @@
前置要求:
- Rust 1.75+
- Go 1.22+
- Go 1.26.4+
- `curl`
- `unzip`,仅旧版 launcher manifest 指向整包 ZIP 且 `--auto-discover` 需要从 ZIP 解析 `GameMainConfig` 时使用;当前目录型 manifest 会直接下载 `resources.assets`
运行当前主要测试
运行当前通用验证
```bash
cargo test -p bat-adapters -- --nocapture
cargo test -p bat-ffi -- --nocapture
cargo test -p bat-infrastructure -- --nocapture
cargo test -p bat-infrastructure --bin bat -- --nocapture
cargo fmt --all -- --check
cargo check --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
make test-go-api
make build-go-api
go vet ./...
make check-docs
```
查看官方同步命令:
@@ -80,7 +93,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` 等控制命令:
后台自动运行可以把 `--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 -- \
@@ -94,11 +107,11 @@ cargo run -p bat-infrastructure --bin bat -- reload
cargo run -p bat-infrastructure --bin bat -- stop
```
`status``stop``logs``reload`默认形态的 `refresh` 会优先连接 live RPC socketsocket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`
`status``stop``restart``logs``reload`默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socketsocket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`restart` 会通过 Rust lifecycle controller 复用 CLI restart 路径替换后台进程;`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh`/`repair` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `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`启用 `--auto-discover` 时,已发布 release 会写入 `official-launcher-bootstrap.json`,其中包含 launcher metadata、launcher CDN config、remote manifest 文件列表、选中的 `resources.assets` 来源和 `GameMainConfig` 摘要;官方资源端尚未开放时会写 `<output>/official-launcher-bootstrap.pending.json`,但不会切换 `current`。新 release 发布后会对比上一完整 release 的 download manifest,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`;新增+变更资源作为解析/翻译候选,删除资源只进入差异记录。复用历史 release 时会先按 destination 找候选并重新校验 size、BLAKE3、ZIP 结构,必要时验证 CAS;候选不可靠就记录诊断并回退网络,不会静默复用。CAS release 引用存放在 `official-cas-reuse-references.json`,孤儿 staging 清理时会递减这些引用。up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;后续 Patch/导出写入 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 指定的独立目录,保留官方相对目录结构,manifest 校验通过后才切换为 `localized`
资源操作命令默认输出人类可读摘要,并在没有显式 metadata 参数时默认走官方自动发现。脚本或上层程序需要稳定结构化输出时加 `--json`
@@ -113,9 +126,9 @@ cargo run -p bat-infrastructure --bin bat -- clean-stable
`verify` 会以只读方式检查当前官方计划、`current` 指向的 active release 中 download manifest 的 size+BLAKE3、ZIP 结构,以及本地已有官方 seed `.bytes/.hash` 对的 xxHash32;发现缺失、远端变化或本地损坏会返回非 0。`repair` 会在异常资源存在时复用当前同步链路重新下载必要文件。`clean-stable` 只清理 `.part`、临时状态文件、失效或损坏的 PID/锁/socket,不删除正式资源。
下载失败会按 curl exit 和 HTTP 状态分类:403/404/普通 4xx 视为不可重试,5xx、429、DNS、连接、超时、中断和网络类错误会按尝试次数重试。某个 URL 最终失败后会写入 `<output>/current` 或 staging 下的 `official-download-quarantine.json`stderr progress、daemon status 和 `bat-events.jsonl` 会记录失败类型、HTTP 状态、是否可重试、尝试次数和 quarantine 状态;同步会中断并阻止发布不完整资源。旧 launcher 包下载路径会在官方 primary CDN 失败后切换官方 backup CDN。
下载失败会按 curl exit 和 HTTP 状态分类:403/404/普通 4xx 视为不可重试,5xx、429、DNS、连接、超时、中断和网络类错误会按尝试次数重试。若官方启动器/server-info 已先行更新,但 client-patch root 下的 seed marker 或必需 seed catalog 仍返回 403/404/普通 4xx`bat` 会返回 `update_status=waiting_for_official_resources`,保留现有 `current`,不创建失败 staging,不把维护期记为失败版本;watch/daemon 会按错误重试间隔继续探测。已进入下载阶段的单个资源 URL 最终失败后会写入 `<output>/current` 或 staging 下的 `official-download-quarantine.json`stderr progress、daemon status 和 `bat-events.jsonl` 会记录失败类型、HTTP 状态、是否可重试、尝试次数和 quarantine 状态;同步会中断并阻止发布不完整资源。旧 launcher 包下载路径会在官方 primary CDN 失败后切换官方 backup CDN。
CLI 默认启动时会向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并继续把阶段进度日志写到 stderr,例如自动发现、拉取 catalog、audit、总体下载进度、单文件下载进度、校验结果摘要、snapshot 和 publish;命令结果默认以人类可读摘要写到 stdout。需要给上层程序保留稳定结构化输出时加 `--json --no-progress`,只想关闭横幅但保留日志时可加 `--no-banner`
CLI 默认启动时会向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并继续把阶段进度日志写到 stderr,例如自动发现、拉取 catalog、audit、下载已完成计数、单文件下载进度、校验结果摘要、snapshot 和 publish;命令结果默认以人类可读摘要写到 stdout。需要给上层程序保留稳定结构化输出时加 `--json --no-progress`,只想关闭横幅但保留日志时可加 `--no-banner`
真实官方网络全量拉取 smoke 已固化为可重复命令,默认使用 `/tmp/bat-official-smoke-<UTC timestamp>/` 隔离目录,不会写入已有客户端、生产目录或开发机人工维护资源目录:
@@ -128,18 +141,18 @@ 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 <资源目录>``.env` 中的 `BAT_OUTPUT`;需要覆盖汉化产物位置时,用 `--localized-output <目录>``.env` 中的 `BAT_LOCALIZED_OUTPUT`;需要启用官方 release 导入 CAS/索引时,用 `--import-repository`,并可用 `--import-cas-root``--import-resource-db``.env` 中的 `BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖默认路径;需要覆盖后台状态目录时,用 `--state-dir <状态目录>`
---
## 技术栈
- RustCAS、官方资源同步核心、AssetBundle/Patch 引擎;当前生产同步入口是 `bat` binary。
- Go计划中的最小 CLI、服务编排、API Server、SDK;默认通过 `bat --json` 进程边界或未来 SDK 集成 Rust 能力
- Go当前正式入口是 `bat-api` 资源 bootstrap/分发服务、内嵌 dashboard 和 `internal/backendrpc`;完整游戏业务 API、SDK、Provider 编排仍按路线图推进,`cmd/bat` 仅为试验 CLI
- `bat-ffi`:可选兼容层,只暴露无状态粗粒度 JSON C ABI,不承载 daemon、下载器、CAS handle 或主控制面。
- PostgreSQL:计划中的服务端主数据库。
- Redis:计划中的缓存、队列状态、限流和短期锁。
- Vue 3 + TypeScript:计划中的 Web 管理后台
- Vue 3 + TypeScript:计划中的完整 Web 协作后台;当前已先提供无构建内嵌 dashboard
- Docker / Docker Compose:数据库和后续服务部署配置。
---
@@ -156,11 +169,12 @@ BlueArchiveToolkit/
│ ├── bat-assetbundle/
│ ├── bat-patch/
│ └── bat-ffi/ # 可选无状态 C ABI 兼容层
├── internal/backendrpc/ # Go -> Rust daemon 的 typed JSON-RPC client
├── internal/ffi/ # 可选 CGO 兼容包装,不是 Go CLI 主路径
├── cmd/ # Go CLI 入口,尚未实现
├── cmd/ # Go CLI 试验骨架与后续产品入口
├── pkg/ # Go SDK 包,尚未实现
├── api/ # API 定义尚未实现
├── web/ # Web 管理后台,尚未实现
├── api/ # 预留 API 定义bat-api OpenAPI 静态规范已提供,完整业务 API 尚未实现
├── web/ # bat-api 内嵌 dashboard 静态资产;完整协作后台仍在后续阶段
├── deployments/ # Docker 和部署配置
├── docs/ # 文档、历史报告和分析资料
├── Cargo.toml
@@ -174,13 +188,13 @@ BlueArchiveToolkit/
近期优先级:
1. 落地 Go CLI 最小可用入口:`bat doctor``bat sync --help`、通过 `bat --json` 包装 Rust 同步命令
2. 补齐 AssetBundle UnityFS header/block/directory 解析。
1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口和内嵌 dashboard`cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,不应误写成完整产品 CLI
2. 补齐 AssetBundle UnityFS 引擎级解析。
3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。
4. 将官方同步结果接入 CAS + ResourceRepository 的用户级工作流
4. 基于 `translation.worker.run` provider worker 推进翻译记忆、完整 Patch 构建和发布/回滚闭环
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
不建议在 Go CLI、资源解析和文本提取基础能力完成前优先开发 Web UI
当前已提供直接调用 bat-api 鉴权接口的内嵌 dashboard;完整 Web 协作后台仍应在翻译记忆、权限模型和持久化 API 明确后推进
---
+186 -11
View File
@@ -11,10 +11,10 @@
`bat` 是 Linux 上官方日服(Yostar JP)资源同步的正式入口。它可以:
- `--auto-discover` 从官方 HTTP metadata 解析 `GameMainConfig`,自动获得 app-version、连接组和 server-info,不安装、不启动官方启动器。
- 生成官方全量 pull plan、执行真实下载,维护 release 内的下载 manifest,并做 size + BLAKE3 复用校验、官方 seed `.hash`(标准 xxHash32(seed=0))强校验、ZIP 结构校验。
- `--auto-discover` 从官方 HTTP metadata 解析 `GameMainConfig`,自动获得 app-version、连接组和 server-info,不安装、不启动官方启动器;已发布 release 会保存 `official-launcher-bootstrap.json`
- 生成官方全量 pull plan、执行真实下载,维护 release 内的下载 manifest,并做 size + BLAKE3 复用校验、已发布历史 release/CAS 复用、官方 seed `.hash`(标准 xxHash32(seed=0))强校验、ZIP 结构校验。
- 断点续传、失败分类重试、下载 quarantine、本地 manifest audit/repair。
- 原子发布:先写 `.staging/<id>`,校验通过后发布 `versions/<id>` 并原子切换 `current` symlink。
- 原子发布:先写 `.staging/<id>`优先复用已验证历史 release/CAS校验通过后发布 `versions/<id>` 并原子切换 `current` symlinkCAS 引用记录在 release 内的 `official-cas-reuse-references.json`
- 常驻运行(`--watch`)或后台化(`--daemon`),通过 `bat.sock` Unix socket JSON-RPC 控制。
### 运行形态
@@ -44,6 +44,23 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
| 命令 | 说明 |
|---|---|
| `res pull` | 拉取官方资源;支持单次、限定次数和 `--watch` 周期执行 |
| `res schedule` | 管理资源拉取计划;CLI、RPC 和 `bat-api` dashboard 共用计划状态 |
| `parse run` | 执行当前官方 release 的解析和 TextUnit 队列刷新 |
| `parse clear-cache` | 使用 `--force` 清理当前 release 的可再生解析缓存和翻译队列 |
| `parse repack` | 根据 JSON spec 批量重打包 UnityFS bundle |
| `parse schedule` | 管理解析计划;与 `res schedule` / `i18n schedule` 共用同一计划状态 |
| `i18n run` / `i18n export` | 刷新离线翻译队列或导出可编辑翻译工作台 |
| `i18n set` / `i18n get` / `i18n unset` | 手动查看、修改或清空一个翻译工作台条目;也可通过 `i18n workbench ...``--workbench` 访问 |
| `i18n validate` | 发布前校验工作台 release、source text 和 patch 目标 |
| `i18n proofread` | 将当前汉化 workflow 标记为人工校对中 |
| `i18n tasks` / `i18n task list` / `i18n task status` | 查询当前离线 TextUnit 翻译任务状态 |
| `i18n handoff` | 查询当前翻译交接视图 |
| `i18n status` | 显示当前汉化 release 状态 |
| `i18n task update` | 回写 provider worker 任务状态 |
| `i18n worker run` | 运行真实 provider worker;支持单次、限定次数和周期执行 |
| `i18n publish` | 校验工作台并发布独立汉化 release;`--force` 使用新的手动 release ID |
| `i18n schedule` | 管理翻译和汉化发布计划 |
| `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 |
| `verify` | 校验远端计划、本地 manifest 和官方 seed hashdry-run + 审计当前 release |
| `repair` | 重新下载本地校验失败的资源 |
@@ -57,6 +74,141 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
`status`/`stop`/`logs`/`reload` 和默认形态的 `refresh` 优先走 `bat.sock` JSON-RPCsocket 不可用时 `status`/`stop` 回退到 PID/状态文件兼容路径。
Rust `bat` 工作流的完整命令、工作台字段、重打包 spec、调度计划和
`bat-api` 调度接口见 [`docs/guides/bat-workflows.md`](docs/guides/bat-workflows.md)。
一级命令推荐使用短名称 `res``parse``i18n``resource``resources`
`translation``translate` 仍是兼容别名。
其中 `translation` / `translate` 也支持 `tasks``handoff``status`
`task update``--translation-file` 也可写成 `--workbench``--schedule-id` /
`--schedule-action` 也可简写为 `--id` / `--action`
`resource status``resource schedule``translation tasks``translation handoff`
`translation status` 也都与对应短命令一致。
### bat-api 资源 bootstrap / 分发服务
`bat-api` 是 Go 侧正式服务入口,用于给客户端、补丁器或上层工具提供启动前资源入口和 CDN 形态只读分发。它不负责自动发现、下载、校验或发布资源;这些长期状态由 Rust `bat` / daemon 持有。
生产拓扑上,`bat-api` 基本应与 Rust `bat` 运行在同一台服务器、同一容器或同一共享文件系统环境。当前可读资源目录不在 `bat-api` 配置里写死,而是由 `bat.sock` RPC 的 `catalog.status` / `resource.manifest` 返回 `resource_root`
推荐运行关系:
```bash
# 先让 Rust bat 生产并维护 release
bat --auto-discover --daemon \
--output /var/lib/bluearchive-toolkit/official \
--state-dir /var/lib/bluearchive-toolkit/daemon-state
# 再启动 bat-api 读取同一个 daemon socket
bat-api \
--listen :18080 \
--public-base-url http://127.0.0.1:18080 \
--socket /var/lib/bluearchive-toolkit/daemon-state/bat.sock \
--refresh-interval 1m
```
测试、fixture 或应急只读诊断场景可用 `--resource-root <DIR>` 直接指向已发布 release 根;生产默认应通过 `--socket` / `BAT_API_SOCKET``bat.sock` 发现当前版本。`bat.sock` 不应暴露到公网;对外发布时只暴露 `bat-api` HTTP,并把 `--public-base-url` 设为客户端实际访问的 HTTPS 根。
开发环境不能本地全量运行 `bat` 时,用 fixture 验证 Go 服务面即可:
```bash
BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
--listen 127.0.0.1:18080 \
--public-base-url http://127.0.0.1:18080 \
--resource-root internal/api/testdata/release \
--refresh-interval 0
```
常用接口:
| 接口 | 说明 |
|---|---|
| `GET /healthz` | 服务存活、RPC 可用性、release ready 状态和最近一次 RPC refresh 诊断 |
| `GET/HEAD /readyz` | release 就绪检查;当前无可分发 release 时返回 `503` |
| `GET /v1/bootstrap` | 启动前资源入口:`bat` RPC 健康、release 摘要、server-info URL、client-patch base、改写后的 Addressables root |
| `GET /v1/launcher/bootstrap` | 启动器资源引导聚合视图:release、launcher metadata、GameMainConfig 摘要和资源 URL |
| `GET /api/launcher/game/config` | launcher 资源 metadata 兼容 envelope;字段来自 Rust `bat` 已发布 snapshot/RPC |
| `GET /api/launcher/game/config/json` | launcher 形状的资源引导 JSON URL;不会返回完整 PC package update manifest |
| `GET /api/launcher/advanced/game/download/cdn` | launcher 形状的 CDN 配置;返回当前 `--public-base-url`,用于资源引导 |
| `GET /api-launcher-jp.yo-star.com/api/launcher/...` | 与上面 `/api/launcher/...` 等价,便于反代或 hosts 映射保持官方 host 形状 |
| `GET /v1/release` | 当前 release 摘要 |
| `GET /v1/resources?offset=0&limit=100` | 当前 manifest 索引分页 |
| `GET /v1/server-info` | 调试用 server-info JSON,只改 `AddressablesCatalogUrlRoot` |
| `GET /yostar-serverinfo.bluearchiveyostar.com/server-info.json` | 官方 host/path 形态的 server-info |
| `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 |
| `GET /openapi.yaml` | bat-api OpenAPI 文档 |
| `GET /admin/` | 管理控制入口与允许操作列表 |
| `GET /admin/dashboard/` | 内嵌管理 dashboard 静态页面;页面调用的管理 API 仍需要 token |
| `GET /admin/diagnostics` | 读取 Rust daemon 诊断;需要管理 token |
| `GET /admin/logs?tail=200` | 读取 Rust daemon 日志尾部;需要管理 token |
| `GET /admin/tasks` | 读取 Rust daemon 任务列表;需要管理 token |
| `GET /admin/tasks/status?task_id=...` | 读取单项任务状态;需要管理 token |
| `GET /admin/tasks/logs?task_id=...` | 读取单项任务日志;需要管理 token |
| `GET /admin/schedules?id=...&group=...&enabled=...` | 读取/过滤 Rust `bat` 调度计划;需要管理 token |
| `GET /admin/parse/status` | 读取当前 release 解析状态;需要管理 token |
| `GET /admin/parse/text-units?...` | 分页查询当前 release TextUnit 明细;需要管理 token |
| `GET /admin/parse/errors?...` | 分页查询当前 release 解析错误;需要管理 token |
| `GET /admin/translation/tasks?limit=100&worker_status=failed` | 读取/过滤 Rust 翻译任务和 provider worker 状态;需要管理 token |
| `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token |
| `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 token |
| `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 |
launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` snapshot 中的 `launcher_metadata``game_main_config_bootstrap`,显式标记 `scope=resource_bootstrap_only` / `package_update_manifest=false``bat-api` 不下载 launcher 包,不生成官方 PC package update manifest,也不仿造登录、账号、网关、鉴权或游戏业务协议。
生产面对玩家分发时,应启用 HTTP token 鉴权、限流和访问日志:
- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer <token>``X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。`/admin/control/*``/admin/schedules``/admin/tasks*``/admin/logs``/admin/diagnostics``/admin/parse/*``/admin/translation/*` 需要此 token`/admin/dashboard/` 静态资产默认免鉴权,便于浏览器打开后再在页面内配置 token。
- `BAT_API_AUTH_QUERY_PARAM`query fallback 参数名,默认 `bat_token`;兼容不能写 header 的客户端,访问日志不会记录 query。
- `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`
- `BAT_API_RATE_LIMIT_RPS` / `BAT_API_RATE_LIMIT_BURST`:按客户端 IP 的进程内 token bucket 限流;边缘反代/CDN 仍应配置独立限流。
- `BAT_API_TRUST_PROXY_HEADERS`:只有反代已经清洗并覆盖 `X-Forwarded-For` / `X-Real-IP` 时才设为 `true`
- `BAT_API_ACCESS_LOG`:结构化访问日志,记录 method/path/status/bytes/duration/client_ip/request_id/user_agent,不记录 query string。
- `BAT_API_MAX_RESOURCE_LIMIT``/v1/resources` 最大分页上限,默认 `1000`
`POST /admin/control/{action}` 只转发固定白名单内的 Rust RPC,不是任意 RPC proxy
| action | Rust RPC | 参数 | 返回 |
|---|---|---|---|
| `reload` | `daemon.reload` | 无 | `202` accepted |
| `refresh` | `daemon.refresh` | 可选 `{ "force": true }` | `202` accepted |
| `restart` | `daemon.restart` | 无 | `202` accepted |
| `sync` | `resource.sync` | 可选 `{ "force": true }` | `202` + task |
| `verify` | `resource.verify` | 无 | `202` + task |
| `repair` | `resource.repair` | 无 | `202` + task |
| `catalog-refresh` | `catalog.refresh` | 可选 `{ "force": true }` | `202` + task |
| `schedule-add` | `schedule.add` | 调度 mutation JSON | `202` + Rust schedule report |
| `schedule-update` | `schedule.update` | 调度 mutation JSON | `202` + Rust schedule report |
| `schedule-remove` | `schedule.remove` | `{ "id": "..." }` | `202` + Rust schedule report |
| `schedule-run` | `schedule.run` | 可选 `{ "id": "...", "force": true }` | `202` + 执行报告 |
| `task-cancel` | `task.cancel` | `{ "task_id": "..." }` | `202` + 取消请求结果 |
| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "completed", "provider": "manual", "provider_run_id": "...", "translation_results": [{ "unit_id": "...", "source_text": "...", "translated_text": "..." }] }` | `202` + 当前任务记录 |
| `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task |
| `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 |
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }``{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest |
| `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report |
`stop``clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。
所有动态 JSONbootstrap、health、ready、release、resources、launcher 兼容、server-info、OpenAPI、admin 和错误响应)显式返回 `Cache-Control: no-store`。资源字节 CDN path 仍返回长期 immutable cache header。
CDN path 只服务 manifest 索引内且磁盘存在、size 匹配的文件。响应支持 `GET``HEAD``Range`、条件请求、ETag、Last-Modified、Accept-Ranges 和长期缓存头;ETag 优先使用 manifest 中的 BLAKE3。`.hash``text/plain` 返回,其它未知扩展默认为 `application/octet-stream`
`bat-api` 只改写资源相关入口:server-info 中的 `AddressablesCatalogUrlRoot` 会指向 `--public-base-url` 下的 `prod-clientpatch...` path`ApiUrl``GatewayUrl`、登录、账号、网关和游戏业务协议不会被仿造或改写。
示例:
```bash
curl -fsS http://127.0.0.1:18080/v1/bootstrap
curl -fsS http://127.0.0.1:18080/v1/launcher/bootstrap
curl -fsS http://127.0.0.1:18080/api-launcher-jp.yo-star.com/api/launcher/game/config
curl -fsS http://127.0.0.1:18080/openapi.yaml
curl -fsS \
http://127.0.0.1:18080/prod-clientpatch.bluearchiveyostar.com/<root_token>/TableBundles/TableCatalog.hash
curl -i -H 'Range: bytes=0-1023' \
http://127.0.0.1:18080/prod-clientpatch.bluearchiveyostar.com/<root_token>/TableBundles/TableCatalog.bytes
```
---
## 3. 选项
@@ -115,7 +267,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
### 默认值与运行时行为
- 平台:`Windows,Android`
- 资源输出:`./bat-resources``current``versions/<id>``.staging/<id>`)。
- 资源输出:`./bat-resources``current``versions/<id>``.staging/<id>`;自动发现 release 下包含 `official-launcher-bootstrap.json`,维护期 pending 证据位于发布根 `official-launcher-bootstrap.pending.json`)。
- 后台状态目录:`/tmp/bat-pid``bat.sock``bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl`、任务历史 `bat-tasks.json`、短生命周期 `bat-control.lock`;代理凭据在 `bat-proxy.secret``0600`)。
- 强制刷新:每天北京时间(UTC+8`03:00``16:00``18:00` 各一次。
- 状态类文件默认 `0600` 权限,读写不跟随 symlink。
@@ -166,7 +318,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
`location` 是稳定的「组件·操作」标签(跟随语义、不随行号漂移)。`retryable` 是该类错误的默认可重试性。
> 说明:错误码模型(`core/src/error_code.rs`)已建立并作为公共契约;将各链路的报错逐步接入该码表的工作在 issue #1 下推进。下表随码表更新
> 说明:错误码模型(`core/src/error_code.rs`)已建立并作为公共契约;下载、launcher/metadata、server-info/marker、配置校验、任务/RPC 等主要链路已接入该码表。剩余未实现命名空间和后续引擎能力继续按本表扩展
### 域一览
@@ -239,7 +391,9 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
## 6. Daemon RPC 接口
`bat --daemon` 在后台状态目录下创建 `bat.sock`Unix socket),提供**换行分隔的 JSON-RPC 2.0** 控制面。CLI 的 `status`/`stop`/`logs`/`reload`/`refresh` 优先走它;Go 服务层也应通过这个进程边界调用,而非 FFI
稳定 contract 以 `docs/reference/rpc-backend-api.md` 为准,本节保留常用说明和命令行示例
`bat --daemon` 在后台状态目录下创建 `bat.sock`Unix socket),提供**换行分隔的 JSON-RPC 2.0** 控制面。CLI 的 `status`/`stop`/`logs`/`reload`/`refresh`/`repair` 优先走它;Go 服务层也应通过这个进程边界调用,而非 FFI 或执行 `bat` binary 后再解析 stdout。
### 传输与 envelope
@@ -271,10 +425,22 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
| `daemon.stop` | ✅ | 请求停止(`accepted` |
| `daemon.reload` | ✅ | 请求重新发现并强制刷新(`accepted` |
| `daemon.refresh` | ✅ | 请求刷新检查(`params.force``accepted` |
| `daemon.restart` | ✅ | 启动 Rust lifecycle controller,并在响应后停止当前 daemon(`accepted` |
| `daemon.doctor` | ✅ | 返回运行时诊断报告(只读,不清理、不重启) |
| `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 |
| `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` |
| `resource.verify` | ✅ | 触发校验任务(dry-run + audit),返回 `task_id` |
| `resource.manifest` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000 |
| `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` |
| `resource.manifest` / `resource.list` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000 |
| `resource.index` | ✅ | 查询现有 SQLite ResourceRepository 索引,支持资源类型、hash、路径模式、release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 过滤 |
| `parse.status` | ✅ | 查询当前 release 的解析缓存、TextUnit 索引和队列摘要 |
| `parse.text_units` / `parse.errors` | ✅ | 查询当前 release 的 TextUnit 明细和解析错误 |
| `translation.tasks` | ✅ | 查询离线 TextUnit 翻译任务及 worker 状态 |
| `translation.handoff` | ✅ | 查询完整 job/unit/provider run 交接视图 |
| `translation.task.update` | ✅ | 回写当前 release 的 provider worker 状态 |
| `translation.worker.run` | ✅ | 触发 Rust provider worker,落库 TextUnit 译文结果、lease、失败分类和重试状态 |
| `translation.proofread` | ✅ | 将当前汉化 workflow 标记为人工校对中 |
| `localized.status` | ✅ | 查询汉化 release 与当前官方 release 的匹配状态 |
| `catalog.status` | ✅ | 当前已发布版本的 catalog 概览(app/bundle 版本、addressables 根、端点与 marker 计数、launcher 元数据) |
| `catalog.versions` | ✅ | 版本历史:current / in_progress / previous / failed |
| `catalog.diff` | ✅ | 当前 snapshot 相对上一个可用版本的差异(base_delta + extended_delta + 变更端点 URL |
@@ -283,18 +449,23 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
| `task.list` | ✅ | 列出全部任务(最新在前) |
| `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 |
| `task.logs` | ✅ | 返回任务的进度日志(`params.task_id`,有界) |
| `resource.repair` / `patch.*` / `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`not implemented);repair 待引擎支持独立修复模式,patch/unityfs 待引擎实现 |
| `patch.apply` | ✅ | 对显式 source/patch/target 文件同步执行 Binary/JSON/Text patch |
| `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` | ✅ | 对显式 UnityFS bundle 文件执行文件级写入并原子输出 |
| `daemon.clean-stable` / 发布级 patch 方法 / 其他未开放 `unityfs.*` / `task.create` | ⏳ | 返回 `BAT-ERR-700003`not implemented);clean-stable 仍由 CLI 侧按进程生命周期显式执行,task.create 暂不开放通用任务入口 |
| 未知方法 | — | `BAT-ERR-700001`unknown method |
只读查询(`resource.state` / `resource.manifest` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` /
`resource.index` / `parse.*` / `translation.tasks` / `translation.handoff` /
`localized.status` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本
或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
### 任务模型
`resource.sync` / `resource.verify` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }``status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }``status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
```json
{ "id": "task-1234-1", "kind": "resource.sync",
"status": "queued|running|succeeded|failed",
"status": "queued|running|succeeded|failed|cancelled",
"stage": "download", "message": "…",
"created_at": …, "updated_at": …, "started_at": …, "finished_at": …,
"error": { … }, "result": { … } }
@@ -324,4 +495,8 @@ printf '{"jsonrpc":"2.0","id":4,"method":"catalog.versions"}\n' \
# 分页读取当前版本的下载 manifest
printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset":0,"limit":50}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
# 触发本地资源审计+修复任务
printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
```
+1 -2
View File
@@ -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]
+79 -1
View File
@@ -2,6 +2,45 @@
use async_trait::async_trait;
use bat_core::domain::{GameClient, GameRegion};
use std::path::PathBuf;
/// Linux-first client discovery backed by explicitly supplied roots.
///
/// The adapter never scans home directories implicitly and does not require
/// the official launcher. The roots are normally a staging/import directory
/// selected by the caller.
#[derive(Debug, Clone, Default)]
pub struct LinuxClientDiscovery {
roots: Vec<PathBuf>,
}
impl LinuxClientDiscovery {
/// Creates a discovery adapter for explicit candidate roots.
pub fn new(roots: Vec<PathBuf>) -> Self {
Self { roots }
}
}
#[async_trait]
impl ClientDiscovery for LinuxClientDiscovery {
async fn discover_all(&self) -> Result<Vec<GameClient>, String> {
GameClient::discover_in_roots(&self.roots).map_err(|error| error.to_string())
}
async fn verify_client(&self, path: &str) -> bool {
GameClient::new(PathBuf::from(path), GameRegion::Japan)
.verify_integrity()
.unwrap_or(false)
}
async fn detect_region(&self, path: &str) -> Result<GameRegion, String> {
if self.verify_client(path).await {
Ok(GameRegion::Japan)
} else {
Err(format!("不是有效的 Linux Blue Archive 客户端:{path}"))
}
}
}
/// 客户端发现接口
///
@@ -49,5 +88,44 @@ pub trait ClientDiscovery: Send + Sync {
#[cfg(test)]
mod tests {
// 测试将在实现时添加
use super::*;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn linux_discovery_uses_explicit_roots_and_verifies_layout() {
let temp = TempDir::new().unwrap();
let client = temp.path().join("BlueArchive_JP");
fs::create_dir_all(client.join("BlueArchive_Data/StreamingAssets/AssetBundles")).unwrap();
let discovery = LinuxClientDiscovery::new(vec![temp.path().to_path_buf()]);
let clients = discovery.discover_all().await.unwrap();
assert_eq!(clients.len(), 1);
assert!(
discovery
.verify_client(clients[0].install_path.to_str().unwrap())
.await
);
assert_eq!(
discovery
.detect_region(clients[0].install_path.to_str().unwrap())
.await
.unwrap(),
GameRegion::Japan
);
}
#[tokio::test]
async fn linux_discovery_rejects_unrelated_path() {
let discovery = LinuxClientDiscovery::default();
assert!(
!discovery
.verify_client("/tmp/not-a-blue-archive-client")
.await
);
assert!(discovery
.detect_region("/tmp/not-a-blue-archive-client")
.await
.is_err());
}
}
+457 -74
View File
@@ -256,6 +256,22 @@ impl AddressablesCatalogDriver {
}
let address = Self::string_field(value, &["address", "Address", "m_Address", "key", "Key"]);
let provider_id = Self::catalog_string_field(
value,
&[
"provider_id",
"providerId",
"ProviderId",
"provider",
"Provider",
"m_ProviderId",
"m_Provider",
],
);
let bundle_name = Self::catalog_string_field(
value,
&["bundle_name", "bundleName", "BundleName", "m_BundleName"],
);
let hash = value
.get("hash")
.or_else(|| value.get("Hash"))
@@ -264,22 +280,33 @@ impl AddressablesCatalogDriver {
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("addressable_{}", index));
let size = value
.get("size")
.or_else(|| value.get("Size"))
.or_else(|| value.get("m_Size"))
.and_then(|value| value.as_u64())
.unwrap_or_default();
let size = Self::u64_field(value, &["size", "Size", "m_Size"]).unwrap_or_default();
let dependencies = Self::dependencies_from_entry(value);
let crc = Self::u32_field(value, &["crc", "Crc", "m_Crc"]);
let resource_type_name = Self::type_name_field(
value,
&[
"resource_type",
"resourceType",
"ResourceType",
"m_ResourceType",
],
);
Some(ResourceEntry {
path: path.to_string(),
hash,
size,
resource_type: Self::resource_type_for_path(path),
resource_type: Self::resource_type_for_compact_entry(
path,
resource_type_name.as_deref(),
),
address,
dependencies,
provider_id,
bundle_name,
crc,
})
}
@@ -292,6 +319,42 @@ impl AddressablesCatalogDriver {
})
}
fn catalog_string_field(value: &Value, fields: &[&str]) -> Option<String> {
Self::string_field(value, fields).or_else(|| {
["extra_data", "ExtraData", "m_ExtraData", "data", "Data"]
.iter()
.find_map(|field| value.get(*field))
.and_then(|extra| Self::string_field(extra, fields))
})
}
fn type_name_field(value: &Value, fields: &[&str]) -> Option<String> {
fields.iter().find_map(|field| {
let value = value.get(*field)?;
value.as_str().map(ToOwned::to_owned).or_else(|| {
value
.get("m_ClassName")
.or_else(|| value.get("ClassName"))
.or_else(|| value.get("class_name"))
.and_then(|name| name.as_str())
.map(ToOwned::to_owned)
})
})
}
fn u64_field(value: &Value, fields: &[&str]) -> Option<u64> {
fields.iter().find_map(|field| {
let value = value.get(*field)?;
value
.as_u64()
.or_else(|| value.as_str()?.parse::<u64>().ok())
})
}
fn u32_field(value: &Value, fields: &[&str]) -> Option<u32> {
Self::u64_field(value, fields).and_then(|value| u32::try_from(value).ok())
}
fn string_array_field(value: &Value, fields: &[&str]) -> Vec<String> {
for field in fields {
if let Some(array) = value.get(field).and_then(|value| value.as_array()) {
@@ -376,6 +439,9 @@ impl AddressablesCatalogDriver {
resource_type: Self::resource_type_for_path(path),
address: None,
dependencies: Vec::new(),
provider_id: None,
bundle_name: None,
crc: None,
})
})
.collect()
@@ -413,63 +479,96 @@ impl AddressablesCatalogDriver {
resource_type,
address: None,
dependencies: Vec::new(),
provider_id: None,
bundle_name: None,
crc: None,
});
}
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 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"
));
}
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()
)
})?;
let provider_id = provider_ids
.get(record.provider_index as usize)
.cloned()
.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)
.unwrap_or_else(|| format!("addressable_{}", index));
.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 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));
Some(ResourceEntry {
resources.push(ResourceEntry {
path: path.clone(),
hash,
size: extra.bundle_size.unwrap_or_default(),
@@ -483,9 +582,13 @@ impl AddressablesCatalogDriver {
Some(primary_key)
},
dependencies,
})
})
.collect()
provider_id: Some(provider_id),
bundle_name: extra.bundle_name,
crc: extra.crc,
});
}
Ok(resources)
}
fn string_array(json: &Value, field: &str) -> Option<Vec<String>> {
@@ -591,25 +694,34 @@ impl AddressablesCatalogDriver {
.collect()
}
fn extra_data_at(extra_data: &[u8], data_index: i32) -> AddressablesExtraData {
fn extra_data_at(extra_data: &[u8], data_index: i32) -> Result<AddressablesExtraData, String> {
if data_index < 0 {
return AddressablesExtraData::default();
return Ok(AddressablesExtraData::default());
}
let Some((object, _)) = Self::read_serialized_object(extra_data, data_index as usize)
else {
return AddressablesExtraData::default();
return Err(format!(
"compact entry extra data index {} is not decodable",
data_index
));
};
let AddressablesObject::JsonObject { json, .. } = object else {
return AddressablesExtraData::default();
return Err(format!(
"compact entry extra data index {} is not a JSON object",
data_index
));
};
let Some(json) = json else {
return AddressablesExtraData::default();
return Err(format!(
"compact entry extra data index {} contains invalid JSON",
data_index
));
};
AddressablesExtraData {
Ok(AddressablesExtraData {
hash: json
.get("m_Hash")
.and_then(|value| value.as_str())
@@ -618,21 +730,31 @@ impl AddressablesCatalogDriver {
.get("m_BundleName")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned),
bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()),
}
bundle_size: Self::u64_field(&json, &["m_BundleSize", "bundle_size", "size"]),
// m_Crc 是 bundle 的 IEEE CRC-320 表示不做 CRC 校验,忠实保留原值。
crc: Self::u32_field(&json, &["m_Crc", "crc", "Crc"]),
})
}
fn resource_type_name(json: &Value, index: i32) -> Option<String> {
fn resource_type_name(json: &Value, index: i32) -> Result<Option<String>, String> {
if index < 0 {
return None;
return Ok(None);
}
json.get("m_resourceTypes")?
.as_array()?
.get(index as usize)?
.get("m_ClassName")?
.as_str()
.map(ToOwned::to_owned)
let resource_types = json
.get("m_resourceTypes")
.and_then(|value| value.as_array())
.ok_or_else(|| "compact catalog missing m_resourceTypes array".to_string())?;
let value = resource_types.get(index as usize).ok_or_else(|| {
format!(
"compact resource_type_index {} out of range {}",
index,
resource_types.len()
)
})?;
Self::type_name_field(value, &["m_ClassName", "ClassName", "class_name"])
.ok_or_else(|| format!("compact resource type {} has no class name", index))
.map(Some)
}
fn normalize_internal_id(prefixes: &[String], internal_id: &str) -> String {
@@ -662,15 +784,30 @@ impl AddressablesCatalogDriver {
format!("{prefix}{path}")
}
fn resources(json: &Value) -> Vec<ResourceEntry> {
let entry_resources = Self::entry_resources(json);
if !entry_resources.is_empty() {
return entry_resources;
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())
}
let compact_resources = Self::compact_entry_resources(json);
fn resources(json: &Value) -> Result<Vec<ResourceEntry>, String> {
let entry_resources = Self::entry_resources(json);
if !entry_resources.is_empty() {
return Ok(entry_resources);
}
if Self::has_compact_catalog_fields(json) {
let compact_resources = Self::compact_entry_resources(json)?;
if !compact_resources.is_empty() {
return compact_resources;
return Ok(compact_resources);
}
}
let key_resources = Self::key_data_resources(json);
@@ -679,13 +816,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");
@@ -717,7 +854,7 @@ impl AddressablesCatalogDriver {
"m_ExtraDataString",
"extra_data_string_len",
);
Self::insert_dependency_count(&mut extra, json);
Self::insert_resource_summary(&mut extra, resources);
extra
}
@@ -757,13 +894,75 @@ 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(),
);
}
let provider_count = resources
.iter()
.filter(|entry| entry.provider_id.is_some())
.count();
if provider_count > 0 {
extra.insert("provider_id_count".to_string(), provider_count.to_string());
}
let bundle_name_count = resources
.iter()
.filter(|entry| entry.bundle_name.is_some())
.count();
if bundle_name_count > 0 {
extra.insert(
"bundle_name_count".to_string(),
bundle_name_count.to_string(),
);
}
}
}
@@ -821,6 +1020,7 @@ struct AddressablesExtraData {
hash: Option<String>,
bundle_name: Option<String>,
bundle_size: Option<u64>,
crc: Option<u32>,
}
impl Default for AddressablesCatalogDriver {
@@ -870,16 +1070,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,
})
}
@@ -888,6 +1089,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() {
@@ -954,6 +1240,9 @@ mod tests {
"internal_id": "synthetic/minimal.bundle",
"hash": "synthetic-entry-hash",
"size": 119,
"crc": 3735928559,
"provider_id": "synthetic-provider",
"bundle_name": "synthetic-bundle",
"address": "Character_001",
"dependencies": ["synthetic/shared.bundle"]
},
@@ -971,6 +1260,17 @@ mod tests {
assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle");
assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash");
assert_eq!(manifest.resources[0].size, 119);
assert_eq!(
manifest.resources[0].provider_id.as_deref(),
Some("synthetic-provider")
);
assert_eq!(
manifest.resources[0].bundle_name.as_deref(),
Some("synthetic-bundle")
);
// m_Crc(此处 0xDEADBEEF)应被提取;缺该字段的条目为 None。
assert_eq!(manifest.resources[0].crc, Some(0xDEAD_BEEF));
assert_eq!(manifest.resources[1].crc, None);
assert_eq!(
manifest.resources[0].address.as_deref(),
Some("Character_001")
@@ -1012,6 +1312,14 @@ mod tests {
manifest.metadata.extra.get("dependency_count"),
Some(&"1".to_string())
);
assert_eq!(
manifest.metadata.extra.get("provider_id_count"),
Some(&"1".to_string())
);
assert_eq!(
manifest.metadata.extra.get("bundle_name_count"),
Some(&"1".to_string())
);
}
#[tokio::test]
@@ -1028,6 +1336,81 @@ 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.provider_id.as_deref(),
Some("UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider")
);
assert_eq!(
resource.bundle_name.as_deref(),
Some("synthetic-bundle-name")
);
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())
);
assert_eq!(
manifest.metadata.extra.get("provider_id_count"),
Some(&"1".to_string())
);
assert_eq!(
manifest.metadata.extra.get("bundle_name_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();
+354
View File
@@ -0,0 +1,354 @@
//! Official resource backend seams.
//!
//! The update pipeline consumes these small contracts instead of depending on
//! one region's URL and catalog rules everywhere. The JP implementation is
//! the only production adapter today; adding another region should implement
//! this module's contracts without changing downloader orchestration.
use super::inventory::{YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory};
use super::yostar_jp::{
is_official_yostar_jp_url, server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan,
YostarJpResourceRoot, YostarJpServerInfo,
};
use std::path::{Path, PathBuf};
/// Catalog bytes required to build one platform's download inventory.
#[derive(Debug, Clone, Copy)]
pub struct PlatformCatalogInput<'a> {
/// Platform represented by the catalog.
pub platform: PatchPlatform,
/// `BundlePackingInfo.bytes` payload.
pub bundle_packing_info: &'a [u8],
/// `MediaCatalog.bytes` payload.
pub media_catalog: &'a [u8],
}
/// Platform catalog parser selected by an official resource backend.
pub trait InventoryParser: Send + Sync {
/// Parses verified seed catalog payloads into a platform-aware inventory.
fn parse_inventory(
&self,
table_catalog: &[u8],
platform_catalogs: &[PlatformCatalogInput<'_>],
) -> YostarJpPlatformDownloadInventory;
}
/// Verification result returned by a sidecar hash strategy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SidecarHashVerification {
/// Decimal or textual expected digest parsed from the sidecar.
pub expected: String,
/// Digest computed from the resource bytes.
pub actual: String,
}
/// Hash sidecar policy independent from download orchestration.
pub trait SidecarHashStrategy: Send + Sync {
/// Stable algorithm identifier used in diagnostics.
fn algorithm_id(&self) -> &'static str;
/// Parses and verifies one resource payload against a sidecar.
fn verify(&self, data: &[u8], sidecar: &[u8]) -> Result<SidecarHashVerification, String>;
}
/// Official JP decimal `xxHash32(seed=0)` sidecar strategy.
#[derive(Debug, Clone, Copy, Default)]
pub struct XxHash32DecimalSeedZero;
impl XxHash32DecimalSeedZero {
/// Computes the decimal digest used by this sidecar strategy.
pub fn digest(self, data: &[u8]) -> String {
xxhash32(data).to_string()
}
}
impl SidecarHashStrategy for XxHash32DecimalSeedZero {
fn algorithm_id(&self) -> &'static str {
"xxhash32_decimal"
}
fn verify(&self, data: &[u8], sidecar: &[u8]) -> Result<SidecarHashVerification, String> {
let expected = std::str::from_utf8(sidecar)
.map_err(|error| format!("官方 hash sidecar 不是 UTF-8{error}"))?
.trim()
.parse::<u32>()
.map_err(|error| format!("官方 hash sidecar 不是十进制 xxHash32{error}"))?;
let actual = self.digest(data);
let mismatch = expected.to_string() != actual;
let verification = SidecarHashVerification {
expected: expected.to_string(),
actual,
};
if mismatch {
return Err(format!(
"官方 hash 校验失败:期望 {},实际 {}",
verification.expected, verification.actual
));
}
Ok(verification)
}
}
/// Region/backend contract used by official resource orchestration.
pub trait OfficialResourceBackend: InventoryParser + Send + Sync {
/// Stable backend identifier persisted in diagnostics.
fn backend_id(&self) -> &'static str;
/// Builds the server-info URL from an official metadata file name.
fn server_info_url(&self, file_name: &str) -> Result<String, String>;
/// Selects a discovery plan from server-info and requested platforms.
fn discovery_plan(
&self,
server_info: &YostarJpServerInfo,
connection_group: &str,
app_version: &str,
platforms: &[PatchPlatform],
) -> Result<YostarJpResourceDiscoveryPlan, String>;
/// Validates that a URL belongs to this backend's official hosts.
fn is_official_url(&self, url: &str) -> bool;
}
/// URL-to-destination mapping contract for a resource backend.
pub trait DownloadUrlMapper: Send + Sync {
/// Maps an official HTTPS URL to a relative release destination.
fn relative_destination(&self, url: &str) -> Result<PathBuf, String>;
}
/// The currently supported official Blue Archive JP backend.
#[derive(Debug, Clone, Copy, Default)]
pub struct YostarJpBackend;
impl InventoryParser for YostarJpBackend {
fn parse_inventory(
&self,
table_catalog: &[u8],
platform_catalogs: &[PlatformCatalogInput<'_>],
) -> YostarJpPlatformDownloadInventory {
let catalogs = platform_catalogs
.iter()
.map(|catalog| {
YostarJpPlatformCatalogInventory::from_catalog_bytes(
catalog.platform,
catalog.bundle_packing_info,
catalog.media_catalog,
)
})
.collect();
YostarJpPlatformDownloadInventory::from_catalog_bytes(table_catalog, catalogs)
}
}
impl OfficialResourceBackend for YostarJpBackend {
fn backend_id(&self) -> &'static str {
"bluearchive.yostar.jp"
}
fn server_info_url(&self, file_name: &str) -> Result<String, String> {
server_info_url(file_name)
}
fn discovery_plan(
&self,
server_info: &YostarJpServerInfo,
connection_group: &str,
app_version: &str,
platforms: &[PatchPlatform],
) -> Result<YostarJpResourceDiscoveryPlan, String> {
server_info.discovery_plan(connection_group, app_version, platforms)
}
fn is_official_url(&self, url: &str) -> bool {
is_official_yostar_jp_url(url)
}
}
fn xxhash32(bytes: &[u8]) -> u32 {
const PRIME1: u32 = 0x9E37_79B1;
const PRIME2: u32 = 0x85EB_CA77;
const PRIME3: u32 = 0xC2B2_AE3D;
const PRIME4: u32 = 0x27D4_EB2F;
const PRIME5: u32 = 0x1656_67B1;
let len = bytes.len();
let mut index = 0usize;
let mut hash = if len >= 16 {
let mut v1 = PRIME1.wrapping_add(PRIME2);
let mut v2 = PRIME2;
let mut v3 = 0;
let mut v4 = 0u32.wrapping_sub(PRIME1);
while index + 16 <= len {
v1 = xxhash32_round(v1, read_u32_le(bytes, index));
v2 = xxhash32_round(v2, read_u32_le(bytes, index + 4));
v3 = xxhash32_round(v3, read_u32_le(bytes, index + 8));
v4 = xxhash32_round(v4, read_u32_le(bytes, index + 12));
index += 16;
}
v1.rotate_left(1)
.wrapping_add(v2.rotate_left(7))
.wrapping_add(v3.rotate_left(12))
.wrapping_add(v4.rotate_left(18))
} else {
PRIME5
}
.wrapping_add(len as u32);
while index + 4 <= len {
hash = hash
.wrapping_add(read_u32_le(bytes, index).wrapping_mul(PRIME3))
.rotate_left(17)
.wrapping_mul(PRIME4);
index += 4;
}
while index < len {
hash = hash
.wrapping_add((bytes[index] as u32).wrapping_mul(PRIME5))
.rotate_left(11)
.wrapping_mul(PRIME1);
index += 1;
}
hash ^= hash >> 15;
hash = hash.wrapping_mul(PRIME2);
hash ^= hash >> 13;
hash = hash.wrapping_mul(PRIME3);
hash ^ (hash >> 16)
}
fn xxhash32_round(acc: u32, input: u32) -> u32 {
acc.wrapping_add(input.wrapping_mul(0x85EB_CA77))
.rotate_left(13)
.wrapping_mul(0x9E37_79B1)
}
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
impl DownloadUrlMapper for YostarJpBackend {
fn relative_destination(&self, url: &str) -> Result<PathBuf, String> {
let rest = url
.strip_prefix("https://")
.ok_or_else(|| format!("官方 URL 必须使用 https{url}"))?;
let (host, path) = rest
.split_once('/')
.ok_or_else(|| format!("官方 URL 缺少路径:{url}"))?;
let mut destination = PathBuf::from(sanitize_component(host, url)?);
for segment in path.split('/') {
if segment.is_empty() {
continue;
}
destination.push(sanitize_component(segment, url)?);
}
Ok(destination)
}
}
impl YostarJpBackend {
/// Returns the validated resource-root builder for an official root.
pub fn resource_root(&self, addressables_root: &str) -> Result<YostarJpResourceRoot, String> {
YostarJpResourceRoot::from_addressables_root(addressables_root)
}
}
fn sanitize_component(component: &str, url: &str) -> Result<String, String> {
if component == "." || component == ".." || component.is_empty() {
return Err(format!("官方 URL 包含不安全路径片段:{url}"));
}
if component.contains('?') || component.contains('#') {
return Err(format!(
"官方资源 URL 包含 query 或 fragment 等不安全路径字符:{url}"
));
}
if component.contains('\\') {
return Err(format!("官方资源 URL 包含不安全路径字符:{url}"));
}
Ok(component.to_string())
}
/// Joins a backend-relative destination below an output root.
pub fn destination_under_root(root: &Path, relative: &Path) -> Result<PathBuf, String> {
if relative.is_absolute() {
return Err(format!(
"backend destination must be relative: {}",
relative.display()
));
}
let destination = root.join(relative);
if destination
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(format!(
"backend destination escapes output root: {}",
relative.display()
));
}
Ok(destination)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jp_backend_keeps_discovery_and_inventory_rules_in_one_adapter() {
let backend = YostarJpBackend;
let server_info = YostarJpServerInfo::from_json(
r#"{"ConnectionGroups":[{"Name":"Prod","AddressablesCatalogUrlRoot":"https://prod-clientpatch.bluearchiveyostar.com/r93_fixture"}]}"#,
)
.unwrap();
let plan = backend
.discovery_plan(&server_info, "Prod", "1.70.0", &[PatchPlatform::Windows])
.unwrap();
assert_eq!(backend.backend_id(), "bluearchive.yostar.jp");
assert!(backend.is_official_url(&plan.endpoints[0].url));
let inventory = backend.parse_inventory(
b"ExcelDB.db ExcelDB.db",
&[PlatformCatalogInput {
platform: PatchPlatform::Windows,
bundle_packing_info: b"FullPatch_000.zip",
media_catalog: b"GameData/Audio/JP.zip",
}],
);
assert_eq!(inventory.table_file_names, vec!["ExcelDB.db"]);
assert_eq!(inventory.platform_catalogs.len(), 1);
}
#[test]
fn jp_hash_strategy_verifies_decimal_xxhash32_sidecars() {
let strategy = XxHash32DecimalSeedZero;
assert_eq!(strategy.algorithm_id(), "xxhash32_decimal");
assert_eq!(
strategy.verify(b"", b"46947589").unwrap(),
SidecarHashVerification {
expected: "46947589".to_string(),
actual: "46947589".to_string(),
}
);
assert!(strategy.verify(b"changed", b"46947589").is_err());
}
#[test]
fn jp_backend_maps_and_rejects_unsafe_destinations() {
let backend = YostarJpBackend;
assert_eq!(
backend
.relative_destination(
"https://prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/a.bytes"
)
.unwrap(),
PathBuf::from("prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/a.bytes")
);
assert!(backend
.relative_destination("https://prod-clientpatch.bluearchiveyostar.com/r93/../secret")
.is_err());
assert!(!backend.is_official_url("https://example.invalid/a"));
}
}
+3 -2
View File
@@ -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)
}
+111 -23
View File
@@ -10,7 +10,7 @@ pub struct YostarJpDownloadInventory {
pub bundle_patch_pack_names: Vec<String>,
/// Table file names from `TableCatalog.bytes`.
pub table_file_names: Vec<String>,
/// Media file names from `MediaCatalog.bytes`.
/// Media file relative paths from `MediaCatalog.bytes`.
pub media_file_names: Vec<String>,
}
@@ -21,7 +21,7 @@ pub struct YostarJpPlatformCatalogInventory {
pub platform: PatchPlatform,
/// Patch-pack zip names from this platform's `BundlePackingInfo.bytes`.
pub bundle_patch_pack_names: Vec<String>,
/// Media file names from this platform's `MediaCatalog.bytes`.
/// Media file relative paths from this platform's `MediaCatalog.bytes`.
pub media_file_names: Vec<String>,
}
@@ -35,10 +35,7 @@ impl YostarJpPlatformCatalogInventory {
Self {
platform,
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
media_file_names: extract_file_names(
media_catalog,
&["zip", "mp4", "png", "ogg", "wav"],
),
media_file_names: extract_media_file_paths(media_catalog),
}
}
@@ -172,10 +169,7 @@ impl YostarJpDownloadInventory {
Self {
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
table_file_names: extract_table_file_names(table_catalog),
media_file_names: extract_file_names(
media_catalog,
&["zip", "mp4", "png", "ogg", "wav"],
),
media_file_names: extract_media_file_paths(media_catalog),
}
}
@@ -327,6 +321,20 @@ fn extract_file_names(data: &[u8], extensions: &[&str]) -> Vec<String> {
names.into_iter().collect()
}
fn extract_media_file_paths(data: &[u8]) -> Vec<String> {
let mut paths = BTreeSet::new();
for string in extract_printable_strings(data, 4) {
for path in
candidate_relative_paths(&string, &["zip", "mp4", "png", "jpg", "jpeg", "ogg", "wav"])
{
paths.insert(path);
}
}
paths.into_iter().collect()
}
fn extract_printable_strings(data: &[u8], min_len: usize) -> Vec<String> {
let mut strings = Vec::new();
let mut current = Vec::new();
@@ -375,6 +383,32 @@ fn candidate_file_names(value: &str, extensions: &[&str]) -> Vec<String> {
names
}
fn candidate_relative_paths(value: &str, extensions: &[&str]) -> Vec<String> {
let mut paths = Vec::new();
let bytes = value.as_bytes();
for extension in extensions {
let suffix = format!(".{extension}");
let mut search_from = 0;
while let Some(relative_index) = value[search_from..].find(&suffix) {
let extension_start = search_from + relative_index;
let start = filename_start(bytes, extension_start);
let end = extension_start + suffix.len();
let candidate = &value[start..end];
let candidate = candidate.replace('\\', "/");
if is_plausible_relative_path(&candidate) {
paths.push(candidate);
}
search_from = end;
}
}
paths
}
fn filename_start(bytes: &[u8], mut index: usize) -> usize {
while index > 0 {
let byte = bytes[index - 1];
@@ -409,6 +443,28 @@ fn is_plausible_file_name(name: &str) -> bool {
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
}
fn is_plausible_relative_path(path: &str) -> bool {
if path.is_empty()
|| path.starts_with('/')
|| path.starts_with('.')
|| path.contains("..")
|| path.contains(':')
|| path.contains('=')
|| !path.contains('/')
{
return false;
}
path.split('/').all(|segment| {
!segment.is_empty()
&& segment != "."
&& segment != ".."
&& segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
})
}
fn unique_platforms(platforms: &[PatchPlatform]) -> Vec<PatchPlatform> {
platforms
.iter()
@@ -441,7 +497,7 @@ mod tests {
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
b"prefix FullPatch_000.zip noise FullPatch_114.zip suffix",
b"GameData\\Table\\ExcelDB.db\0ExcelDB.db\0rawdata/table/excel/ignored.bytes\0Battle.zip\0Battle.zip8",
b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0JP_Akane.zip",
b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0audio/voc_jp/jp_akane/jp_akane\0GameData\\Audio\\VOC_JP\\JP_Akane.zip",
);
assert_eq!(
@@ -457,7 +513,10 @@ mod tests {
);
assert_eq!(
inventory.media_file_names,
vec!["JP_Airi.zip".to_string(), "JP_Akane.zip".to_string(),]
vec![
"GameData/Audio/VOC_JP/JP_Airi.zip".to_string(),
"GameData/Audio/VOC_JP/JP_Akane.zip".to_string(),
]
);
}
@@ -482,7 +541,7 @@ mod tests {
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip FullPatch_001.zip",
b"ExcelDB.db ExcelDB.db Battle.zip Battle.zip",
b"JP_Airi.zip JP_Akane.zip",
b"GameData\\Audio\\VOC_JP\\JP_Airi.zip GameData\\Audio\\VOC_JP\\JP_Akane.zip",
);
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
@@ -496,7 +555,7 @@ mod tests {
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
.any(|url| url.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip")));
}
#[test]
@@ -504,7 +563,7 @@ mod tests {
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip",
b"ExcelDB.db ExcelDB.db",
b"JP_Airi.zip",
b"GameData\\Audio\\VOC_JP\\JP_Airi.zip",
);
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
@@ -524,10 +583,10 @@ mod tests {
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
.any(|url| url.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources/JP_Airi.zip")));
.any(|url| url.ends_with("/MediaResources/GameData/Audio/VOC_JP/JP_Airi.zip")));
}
#[test]
@@ -538,12 +597,12 @@ mod tests {
YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Windows,
b"FullPatch_000.zip",
b"JP_Airi_Win.zip",
b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip",
),
YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Android,
b"FullPatch_001.zip",
b"JP_Airi_Android.zip",
b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip",
),
],
);
@@ -561,10 +620,11 @@ mod tests {
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi_Win.zip")));
.any(|url| url
.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi_Win.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources/JP_Airi_Android.zip")));
.any(|url| url.ends_with("/MediaResources/GameData/Audio/VOC_JP/JP_Airi_Android.zip")));
assert!(!urls
.iter()
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_000.zip")));
@@ -573,6 +633,30 @@ mod tests {
.any(|url| url.ends_with("/Windows_PatchPack/FullPatch_001.zip")));
}
#[test]
fn media_catalog_uses_download_relative_path_not_leaf_name() {
let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes(
b"ExcelDB.db ExcelDB.db",
vec![YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Windows,
b"FullPatch_000.zip",
b"scenario/event/10000_title_sound\0Prologue\\Scenario\\Event\\10000_Title_Sound.ogg\0 10000_Title_Sound.ogg",
)],
);
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
let urls = inventory
.direct_download_urls_for_platforms(&root, &[PatchPlatform::Windows])
.unwrap();
assert!(urls.iter().any(|url| {
url.ends_with("/MediaResources-Windows/Prologue/Scenario/Event/10000_Title_Sound.ogg")
}));
assert!(!urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/10000_Title_Sound.ogg")));
}
#[test]
#[ignore = "requires BAT_REAL_OFFICIAL_BUNDLE_PACKING_INFO, BAT_REAL_OFFICIAL_TABLE_CATALOG, BAT_REAL_OFFICIAL_MEDIA_CATALOG"]
fn extracts_realistic_counts_from_official_shape() {
@@ -591,7 +675,7 @@ mod tests {
assert_eq!(inventory.bundle_patch_pack_names.len(), 142);
assert!(inventory.table_file_names.len() < 1000);
assert_eq!(inventory.media_file_names.len(), 1887);
assert!(inventory.media_file_names.len() >= 4000);
assert!(inventory
.bundle_patch_pack_names
.iter()
@@ -603,6 +687,10 @@ mod tests {
assert!(inventory
.media_file_names
.iter()
.any(|name| name == "JP_Airi.zip"));
.any(|name| name == "GameData/Audio/VOC_JP/JP_Airi.zip"));
assert!(inventory
.media_file_names
.iter()
.any(|name| name.ends_with(".jpg")));
}
}
+6
View File
@@ -4,11 +4,17 @@
//! client endpoints. Mirror-specific layers such as `bluearchive.cafe` or
//! `text=jp/voice=jp/media=jp` are intentionally excluded.
pub mod backend;
pub mod game_main_config;
pub mod inventory;
pub mod launcher;
pub mod yostar_jp;
pub use backend::{
destination_under_root, DownloadUrlMapper, InventoryParser, OfficialResourceBackend,
PlatformCatalogInput, SidecarHashStrategy, SidecarHashVerification, XxHash32DecimalSeedZero,
YostarJpBackend,
};
pub use game_main_config::YostarJpGameMainConfig;
pub use inventory::{
YostarJpDownloadInventory, YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
+6 -5
View File
@@ -646,8 +646,8 @@ impl YostarJpResourceRoot {
/// Returns an official media archive URL.
///
/// The argument is the `Media.FileName` field from `MediaCatalog.bytes`,
/// for example `JP_Airi.zip`.
/// The argument is the downloadable relative path from `MediaCatalog.bytes`,
/// for example `GameData/Audio/VOC_JP/JP_Airi.zip`.
pub fn media_file(&self, platform: PatchPlatform, file_name: &str) -> Result<String, String> {
validate_relative_path(file_name, "media file")?;
Ok(format!(
@@ -1134,8 +1134,9 @@ mod tests {
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/Catalog/MediaCatalog.bytes"
);
assert_eq!(
root.media_file(PatchPlatform::Windows, "JP_Airi.zip").unwrap(),
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/JP_Airi.zip"
root.media_file(PatchPlatform::Windows, "GameData/Audio/VOC_JP/JP_Airi.zip")
.unwrap(),
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip"
);
}
@@ -1211,7 +1212,7 @@ mod tests {
);
assert!(root.table_bundle("text=jp/ExcelDB.db").is_err());
assert!(root
.media_file(PatchPlatform::Windows, "/JP_Airi.zip")
.media_file(PatchPlatform::Windows, "/GameData/Audio/VOC_JP/JP_Airi.zip")
.is_err());
assert!(root
.bundle_patch_pack(PatchPlatform::Windows, "../FullPatch_000.zip")
+5 -2
View File
@@ -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::{
UnitySerializedField, UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset,
UnitySerializedType, UnitySerializedValue, UnityTypeTreeNode,
};
pub use unity_2021_3::Unity2021_3Adapter;
+6 -90
View File
@@ -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 中标记为 TODOPhase 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 中标记为 TODOPhase 2 实现
/// 当前阶段只定义接口;具体序列化能力尚未进入实现范围。
async fn serialize(&self, parsed: &ParsedAssetBundle) -> Result<Vec<u8>, String>;
}
+6 -518
View File
@@ -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)
pub use bat_assetbundle::{
UnitySerializedField, UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset,
UnitySerializedType, UnitySerializedValue, UnityTypeTreeNode,
};
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());
}
}
+51 -318
View File
@@ -1,313 +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)?;
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))
}
}
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)
}
}
@@ -328,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 {}: {}",
@@ -349,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())
}
}
@@ -362,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());
@@ -391,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);
@@ -408,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);
@@ -418,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
}
@@ -445,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()),
};
@@ -469,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()),
};
@@ -493,8 +200,34 @@ mod tests {
path: None,
};
let result = adapter.parse(&bundle).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("signature"));
let error = adapter.parse(&bundle).await.unwrap_err();
assert!(error.contains("signature"), "{error}");
}
#[tokio::test]
async fn test_parse_rejects_unsupported_unity_version() {
let adapter = Unity2021_3Adapter::new();
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: synthetic_minimal_unityfs_bundle("2021.3.56f2"),
path: Some("synthetic-minimal.bundle".to_string()),
};
let parsed = adapter.parse(&bundle).await.unwrap();
let error = adapter.serialize(&parsed).await.unwrap_err();
assert_eq!(error, SERIALIZE_NOT_IMPLEMENTED);
}
}
+25
View File
@@ -21,6 +21,19 @@ async fn parses_current_catalog_fixture_with_resource_categories() {
manifest.resources[3].dependencies,
vec!["shared_dependencies.bundle".to_string()]
);
assert_eq!(
manifest.resources[0].provider_id.as_deref(),
Some("provider-table")
);
assert_eq!(
manifest.resources[0].bundle_name.as_deref(),
Some("table-bundle")
);
assert_eq!(manifest.resources[0].crc, Some(0x1234_5678));
assert_eq!(
manifest.resources[3].provider_id.as_deref(),
Some("provider-bundle")
);
assert_eq!(
manifest.metadata.cdn_prefixes,
vec!["https://fixture.invalid/current/".to_string()]
@@ -63,6 +76,18 @@ async fn parses_catalog_structure_change_with_alias_fields() {
manifest.resources[0].dependencies,
vec!["shared_assets_current.bundle".to_string()]
);
assert_eq!(
manifest.resources[0].provider_id.as_deref(),
Some("provider-android")
);
assert_eq!(
manifest.resources[0].bundle_name.as_deref(),
Some("title-android-bundle")
);
assert_eq!(manifest.resources[1].resource_type, ResourceType::TextAsset);
assert_eq!(manifest.resources[1].address.as_deref(), Some("lesson"));
assert_eq!(
manifest.resources[1].provider_id.as_deref(),
Some("provider-text")
);
}
@@ -8,6 +8,9 @@
"internal_id": "TableBundles/ExcelDB.db",
"hash": "current-table-hash",
"size": 4096,
"provider_id": "provider-table",
"bundle_name": "table-bundle",
"crc": "305419896",
"address": "ExcelDB",
"dependencies": []
},
@@ -15,6 +18,8 @@
"internal_id": "MediaResources-Windows/voice/title.acb",
"hash": "current-media-hash",
"size": 2048,
"m_ProviderId": "provider-media",
"m_BundleName": "media-bundle",
"address": "title",
"dependencies": []
},
@@ -22,6 +27,8 @@
"internal_id": "TextAssets/dialogue.csv",
"hash": "current-text-hash",
"size": 128,
"Provider": "provider-text",
"BundleName": "text-bundle",
"address": "dialogue",
"dependencies": []
},
@@ -29,6 +36,8 @@
"internal_id": "shared_assets_current.bundle",
"hash": "current-bundle-hash",
"size": 8192,
"provider": "provider-bundle",
"bundleName": "shared-bundle",
"address": "shared_assets_current",
"dependencies": [
"shared_dependencies.bundle"
@@ -9,6 +9,8 @@
"InternalId": "MediaResources-Android/voice/title.awb",
"Hash": "changed-media-hash",
"Size": 65536,
"ProviderId": "provider-android",
"BundleName": "title-android-bundle",
"Address": "title-android",
"m_Dependencies": [
"shared_assets_current.bundle"
@@ -18,6 +20,8 @@
"Path": "TextAssets/lesson.json",
"Hash": "changed-text-hash",
"Size": 512,
"provider_id": "provider-text",
"bundle_name": "lesson-bundle",
"Key": "lesson"
}
]
+18 -3
View File
@@ -12,7 +12,10 @@
"address": "academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle",
"dependencies": [
"shared_assets_all_123.bundle"
]
],
"provider_id": "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider",
"bundle_name": "bundle-main",
"crc": 0
},
{
"path": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
@@ -20,7 +23,10 @@
"size": 162134,
"resource_type": "AssetBundle",
"address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
"dependencies": []
"dependencies": [],
"provider_id": "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider",
"bundle_name": "bundle-second",
"crc": 0
},
{
"path": "shared_assets_all_123.bundle",
@@ -28,12 +34,21 @@
"size": 153480,
"resource_type": "AssetBundle",
"address": "shared_assets_all_123.bundle",
"dependencies": []
"dependencies": [],
"provider_id": "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider",
"bundle_name": "bundle-shared",
"crc": 0
}
],
"metadata": {
"asset_bundle_count": "3",
"declared_size_count": "3",
"bundle_name_count": "3",
"dependency_count": "1",
"internal_id_count": "3",
"resource_count": "3",
"resource_type_count": "1",
"provider_id_count": "3",
"key_object_count": "4",
"bucket_record_count": "4",
"entry_record_count": "3",
@@ -22,6 +22,9 @@ async fn parses_real_shape_addressables_catalog_against_golden() {
"resource_type": format!("{:?}", resource.resource_type),
"address": resource.address,
"dependencies": resource.dependencies,
"provider_id": resource.provider_id,
"bundle_name": resource.bundle_name,
"crc": resource.crc,
})
}).collect::<Vec<_>>(),
"metadata": manifest.metadata.extra,
+2
View File
@@ -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()));
}
+3
View File
@@ -0,0 +1,3 @@
# Reserved empty directory
Placeholder only. **Not implemented.** See `docs/reports/GO_STATUS.md`.
+9
View File
@@ -0,0 +1,9 @@
# bat-api OpenAPI
`bat-api.yaml` describes the current resource bootstrap / read-only distribution
HTTP surface. The running service also exposes the same contract at
`GET /openapi.yaml`.
This contract covers resource bootstrap, launcher resource compatibility,
server-info rewrite, CDN-shaped resource bytes, auth schemes, and the reserved
admin panel entry. It does not describe a full game business API.
+563
View File
@@ -0,0 +1,563 @@
openapi: 3.0.3
info:
title: BlueArchive Toolkit bat-api
version: 0.1.0
description: Resource bootstrap, read-only distribution, and authenticated Rust bat control proxy.
servers:
- url: http://127.0.0.1:18080
security:
- bearerAuth: []
- queryToken: []
paths:
/healthz:
get:
summary: Liveness and refresh diagnostics
responses:
"200":
description: Service is alive.
/readyz:
get:
summary: Release readiness
responses:
"200":
description: A distributable release is available.
"503":
description: No distributable release is available.
/v1/bootstrap:
get:
summary: Startup resource bootstrap
responses:
"200":
description: Resource bootstrap response.
"503":
description: Release is not ready.
/v1/launcher/bootstrap:
get:
summary: Launcher-shaped resource bootstrap
responses:
"200":
description: Launcher bootstrap response.
"503":
description: Release is not ready.
/api/launcher/game/config:
get:
summary: Resource-only launcher game config compatibility
responses:
"200":
description: Launcher envelope with resource metadata.
/api/launcher/game/config/json:
get:
summary: Resource-only launcher manifest URL compatibility
parameters:
- name: version
in: query
schema:
type: string
- name: file_path
in: query
schema:
type: string
responses:
"200":
description: Launcher envelope pointing to resource bootstrap JSON.
/api/launcher/advanced/game/download/cdn:
get:
summary: Resource-only launcher CDN compatibility
responses:
"200":
description: Launcher envelope with public base URL as CDN root.
/v1/release:
get:
summary: Current release summary
responses:
"200":
description: Release summary.
/v1/resources:
get:
summary: Paginated resource manifest entries
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
responses:
"200":
description: Resource list page.
/v1/server-info:
get:
summary: Rewritten server-info document
responses:
"200":
description: Server-info JSON with AddressablesCatalogUrlRoot rewritten.
/openapi.yaml:
get:
summary: OpenAPI document
responses:
"200":
description: OpenAPI YAML.
/admin/dashboard/:
get:
summary: Embedded bat-api dashboard
security: []
responses:
"200":
description: Static dashboard HTML.
/admin/:
get:
summary: Admin control entry
responses:
"200":
description: Admin links and allowlisted control actions.
/admin/diagnostics:
get:
summary: Read Rust daemon doctor diagnostics
responses:
"200":
description: Current daemon.doctor report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat diagnostics backend is unavailable.
/admin/logs:
get:
summary: Read Rust daemon log tail
parameters:
- name: tail
in: query
schema:
type: integer
minimum: 1
maximum: 2000
responses:
"200":
description: Current daemon.logs report.
"400":
description: Invalid log query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat log backend is unavailable.
/admin/tasks:
get:
summary: List Rust-owned async daemon tasks
responses:
"200":
description: Current task.list report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat task backend is unavailable.
/admin/tasks/status:
get:
summary: Read one Rust-owned async daemon task
parameters:
- name: task_id
in: query
required: true
schema:
type: string
responses:
"200":
description: Current task.status report.
"400":
description: Missing or invalid task_id.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat task backend is unavailable.
/admin/tasks/logs:
get:
summary: Read one Rust-owned async daemon task log
parameters:
- name: task_id
in: query
required: true
schema:
type: string
responses:
"200":
description: Current task.logs report.
"400":
description: Missing or invalid task_id.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat task backend is unavailable.
/admin/parse/status:
get:
summary: Read Rust-owned parse/TextUnit index status
responses:
"200":
description: Current parse.status report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat parse backend is unavailable.
/admin/parse/text-units:
get:
summary: Query Rust-owned TextUnit index entries
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
- name: destination
in: query
schema:
type: string
- name: path_pattern
in: query
schema:
type: string
- name: archive_entry
in: query
schema:
type: string
- name: path_id
in: query
schema:
type: integer
format: int64
- name: class_id
in: query
schema:
type: integer
- name: field_path
in: query
schema:
type: string
- name: format
in: query
schema:
type: string
responses:
"200":
description: Current parse.text_units report.
"400":
description: Invalid TextUnit query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat parse backend is unavailable.
/admin/parse/errors:
get:
summary: Query Rust-owned TextUnit extraction diagnostics
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
- name: destination
in: query
schema:
type: string
- name: path_pattern
in: query
schema:
type: string
- name: archive_entry
in: query
schema:
type: string
- name: path_id
in: query
schema:
type: integer
format: int64
- name: class_id
in: query
schema:
type: integer
- name: field_path
in: query
schema:
type: string
- name: format
in: query
schema:
type: string
responses:
"200":
description: Current parse.errors report.
"400":
description: Invalid parse error query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat parse backend is unavailable.
/admin/schedules:
get:
summary: List Rust-owned resource workflow schedules
parameters:
- name: id
in: query
schema:
type: string
- name: group
in: query
schema:
type: string
enum: [res, parse, i18n]
- name: enabled
in: query
schema:
type: boolean
responses:
"200":
description: Current schedule JSON report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat schedule backend is unavailable.
/admin/translation/tasks:
get:
summary: List Rust-owned translation task worker status
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
- name: task_id
in: query
schema:
type: string
- name: release_id
in: query
schema:
type: string
- name: destination
in: query
schema:
type: string
- name: archive_entry
in: query
schema:
type: string
- name: status
in: query
schema:
type: string
- name: worker_status
in: query
schema:
type: string
- name: parse_status
in: query
schema:
type: string
- name: format
in: query
schema:
type: string
- name: has_reason
in: query
schema:
type: boolean
- name: has_failure_reason
in: query
schema:
type: boolean
responses:
"200":
description: Current translation task JSON report from Rust bat.
"400":
description: Invalid translation task query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat translation backend is unavailable.
/admin/translation/handoff:
get:
summary: Read Rust-owned translation handoff state
responses:
"200":
description: Current translation handoff JSON report from Rust bat.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat translation backend is unavailable.
/admin/translation/status:
get:
summary: Read Rust-owned localized release status
responses:
"200":
description: Current localized status JSON report from Rust bat.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat localized backend is unavailable.
/admin/control/{action}:
post:
summary: Forward an allowlisted control or schedule action to Rust bat
parameters:
- name: action
in: path
required: true
schema:
type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback]
requestBody:
required: false
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
force:
type: boolean
id:
type: string
group:
type: string
action:
type: string
args:
type: array
items:
type: string
next_run_unix_seconds:
type: integer
format: int64
delay_seconds:
type: integer
format: int64
every_seconds:
type: integer
format: int64
count:
type: integer
format: int64
max_runs:
type: integer
format: int64
minimum: 1
clear_args:
type: boolean
clear_every:
type: boolean
enabled:
type: boolean
task_id:
type: string
status:
type: string
failure_reason:
type: string
provider_run_id:
type: string
provider:
type: string
translation_results:
type: array
items:
type: object
required: [unit_id, source_text, translated_text]
additionalProperties: false
properties:
unit_id:
type: string
source_text:
type: string
translated_text:
type: string
fixture_path:
type: string
concurrency:
type: integer
format: int64
minimum: 1
maximum: 256
max_attempts:
type: integer
format: int64
minimum: 1
lease_seconds:
type: integer
format: int64
minimum: 1
retry_backoff_seconds:
type: integer
format: int64
minimum: 0
max_tasks:
type: integer
format: int64
minimum: 1
worker_id:
type: string
translation_file:
type: string
from_worker:
type: boolean
localized_release_id:
type: string
responses:
"202":
description: Rust bat accepted the control request.
"400":
description: Invalid action parameters.
"401":
description: Missing or invalid admin token.
"403":
description: Control is not exposed or no admin token is configured.
"501":
description: Rust bat does not implement the requested control action.
"502":
description: Rust bat rejected the control request.
/prod-clientpatch.bluearchiveyostar.com/{path}:
get:
summary: CDN-shaped resource bytes
parameters:
- name: path
in: path
required: true
schema:
type: string
responses:
"200":
description: Resource bytes.
"206":
description: Partial resource bytes.
head:
summary: CDN-shaped resource metadata
responses:
"200":
description: Resource headers.
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
queryToken:
type: apiKey
in: query
name: bat_token
+4
View File
@@ -0,0 +1,4 @@
# Reserved empty directory
This path is a monorepo placeholder and is **not implemented**.
Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`.
+32
View File
@@ -0,0 +1,32 @@
# bat-api configuration example (copy to .env next to the binary or export)
# Priority: CLI flags > process environment > .env > built-in defaults.
#
# Boundary:
# - Rust bat: resource auto-discover / pull / verify / publish / daemon RPC
# - bat-api: resource bootstrap + read-only distribution (official CDN-shaped paths)
# + management APIs
BAT_API_LISTEN=:18080
BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080
# Primary discovery: bat daemon JSON-RPC socket file
BAT_API_STATE_DIR=/tmp/bat-pid
# BAT_API_SOCKET=/tmp/bat-pid/bat.sock
# Optional release root override (local fixtures / emergency read-only diagnostics only).
# Production obtains resource_root from BAT_API_SOCKET RPC; do not set this there.
# BAT_API_RESOURCE_ROOT=
# BAT_API_SERVER_INFO_FILE=
BAT_API_REQUIRE_INDEXED=true
BAT_API_VERIFY_SIZE=true
BAT_API_RPC_TIMEOUT=30s
# Periodically re-read bat.sock so bat-api follows Rust bat release switches.
# Set to 0 in fixture-only local development.
BAT_API_REFRESH_INTERVAL=1m
# Reserved for future API persistence
# BAT_API_DATABASE_URL=postgres://bat:@127.0.0.1:5432/bat?sslmode=disable
# BAT_API_DATABASE_PASSWORD=
# BAT_API_REDIS_URL=redis://127.0.0.1:6379/0
# BAT_API_REDIS_PASSWORD=
+143
View File
@@ -0,0 +1,143 @@
// Command bat-api is the resource bootstrap and distribution HTTP service for BlueArchiveToolkit.
//
// Responsibility boundary:
// - bat (Rust): official resource auto-discover, pull, verify, publish, daemon RPC
// - bat-api (Go): startup resource bootstrap, server-info rewrite,
// read-only distribution of published resources (CDN-shaped paths), release
// inspection APIs, and normal process configuration (.env / flags for listen
// port, RPC socket, reserved database/redis settings)
//
// bat-api discovers and periodically refreshes the current release through the
// bat.sock JSON-RPC contract (daemon.status first, then daemon.doctor, then
// catalog/resource methods). The production resource root comes from RPC; the
// resource-root override is for local fixtures or emergency diagnostics.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"bat-api/internal/api"
"bat-api/internal/backendrpc"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("bat-api ")
cfg := api.DefaultConfig()
if os.Getenv("BAT_API_SKIP_ENV_FILE") != "1" {
envPath := envFilePath()
if err := ensureEnvTemplate(envPath); err != nil {
log.Printf("warn: env template: %v", err)
}
if err := api.LoadEnvFile(envPath); err != nil {
log.Fatalf("load .env: %v", err)
}
}
api.ApplyEnv(&cfg)
listen := flag.String("listen", cfg.Listen, "HTTP listen address")
publicBase := flag.String("public-base-url", cfg.PublicBaseURL, "public base URL for Addressables rewrite")
stateDir := flag.String("state-dir", cfg.StateDir, "bat daemon state dir (derives default socket)")
socket := flag.String("socket", cfg.SocketPath, "path to bat.sock JSON-RPC socket (primary discovery)")
resourceRoot := flag.String("resource-root", cfg.ResourceRoot, "override published release root (tests/emergency)")
serverInfo := flag.String("server-info-file", cfg.ServerInfoFile, "optional server-info JSON path")
requireIndexed := flag.Bool("require-indexed", cfg.RequireIndexed, "only serve files present in the release index")
verifySize := flag.Bool("verify-size", cfg.VerifySize, "reject CDN files whose size differs from the index")
rpcTimeout := flag.Duration("rpc-timeout", cfg.RPCTimeout, "daemon RPC timeout")
refreshInterval := flag.Duration("refresh-interval", cfg.RefreshInterval, "periodic release discovery interval (0 disables)")
authQueryParam := flag.String("auth-query-param", cfg.AuthQueryParam, "query parameter accepted for token auth fallback")
authExemptPaths := flag.String("auth-exempt-paths", strings.Join(cfg.AuthExemptPaths, ","), "comma-separated auth-exempt exact paths or slash-prefixes")
trustProxyHeaders := flag.Bool("trust-proxy-headers", cfg.TrustProxyHeaders, "trust X-Forwarded-For and X-Real-IP from reverse proxy")
accessLog := flag.Bool("access-log", cfg.AccessLog, "enable per-request access logs without query strings")
rateLimitRPS := flag.Float64("rate-limit-rps", cfg.RateLimitRPS, "per-client request rate limit; 0 disables")
rateLimitBurst := flag.Int("rate-limit-burst", cfg.RateLimitBurst, "per-client rate limit burst")
maxResourceLimit := flag.Int("max-resource-limit", cfg.MaxResourcePageLimit, "maximum /v1/resources page size")
flag.Parse()
cfg.Listen = *listen
cfg.PublicBaseURL = *publicBase
cfg.StateDir = *stateDir
cfg.SocketPath = *socket
cfg.ResourceRoot = *resourceRoot
cfg.ServerInfoFile = *serverInfo
cfg.RequireIndexed = *requireIndexed
cfg.VerifySize = *verifySize
cfg.RPCTimeout = *rpcTimeout
cfg.RefreshInterval = *refreshInterval
cfg.AuthQueryParam = *authQueryParam
cfg.AuthExemptPaths = splitFlagCSV(*authExemptPaths)
cfg.TrustProxyHeaders = *trustProxyHeaders
cfg.AccessLog = *accessLog
cfg.RateLimitRPS = *rateLimitRPS
cfg.RateLimitBurst = *rateLimitBurst
cfg.MaxResourcePageLimit = *maxResourceLimit
// If socket still empty after flags, derive from state-dir.
if cfg.SocketPath == "" {
cfg.SocketPath = filepath.Join(cfg.StateDir, "bat.sock")
}
if err := cfg.Normalize(); err != nil {
log.Fatalf("config: %v", err)
}
var backend api.Backend
client := backendrpc.New(cfg.SocketPath)
client.Timeout = cfg.RPCTimeout
backend = api.RPCClient{Client: client}
server := api.NewServer(cfg, backend, log.Default())
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
refreshCtx, refreshCancel := context.WithTimeout(ctx, cfg.RPCTimeout+5*time.Second)
if err := server.Refresh(refreshCtx); err != nil {
log.Printf("initial discover failed: %v (serving with empty/partial index)", err)
}
refreshCancel()
server.StartRefreshLoop(ctx)
if err := server.ListenAndServe(ctx); err != nil && err != context.Canceled {
log.Fatalf("serve: %v", err)
}
}
func envFilePath() string {
exe, err := os.Executable()
if err != nil {
return api.EnvFileName
}
return filepath.Join(filepath.Dir(exe), api.EnvFileName)
}
func ensureEnvTemplate(path string) error {
if _, err := os.Stat(path); err == nil {
return nil
} else if !os.IsNotExist(err) {
return err
}
if err := os.WriteFile(path, []byte(api.EnvTemplate), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
log.Printf("wrote config template %s", path)
return nil
}
func splitFlagCSV(raw string) []string {
var out []string
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
+10
View File
@@ -0,0 +1,10 @@
package main
import (
"fmt"
)
func runDoctor() error {
fmt.Println("bat doctor: ok")
return nil
}
+15
View File
@@ -0,0 +1,15 @@
package main
import "bat-api/internal/ffi"
func InspectManifest(rawJSON string) (string, error) {
return ffi.InspectManifest(rawJSON)
}
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
return ffi.BuildSyncPlan(currentJSON, previousJSON)
}
func batVersion() (string, error) {
return ffi.Version()
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
var err error
switch os.Args[1] {
case "doctor":
err = runDoctor()
case "manifest":
err = runManifest(os.Args[2:])
case "sync":
err = runSync(os.Args[2:])
case "help", "-h", "--help":
printUsage()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
printUsage()
os.Exit(1)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func printUsage() {
fmt.Println("bat-go - experimental Go helper (NOT the product CLI)")
fmt.Println()
fmt.Println("Product sync/ops CLI is the Rust binary `bat` (nearly fully automatic).")
fmt.Println("Product resource HTTP service is `bat-api` (see docs/reports/GO_STATUS.md).")
fmt.Println()
fmt.Println("This binary is experimental FFI demos only. Build output must be bin/bat-go.")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" bat-go doctor")
fmt.Println(" bat-go manifest inspect <file>")
fmt.Println(" bat-go sync plan <current-json> [previous-json]")
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"fmt"
"os"
)
func runManifest(args []string) error {
if len(args) < 2 || args[0] != "inspect" {
return fmt.Errorf("usage: bat manifest inspect <file>")
}
path := args[1]
data, err := os.ReadFile(path)
if err != nil {
return err
}
result, err := InspectManifest(string(data))
if err != nil {
return err
}
fmt.Println(result)
return nil
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"fmt"
"os"
)
func runSync(args []string) error {
if len(args) < 2 || args[0] != "plan" {
return fmt.Errorf("usage: bat sync plan <current-json> [previous-json]")
}
current := args[1]
previous := ""
if len(args) > 2 {
previous = args[2]
}
result, err := BuildSyncPlan(current, previous)
if err != nil {
return err
}
fmt.Fprintln(os.Stdout, result)
return nil
}
+130 -19
View File
@@ -1,6 +1,11 @@
//! 游戏客户端领域对象
use std::path::PathBuf;
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const CLIENT_ROOTS_ENV: &str = "BAT_CLIENT_ROOTS";
/// 游戏区域
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
@@ -69,18 +74,58 @@ impl GameClient {
}
}
/// 发现本地安装的客户端
/// 发现显式配置根目录下的本地客户端
///
/// # 返回
/// - 成功:返回找到的所有客户端
/// - 失败:返回错误
///
/// # 注意
/// 此功能将在 Phase 3 实现
/// 默认不扫描系统目录。调用方必须通过 `BAT_CLIENT_ROOTS` 提供一个或
/// 多个路径;路径格式使用平台原生路径分隔符。没有配置时返回空列表。
pub fn discover() -> crate::Result<Vec<GameClient>> {
Err(crate::Error::NotImplemented(
"客户端发现功能将在 Phase 3 实现".to_string(),
))
let Some(value) = env::var_os(CLIENT_ROOTS_ENV) else {
return Ok(Vec::new());
};
let roots = env::split_paths(&value).collect::<Vec<_>>();
Self::discover_in_roots(&roots)
}
/// 在调用方明确提供的隔离根目录下发现客户端。
///
/// 每个根目录只检查根本身和它的直接子目录,不递归扫描用户目录。
/// 当前核心模型的默认发现区域为日本服;其他区域应由适配器提供
/// 专用区域识别策略。
pub fn discover_in_roots(roots: &[PathBuf]) -> crate::Result<Vec<GameClient>> {
let mut candidates = Vec::new();
let mut seen = HashSet::new();
for root in roots {
if !is_real_directory(root)? || has_symlink_component(root)? {
continue;
}
if seen.insert(root.clone()) {
candidates.push(root.clone());
}
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if !is_real_directory(&path)? || has_symlink_component(&path)? {
continue;
}
if seen.insert(path.clone()) {
candidates.push(path);
}
}
}
let mut clients = Vec::new();
for path in candidates {
if client_layout_is_present(&path)? {
clients.push(GameClient::new(path, GameRegion::Japan));
}
}
Ok(clients)
}
/// 验证客户端完整性
@@ -89,12 +134,11 @@ impl GameClient {
/// - true: 客户端完整
/// - false: 客户端损坏
///
/// # 注意
/// 此功能将在 Phase 3 实现
pub fn verify_integrity(&self) -> crate::Result<bool> {
Err(crate::Error::NotImplemented(
"完整性验证将在 Phase 3 实现".to_string(),
))
if !is_real_directory(&self.install_path)? || has_symlink_component(&self.install_path)? {
return Ok(false);
}
client_layout_is_present(&self.install_path)
}
/// 获取 StreamingAssets 目录路径
@@ -110,9 +154,40 @@ impl GameClient {
}
}
fn client_layout_is_present(path: &Path) -> crate::Result<bool> {
Ok(is_real_directory(&path.join("BlueArchive_Data"))?
&& is_real_directory(&path.join("BlueArchive_Data/StreamingAssets"))?
&& is_real_directory(&path.join("BlueArchive_Data/StreamingAssets/AssetBundles"))?
&& !has_symlink_component(path)?)
}
fn is_real_directory(path: &Path) -> crate::Result<bool> {
match fs::symlink_metadata(path) {
Ok(metadata) => Ok(metadata.is_dir() && !metadata.file_type().is_symlink()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
fn has_symlink_component(path: &Path) -> crate::Result<bool> {
let mut current = PathBuf::new();
for component in path.components() {
current.push(component.as_os_str());
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => return Ok(true),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_game_region_code() {
@@ -153,12 +228,48 @@ mod tests {
}
#[test]
fn test_discover_not_implemented() {
let result = GameClient::discover();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
crate::Error::NotImplemented(_)
));
fn test_discover_without_explicit_roots_is_empty() {
// discover() 不得因为测试机或用户 home 中存在目录而扫描它们。
assert!(GameClient::discover_in_roots(&[]).unwrap().is_empty());
}
#[test]
fn test_discover_and_verify_isolated_client_layout() {
let temp = TempDir::new().unwrap();
let client_root = temp.path().join("BlueArchive_JP");
fs::create_dir_all(client_root.join("BlueArchive_Data/StreamingAssets/AssetBundles"))
.unwrap();
let clients = GameClient::discover_in_roots(&[temp.path().to_path_buf()]).unwrap();
assert_eq!(clients.len(), 1);
assert_eq!(clients[0].install_path, client_root);
assert_eq!(clients[0].region, GameRegion::Japan);
assert!(clients[0].verify_integrity().unwrap());
}
#[test]
fn test_integrity_rejects_incomplete_layout() {
let temp = TempDir::new().unwrap();
let client = GameClient::new(temp.path().join("missing"), GameRegion::Japan);
assert!(!client.verify_integrity().unwrap());
}
#[cfg(unix)]
#[test]
fn test_discovery_and_integrity_reject_symlinked_client() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let real = temp.path().join("real");
fs::create_dir_all(real.join("BlueArchive_Data/StreamingAssets/AssetBundles")).unwrap();
let link = temp.path().join("link");
symlink(&real, &link).unwrap();
let clients = GameClient::discover_in_roots(&[temp.path().to_path_buf()]).unwrap();
assert_eq!(clients.len(), 1);
assert_eq!(clients[0].install_path, real);
assert!(!GameClient::new(link, GameRegion::Japan)
.verify_integrity()
.unwrap());
}
}
+3 -1
View File
@@ -7,7 +7,9 @@ pub mod translation;
pub use game_client::{ClientStatus, GameClient, GameRegion};
pub use game_version::{GameVersion, UnityVersion};
pub use resource::{Resource, ResourceEntry, ResourceType};
pub use resource::{
crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceMetadata, ResourceType,
};
pub use translation::{
ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText,
TranslationStatus,
+212 -5
View File
@@ -34,6 +34,156 @@ pub struct ResourceEntry {
pub address: Option<String>,
/// 该资源依赖的其他资源标识
pub dependencies: Vec<String>,
/// Addressables provider ID。
///
/// 旧的 manifest 和资源索引没有该字段,缺省时保持 `None`。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_id: Option<String>,
/// Addressables bundle name。
///
/// 该值是定位/诊断字段,不作为资源 hash 的替代值。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bundle_name: Option<String>,
/// Addressables bundle 的 CRC32catalog 中的 `m_Crc`)。
///
/// `None` 表示 catalog 未提供该字段;Unity 用 `0` 表示「不做 CRC 校验」,
/// 因此 `Some(0)` 与 `None` 在校验时同样视为「无 CRC」。为向后兼容旧的
/// 持久化数据,反序列化时缺省为 `None`。
#[serde(default)]
pub crc: Option<u32>,
}
/// 资源解析与发布侧元数据。
///
/// 该结构默认全空,保证旧索引和只保存基础 manifest 信息的资源仍可反序列化。
/// 官方资源导入会按 release manifest 和 parse cache 填充这些字段,供
/// `resource.index` 等只读接口暴露版本、平台、bundle、TextAsset 和 TextUnit 摘要。
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ResourceMetadata {
/// 资源所属的官方 release ID。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub official_release_id: Option<String>,
/// 从官方相对路径推断的平台标签,例如 `windows` 或 `android`。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
/// 资源本身或所在 bundle 的官方相对路径。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bundle_path: Option<String>,
/// ZIP 内被解析到的 bundle entry;直接 bundle 为空。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub archive_entries: Vec<String>,
/// parse cache 中出现过的解析状态标签。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub parse_statuses: Vec<String>,
/// 解析到的 Unity 版本集合。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unity_versions: Vec<String>,
/// UnityFS directory file 总数。
#[serde(default, skip_serializing_if = "is_zero")]
pub unityfs_file_count: u64,
/// Unity serialized file 总数。
#[serde(default, skip_serializing_if = "is_zero")]
pub serialized_file_count: u64,
/// TextAsset 对象总数。
#[serde(default, skip_serializing_if = "is_zero")]
pub text_asset_count: u64,
/// TextAsset 名称集合。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub text_assets: Vec<String>,
/// TextUnit 总数。
#[serde(default, skip_serializing_if = "is_zero")]
pub text_unit_count: u64,
/// TextUnit 格式标签集合,例如 `json`、`csv`、`tsv`、`plain`。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub text_unit_formats: Vec<String>,
/// TextUnit 提取阶段的非致命诊断数量。
#[serde(default, skip_serializing_if = "is_zero")]
pub text_unit_error_count: u64,
}
fn is_zero(value: &u64) -> bool {
*value == 0
}
/// 已下载字节与 catalog 声明的可校验字段不一致。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IntegrityMismatch {
/// 实际字节数与声明的 `size` 不符。
Size {
/// catalog 声明的大小。
expected: u64,
/// 实际字节数。
actual: u64,
},
/// 实际 CRC32 与声明的 `crc` 不符。
Crc {
/// catalog 声明的 CRC32。
expected: u32,
/// 实际计算出的 CRC32。
actual: u32,
},
}
impl std::fmt::Display for IntegrityMismatch {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Size { expected, actual } => {
write!(formatter, "大小不符:声明 {expected},实际 {actual}")
}
Self::Crc { expected, actual } => write!(
formatter,
"CRC32 不符:声明 {expected:#010x},实际 {actual:#010x}"
),
}
}
}
impl std::error::Error for IntegrityMismatch {}
impl ResourceEntry {
/// catalog 声明的 CRC32`m_Crc`),`0` 归一化为「无 CRC」(返回 `None`)。
pub fn declared_crc(&self) -> Option<u32> {
self.crc.filter(|value| *value != 0)
}
/// 用 catalog 声明的可校验字段(`size`、`crc`)校验已下载/已解出的字节。
///
/// - `size`:声明值为 `0` 视为未提供,跳过;否则要求与 `data.len()` 相等。
/// - `crc`:无声明(`None`/`Some(0)`)时跳过;否则按 IEEE CRC-32 计算 `data`
/// 的 CRC 并比对。Unity AssetBundle 的 `m_Crc` 即标准 IEEE CRC-32(与
/// zlib `crc32` 一致,UnityPy/AssetStudio 等生态一致采用)。
///
/// 校验通过返回 `Ok(())`;不一致返回首个失败项(先 size 后 crc)。
pub fn verify_downloaded_bytes(&self, data: &[u8]) -> Result<(), IntegrityMismatch> {
if self.size != 0 && self.size != data.len() as u64 {
return Err(IntegrityMismatch::Size {
expected: self.size,
actual: data.len() as u64,
});
}
if let Some(expected) = self.declared_crc() {
let actual = crc32_ieee(data);
if actual != expected {
return Err(IntegrityMismatch::Crc { expected, actual });
}
}
Ok(())
}
}
/// 计算 IEEE CRC-32(多项式 `0xEDB88320`,反射,初值/终值 `0xFFFFFFFF`)。
///
/// 与 zlib `crc32` 及 Unity AssetBundle `m_Crc` 使用的算法一致。
pub fn crc32_ieee(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= u32::from(byte);
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
/// 资源
@@ -45,24 +195,81 @@ pub struct Resource {
pub local_path: PathBuf,
/// 资源条目
pub entry: ResourceEntry,
/// 解析、发布和索引侧扩展元数据。
#[serde(default)]
pub metadata: ResourceMetadata,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resource_entry() {
let entry = ResourceEntry {
fn entry_with(size: u64, crc: Option<u32>) -> ResourceEntry {
ResourceEntry {
path: "test.bundle".to_string(),
hash: "abc123".to_string(),
size: 1024,
size,
resource_type: ResourceType::AssetBundle,
address: None,
dependencies: Vec::new(),
};
provider_id: None,
bundle_name: None,
crc,
}
}
#[test]
fn test_resource_entry() {
let entry = entry_with(1024, None);
assert_eq!(entry.path, "test.bundle");
assert_eq!(entry.size, 1024);
assert_eq!(entry.crc, None);
}
#[test]
fn crc32_matches_known_vector() {
// 标准 IEEE CRC-32 测试向量:crc32("123456789") == 0xCBF43926。
assert_eq!(crc32_ieee(b"123456789"), 0xCBF4_3926);
assert_eq!(crc32_ieee(b""), 0);
}
#[test]
fn declared_crc_treats_zero_as_absent() {
assert_eq!(entry_with(0, None).declared_crc(), None);
assert_eq!(entry_with(0, Some(0)).declared_crc(), None);
assert_eq!(entry_with(0, Some(42)).declared_crc(), Some(42));
}
#[test]
fn verify_downloaded_bytes_checks_size_and_crc() {
let data = b"123456789";
let crc = crc32_ieee(data);
// size + crc 均匹配。
assert!(entry_with(data.len() as u64, Some(crc))
.verify_downloaded_bytes(data)
.is_ok());
// size=0 与 crc=0/None 视为未声明,跳过校验。
assert!(entry_with(0, None).verify_downloaded_bytes(data).is_ok());
assert!(entry_with(0, Some(0)).verify_downloaded_bytes(data).is_ok());
// size 不符。
assert_eq!(
entry_with(3, None).verify_downloaded_bytes(data),
Err(IntegrityMismatch::Size {
expected: 3,
actual: 9
})
);
// size 通过、crc 不符。
assert_eq!(
entry_with(data.len() as u64, Some(0xDEAD_BEEF)).verify_downloaded_bytes(data),
Err(IntegrityMismatch::Crc {
expected: 0xDEAD_BEEF,
actual: crc
})
);
}
}
+85 -11
View File
@@ -37,7 +37,7 @@ use async_trait::async_trait;
/// 资源查询条件
///
/// 用于构建灵活的资源查询。支持按类型、Hash、路径模式过滤。
/// 用于构建灵活的资源查询。支持按类型、Hash、路径、官方 release 和解析摘要过滤。
///
/// # 示例
///
@@ -56,9 +56,10 @@ use async_trait::async_trait;
/// resource_type: Some(ResourceType::AssetBundle),
/// hash: Some("abc123".to_string()),
/// path_pattern: Some("academy-*.bundle".to_string()),
/// ..ResourceQuery::all()
/// };
/// ```
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct ResourceQuery {
/// 按资源类型过滤
///
@@ -90,6 +91,41 @@ pub struct ResourceQuery {
/// - `"**/*.json"` - 匹配所有 JSON 文件
/// - `"assets/???.png"` - 匹配三个字符的 PNG 文件
pub path_pattern: Option<String>,
/// 按官方 release ID 过滤。
///
/// 匹配 `ResourceMetadata::official_release_id`。
pub official_release_id: Option<String>,
/// 按资源平台过滤。
///
/// 匹配 `ResourceMetadata::platform`,例如 `windows` 或 `android`。
pub platform: Option<String>,
/// 按官方 destination 过滤。
///
/// 匹配 `ResourceEntry::path`,用于从 release manifest destination 反查资源。
pub destination: Option<String>,
/// 按资源或所在 bundle 的官方相对路径过滤。
///
/// 匹配 `ResourceMetadata::bundle_path`。
pub bundle_path: Option<String>,
/// 按 ZIP/archive entry 过滤。
///
/// 匹配 `ResourceMetadata::archive_entries` 中的任意一项。
pub archive_entry: Option<String>,
/// 按解析状态过滤。
///
/// 匹配 `ResourceMetadata::parse_statuses` 中的任意一项。
pub parse_status: Option<String>,
/// 按 TextUnit payload format 过滤。
///
/// 匹配 `ResourceMetadata::text_unit_formats` 中的任意一项。
pub text_unit_format: Option<String>,
}
impl ResourceQuery {
@@ -105,11 +141,7 @@ impl ResourceQuery {
/// let all_resources = repo.list(ResourceQuery::all()).await?;
/// ```
pub fn all() -> Self {
Self {
resource_type: None,
hash: None,
path_pattern: None,
}
Self::default()
}
/// 按类型查询
@@ -132,8 +164,7 @@ impl ResourceQuery {
pub fn by_type(resource_type: ResourceType) -> Self {
Self {
resource_type: Some(resource_type),
hash: None,
path_pattern: None,
..Self::default()
}
}
@@ -156,11 +187,23 @@ impl ResourceQuery {
/// ```
pub fn by_hash(hash: String) -> Self {
Self {
resource_type: None,
hash: Some(hash),
path_pattern: None,
..Self::default()
}
}
/// 是否包含通用仓储需要读取完整 `Resource` 后才能判断的条件。
///
/// 具体后端可以把这些 metadata 条件下推到自身索引;内存实现仍用完整
/// `Resource` 过滤来保持 `list()` 与 `count()` 的语义一致。
pub fn requires_resource_scan(&self) -> bool {
self.official_release_id.is_some()
|| self.platform.is_some()
|| self.bundle_path.is_some()
|| self.archive_entry.is_some()
|| self.parse_status.is_some()
|| self.text_unit_format.is_some()
}
}
/// 资源仓储接口
@@ -305,6 +348,7 @@ pub trait ResourceRepository: Send + Sync {
/// resource_type: Some(ResourceType::AssetBundle),
/// path_pattern: Some("academy-*.bundle".to_string()),
/// hash: None,
/// ..ResourceQuery::all()
/// };
/// let filtered = repo.list(query).await?;
/// ```
@@ -416,6 +460,13 @@ mod tests {
assert!(query.resource_type.is_none());
assert!(query.hash.is_none());
assert!(query.path_pattern.is_none());
assert!(query.official_release_id.is_none());
assert!(query.platform.is_none());
assert!(query.destination.is_none());
assert!(query.bundle_path.is_none());
assert!(query.archive_entry.is_none());
assert!(query.parse_status.is_none());
assert!(query.text_unit_format.is_none());
}
/// 测试按类型查询
@@ -425,6 +476,7 @@ mod tests {
assert_eq!(query.resource_type, Some(ResourceType::AssetBundle));
assert!(query.hash.is_none());
assert!(query.path_pattern.is_none());
assert!(!query.requires_resource_scan());
}
/// 测试按 Hash 查询
@@ -434,6 +486,7 @@ mod tests {
assert_eq!(query.hash, Some("abc123".to_string()));
assert!(query.resource_type.is_none());
assert!(query.path_pattern.is_none());
assert!(!query.requires_resource_scan());
}
/// 测试组合查询
@@ -443,11 +496,32 @@ mod tests {
resource_type: Some(ResourceType::AssetBundle),
hash: Some("hash123".to_string()),
path_pattern: Some("*.bundle".to_string()),
official_release_id: Some("v-current".to_string()),
platform: Some("windows".to_string()),
destination: Some("Bundles/academy.bundle".to_string()),
bundle_path: Some("Bundles/academy.bundle".to_string()),
archive_entry: Some("academy".to_string()),
parse_status: Some("parsed".to_string()),
text_unit_format: Some("json".to_string()),
};
assert_eq!(query.resource_type, Some(ResourceType::AssetBundle));
assert_eq!(query.hash, Some("hash123".to_string()));
assert_eq!(query.path_pattern, Some("*.bundle".to_string()));
assert_eq!(query.official_release_id, Some("v-current".to_string()));
assert_eq!(query.platform, Some("windows".to_string()));
assert_eq!(
query.destination,
Some("Bundles/academy.bundle".to_string())
);
assert_eq!(
query.bundle_path,
Some("Bundles/academy.bundle".to_string())
);
assert_eq!(query.archive_entry, Some("academy".to_string()));
assert_eq!(query.parse_status, Some("parsed".to_string()));
assert_eq!(query.text_unit_format, Some("json".to_string()));
assert!(query.requires_resource_scan());
}
/// 测试 ResourceQuery 可以被克隆
+3 -3
View File
@@ -11,9 +11,9 @@ 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"
md-5 = "0.10"
[dev-dependencies]
hex = "0.4"
+33 -7
View File
@@ -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>;
+23
View File
@@ -9,9 +9,32 @@
pub mod error;
pub mod parser;
pub mod patch;
pub mod serialized;
pub mod text;
pub mod types;
pub use error::{AssetBundleError, Result};
pub use parser::{compression_from_flags, Parser, UnityFsParser};
pub use patch::{
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
StringFieldPatch, TextAssetPatch,
};
pub use serialized::{
UnityManagedReferenceMetadata, UnityManagedReferenceRecord, UnitySerializedField,
UnitySerializedFieldReplacement, UnitySerializedFile, UnitySerializedObject,
UnitySerializedReplacementValue, UnitySerializedTextAsset, UnitySerializedType,
UnitySerializedValue, UnityTypeTreeNode,
};
pub use text::{
text_units_to_jsonl, TextUnit, TextUnitExtractionError, TextUnitExtractionReport,
TextUnitExtractor,
};
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+949
View File
@@ -0,0 +1,949 @@
//! Text extraction from parsed Unity serialized objects.
use crate::serialized::{
managed_reference_metadata_from_fields, UnityManagedReferenceMetadata,
UnityManagedReferenceRecord, UnitySerializedField, UnitySerializedValue,
};
use crate::types::ParsedAssetBundle;
use std::collections::BTreeMap;
/// One text unit used by translation, glossary and patch pipelines.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TextUnit {
/// Original source text.
pub source_text: String,
/// Logical AssetBundle path, when provided by the caller.
pub bundle_path: Option<String>,
/// ZIP/archive entry containing the bundle, when known.
pub archive_entry: Option<String>,
/// Unity serialized file path.
pub serialized_file: Option<String>,
/// Unity object path ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_id: Option<i64>,
/// Unity class ID, for example `49` for `TextAsset`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class_id: Option<i32>,
/// TypeTree field path. `TextAsset` is used for a whole TextAsset payload.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field_path: Option<String>,
/// Byte offset relative to the beginning of the Unity object payload.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field_offset: Option<usize>,
/// Number of bytes consumed by this field, including alignment padding.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field_byte_size: Option<usize>,
/// Unity version associated with the source.
pub version: String,
/// Stable context for format, asset name and extraction details.
pub context: BTreeMap<String, String>,
}
/// Non-fatal diagnostic generated while extracting text units.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TextUnitExtractionError {
/// Serialized file containing the failed object.
pub serialized_file: Option<String>,
/// Object path ID, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_id: Option<i64>,
/// Unity class ID, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class_id: Option<i32>,
/// TypeTree field path, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field_path: Option<String>,
/// Byte offset relative to the beginning of the Unity object payload.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset: Option<usize>,
/// Human-readable error.
pub error: String,
}
/// Result of extracting text units from one parsed bundle.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TextUnitExtractionReport {
/// Extracted text units in deterministic traversal order.
pub units: Vec<TextUnit>,
/// Non-fatal object-level errors.
pub errors: Vec<TextUnitExtractionError>,
/// TextAsset payloads that were binary or invalid UTF-8.
pub skipped_binary_text_assets: usize,
}
/// Extracts translation-ready text from Unity bundle data.
#[derive(Debug, Default, Clone, Copy)]
pub struct TextUnitExtractor;
impl TextUnitExtractor {
/// Creates an extractor.
pub fn new() -> Self {
Self
}
/// Extracts TextAsset and TypeTree string fields from a parsed bundle.
pub fn extract_bundle(
&self,
bundle: &ParsedAssetBundle,
bundle_path: Option<&str>,
) -> TextUnitExtractionReport {
self.extract_bundle_with_context(bundle, bundle_path, None)
}
/// Extracts text with both logical bundle and archive-entry context.
pub fn extract_bundle_with_context(
&self,
bundle: &ParsedAssetBundle,
bundle_path: Option<&str>,
archive_entry: Option<&str>,
) -> TextUnitExtractionReport {
let mut report = TextUnitExtractionReport {
units: Vec::new(),
errors: Vec::new(),
skipped_binary_text_assets: 0,
};
for asset in &bundle.text_assets {
if let Some((format, text)) = decode_text_payload(&asset.bytes) {
let mut context = BTreeMap::new();
context.insert("format".to_string(), format.to_string());
context.insert("asset_name".to_string(), asset.name.clone());
context.insert("source_kind".to_string(), "TextAsset".to_string());
report.units.push(TextUnit {
source_text: text,
bundle_path: bundle_path.map(ToOwned::to_owned),
archive_entry: archive_entry.map(ToOwned::to_owned),
serialized_file: asset.source_path.clone(),
path_id: Some(asset.path_id),
class_id: Some(49),
field_path: Some("TextAsset".to_string()),
field_offset: None,
field_byte_size: None,
version: bundle.unity_version.clone(),
context,
});
} else {
report.skipped_binary_text_assets += 1;
}
}
for serialized_file in &bundle.serialized_files {
for object in &serialized_file.objects {
if object.class_id == 49 || !serialized_file.object_has_type_tree(object) {
continue;
}
let fields = match serialized_file.fields_for_object_entry(object) {
Ok(fields) => fields,
Err(error) => {
report.errors.push(TextUnitExtractionError {
serialized_file: serialized_file.source_path.clone(),
path_id: Some(object.path_id),
class_id: Some(object.class_id),
field_path: None,
offset: None,
error: error.to_string(),
});
continue;
}
};
let context = FieldTextContext {
serialized_file_path: serialized_file.source_path.as_deref(),
path_id: object.path_id,
class_id: object.class_id,
version: &bundle.unity_version,
bundle_path,
archive_entry,
managed_reference: None,
};
for field in fields {
collect_field_text(&mut report.units, &context, &field);
}
}
}
report
}
}
/// Serializes text units as one stable JSON object per line.
pub fn text_units_to_jsonl(units: &[TextUnit]) -> Result<String, serde_json::Error> {
let mut output = String::new();
for unit in units {
output.push_str(&serde_json::to_string(unit)?);
output.push('\n');
}
Ok(output)
}
#[derive(Clone)]
struct FieldTextContext<'a> {
serialized_file_path: Option<&'a str>,
path_id: i64,
class_id: i32,
version: &'a str,
bundle_path: Option<&'a str>,
archive_entry: Option<&'a str>,
managed_reference: Option<UnityManagedReferenceMetadata>,
}
impl<'a> FieldTextContext<'a> {
fn with_managed_reference(&self, metadata: Option<&UnityManagedReferenceMetadata>) -> Self {
Self {
serialized_file_path: self.serialized_file_path,
path_id: self.path_id,
class_id: self.class_id,
version: self.version,
bundle_path: self.bundle_path,
archive_entry: self.archive_entry,
managed_reference: metadata.cloned().or_else(|| self.managed_reference.clone()),
}
}
}
fn collect_field_text<'a>(
units: &mut Vec<TextUnit>,
context: &FieldTextContext<'a>,
field: &'a UnitySerializedField,
) {
match &field.value {
UnitySerializedValue::String(text) if !text.is_empty() => {
let mut unit_context = BTreeMap::new();
unit_context.insert("format".to_string(), "plain".to_string());
unit_context.insert(
"source_kind".to_string(),
if context.managed_reference.is_some() {
"ManagedReferenceField"
} else {
"TypeTreeField"
}
.to_string(),
);
unit_context.insert("type_name".to_string(), field.type_name.clone());
if let Some(metadata) = &context.managed_reference {
insert_managed_reference_context(&mut unit_context, metadata);
}
units.push(TextUnit {
source_text: text.clone(),
bundle_path: context.bundle_path.map(ToOwned::to_owned),
archive_entry: context.archive_entry.map(ToOwned::to_owned),
serialized_file: context.serialized_file_path.map(ToOwned::to_owned),
path_id: Some(context.path_id),
class_id: Some(context.class_id),
field_path: Some(field.path.clone()),
field_offset: Some(field.offset),
field_byte_size: Some(field.byte_size),
version: context.version.to_string(),
context: unit_context,
});
}
UnitySerializedValue::Object(fields) => {
for field in fields {
collect_field_text(units, context, field);
}
}
UnitySerializedValue::ManagedReference {
metadata, fields, ..
} => {
let managed_context = context.with_managed_reference(metadata.as_ref());
for field in fields {
collect_managed_reference_child_text(units, &managed_context, field);
}
}
UnitySerializedValue::ManagedReferenceRegistry { references, fields } => {
if references.is_empty() {
let fallback_metadata = managed_reference_metadata_from_fields(fields);
for field in fields {
let sibling_metadata =
managed_reference_metadata_from_sibling_fields(fields, field);
let managed_context = context.with_managed_reference(
sibling_metadata.as_ref().or(fallback_metadata.as_ref()),
);
collect_managed_reference_child_text(units, &managed_context, field);
}
} else {
for reference in references {
collect_managed_reference_record_text(units, context, reference);
}
}
}
UnitySerializedValue::Array(values) | UnitySerializedValue::Map(values) => {
for field in values {
collect_field_text(units, context, field);
}
}
_ => {}
}
}
fn collect_managed_reference_record_text<'a>(
units: &mut Vec<TextUnit>,
context: &FieldTextContext<'a>,
reference: &'a UnityManagedReferenceRecord,
) {
let managed_context = context.with_managed_reference(Some(&reference.metadata));
for field in &reference.fields {
collect_field_text(units, &managed_context, field);
}
}
fn collect_managed_reference_child_text<'a>(
units: &mut Vec<TextUnit>,
context: &FieldTextContext<'a>,
field: &'a UnitySerializedField,
) {
let key = normalized_text_metadata_key(&field.name);
if is_managed_reference_metadata_text_key(&key) {
return;
}
if is_managed_reference_payload_text_key(&key) {
if let Some(children) = serialized_field_children(field) {
for child in children {
collect_field_text(units, context, child);
}
} else {
collect_field_text(units, context, field);
}
return;
}
if let Some(children) = serialized_field_children(field) {
if let Some(metadata) = managed_reference_metadata_from_fields(children) {
let managed_context = context.with_managed_reference(Some(&metadata));
for child in children {
collect_managed_reference_child_text(units, &managed_context, child);
}
return;
}
}
collect_field_text(units, context, field);
}
fn managed_reference_metadata_from_sibling_fields(
fields: &[UnitySerializedField],
field: &UnitySerializedField,
) -> Option<UnityManagedReferenceMetadata> {
let record_prefix = managed_reference_record_path_prefix(&field.path)?;
let grouped_fields = fields
.iter()
.filter(|candidate| {
candidate.path == record_prefix
|| candidate
.path
.strip_prefix(record_prefix)
.is_some_and(|suffix| suffix.starts_with('.'))
})
.cloned()
.collect::<Vec<_>>();
managed_reference_metadata_from_fields(&grouped_fields)
}
fn managed_reference_record_path_prefix(path: &str) -> Option<&str> {
if let Some(index_end) = path.rfind(']') {
return Some(&path[..=index_end]);
}
path.rsplit_once('.')
.map(|(parent, _)| parent)
.filter(|parent| !parent.is_empty())
}
fn insert_managed_reference_context(
context: &mut BTreeMap<String, String>,
metadata: &UnityManagedReferenceMetadata,
) {
if let Some(reference_id) = metadata.reference_id {
context.insert("managed_reference_id".to_string(), reference_id.to_string());
}
if let Some(value) = &metadata.full_type_name {
context.insert(
"managed_reference_full_type_name".to_string(),
value.clone(),
);
}
if let Some(value) = &metadata.type_name {
context.insert("managed_reference_type".to_string(), value.clone());
}
if let Some(value) = &metadata.namespace {
context.insert("managed_reference_namespace".to_string(), value.clone());
}
if let Some(value) = &metadata.assembly_name {
context.insert("managed_reference_assembly".to_string(), value.clone());
}
}
fn serialized_field_children(field: &UnitySerializedField) -> Option<&[UnitySerializedField]> {
match &field.value {
UnitySerializedValue::Object(fields)
| UnitySerializedValue::Array(fields)
| UnitySerializedValue::Map(fields)
| UnitySerializedValue::ManagedReference { fields, .. }
| UnitySerializedValue::ManagedReferenceRegistry { fields, .. } => Some(fields),
_ => None,
}
}
fn normalized_text_metadata_key(name: &str) -> String {
name.strip_prefix("m_")
.unwrap_or(name)
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
fn is_managed_reference_metadata_text_key(key: &str) -> bool {
matches!(
key,
"rid"
| "id"
| "identifier"
| "refid"
| "referenceid"
| "managedreferenceid"
| "managedreferenceids"
| "managedreferencesid"
| "managedreferencesids"
| "serializedreferenceid"
| "serializedreferenceids"
| "refids"
| "type"
| "typeid"
| "typeinfo"
| "typename"
| "fullname"
| "fulltypename"
| "class"
| "classname"
| "managedreferenceclassname"
| "serializedreferenceclassname"
| "klass"
| "managedtype"
| "managedreferencetype"
| "managedreferencefullname"
| "managedreferencefulltypename"
| "serializedreferencetype"
| "serializedreferencefullname"
| "serializedreferencefulltypename"
| "assemblyqualifiedname"
| "ns"
| "namespace"
| "namespacename"
| "managedreferencenamespace"
| "managedreferencenamespacename"
| "serializedreferencenamespace"
| "serializedreferencenamespacename"
| "asm"
| "asmname"
| "assembly"
| "assemblyname"
| "managedreferenceassembly"
| "managedreferenceassemblyname"
| "serializedreferenceassembly"
| "serializedreferenceassemblyname"
)
}
fn is_managed_reference_payload_text_key(key: &str) -> bool {
matches!(
key,
"data"
| "payload"
| "value"
| "object"
| "instance"
| "managedreferencepayload"
| "referencepayload"
| "serializedreferencepayload"
| "managedreferencevalue"
| "referencevalue"
| "serializedreferencevalue"
| "managedreferenceobject"
| "referenceobject"
| "serializedreferenceobject"
| "managedreferencedata"
| "referencedata"
| "serializeddata"
| "serializedreferencedata"
)
}
fn decode_text_payload(bytes: &[u8]) -> Option<(&'static str, String)> {
let bytes = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes);
if bytes.contains(&0) {
return None;
}
let text = std::str::from_utf8(bytes).ok()?.to_string();
if text.trim().is_empty()
|| text
.chars()
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
{
return None;
}
let trimmed = text.trim();
if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
return Some(("json", text));
}
if text.lines().any(|line| line.contains('\t')) {
return Some(("tsv", text));
}
if text.lines().count() > 1 && text.lines().any(|line| line.contains(',')) {
return Some(("csv", text));
}
Some(("plain", text))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::serialized::UnitySerializedTextAsset;
use crate::types::{
ParsedAssetBundle, UnityFsBlockInfo, UnityFsDirectoryInfo, UnityFsHeader,
UnitySerializedParseError,
};
#[test]
fn extracts_textasset_and_writes_jsonl() {
let bundle = ParsedAssetBundle {
unity_version: "2021.3.56f2".to_string(),
assets: vec!["CAB-test".to_string()],
raw_data: Vec::new(),
unityfs_header: Some(UnityFsHeader {
format_version: 8,
target_version: "5.x.x".to_string(),
unity_version: "2021.3.56f2".to_string(),
total_size: 0,
compressed_blocks_info_size: 0,
uncompressed_blocks_info_size: 0,
flags: 0,
}),
blocks: vec![UnityFsBlockInfo {
uncompressed_size: 0,
compressed_size: 0,
flags: 0,
compression: crate::types::UnityFsCompression::None,
}],
directories: vec![UnityFsDirectoryInfo {
offset: 0,
size: 0,
flags: 0,
path: "CAB-test".to_string(),
}],
files: Vec::new(),
serialized_files: Vec::new(),
text_assets: vec![UnitySerializedTextAsset {
source_path: Some("CAB-test".to_string()),
path_id: 1,
name: "dialogue.json".to_string(),
bytes: br#"{"text":"hello"}"#.to_vec(),
}],
serialized_parse_errors: Vec::<UnitySerializedParseError>::new(),
};
let report = TextUnitExtractor::new().extract_bundle_with_context(
&bundle,
Some("dialogue.bundle"),
Some("assets/dialogue.bundle"),
);
assert_eq!(report.units.len(), 1);
assert_eq!(
report.units[0].archive_entry.as_deref(),
Some("assets/dialogue.bundle")
);
assert_eq!(
report.units[0].context.get("format"),
Some(&"json".to_string())
);
assert_eq!(
text_units_to_jsonl(&report.units).unwrap(),
format!("{}\n", serde_json::to_string(&report.units[0]).unwrap())
);
}
#[test]
fn extracts_managed_reference_payload_without_metadata_strings() {
let payload_field = string_field(
"m_ManagedReferences.references[0].data.message",
"message",
"こんにちは",
);
let metadata_field = string_field(
"m_ManagedReferences.references[0].managedReferenceFullTypeName",
"managedReferenceFullTypeName",
"Game BA.Text.ScenarioLine",
);
let registry_field = UnitySerializedField {
path: "m_ManagedReferences".to_string(),
name: "m_ManagedReferences".to_string(),
type_name: "ManagedReferenceRegistry".to_string(),
offset: 0,
byte_size: 64,
type_tree_node_index: None,
value: UnitySerializedValue::ManagedReferenceRegistry {
references: vec![UnityManagedReferenceRecord {
metadata: UnityManagedReferenceMetadata {
reference_id: Some(42),
full_type_name: Some("Game BA.Text.ScenarioLine".to_string()),
type_name: Some("ScenarioLine".to_string()),
namespace: Some("BA.Text".to_string()),
assembly_name: Some("Game".to_string()),
},
fields: vec![payload_field.clone()],
}],
fields: vec![metadata_field],
},
};
let context = FieldTextContext {
serialized_file_path: Some("CAB-test"),
path_id: 7,
class_id: 114,
version: "2021.3.56f2",
bundle_path: Some("scenario.bundle"),
archive_entry: None,
managed_reference: None,
};
let mut units = Vec::new();
collect_field_text(&mut units, &context, &registry_field);
assert_eq!(units.len(), 1);
assert_eq!(units[0].source_text, "こんにちは");
assert_eq!(
units[0].field_path.as_deref(),
Some("m_ManagedReferences.references[0].data.message")
);
assert_eq!(
units[0].context.get("source_kind"),
Some(&"ManagedReferenceField".to_string())
);
assert_eq!(
units[0].context.get("managed_reference_id"),
Some(&"42".to_string())
);
assert_eq!(
units[0].context.get("managed_reference_full_type_name"),
Some(&"Game BA.Text.ScenarioLine".to_string())
);
assert_eq!(
units[0].context.get("managed_reference_type"),
Some(&"ScenarioLine".to_string())
);
assert_eq!(
units[0].context.get("managed_reference_namespace"),
Some(&"BA.Text".to_string())
);
assert_eq!(
units[0].context.get("managed_reference_assembly"),
Some(&"Game".to_string())
);
}
#[test]
fn extracts_fallback_managed_reference_payload_alias_without_metadata_strings() {
let payload_field = string_field(
"m_ManagedReferences.RefIds[0].managedReferenceData.message",
"message",
"こんにちは",
);
let registry_field = UnitySerializedField {
path: "m_ManagedReferences".to_string(),
name: "m_ManagedReferences".to_string(),
type_name: "ManagedReferencesRegistry".to_string(),
offset: 0,
byte_size: 96,
type_tree_node_index: None,
value: UnitySerializedValue::ManagedReferenceRegistry {
references: Vec::new(),
fields: vec![
string_field(
"m_ManagedReferences.RefIds[0].managedReferenceClassName",
"managedReferenceClassName",
"ScenarioLine",
),
string_field(
"m_ManagedReferences.RefIds[0].managedReferenceNamespaceName",
"managedReferenceNamespaceName",
"BA.Text",
),
string_field(
"m_ManagedReferences.RefIds[0].managedReferenceAssemblyName",
"managedReferenceAssemblyName",
"Game",
),
string_field(
"m_ManagedReferences.RefIds[0].serializedReferenceFullTypeName",
"serializedReferenceFullTypeName",
"Game BA.Text.ScenarioLine",
),
string_field(
"m_ManagedReferences.RefIds[0].typeInfo",
"typeInfo",
"Game BA.Text.ScenarioLine",
),
string_field(
"m_ManagedReferences.RefIds[0].typeID.className",
"className",
"ScenarioLine",
),
string_field(
"m_ManagedReferences.RefIds[0].typeID.namespaceName",
"namespaceName",
"BA.Text",
),
string_field(
"m_ManagedReferences.RefIds[0].typeID.asmName",
"asmName",
"Game",
),
UnitySerializedField {
path: "m_ManagedReferences.RefIds[0].managedReferenceData".to_string(),
name: "managedReferenceData".to_string(),
type_name: "managedReference".to_string(),
offset: 64,
byte_size: 24,
type_tree_node_index: None,
value: UnitySerializedValue::Object(vec![payload_field]),
},
],
},
};
let context = FieldTextContext {
serialized_file_path: Some("CAB-test"),
path_id: 7,
class_id: 114,
version: "2021.3.56f2",
bundle_path: Some("scenario.bundle"),
archive_entry: None,
managed_reference: None,
};
let mut units = Vec::new();
collect_field_text(&mut units, &context, &registry_field);
assert_eq!(units.len(), 1);
assert_eq!(units[0].source_text, "こんにちは");
assert_eq!(
units[0].field_path.as_deref(),
Some("m_ManagedReferences.RefIds[0].managedReferenceData.message")
);
assert_eq!(
units[0].context.get("source_kind"),
Some(&"ManagedReferenceField".to_string())
);
assert_eq!(
units[0].context.get("managed_reference_type"),
Some(&"ScenarioLine".to_string())
);
assert_eq!(
units[0].context.get("managed_reference_namespace"),
Some(&"BA.Text".to_string())
);
assert_eq!(
units[0].context.get("managed_reference_assembly"),
Some(&"Game".to_string())
);
assert_eq!(
units[0].context.get("managed_reference_full_type_name"),
Some(&"Game BA.Text.ScenarioLine".to_string())
);
}
#[test]
fn extracts_fallback_managed_reference_payload_family_alias_with_context() {
let payload_field = string_field(
"m_ManagedReferences.RefIds[0].serializedReferencePayload.message",
"message",
"こんにちは",
);
let registry_field = UnitySerializedField {
path: "m_ManagedReferences".to_string(),
name: "m_ManagedReferences".to_string(),
type_name: "ManagedReferencesRegistry".to_string(),
offset: 0,
byte_size: 96,
type_tree_node_index: None,
value: UnitySerializedValue::ManagedReferenceRegistry {
references: Vec::new(),
fields: vec![
string_field(
"m_ManagedReferences.RefIds[0].managedReferenceClassName",
"managedReferenceClassName",
"ScenarioLine",
),
string_field(
"m_ManagedReferences.RefIds[0].managedReferenceNamespaceName",
"managedReferenceNamespaceName",
"BA.Text",
),
string_field(
"m_ManagedReferences.RefIds[0].managedReferenceAssemblyName",
"managedReferenceAssemblyName",
"Game",
),
UnitySerializedField {
path: "m_ManagedReferences.RefIds[0].serializedReferencePayload"
.to_string(),
name: "serializedReferencePayload".to_string(),
type_name: "managedReference".to_string(),
offset: 64,
byte_size: 24,
type_tree_node_index: None,
value: UnitySerializedValue::Object(vec![payload_field]),
},
],
},
};
let context = FieldTextContext {
serialized_file_path: Some("CAB-test"),
path_id: 7,
class_id: 114,
version: "2021.3.56f2",
bundle_path: Some("scenario.bundle"),
archive_entry: None,
managed_reference: None,
};
let mut units = Vec::new();
collect_field_text(&mut units, &context, &registry_field);
assert_eq!(units.len(), 1);
assert_eq!(units[0].source_text, "こんにちは");
assert_eq!(
units[0].field_path.as_deref(),
Some("m_ManagedReferences.RefIds[0].serializedReferencePayload.message")
);
assert_eq!(
units[0].context.get("source_kind"),
Some(&"ManagedReferenceField".to_string())
);
assert_eq!(
units[0].context.get("managed_reference_type"),
Some(&"ScenarioLine".to_string())
);
assert_eq!(
units[0].context.get("managed_reference_namespace"),
Some(&"BA.Text".to_string())
);
assert_eq!(
units[0].context.get("managed_reference_assembly"),
Some(&"Game".to_string())
);
}
#[test]
fn extracts_fallback_managed_reference_sibling_records_with_separate_context() {
let registry_field = UnitySerializedField {
path: "m_ManagedReferences".to_string(),
name: "m_ManagedReferences".to_string(),
type_name: "ManagedReferencesRegistry".to_string(),
offset: 0,
byte_size: 160,
type_tree_node_index: None,
value: UnitySerializedValue::ManagedReferenceRegistry {
references: Vec::new(),
fields: vec![
string_field(
"m_ManagedReferences.RefIds[0].managedReferenceClassName",
"managedReferenceClassName",
"ScenarioLine",
),
string_field(
"m_ManagedReferences.RefIds[0].managedReferenceNamespaceName",
"managedReferenceNamespaceName",
"BA.Text",
),
string_field(
"m_ManagedReferences.RefIds[0].managedReferenceAssemblyName",
"managedReferenceAssemblyName",
"Game",
),
UnitySerializedField {
path: "m_ManagedReferences.RefIds[0].managedReferenceData".to_string(),
name: "managedReferenceData".to_string(),
type_name: "managedReference".to_string(),
offset: 64,
byte_size: 24,
type_tree_node_index: None,
value: UnitySerializedValue::Object(vec![string_field(
"m_ManagedReferences.RefIds[0].managedReferenceData.message",
"message",
"こんにちは",
)]),
},
string_field(
"m_ManagedReferences.RefIds[1].managedReferenceClassName",
"managedReferenceClassName",
"ChoiceLine",
),
string_field(
"m_ManagedReferences.RefIds[1].managedReferenceNamespaceName",
"managedReferenceNamespaceName",
"BA.Text",
),
string_field(
"m_ManagedReferences.RefIds[1].managedReferenceAssemblyName",
"managedReferenceAssemblyName",
"Game",
),
UnitySerializedField {
path: "m_ManagedReferences.RefIds[1].referencePayload".to_string(),
name: "referencePayload".to_string(),
type_name: "managedReference".to_string(),
offset: 120,
byte_size: 24,
type_tree_node_index: None,
value: UnitySerializedValue::Object(vec![string_field(
"m_ManagedReferences.RefIds[1].referencePayload.message",
"message",
"選択肢",
)]),
},
],
},
};
let context = FieldTextContext {
serialized_file_path: Some("CAB-test"),
path_id: 7,
class_id: 114,
version: "2021.3.56f2",
bundle_path: Some("scenario.bundle"),
archive_entry: None,
managed_reference: None,
};
let mut units = Vec::new();
collect_field_text(&mut units, &context, &registry_field);
assert_eq!(units.len(), 2);
assert_eq!(
units[0].field_path.as_deref(),
Some("m_ManagedReferences.RefIds[0].managedReferenceData.message")
);
assert_eq!(units[0].source_text, "こんにちは");
assert_eq!(
units[0].context.get("managed_reference_type"),
Some(&"ScenarioLine".to_string())
);
assert_eq!(
units[1].field_path.as_deref(),
Some("m_ManagedReferences.RefIds[1].referencePayload.message")
);
assert_eq!(units[1].source_text, "選択肢");
assert_eq!(
units[1].context.get("managed_reference_type"),
Some(&"ChoiceLine".to_string())
);
}
fn string_field(path: &str, name: &str, value: &str) -> UnitySerializedField {
UnitySerializedField {
path: path.to_string(),
name: name.to_string(),
type_name: "string".to_string(),
offset: 0,
byte_size: value.len() + 4,
type_tree_node_index: None,
value: UnitySerializedValue::String(value.to_string()),
}
}
}
+180 -3
View File
@@ -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,
}
@@ -0,0 +1,24 @@
use bat_assetbundle::UnityFsParser;
use std::path::PathBuf;
#[test]
#[ignore = "requires BAT_REAL_UNITYFS_BUNDLE pointing at an isolated real UnityFS bundle"]
fn parses_isolated_real_unityfs_bundle() {
let path = PathBuf::from(
std::env::var("BAT_REAL_UNITYFS_BUNDLE").expect("BAT_REAL_UNITYFS_BUNDLE must be set"),
);
let data = std::fs::read(&path).expect("read isolated real UnityFS bundle");
let parsed = UnityFsParser::new()
.parse_bytes(&data)
.expect("parse isolated real UnityFS bundle");
assert_eq!(parsed.header.total_size, data.len() as u64);
assert!(!parsed.header.unity_version.is_empty());
assert!(!parsed.blocks.is_empty());
assert!(!parsed.directories.is_empty());
assert_eq!(parsed.files.len(), parsed.directories.len());
assert_eq!(
parsed.uncompressed_data_size,
parsed.files.iter().map(|file| file.size).sum::<u64>()
);
}
+5 -3
View File
@@ -2,11 +2,11 @@
use crate::error::{CasError, Result};
use crate::hash::Hash;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteQueryResult};
use sqlx::SqlitePool;
use std::path::Path;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// CAS 对象元数据。
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -41,7 +41,9 @@ impl SqliteRefCounter {
let options =
SqliteConnectOptions::from_str(&format!("sqlite://{}", path.as_ref().display()))
.map_err(|error| CasError::Database(error.to_string()))?
.create_if_missing(true);
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.busy_timeout(Duration::from_secs(30));
let pool = SqlitePoolOptions::new()
.max_connections(1)
+157 -9
View File
@@ -1,13 +1,145 @@
//! Binary Patch 模块占位
//! Deterministic binary hunk patch.
/// Binary Patch 应用(尚未实现)。
use crate::PatchError;
use serde::{Deserialize, Serialize};
/// Current binary patch schema version.
pub const BINARY_PATCH_VERSION: u32 = 1;
/// Binary patch made of deterministic copy/insert hunks.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BinaryPatch {
/// Patch schema version.
pub version: u32,
/// Expected BLAKE3 hash of the source bytes.
pub source_blake3: String,
/// Expected BLAKE3 hash of the target bytes.
pub target_blake3: String,
/// Source byte length.
pub source_size: u64,
/// Target byte length.
pub target_size: u64,
/// Ordered hunks.
pub hunks: Vec<BinaryPatchHunk>,
}
/// One binary patch hunk.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum BinaryPatchHunk {
/// Copy a byte range from the source.
Copy {
/// Source offset.
offset: u64,
/// Number of bytes to copy.
length: u64,
},
/// Insert literal bytes.
Insert {
/// Literal bytes.
bytes: Vec<u8>,
},
}
/// Creates a deterministic hunk patch.
///
/// 返回 [`crate::PatchError::ApplyFailed`] 而非空结果,避免调用方把未实现的
/// 占位当成一次成功的补丁应用。
pub fn apply_patch(_old: &[u8], _patch: &[u8]) -> crate::Result<Vec<u8>> {
Err(crate::PatchError::ApplyFailed(
"binary patch 尚未实现".to_string(),
/// The first implementation optimizes for correctness and stable output. It
/// emits copy hunks for equal runs and insert hunks for changed runs; more
/// compact suffix/prefix matching can be added later without changing the
/// manifest/integrity contract.
pub fn diff(old: &[u8], new: &[u8]) -> BinaryPatch {
let mut hunks = Vec::new();
let mut index = 0usize;
while index < new.len() {
if index < old.len() && old[index] == new[index] {
let start = index;
while index < new.len() && index < old.len() && old[index] == new[index] {
index += 1;
}
hunks.push(BinaryPatchHunk::Copy {
offset: start as u64,
length: (index - start) as u64,
});
continue;
}
let start = index;
while index < new.len() && (index >= old.len() || old[index] != new[index]) {
index += 1;
}
hunks.push(BinaryPatchHunk::Insert {
bytes: new[start..index].to_vec(),
});
}
BinaryPatch {
version: BINARY_PATCH_VERSION,
source_blake3: blake3_hex(old),
target_blake3: blake3_hex(new),
source_size: old.len() as u64,
target_size: new.len() as u64,
hunks,
}
}
/// Applies a structured binary patch.
pub fn apply_binary_patch(old: &[u8], patch: &BinaryPatch) -> crate::Result<Vec<u8>> {
if patch.version != BINARY_PATCH_VERSION {
return Err(PatchError::ApplyFailed(format!(
"unsupported binary patch version {}",
patch.version
)));
}
if patch.source_size != old.len() as u64 || patch.source_blake3 != blake3_hex(old) {
return Err(PatchError::ApplyFailed(
"binary patch source integrity mismatch".to_string(),
));
}
let target_capacity = usize::try_from(patch.target_size)
.map_err(|_| PatchError::ApplyFailed("binary patch target too large".to_string()))?;
let mut output = Vec::with_capacity(target_capacity);
for hunk in &patch.hunks {
match hunk {
BinaryPatchHunk::Copy { offset, length } => {
let start = usize::try_from(*offset).map_err(|_| {
PatchError::ApplyFailed("binary patch copy offset overflow".to_string())
})?;
let length = usize::try_from(*length).map_err(|_| {
PatchError::ApplyFailed("binary patch copy length overflow".to_string())
})?;
let end = start.checked_add(length).ok_or_else(|| {
PatchError::ApplyFailed("binary patch copy range overflow".to_string())
})?;
let bytes = old.get(start..end).ok_or_else(|| {
PatchError::ApplyFailed(format!(
"binary patch copy range {start}..{end} exceeds source {}",
old.len()
))
})?;
output.extend_from_slice(bytes);
}
BinaryPatchHunk::Insert { bytes } => output.extend_from_slice(bytes),
}
}
if output.len() as u64 != patch.target_size || blake3_hex(&output) != patch.target_blake3 {
return Err(PatchError::ApplyFailed(
"binary patch target integrity mismatch".to_string(),
));
}
Ok(output)
}
/// Serializes and applies a binary patch.
pub fn apply_patch(old: &[u8], patch: &[u8]) -> crate::Result<Vec<u8>> {
let patch: BinaryPatch = serde_json::from_slice(patch)
.map_err(|error| PatchError::ApplyFailed(format!("invalid binary patch JSON: {error}")))?;
apply_binary_patch(old, &patch)
}
fn blake3_hex(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string()
}
#[cfg(test)]
@@ -15,8 +147,24 @@ mod tests {
use super::*;
#[test]
fn apply_patch_reports_not_implemented() {
let error = apply_patch(b"old", b"patch").unwrap_err();
fn binary_patch_round_trips_changed_bytes() {
let old = b"abcdef012345";
let new = b"abcXYZ012345!";
let patch = diff(old, new);
let patch_json = serde_json::to_vec(&patch).unwrap();
assert_eq!(apply_binary_patch(old, &patch).unwrap(), new);
assert_eq!(apply_patch(old, &patch_json).unwrap(), new);
assert!(patch
.hunks
.iter()
.any(|hunk| matches!(hunk, BinaryPatchHunk::Insert { .. })));
}
#[test]
fn binary_patch_rejects_wrong_source() {
let patch = diff(b"old", b"new");
let error = apply_binary_patch(b"bad", &patch).unwrap_err();
assert!(matches!(error, crate::PatchError::ApplyFailed(_)));
}
}
+354 -10
View File
@@ -1,22 +1,366 @@
//! JSON Patch 模块占位
//! RFC 6902 JSON Patch support.
/// JSON Patch 应用(尚未实现)。
use crate::PatchError;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// One RFC 6902 JSON Patch operation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")]
pub enum JsonPatchOperation {
/// Add a value at the target JSON Pointer.
Add {
/// Target JSON Pointer.
path: String,
/// Value to insert.
value: Value,
},
/// Remove the value at the target JSON Pointer.
Remove {
/// Target JSON Pointer.
path: String,
},
/// Replace the value at the target JSON Pointer.
Replace {
/// Target JSON Pointer.
path: String,
/// Replacement value.
value: Value,
},
/// Move a value from one JSON Pointer to another.
Move {
/// Source JSON Pointer.
from: String,
/// Target JSON Pointer.
path: String,
},
/// Copy a value from one JSON Pointer to another.
Copy {
/// Source JSON Pointer.
from: String,
/// Target JSON Pointer.
path: String,
},
/// Assert that a JSON Pointer currently contains a value.
Test {
/// Target JSON Pointer.
path: String,
/// Expected value.
value: Value,
},
}
/// Applies an RFC 6902 JSON Patch document to a JSON document string.
pub fn apply_json_patch(doc: &str, patch: &str) -> crate::Result<String> {
let mut document: Value = serde_json::from_str(doc)
.map_err(|error| PatchError::ApplyFailed(format!("invalid JSON document: {error}")))?;
let operations: Vec<JsonPatchOperation> = serde_json::from_str(patch)
.map_err(|error| PatchError::ApplyFailed(format!("invalid JSON patch: {error}")))?;
apply_json_patch_value(&mut document, &operations)?;
serde_json::to_string(&document)
.map_err(|error| PatchError::ApplyFailed(format!("failed to serialize JSON: {error}")))
}
/// Applies parsed JSON Patch operations to a JSON value.
///
/// 返回 [`crate::PatchError::ApplyFailed`] 而非空字符串,避免调用方把未实现的
/// 占位当成一次成功的补丁应用。
pub fn apply_json_patch(_doc: &str, _patch: &str) -> crate::Result<String> {
Err(crate::PatchError::ApplyFailed(
"json patch 尚未实现".to_string(),
))
/// Each operation is applied atomically: when one operation fails, the document
/// remains at the state produced by the previous successful operation.
pub fn apply_json_patch_value(
document: &mut Value,
operations: &[JsonPatchOperation],
) -> crate::Result<()> {
for operation in operations {
let mut next = document.clone();
apply_operation(&mut next, operation)?;
*document = next;
}
Ok(())
}
fn apply_operation(document: &mut Value, operation: &JsonPatchOperation) -> crate::Result<()> {
match operation {
JsonPatchOperation::Add { path, value } => add_value(document, path, value.clone()),
JsonPatchOperation::Remove { path } => remove_value(document, path).map(drop),
JsonPatchOperation::Replace { path, value } => replace_value(document, path, value.clone()),
JsonPatchOperation::Move { from, path } => {
if from == path {
return Ok(());
}
let value = get_value(document, from)?.clone();
remove_value(document, from)?;
add_value(document, path, value)
}
JsonPatchOperation::Copy { from, path } => {
let value = get_value(document, from)?.clone();
add_value(document, path, value)
}
JsonPatchOperation::Test { path, value } => {
let actual = get_value(document, path)?;
if actual == value {
Ok(())
} else {
Err(failed(format!(
"JSON patch test failed at {path}: expected {value}, actual {actual}"
)))
}
}
}
}
fn add_value(document: &mut Value, path: &str, value: Value) -> crate::Result<()> {
let tokens = parse_json_pointer(path)?;
if tokens.is_empty() {
*document = value;
return Ok(());
}
let key = tokens.last().expect("checked non-empty").clone();
let parent = get_mut_by_tokens(document, &tokens[..tokens.len() - 1])?;
match parent {
Value::Object(map) => {
map.insert(key, value);
Ok(())
}
Value::Array(items) => {
if key == "-" {
items.push(value);
return Ok(());
}
let index = parse_array_index(&key, items.len(), true)?;
items.insert(index, value);
Ok(())
}
other => Err(failed(format!(
"cannot add JSON patch value below non-container value {other}"
))),
}
}
fn remove_value(document: &mut Value, path: &str) -> crate::Result<Value> {
let tokens = parse_json_pointer(path)?;
if tokens.is_empty() {
return Ok(std::mem::take(document));
}
let key = tokens.last().expect("checked non-empty").clone();
let parent = get_mut_by_tokens(document, &tokens[..tokens.len() - 1])?;
match parent {
Value::Object(map) => map
.remove(&key)
.ok_or_else(|| failed(format!("JSON patch path {path} does not exist"))),
Value::Array(items) => {
let index = parse_array_index(&key, items.len(), false)?;
Ok(items.remove(index))
}
other => Err(failed(format!(
"cannot remove JSON patch value below non-container value {other}"
))),
}
}
fn replace_value(document: &mut Value, path: &str, value: Value) -> crate::Result<()> {
let tokens = parse_json_pointer(path)?;
if tokens.is_empty() {
*document = value;
return Ok(());
}
let key = tokens.last().expect("checked non-empty").clone();
let parent = get_mut_by_tokens(document, &tokens[..tokens.len() - 1])?;
match parent {
Value::Object(map) => {
let slot = map
.get_mut(&key)
.ok_or_else(|| failed(format!("JSON patch path {path} does not exist")))?;
*slot = value;
Ok(())
}
Value::Array(items) => {
let index = parse_array_index(&key, items.len(), false)?;
items[index] = value;
Ok(())
}
other => Err(failed(format!(
"cannot replace JSON patch value below non-container value {other}"
))),
}
}
fn get_value<'a>(document: &'a Value, path: &str) -> crate::Result<&'a Value> {
let tokens = parse_json_pointer(path)?;
let mut current = document;
for token in &tokens {
current = match current {
Value::Object(map) => map
.get(token)
.ok_or_else(|| failed(format!("JSON patch path {path} does not exist")))?,
Value::Array(items) => {
let index = parse_array_index(token, items.len(), false)?;
items
.get(index)
.ok_or_else(|| failed(format!("JSON patch path {path} does not exist")))?
}
other => {
return Err(failed(format!(
"cannot traverse JSON patch path {path} through non-container value {other}"
)))
}
};
}
Ok(current)
}
fn get_mut_by_tokens<'a>(
document: &'a mut Value,
tokens: &[String],
) -> crate::Result<&'a mut Value> {
let mut current = document;
for token in tokens {
current = match current {
Value::Object(map) => map
.get_mut(token)
.ok_or_else(|| failed(format!("JSON patch path segment {token} does not exist")))?,
Value::Array(items) => {
let index = parse_array_index(token, items.len(), false)?;
items.get_mut(index).ok_or_else(|| {
failed(format!("JSON patch path segment {token} does not exist"))
})?
}
other => {
return Err(failed(format!(
"cannot traverse JSON patch path through non-container value {other}"
)))
}
};
}
Ok(current)
}
fn parse_array_index(token: &str, len: usize, allow_end: bool) -> crate::Result<usize> {
if token.is_empty() || token == "-" {
return Err(failed(format!("invalid JSON patch array index {token}")));
}
let index = token
.parse::<usize>()
.map_err(|_| failed(format!("invalid JSON patch array index {token}")))?;
let max = if allow_end {
len
} else {
len.checked_sub(1)
.ok_or_else(|| failed("JSON patch array index exceeds empty array".to_string()))?
};
if index > max {
return Err(failed(format!(
"JSON patch array index {index} exceeds length {len}"
)));
}
Ok(index)
}
fn parse_json_pointer(pointer: &str) -> crate::Result<Vec<String>> {
if pointer.is_empty() {
return Ok(Vec::new());
}
if !pointer.starts_with('/') {
return Err(failed(format!(
"JSON patch pointer must be empty or start with '/': {pointer}"
)));
}
pointer[1..]
.split('/')
.map(decode_json_pointer_token)
.collect()
}
fn decode_json_pointer_token(token: &str) -> crate::Result<String> {
let mut decoded = String::with_capacity(token.len());
let mut chars = token.chars();
while let Some(character) = chars.next() {
if character != '~' {
decoded.push(character);
continue;
}
match chars.next() {
Some('0') => decoded.push('~'),
Some('1') => decoded.push('/'),
Some(other) => {
return Err(failed(format!(
"invalid JSON patch pointer escape ~{other}"
)))
}
None => return Err(failed("invalid JSON patch pointer escape ~".to_string())),
}
}
Ok(decoded)
}
fn failed(message: String) -> PatchError {
PatchError::ApplyFailed(message)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn apply_json_patch_reports_not_implemented() {
let error = apply_json_patch("{}", "[]").unwrap_err();
fn apply_json_patch_handles_all_core_operations() {
let document = r#"{"name":"alice","items":["a","b"],"meta":{"keep":true}}"#;
let patch = r#"[
{"op":"test","path":"/meta/keep","value":true},
{"op":"add","path":"/items/-","value":"c"},
{"op":"replace","path":"/name","value":"bob"},
{"op":"copy","from":"/meta","path":"/copied"},
{"op":"move","from":"/items/0","path":"/first"},
{"op":"remove","path":"/meta/keep"}
]"#;
let output = apply_json_patch(document, patch).unwrap();
let value: Value = serde_json::from_str(&output).unwrap();
assert_eq!(value["name"], json!("bob"));
assert_eq!(value["items"], json!(["b", "c"]));
assert_eq!(value["first"], json!("a"));
assert_eq!(value["copied"], json!({"keep": true}));
assert_eq!(value["meta"], json!({}));
}
#[test]
fn apply_json_patch_supports_pointer_escapes() {
let document = r#"{"a/b":{"tilde~key":1}}"#;
let patch = r#"[{"op":"replace","path":"/a~1b/tilde~0key","value":2}]"#;
let output = apply_json_patch(document, patch).unwrap();
let value: Value = serde_json::from_str(&output).unwrap();
assert_eq!(value["a/b"]["tilde~key"], json!(2));
}
#[test]
fn apply_json_patch_rejects_failed_test_without_mutating_value() {
let mut value = json!({"enabled": true});
let operations = vec![
JsonPatchOperation::Add {
path: "/count".to_string(),
value: json!(1),
},
JsonPatchOperation::Test {
path: "/enabled".to_string(),
value: json!(false),
},
];
let error = apply_json_patch_value(&mut value, &operations).unwrap_err();
assert!(matches!(error, crate::PatchError::ApplyFailed(_)));
assert_eq!(value, json!({"enabled": true, "count": 1}));
}
#[test]
fn apply_json_patch_rejects_missing_remove_path() {
let error = apply_json_patch(r#"{"items":[]}"#, r#"[{"op":"remove","path":"/missing"}]"#)
.unwrap_err();
assert!(matches!(error, crate::PatchError::ApplyFailed(_)));
}
}
+6
View File
@@ -14,8 +14,14 @@
pub mod binary;
pub mod error;
pub mod json;
pub mod manifest;
pub mod text;
pub use error::{PatchError, Result};
pub use manifest::{
PatchIntegrity, PatchKind, PatchManifest, PatchManifestFile, PatchRollback,
PATCH_MANIFEST_VERSION,
};
/// Patch 引擎版本号
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+261
View File
@@ -0,0 +1,261 @@
//! Patch manifest, integrity and rollback primitives.
use crate::PatchError;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Component, Path, PathBuf};
/// Current patch manifest schema version.
pub const PATCH_MANIFEST_VERSION: u32 = 1;
/// Persisted manifest for a generated patch set.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchManifest {
/// Manifest schema version.
#[serde(default = "default_patch_manifest_version")]
pub version: u32,
/// Stable patch identifier.
pub patch_id: String,
/// Source resource version identifier.
pub source_version: String,
/// Target resource version identifier.
pub target_version: String,
/// Files covered by this patch set.
pub files: Vec<PatchManifestFile>,
/// Rollback metadata for the publication layer.
pub rollback: PatchRollback,
}
impl PatchManifest {
/// Builds a manifest-level integrity summary from recorded file metadata.
pub fn integrity_summary(&self) -> PatchIntegrity {
PatchIntegrity {
file_count: self.files.len(),
source_bytes: self.files.iter().map(|file| file.source_size).sum(),
target_bytes: self.files.iter().map(|file| file.target_size).sum(),
}
}
}
/// One release-relative file entry in a patch manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchManifestFile {
/// Release-relative path.
pub path: PathBuf,
/// Patch algorithm used to produce the target bytes.
pub patch_kind: PatchKind,
/// Expected BLAKE3 hash of the source bytes.
pub source_blake3: String,
/// Expected BLAKE3 hash of the target bytes.
pub target_blake3: String,
/// Expected source byte length.
pub source_size: u64,
/// Expected target byte length.
pub target_size: u64,
}
/// Patch algorithm family used by one manifest file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PatchKind {
/// Deterministic binary hunk patch.
Binary,
/// RFC 6902 JSON Patch.
Json,
/// UTF-8 text patch.
Text,
/// UnityFS TextAsset replacement patch.
UnityFsTextAsset,
}
/// Rollback metadata owned by higher-level publication code.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchRollback {
/// Previous `current` pointer target before publication.
pub previous_current_target: Option<PathBuf>,
/// Published target path that can be removed on rollback.
pub remove_target_path: Option<PathBuf>,
}
/// Manifest-level integrity summary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchIntegrity {
/// Number of manifest files verified or summarized.
pub file_count: usize,
/// Total source bytes.
pub source_bytes: u64,
/// Total target bytes.
pub target_bytes: u64,
}
/// Verifies all manifest files against source and target roots.
pub fn verify_patch_manifest_files(
source_root: &Path,
target_root: &Path,
manifest: &PatchManifest,
) -> crate::Result<PatchIntegrity> {
if manifest.version != PATCH_MANIFEST_VERSION {
return Err(PatchError::ApplyFailed(format!(
"unsupported patch manifest version {}",
manifest.version
)));
}
let mut integrity = PatchIntegrity {
file_count: 0,
source_bytes: 0,
target_bytes: 0,
};
for file in &manifest.files {
let source_path = resolve_manifest_path(source_root, &file.path)?;
let target_path = resolve_manifest_path(target_root, &file.path)?;
let source = read_manifest_file(&source_path, "source")?;
let target = read_manifest_file(&target_path, "target")?;
verify_patch_file_bytes(&source, &target, file)?;
integrity.file_count += 1;
integrity.source_bytes += source.len() as u64;
integrity.target_bytes += target.len() as u64;
}
Ok(integrity)
}
/// Verifies one manifest file entry against source and target bytes.
pub fn verify_patch_file_bytes(
source: &[u8],
target: &[u8],
file: &PatchManifestFile,
) -> crate::Result<()> {
let source_hash = blake3_hex(source);
let target_hash = blake3_hex(target);
if source_hash != file.source_blake3 || source.len() as u64 != file.source_size {
return Err(PatchError::ApplyFailed(format!(
"patch source integrity mismatch {}: expected hash={} bytes={}, actual hash={} bytes={}",
file.path.display(),
file.source_blake3,
file.source_size,
source_hash,
source.len()
)));
}
if target_hash != file.target_blake3 || target.len() as u64 != file.target_size {
return Err(PatchError::ApplyFailed(format!(
"patch target integrity mismatch {}: expected hash={} bytes={}, actual hash={} bytes={}",
file.path.display(),
file.target_blake3,
file.target_size,
target_hash,
target.len()
)));
}
Ok(())
}
fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf> {
if relative.is_absolute() {
return Err(PatchError::ApplyFailed(format!(
"patch manifest path must be relative: {}",
relative.display()
)));
}
for component in relative.components() {
match component {
Component::Normal(_) | Component::CurDir => {}
_ => {
return Err(PatchError::ApplyFailed(format!(
"patch manifest path escapes release root: {}",
relative.display()
)))
}
}
}
Ok(root.join(relative))
}
fn read_manifest_file(path: &Path, label: &str) -> crate::Result<Vec<u8>> {
fs::read(path).map_err(|error| {
PatchError::ApplyFailed(format!(
"failed to read patch {label} file {}: {error}",
path.display()
))
})
}
fn blake3_hex(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string()
}
fn default_patch_manifest_version() -> u32 {
PATCH_MANIFEST_VERSION
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_patch_manifest_files_accepts_matching_roots() {
let temp = tempfile::tempdir().unwrap();
let source_root = temp.path().join("source");
let target_root = temp.path().join("target");
fs::create_dir_all(source_root.join("TableBundles")).unwrap();
fs::create_dir_all(target_root.join("TableBundles")).unwrap();
let source = b"before";
let target = b"after";
fs::write(source_root.join("TableBundles/file.bytes"), source).unwrap();
fs::write(target_root.join("TableBundles/file.bytes"), target).unwrap();
let manifest = manifest_for("TableBundles/file.bytes", source, target);
let integrity = verify_patch_manifest_files(&source_root, &target_root, &manifest).unwrap();
assert_eq!(
integrity,
PatchIntegrity {
file_count: 1,
source_bytes: source.len() as u64,
target_bytes: target.len() as u64,
}
);
assert_eq!(manifest.integrity_summary(), integrity);
}
#[test]
fn verify_patch_manifest_files_rejects_path_escape() {
let temp = tempfile::tempdir().unwrap();
let manifest = manifest_for("../escape", b"source", b"target");
let error = verify_patch_manifest_files(temp.path(), temp.path(), &manifest).unwrap_err();
assert!(matches!(error, PatchError::ApplyFailed(_)));
}
#[test]
fn verify_patch_file_bytes_rejects_hash_mismatch() {
let mut manifest = manifest_for("file.bin", b"source", b"target");
manifest.files[0].target_blake3 = blake3_hex(b"other");
let error = verify_patch_file_bytes(b"source", b"target", &manifest.files[0]).unwrap_err();
assert!(matches!(error, PatchError::ApplyFailed(_)));
}
fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest {
PatchManifest {
version: PATCH_MANIFEST_VERSION,
patch_id: "patch-id".to_string(),
source_version: "source-version".to_string(),
target_version: "target-version".to_string(),
files: vec![PatchManifestFile {
path: PathBuf::from(path),
patch_kind: PatchKind::Binary,
source_blake3: blake3_hex(source),
target_blake3: blake3_hex(target),
source_size: source.len() as u64,
target_size: target.len() as u64,
}],
rollback: PatchRollback {
previous_current_target: None,
remove_target_path: None,
},
}
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Deterministic UTF-8 text patch support.
use crate::PatchError;
use serde::{Deserialize, Serialize};
/// Current text patch schema version.
pub const TEXT_PATCH_VERSION: u32 = 1;
/// UTF-8 text patch made of source-relative replacement ranges.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextPatch {
/// Patch schema version.
pub version: u32,
/// Expected BLAKE3 hash of the source UTF-8 bytes.
pub source_blake3: String,
/// Expected BLAKE3 hash of the target UTF-8 bytes.
pub target_blake3: String,
/// Source byte length.
pub source_size: u64,
/// Target byte length.
pub target_size: u64,
/// Ordered source-relative operations.
pub operations: Vec<TextPatchOperation>,
}
/// One source-relative text patch operation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TextPatchOperation {
/// Replaces a UTF-8 byte range in the original source text.
ReplaceRange {
/// Byte offset in the original source text.
offset: u64,
/// Number of source bytes to replace.
length: u64,
/// Optional text that must exactly match the source range.
expected: Option<String>,
/// Replacement text.
replacement: String,
},
}
/// Builds a deterministic one-hunk text patch from source and target text.
pub fn diff(source: &str, target: &str) -> TextPatch {
if source == target {
return TextPatch {
version: TEXT_PATCH_VERSION,
source_blake3: blake3_hex(source.as_bytes()),
target_blake3: blake3_hex(target.as_bytes()),
source_size: source.len() as u64,
target_size: target.len() as u64,
operations: Vec::new(),
};
}
let prefix = common_prefix_boundary(source, target);
let (source_suffix, target_suffix) = common_suffix_boundaries(source, target, prefix);
let operation = TextPatchOperation::ReplaceRange {
offset: prefix as u64,
length: (source_suffix - prefix) as u64,
expected: Some(source[prefix..source_suffix].to_string()),
replacement: target[prefix..target_suffix].to_string(),
};
TextPatch {
version: TEXT_PATCH_VERSION,
source_blake3: blake3_hex(source.as_bytes()),
target_blake3: blake3_hex(target.as_bytes()),
source_size: source.len() as u64,
target_size: target.len() as u64,
operations: vec![operation],
}
}
/// Builds a text patch from caller-provided source-relative operations.
pub fn from_operations(
source: &str,
operations: Vec<TextPatchOperation>,
) -> crate::Result<TextPatch> {
let target = apply_operations(source, &operations)?;
Ok(TextPatch {
version: TEXT_PATCH_VERSION,
source_blake3: blake3_hex(source.as_bytes()),
target_blake3: blake3_hex(target.as_bytes()),
source_size: source.len() as u64,
target_size: target.len() as u64,
operations,
})
}
/// Applies a structured text patch.
pub fn apply_text_patch(source: &str, patch: &TextPatch) -> crate::Result<String> {
if patch.version != TEXT_PATCH_VERSION {
return Err(PatchError::ApplyFailed(format!(
"unsupported text patch version {}",
patch.version
)));
}
if patch.source_size != source.len() as u64
|| patch.source_blake3 != blake3_hex(source.as_bytes())
{
return Err(PatchError::ApplyFailed(
"text patch source integrity mismatch".to_string(),
));
}
let output = apply_operations(source, &patch.operations)?;
if output.len() as u64 != patch.target_size
|| patch.target_blake3 != blake3_hex(output.as_bytes())
{
return Err(PatchError::ApplyFailed(
"text patch target integrity mismatch".to_string(),
));
}
Ok(output)
}
/// Parses and applies a JSON-encoded text patch to a UTF-8 string.
pub fn apply_patch(source: &str, patch: &str) -> crate::Result<String> {
let patch: TextPatch = serde_json::from_str(patch)
.map_err(|error| PatchError::ApplyFailed(format!("invalid text patch JSON: {error}")))?;
apply_text_patch(source, &patch)
}
/// Parses and applies a JSON-encoded text patch to UTF-8 bytes.
pub fn apply_patch_bytes(source: &[u8], patch: &[u8]) -> crate::Result<Vec<u8>> {
let source = std::str::from_utf8(source)
.map_err(|error| PatchError::ApplyFailed(format!("source is not UTF-8: {error}")))?;
let patch = std::str::from_utf8(patch)
.map_err(|error| PatchError::ApplyFailed(format!("patch is not UTF-8: {error}")))?;
Ok(apply_patch(source, patch)?.into_bytes())
}
fn apply_operations(source: &str, operations: &[TextPatchOperation]) -> crate::Result<String> {
let mut output = String::with_capacity(source.len());
let mut cursor = 0usize;
for operation in operations {
let (offset, length, expected, replacement) = match operation {
TextPatchOperation::ReplaceRange {
offset,
length,
expected,
replacement,
} => (*offset, *length, expected, replacement),
};
let start = usize::try_from(offset)
.map_err(|_| PatchError::ApplyFailed("text patch offset overflow".to_string()))?;
let length = usize::try_from(length)
.map_err(|_| PatchError::ApplyFailed("text patch length overflow".to_string()))?;
if start < cursor {
return Err(PatchError::ApplyFailed(format!(
"text patch operation at {start} overlaps previous range ending at {cursor}"
)));
}
let end = start
.checked_add(length)
.ok_or_else(|| PatchError::ApplyFailed("text patch range overflow".to_string()))?;
let replaced = source.get(start..end).ok_or_else(|| {
PatchError::ApplyFailed(format!(
"text patch range {start}..{end} is outside the source or not UTF-8 aligned"
))
})?;
if let Some(expected) = expected {
if replaced != expected {
return Err(PatchError::ApplyFailed(format!(
"text patch expected mismatch at {start}..{end}"
)));
}
}
output.push_str(&source[cursor..start]);
output.push_str(replacement);
cursor = end;
}
output.push_str(&source[cursor..]);
Ok(output)
}
fn common_prefix_boundary(source: &str, target: &str) -> usize {
let mut prefix = 0usize;
for ((source_index, source_char), (target_index, target_char)) in
source.char_indices().zip(target.char_indices())
{
if source_index != target_index || source_char != target_char {
break;
}
prefix = source_index + source_char.len_utf8();
}
prefix
}
fn common_suffix_boundaries(source: &str, target: &str, prefix: usize) -> (usize, usize) {
let mut source_suffix = source.len();
let mut target_suffix = target.len();
let mut source_chars = source[prefix..].char_indices().rev();
let mut target_chars = target[prefix..].char_indices().rev();
while let (Some((source_index, source_char)), Some((target_index, target_char))) =
(source_chars.next(), target_chars.next())
{
if source_char != target_char {
break;
}
source_suffix = prefix + source_index;
target_suffix = prefix + target_index;
}
(source_suffix, target_suffix)
}
fn blake3_hex(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_patch_round_trips_unicode_change() {
let source = "先生、こんにちは\nAbydos";
let target = "老师、你好\nAbydos";
let patch = diff(source, target);
let patch_json = serde_json::to_string(&patch).unwrap();
assert_eq!(apply_text_patch(source, &patch).unwrap(), target);
assert_eq!(apply_patch(source, &patch_json).unwrap(), target);
assert_eq!(
apply_patch_bytes(source.as_bytes(), patch_json.as_bytes()).unwrap(),
target.as_bytes()
);
}
#[test]
fn text_patch_applies_multiple_source_relative_ranges() {
let source = "alpha beta gamma";
let patch = from_operations(
source,
vec![
TextPatchOperation::ReplaceRange {
offset: 0,
length: 5,
expected: Some("alpha".to_string()),
replacement: "one".to_string(),
},
TextPatchOperation::ReplaceRange {
offset: 11,
length: 5,
expected: Some("gamma".to_string()),
replacement: "three".to_string(),
},
],
)
.unwrap();
assert_eq!(apply_text_patch(source, &patch).unwrap(), "one beta three");
}
#[test]
fn text_patch_rejects_expected_mismatch() {
let source = "alpha beta";
let operation = TextPatchOperation::ReplaceRange {
offset: 0,
length: 5,
expected: Some("wrong".to_string()),
replacement: "one".to_string(),
};
let error = from_operations(source, vec![operation]).unwrap_err();
assert!(matches!(error, PatchError::ApplyFailed(_)));
}
#[test]
fn text_patch_rejects_overlapping_ranges() {
let source = "alpha beta";
let operation_a = TextPatchOperation::ReplaceRange {
offset: 0,
length: 5,
expected: None,
replacement: "one".to_string(),
};
let operation_b = TextPatchOperation::ReplaceRange {
offset: 3,
length: 2,
expected: None,
replacement: "two".to_string(),
};
let error = from_operations(source, vec![operation_a, operation_b]).unwrap_err();
assert!(matches!(error, PatchError::ApplyFailed(_)));
}
#[test]
fn text_patch_rejects_non_boundary_range() {
let source = "éclair";
let operation = TextPatchOperation::ReplaceRange {
offset: 1,
length: 1,
expected: None,
replacement: "e".to_string(),
};
let error = from_operations(source, vec![operation]).unwrap_err();
assert!(matches!(error, PatchError::ApplyFailed(_)));
}
}
+6 -6
View File
@@ -10,9 +10,9 @@ DB_MODE=remote
# PostgreSQL 配置
# 本地模式:使用 localhost:5432
# 远程模式:填写远程服务器的公网 IP 和端口
DB_HOST=your.remote.server.com # 远程服务器地址(或 localhost 用于本地)
DB_PORT=5432
# 远程模式:优先使用私网/VPN;SSH tunnel 时填写本地转发地址和端口
DB_HOST=127.0.0.1 # 本地或 SSH tunnel 地址
DB_PORT=15432
DB_USER=bat_user
DB_PASSWORD=your_secure_password_here
DB_NAME=bluearchive_toolkit
@@ -32,9 +32,9 @@ DB_SSL_MODE=prefer
# Redis 配置
# 本地模式:使用 localhost:6379
# 远程模式:填写远程服务器的公网 IP 和端口
REDIS_HOST=your.remote.server.com # 远程服务器地址(或 localhost 用于本地)
REDIS_PORT=6379
# 远程模式:优先使用私网/VPN;SSH tunnel 时填写本地转发地址和端口
REDIS_HOST=127.0.0.1 # 本地或 SSH tunnel 地址
REDIS_PORT=16379
REDIS_PASSWORD=your_redis_password_here
REDIS_DB=0
+2 -2
View File
@@ -19,7 +19,7 @@ services:
POSTGRES_PASSWORD: bat_dev_password
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
ports:
- "0.0.0.0:5432:5432"
- "127.0.0.1:5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./postgres-init:/docker-entrypoint-initdb.d
@@ -41,7 +41,7 @@ services:
profiles: ["local-db"] # 只有指定 --profile local-db 才启动
command: redis-server /usr/local/etc/redis/redis.conf
ports:
- "0.0.0.0:6379:6379"
- "127.0.0.1:6379:6379"
volumes:
- redis_data:/data
- ./redis.conf:/usr/local/etc/redis/redis.conf
+3 -3
View File
@@ -5,7 +5,7 @@
# 1. 将此文件和相关配置上传到远程服务器
# 2. 复制 .env.example 为 .env 并配置密码
# 3. 运行:docker compose -f docker-compose.remote-db.yml up -d
# 4. 确保防火墙开放 5432 和 6379 端口
# 4. 默认仅绑定宿主机回环地址;远程开发使用私网、VPN 或 SSH tunnel
version: '3.9'
@@ -20,7 +20,7 @@ services:
POSTGRES_PASSWORD: ${REMOTE_DB_POSTGRES_PASSWORD}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
ports:
- "0.0.0.0:5432:5432" # 监听所有网络接口
- "127.0.0.1:5432:5432" # 不直接暴露到公网
volumes:
- postgres_data:/var/lib/postgresql/data
- ./postgres-init:/docker-entrypoint-initdb.d
@@ -44,7 +44,7 @@ services:
container_name: bat-redis
command: redis-server /usr/local/etc/redis/redis.conf
ports:
- "0.0.0.0:6379:6379" # 监听所有网络接口
- "127.0.0.1:6379:6379" # 不直接暴露到公网
volumes:
- redis_data:/data
- ./redis-remote.conf:/usr/local/etc/redis/redis.conf
+41
View File
@@ -0,0 +1,41 @@
# Optional overrides for bluearchive-toolkit-bat-api.service.
#
# Install as:
# sudo install -o root -g root -m 0644 deployments/systemd/bat-api.env.example /etc/bluearchive-toolkit/bat-api.env
#
# Production contract:
# - bat-api runs in the same server/container environment as Rust bat.
# - The current resource_root comes from bat.sock RPC.
# - Do not set BAT_API_RESOURCE_ROOT in production; it is only for local
# fixtures or emergency read-only diagnostics when RPC is unavailable.
# - Publish HTTP through a reverse proxy/TLS if exposed publicly; never expose
# bat.sock outside the host.
# - Player-facing deployments should set BAT_API_AUTH_TOKEN through a secret
# manager or process environment, not in a committed file.
BAT_API_LISTEN=127.0.0.1:18080
BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080
BAT_API_STATE_DIR=/var/lib/bluearchive-toolkit/daemon-state
BAT_API_SOCKET=/var/lib/bluearchive-toolkit/daemon-state/bat.sock
BAT_API_REQUIRE_INDEXED=true
BAT_API_VERIFY_SIZE=true
BAT_API_RPC_TIMEOUT=30s
BAT_API_REFRESH_INTERVAL=1m
BAT_API_SKIP_ENV_FILE=1
BAT_API_AUTH_QUERY_PARAM=bat_token
# BAT_API_AUTH_TOKEN=
# BAT_API_AUTH_EXEMPT_PATHS=/healthz,/readyz
BAT_API_TRUST_PROXY_HEADERS=false
BAT_API_ACCESS_LOG=true
BAT_API_RATE_LIMIT_RPS=30
BAT_API_RATE_LIMIT_BURST=120
BAT_API_MAX_RESOURCE_LIMIT=1000
# Local fixture / emergency only:
# BAT_API_RESOURCE_ROOT=/var/lib/bluearchive-toolkit/official/current
# Reserved for future API persistence:
# BAT_API_DATABASE_URL=postgres://bat:@127.0.0.1:5432/bat?sslmode=disable
# BAT_API_DATABASE_PASSWORD=
# BAT_API_REDIS_URL=redis://127.0.0.1:6379/0
# BAT_API_REDIS_PASSWORD=
@@ -0,0 +1,43 @@
[Unit]
Description=BlueArchiveToolkit bat-api resource bootstrap and distribution
Documentation=https://github.com/Yuyi-Oak/BlueArchiveToolkit
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=bat
Group=bat
WorkingDirectory=/var/lib/bluearchive-toolkit
Environment=BAT_API_LISTEN=127.0.0.1:18080
Environment=BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080
Environment=BAT_API_STATE_DIR=/var/lib/bluearchive-toolkit/daemon-state
Environment=BAT_API_SOCKET=/var/lib/bluearchive-toolkit/daemon-state/bat.sock
Environment=BAT_API_REQUIRE_INDEXED=true
Environment=BAT_API_VERIFY_SIZE=true
Environment=BAT_API_RPC_TIMEOUT=30s
Environment=BAT_API_REFRESH_INTERVAL=1m
Environment=BAT_API_SKIP_ENV_FILE=1
EnvironmentFile=-/etc/bluearchive-toolkit/bat-api.env
ExecStart=/opt/bluearchive-toolkit/bin/bat-api
Restart=on-failure
RestartSec=10
TimeoutStopSec=30
KillSignal=SIGTERM
StandardOutput=journal
StandardError=journal
RuntimeDirectory=bluearchive-toolkit-bat-api
RuntimeDirectoryMode=0750
LogsDirectory=bluearchive-toolkit
LogsDirectoryMode=0750
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadOnlyPaths=/var/lib/bluearchive-toolkit
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target
@@ -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
+21 -39
View File
@@ -1,48 +1,30 @@
# API 文档
本目录包含 BlueArchive Toolkit 的 API 文档。
本目录是 API 文档入口。当前实现分为两层,不能把 Rust daemon RPC
和 Go HTTP 服务混写成一个接口:
当前 API Server 尚未实现,本文件只记录规划边界,不代表已有可运行 HTTP 服务或 OpenAPI 产物。
## Rust daemon RPC
## OpenAPI 规范
Rust `bat` 通过 `/tmp/bat-pid/bat.sock` 提供换行分隔的 JSON-RPC 2.0
Resource Backend。方法、参数、envelope、错误码、Go 调用白名单以
[`rpc-backend-api.md`](../reference/rpc-backend-api.md) 为准。
OpenAPI 文档将在 API Server 落地后生成,目标使用 OpenAPI 3.0 标准。当前仓库尚未提供 `openapi/` 生成产物。
## Go bat-api HTTP
## 文档生成
Go `cmd/bat-api` 是资源 bootstrap、已发布资源分发和鉴权控制服务,不是完整
游戏业务 API。已实现的 HTTP surface 包括:
API 文档将在开发过程中自动生成和更新。
- `/healthz``/readyz`
- `/v1/bootstrap``/v1/launcher/bootstrap``/v1/release``/v1/resources`
- `/v1/server-info` 和 CDN 形状资源路径
- `/api/launcher/game/config` 兼容端点
- `/admin/` 与白名单 `/admin/control/{action}`
- `/openapi.yaml`
**计划**
- 使用 `swag` (Go) 从代码注释生成 OpenAPI 文档
- 提供 Swagger UI 在线查看
- 支持导出为 Markdown、HTML 等格式
HTTP 路由的 OpenAPI 文本由 `internal/api/openapi.go` 提供,运行中的服务也可
通过 `GET /openapi.yaml` 获取。配置、鉴权、部署边界和示例见
[`USERGUIDE.md`](../../USERGUIDE.md) 与
[`GO_STATUS.md`](../reports/GO_STATUS.md)。
---
## 核心 API 端点(规划中)
### 认证
- `POST /api/v1/auth/login` - 用户登录
- `POST /api/v1/auth/logout` - 用户登出
- `POST /api/v1/auth/refresh` - 刷新 Token
### 翻译管理
- `GET /api/v1/translations` - 获取翻译列表
- `POST /api/v1/translations` - 创建翻译
- `PUT /api/v1/translations/:id` - 更新翻译
- `DELETE /api/v1/translations/:id` - 删除翻译
### 术语管理
- `GET /api/v1/glossary` - 获取术语列表
- `POST /api/v1/glossary` - 创建术语
- `PUT /api/v1/glossary/:id` - 更新术语
- `DELETE /api/v1/glossary/:id` - 删除术语
### 资源同步
- `POST /api/v1/sync/start` - 启动同步
- `GET /api/v1/sync/status` - 查询同步状态
- `POST /api/v1/sync/cancel` - 取消同步
---
更多详细文档将在 API Server 实现后补充。
账号登录、完整翻译管理、术语库、游戏业务协议和完整 launcher 安装包更新链
当前不属于已实现接口。
+78 -34
View File
@@ -4,11 +4,19 @@
BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建一个可持续维护十年以上的工业级开源项目。
当前文档描述目标架构和已经落地的关键边界。它不是部署手册;当前可部署能力只有 Rust 官方资源同步任务。API Server、Web、Provider 编排和完整 Go CLI 仍未实现,实际实现状态以根目录 `CURRENT_STATUS.md` `PROJECT_PLAN.md` 为准
当前文档描述目标架构和已经落地的关键边界。它不是部署手册;当前可部署能力包括 Rust 官方资源同步任务和 Go `cmd/bat-api` 资源 bootstrap/分发服务。完整游戏业务 API、Web、Provider 编排和 SDK 仍未完成,实际实现状态以源码、测试和根目录 `CURRENT_STATUS.md` 为准;`PROJECT_PLAN.md` 只描述目标和路线图
当前已经可用的官方资源入口包括:
- `infrastructure/src/bin/bat_official_sync.rs`Linux 官方资源同步正式入口,构建为 `bat`,支持 one-shot、`--watch``--daemon``status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`
- `infrastructure/src/bin/bat_official_sync.rs`Linux 官方资源同步入口,构建为 `bat`;控制面组合与实现位于 `infrastructure/src/bin/bat/`,支持 one-shot、`--watch``--daemon``status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`
- `infrastructure/src/bin/bat/app.rs`CLI/env、daemon/watch、RPC dispatch 与顶层流程组合。
- `infrastructure/src/bin/bat/report_output.rs`:人类可读报告、JSON 查询结果和报告格式化。
- `infrastructure/src/bin/bat/terminal_output.rs`:前台错误、帮助、启动提示、进度和结构化日志输出。
- `infrastructure/src/bin/bat/task_registry.rs`:任务注册表、任务持久化、取消和 daemon worker。
- `infrastructure/src/bin/bat/readonly_query.rs`parse/resource/translation/localized 只读查询及 RPC 选择。
- `infrastructure/src/bin/bat/translation_query.rs`:翻译任务与 handoff 查询、worker 状态更新。
- `infrastructure/src/bin/bat/patch_commands.rs`:文件 patch 与 UnityFS 写入命令参数校验和执行。
- `infrastructure/src/bin/bat/app_tests.rs`:控制面回归测试,避免测试代码继续堆积在入口实现中。
- `infrastructure/src/official_update.rs`:官方自动更新核心服务,负责 auto-discover、snapshot、marker diff、本地 audit/repair。
- `infrastructure/examples/official_pull_plan.rs`:开发/审计用 pull plan 入口。
- `infrastructure/examples/official_update_check.rs`:历史/开发入口,生产优先使用 `bat`
@@ -17,9 +25,11 @@ BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建
已接受的架构决策:
- `adr/0001-engine-and-application-boundaries.md`Rust 引擎与 Go 应用层边界
- `adr/0001-engine-and-application-boundaries.md`历史语言/层次边界决策;资源同步职责已由 ADR 0004 取代
- `adr/0002-cas-v1-design-boundary.md`CAS V1 设计边界。
- `adr/0003-cas-core-interface-and-error-boundary.md`CAS 核心接口与错误边界冻结。
- `adr/0004-rust-bat-go-bat-api-resource-boundary.md`:当前 Rust `bat` 与 Go `bat-api`
的资源控制面边界。
---
@@ -33,16 +43,30 @@ BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建
### 2. 语言选型
| 模块 | 语言 | 理由 |
| 模块 | 语言 | 当前定位 |
|------|------|------|
| CLI、API Server、服务编排 | Go | 并发模型优秀、部署简单、生态成熟 |
| 官方资源同步核心、AssetBundle 解析、Patch 引擎、CAS 引擎 | Rust | 零成本抽象、内存安全、性能和二进制处理更可靠 |
| Web 管理后台 | Vue 3 + TypeScript | 渐进式、类型安全、生态完善 |
| 官方资源同步与运维 CLI、同步核心 | Rust | **当前实现**`bat` 负责生产资源和长期状态 |
| 资源 bootstrap、只读分发和 Rust 管理入口 | Go | **当前实现**`cmd/bat-api` 通过 `bat.sock` RPC 工作 |
| AssetBundle 解析、Patch 引擎、CAS 引擎 | Rust | **当前已有基础,复杂覆盖仍按路线图推进** |
| 完整 API、服务编排和 Provider | Go | **目标设计,尚未完整实现** |
| 完整 Web 协作后台 | Vue 3 + TypeScript | **目标设计**;当前只有内嵌 dashboard MVP |
### 3. 数据流设计
当前已落地的数据流:
```
用户请求 → CLI/API → Go 业务层 → bat --json / SDK → Rust 核心/同步层 → CAS 存储 → 数据库
官方 metadata → Rust bat / daemon → release + current + manifest
bat.sock JSON-RPC
Go bat-api → bootstrap / CDN / dashboard
```
目标扩展数据流(其中 Go 业务层、SDK、数据库和 Redis 尚未全部实现):
```
用户请求 → CLI/API → Go 业务层 → bat.sock RPC / SDK → Rust 核心/同步层 → CAS 存储 → 数据库
↓ ↓
Web UI 缓存层 (Redis)
```
@@ -91,7 +115,7 @@ cas/
---
### 2. 官方资源同步器 (Rust 当前实现,Go 后续编排)
### 2. 官方资源同步器 (Rust 当前实现,Go 侧读取)
**职责**:从官方日服 HTTP metadata 自动发现资源入口,下载 Windows + Android 官方资源,增量检查,完整性校验,保持本地状态。
@@ -120,25 +144,29 @@ current symlink → official-sync-snapshot.json + official-download-manifest.jso
- `refresh --force` 可手动强制刷新;`verify` 只读校验当前官方计划、本地 manifest 和官方 seed hash`repair` 尝试修复异常资源。
- 非 dry-run 同步先写 `.staging/<id>`,校验完成后发布 `versions/<id>` 并原子切换 `current` symlink。
- `--daemon` 使用状态目录下的 `bat.sock` 作为 Unix socket JSON-RPC live control planePID、状态和日志文件是快照与 fallback,`bat-events.jsonl` 是结构化轮转日志。
- `status``logs``reload``stop` 和默认形态的 `refresh` 优先通过 RPC 管理后台进程;控制命令通过 `bat-control.lock` 串行化;`restart` 负责重启或替换启动参数;live daemon 会阻止前台写命令直接修改同一资源目录;`doctor` 做运行时诊断;`clean-stable` 清理临时文件和失效/损坏状态。
- `status``logs``restart``reload``stop` 和默认形态的 `refresh` 优先通过 RPC 管理后台进程;控制命令通过 `bat-control.lock` 串行化;`restart` 通过 Rust lifecycle controller 复用 CLI restart 路径重启或替换启动参数;live daemon 会阻止前台写命令直接修改同一资源目录;`doctor` 做运行时诊断;`clean-stable` 清理临时文件和失效/损坏状态。
- 远端 marker 无变化且本地 manifest clean 时不下载。
- 本地文件损坏时 repair。
- 官方 seed `.hash` 强校验;Addressables `catalog_*.hash` 作为变更 marker。
**后续 Go 职责**
**Go 当前职责**
- 提供最小稳定 CLI
- 默认通过 `bat --json` 进程边界包装 Rust 同步入口,并转发结构化 report
- `bat-ffi` 仅作为可选无状态 C ABI 兼容层,不承载官方同步 daemon、下载器或 CAS handle
- 编排 API Server、任务队列、Provider 和用户配置。
- `bat-api` 通过 `bat.sock` RPC 读取 Rust 已发布 release、manifest、snapshot 和状态
- 提供资源 bootstrap、server-info 改写、只读 CDN path、readiness、OpenAPI 和白名单管理转发
- 不运行另一套同步器,不直接管理官方下载、staging、version-state、CAS 或解析状态
完整 API、服务编排、Provider 和用户配置属于目标扩展,不能从本节推断为当前已实现。
---
### 3. AssetBundle 解析器 (Rust)
### 3. AssetBundle 解析器 (Rust,当前基础与目标扩展)
**职责**:解析 Unity AssetBundle,提取资源
**插件化架构**
以下插件注册和动态加载是目标扩展;当前实现以 `crates/bat-assetbundle`
`bat-adapters` 和真实 fixture 覆盖为准。
**目标插件化架构**
```rust
pub trait AssetParser {
fn name(&self) -> &str;
@@ -164,7 +192,11 @@ pub struct ParserRegistry {
---
### 4. 翻译系统 (Go)
### 4. 翻译系统(目标扩展,Go;当前 worker 由 Rust `bat` 承担)
当前已实现的是 Rust `bat` 的离线 TextUnit 队列、mock/Crowdin provider worker、
lease/retry 和结果落库;Translation Memory、Glossary 和完整 Provider 体系仍属
后续缺口。
**架构**
```
@@ -182,7 +214,11 @@ type TranslationProvider interface {
}
```
**实现**
**当前实现**
- Rust `bat` 的 mock provider worker
- Rust `bat` 的 Crowdin provider worker
**目标 Provider**
- DeepL Provider
- OpenAI Provider
- Anthropic Provider
@@ -196,12 +232,12 @@ type TranslationProvider interface {
---
### 5. Patch 引擎 (Rust)
### 5. Patch 引擎 (Rust,当前基础与目标扩展)
**职责**:生成和应用补丁
**支持的 Patch 类型**
1. **Binary Patch**使用 bsdiff 算法
1. **Binary Patch**确定性 Binary hunk diff/apply(当前实现)
2. **JSON Patch**RFC 6902 标准
3. **Text Patch**:基于 diff 算法
@@ -224,7 +260,10 @@ patch/
---
### 6. API Server (Go)
### 6. API Server (Go,目标设计)
当前可用的 Go HTTP 服务是 `cmd/bat-api` 的资源 bootstrap、只读分发和 Rust 管理
入口,不是下列完整游戏业务 API。
**框架**Gin 或 Echo
@@ -251,7 +290,9 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
---
### 7. Web 后台 (Vue 3)
### 7. Web 后台 (Vue 3,目标设计)
当前只有 `bat-api` 内嵌 dashboard MVP;登录、角色、术语管理和完整协作审核仍未实现。
**技术栈**
- Vue 3 + Composition API
@@ -270,7 +311,10 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
---
## 数据库设计
## 数据库设计(目标设计)
当前 Rust 资源链路使用 SQLite 维护本地 CAS、ResourceRepository 和翻译任务状态;
PostgreSQL/Redis 业务服务端方案尚未完整落地。
### PostgreSQL Schema
@@ -314,7 +358,11 @@ CREATE TABLE resource_versions (
---
## 部署架构
## 部署架构(目标设计)
当前可部署形态是 Rust `bat` 官方资源同步任务和同机/共享文件系统的 Go
`bat-api` 资源 bootstrap/分发服务。以下多实例 API、PostgreSQL 主从和 Redis
集群属于目标部署形态。
### 本地开发模式
@@ -342,7 +390,7 @@ API Server (多实例)
---
## 安全设计
## 安全设计(目标设计)
1. **认证**JWT Token
2. **授权**RBAC (Role-Based Access Control)
@@ -353,7 +401,7 @@ API Server (多实例)
---
## 性能优化
## 性能优化(目标设计)
1. **缓存策略**
- Redis 缓存热点数据
@@ -372,7 +420,7 @@ API Server (多实例)
---
## 监控与日志
## 监控与日志(目标设计)
- **日志**:结构化日志(JSON 格式)
- **指标**Prometheus + Grafana
@@ -393,10 +441,6 @@ API Server (多实例)
更多详细设计文档:
- [官方资源后端说明](./official-resource-backend.md)
- [资源 release 布局与分发契约](./resource-release-layout.md)
- [AssetBundle 解析与发布路线图](./assetbundle.md)
- [API 设计](../api/README.md)
待创建的详细设计文档:
- `docs/architecture/cas.md`
- `docs/architecture/assetbundle.md`
- `docs/architecture/translation.md`
@@ -1,11 +1,17 @@
# ADR 0001: Rust 引擎与 Go 应用层边界
**状态**:已接受
**状态**:已接受(历史决策;资源同步职责已由 ADR 0004 取代)
**日期**2026-06-28
**关联计划**`../../../PROJECT_PLAN.md`
---
> 历史说明:本文保留 2026-06-28 的原始语言和层次决策。其关于 Go 负责资源同步、
> 下载器和任务调度的职责描述已被当前实现和 ADR 0004 取代;阅读当前资源边界时,
> 以 ADR 0004、`CURRENT_STATUS.md``docs/reports/GO_STATUS.md` 为准。
---
## 背景
BlueArchiveToolkit 的最终目标覆盖资源同步、CAS、AssetBundle 解析、文本提取、翻译、Patch、CLI、API Server、Web 和 SDK。项目天然包含二进制解析、文件完整性、网络同步、任务编排、数据库、用户界面等不同类型的问题。
@@ -65,7 +65,7 @@ CAS V1 不以“能通过简单 put/get 测试”为完成标准。必须满足
4. 并发写入相同内容测试通过。
5. 损坏对象读取返回明确错误。
6. 权限或路径错误有清晰错误类型。
7. `cargo test --workspace``cargo clippy --workspace -- -D warnings` 通过。
7. `cargo test --workspace``cargo clippy --workspace --all-targets -- -D warnings` 通过。
---
@@ -0,0 +1,60 @@
# ADR 0004: Rust bat 与 Go bat-api 当前资源控制面边界
**状态**:已接受
**日期**2026-09-04
**关联文档**`../../../CURRENT_STATUS.md``../../../docs/reports/GO_STATUS.md`
---
## 背景
项目同时包含 Rust 资源引擎和 Go HTTP 服务。历史设计曾把资源同步、下载器
和任务调度归入 Go 应用层,但当前实现已经由 Rust `bat` 统一持有这些长期状态。
如果继续沿用旧职责描述,会让 Go、Web 或其他入口重复实现资源状态机。
---
## 决策
1. **Rust `bat` 是官方资源生产者和状态拥有者**
- 负责官方 metadata 发现、下载、校验、staging、release 发布和 `current` 切换;
- 负责 watch/daemon、`bat.sock` JSON-RPC、任务、日志、版本状态、解析、
翻译 worker 和 localized release 状态;
- 负责 CAS、AssetBundle 解析、Patch 核心算法及其文件安全边界。
2. **Go `bat-api` 是资源读侧和管理入口**
- 通过 `bat.sock` RPC 发现 Rust 已发布的 `resource_root`、snapshot、manifest
和状态;
- 提供资源 bootstrap、server-info 改写、只读 CDN path、readiness、OpenAPI
以及鉴权后的白名单管理转发;
- 不下载官方资源、不写 staging、不维护 version-state,不复制 CAS、解析器、
Patch 核心算法或同步状态机。
3. **Go `cmd/bat` 和 `bat-ffi` 不是主集成边界**
- `cmd/bat` 只保留试验 CLI
- `bat-ffi` 只保留无状态、粗粒度、一次调用一次输入输出的兼容 helper;
- 新的跨语言控制和查询能力优先增加 Rust RPC contract,再由
`internal/backendrpc` 消费。
4. **完整游戏业务 API、完整 Web 协作后台、Translation Memory、Glossary 和
Provider 扩展体系仍是后续目标**,不能从目标架构图推断为当前已实现。
---
## 后果
- 资源同步只有一个长期状态拥有者,`bat-api` 可以安全地横向扩展为只读服务。
- Rust RPC、release layout、manifest 和 `status/status_code` 成为跨语言稳定契约。
- Go 侧新增控制接口必须经过白名单和 RPC schema 复核。
- 完整业务 API 和协作后台未来落地时,仍需遵守 Rust `bat` 对资源状态的所有权。
---
## 当前验证依据
- `infrastructure/src/bin/bat/`
- `infrastructure/src/official_update.rs`
- `internal/backendrpc/`
- `cmd/bat-api/`
- `docs/reference/rpc-backend-api.md`
- `internal/api/testdata/contract/`
+214
View File
@@ -0,0 +1,214 @@
# AssetBundle 与资源解析路线图
- **更新时间**2026-09-04
- **适用范围**: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` / `BAT_LOCALIZED_OUTPUT` 指定的汉化发布根,不能写回官方资源目录。
5. 解析器必须与 CLI、daemon、Go API、Patch 业务流程解耦。
当前官方同步在新 release 发布后会先维护 `official-resource-changes.json``crowdin-translation-handoff.json`,再维护 `official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`。这些都是官方 release 的派生索引,不是汉化产物;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。
---
## 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、alignment、大小/计数/路径/边界校验 |
| Serialized file | UnityFS directory 文件 | header、type table、TypeTree node、object table、TextAsset bytes | 已支持基础表结构和 TextAsset bytes |
| Unity 对象字段 | TextAsset、MonoBehaviour、ScriptableObject | 可翻译文本单元、上下文、资源定位 | TypeTree 基础字段读取、`SerializedReference` / prefixed managed-reference metadata alias、payload 提取和字符串提取已落地,真实结构覆盖继续扩大 |
| Patch 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | TextAsset、TypeTree string field 和 managed-reference string field 的 localized publish/rollback 已落地;整体 AssetBundle 重打包与通用 manifest 发布仍未完成 |
---
## 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. 对 block/directory 计数先按剩余字节做有界检查,避免损坏输入触发超大内存分配。
6. `serialized` 模块能读取 Unity serialized file header、type table、TypeTree node 元数据、object table。
7. 能提取 TextAsset 的 name 和原始 bytes。
8. TypeTree field reader 已支持基础标量、string、bytes、array、vector/staticvector 嵌套 `Array` 形态、`List<T>` / `HashSet<T>` 集合 alias、map、PPtr、enum `value__` backing field、`LayerMask` / `BitField``m_Bits` backing field、嵌套对象、常见固定 Unity float/int/hash 值类型的 leaf 和 direct child TypeTree 形态、unknown fixed-size raw bytes 保留和同长度替换、TypeTree-covered managed reference、TypeTree-covered managed reference registry 记录、`m_ManagedReferences` / `RefIds` / `m_RefIds` / verbose type 字段等 registry 命名变体、`id` / `typeInfo` 等 metadata 命名变体、`data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` 等 payload 命名变体、managed-reference full typename 拆解和字段 offset/size 诊断。
9. `TextUnitExtractor` 已把 JSON/CSV/TSV/plain TextAsset、TypeTree 字符串字段和 TypeTree-covered managed reference payload 字符串输出为可序列化 TextUnit/JSONLzip 场景保留 archive entryTextUnit 明细包含 serialized file、path id、class id、field path、字段 offset/byte size、format、asset name 和上下文。managed-reference 类型元数据保留为 payload context,不进入翻译文本队列;即使 registry 暂时只能走 fallback 字段遍历,`RefIds``className``namespaceName``asmName` 等元数据别名也会被跳过,payload/value/object 家族和 `managedReferenceData` / `referenceData` / `serializedData` 仍按 payload 处理,并按 `RefIds[n]` 等记录前缀或子字段推导 metadata,避免多条 fallback record 混用 managed-reference context。
10. `ResourceImportService` 能把 AssetBundle 摘要、TextAsset/Table/Media 分类和 TextUnit 摘要写入导入报告。
11. 官方同步后 `OfficialParseCacheService` 能从 `official-download-manifest.json` 遍历所有资源,解析直接 bundle 和 zip 内条目,非候选资源记录为 unsupported,并缓存 TextUnit 数量/格式/诊断摘要,同时写出 `official-textunit-index.json``parse.text_units` / `parse.errors` 查询。
当前还不能宣称完整:
1. TypeTree-covered managed reference 字段和 registry 记录已可结构化解码并参与文本提取,常见 registry 命名别名(含 `m_ManagedReferences``RefIds``m_RefIds`、verbose type 字段)、metadata 命名别名(含 `id``typeInfo`)、payload 命名别名(含 `data``value``payload``object``managedReferencePayload``referencePayload``serializedReferencePayload``managedReferenceValue``referenceValue``serializedReferenceValue``managedReferenceObject``referenceObject``serializedReferenceObject``managedReferenceData``referenceData``serializedData`)、full typename 拆解和 payload-only TextUnit 提取已有回归覆盖,多记录 registry 聚合也已有单元回归;fallback 字段遍历会跳过常见 registry 元数据字符串,避免误入翻译队列,并按记录前缀或子字段可推导 metadata 保留 managed-reference TextUnit context。enum `value__` backing field 和 `LayerMask` / `BitField``m_Bits` backing field 已可语义化解码和替换;`Vector2f/3f/4f``Quaternionf``ColorRGBA``Rectf``AABB/Bounds/Ray``Matrix4x4f``Vector2Int/Vector3Int``RectInt``BoundsInt``RangeInt``GUID``Hash128` 等固定 Unity 值类型的 leaf 和 direct child TypeTree 形态已可结构化解码和语义替换;array/vector/staticvector/List/HashSet/map 元素与 registry payload 字段已保留独立 field path、offset 和 byte size,可用于字符串元素 patchmanaged-reference registry payload 字符串、enum、bit_field、unknown fixed-size raw bytes、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 已可整体变长替换,`first/second``key/value` map entry schema 已有 serialized 和 UnityFS 重建回归,ScriptableObject `key/value` map 解析、变长替换和 UnityFS 重建已有专门回归,且嵌套 vector `Array``List<T>` / `HashSet<T>` 集合 alias、enum、bit_field、unknown fixed-size raw bytes 与 managed-reference payload 字段已有重建回归覆盖;后续仍需继续补齐真实样本驱动的完整 managed reference registry / map entry 变体、unknown 字段结构语义和版本差异。
2. Addressables 当前目标 JSON/compact 字段链已补齐;未识别的独立二进制格式仍返回明确错误,不静默降级。
3. 官方 release 已可配置导入 CAS + ResourceRepository,并可通过 `resource.index` 查询现有资源索引;Resource metadata 已记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要。
4. 不能完成复杂对象字段重打包,也不能从真实 Crowdin 结果自动生成完整汉化文件集合。
---
## 4. 补全顺序
### P0:解析缓存和样本闭环
目标:让官方同步后的解析结果可复用、可诊断、可回归。
交付:
1. `official-resource-changes.json` 记录当前 release 相对上一完整 release 的新增、变更、删除资源,以及解析/翻译候选计数。
2. `crowdin-translation-handoff.json` 只包含新增+变更资源,作为后续 Crowdin worker 的本地队列输入;当前解析阶段不直接调用 Crowdin API。
3. `official-parse-cache.json` 记录 manifest entry、zip entry、解析状态、Unity 版本、文件数、TextAsset 数、TextUnit 数/格式、错误摘要和缓存复用状态。
4. `official-textunit-index.json` 持久化单条 TextUnit 和解析错误,保留 destination、archive entry、serialized file、path id、class id、field path、offset 和 format 等定位信息。
5. `official-textunit-tasks.json` 只从 Added/Modified 资源、parse cache 和 TextUnit 明细索引派生,记录可翻译 TextUnit 任务和跳过原因。
6. `crowdin-textunit-queue.json` 只包含已经产生 TextUnit 的离线任务,当前不调用 Crowdin 网络 API。
7. 解析直接 `.bundle` / `.unity3d` 和 zip 内全部文件条目,不能只假设 `FullPatch_*.zip`
8. 非候选资源记录为 unsupported,不影响官方同步发布。
9. 缺失、损坏或无法解析的 bundle 记录 failed,但不回滚已经完成校验的官方原版 release。
10. 用合成 fixture、隔离真实样本和回归 fixture 覆盖资源变更集、Crowdin handoff、TextUnit 队列、缓存复用、zip 内条目、非候选资源、解析失败。
验收:
1. 新 release 发布时能生成资源变更集,新增+变更资源进入解析/翻译候选,删除资源不进入翻译队列。
2. 第二次 up-to-date 轮询不会重复解析已有有效缓存和 TextUnit 明细索引。
3. 修改任意 manifest entry 的 size/BLAKE3 后,变更集能标记对应资源并让后续解析/翻译只消费候选。
4. 解析缓存、handoff 和 TextUnit 队列不会写入汉化发布根。
### P1Addressables 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,不静默丢字段。
当前 JSON/compact 字段链已完成:provider ID、bundle name、
primary/dependency key、resource type、hash、size 和 CRC 会进入 `ResourceEntry`
并通过 SQLite `ResourceRepository` 持久化;旧索引会按列迁移继续可读。独立二进制
catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整解析。
验收:
1. 当前目标版本 Windows/Android catalog 样本集合解析通过。
2. 解析结果能反查 bundle 文件和依赖链。
3. size/CRC/hash 字段能参与本地文件验证或至少进入诊断报告。
### P2Unity Serialized 字段级解析
目标:把 Unity object table 推进到可提取文本字段。
交付:
1. TypeTree schema 内部表示稳定化:node path、type、name、size、flags、array 信息。
2. 基础字段 reader 已支持 bool、integer、float、string、bytes、array、vector/staticvector 嵌套 `Array``List<T>` / `HashSet<T>` 集合 alias、map、PPtr、enum `value__` backing field、`LayerMask` / `BitField``m_Bits` backing field、常见固定 Unity 值类型的 leaf/direct-child 形态,以及 unknown fixed-size raw bytes 保留和同长度替换。
3. TextAsset 已有专用 name/bytes 读取,避免和字段级遍历重复报错。
4. MonoBehaviour 和 ScriptableObject 的 TypeTree 字段遍历入口已落地,复杂版本差异继续补 fixture。
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、bundle path、archive entry、serialized file、object path id、class id、field path、字段 offset/byte size、版本和上下文;managed-reference payload 会额外写入 reference id、full type name、assembly、namespace 和 class 上下文。
2. TextAsset 已支持 JSON/CSV/TSV/plain text 探测,二进制 payload 单独计数。
3. MonoBehaviour/ScriptableObject 已按字段路径提取字符串。
4. 保留重复文本和上下文,不在解析阶段做会丢定位的合并。
5. 已提供 JSONL 第一稳定格式,CSV/XLIFF 可后置。
验收:
1. 提取不会修改官方资源。
2. 每条文本能追溯回原 bundle、serialized file、path id 和字段路径。
3. 同一文本在不同上下文中保持可区分。
### P4CAS/Repository 用户级接入
目标:让解析结果进入可查询资源库,而不是只停留在文件系统缓存。
交付:
1. 官方同步完成后可配置触发导入 CAS + ResourceRepository(已具备 `--import-repository` / `BAT_IMPORT_REPOSITORY=1`)。
2. ResourceRepository 已保存官方 manifest 资源的类型、路径、hash、size 和 metadatametadata 包含 release、平台、bundle path、parse status、TextAsset 名称、TextUnit 数量/格式。
3. 支持 RPC/CLI 查询资源、bundle、TextAsset、解析错误和缓存状态;当前 `resource.index` 会返回资源 metadata`parse-status` 会返回 TextUnit 索引和队列摘要,`parse-text-units` / `parse-errors` 会按当前 release 查询明细,`localized-status` 会校验 patch manifest。
4. schema 迁移可重复执行;当前 SQLite 已有 `crc``metadata_json` 兼容迁移。
验收:
1. 可以按版本、路径、类型、hash 查询,并在结果 metadata 中看到 TextAsset / TextUnit 摘要。
2. 解析缓存、资源变更集和 repository 数据能从同一 manifest fingerprint 追溯。
3. CAS 对象跨版本复用,不重复存储相同文件。
### P5Patch 发布
目标:让解析结果成为可生成、校验和回滚汉化 patch 的输入。
交付:
1. 已定义 `localized-patch-manifest.json`:目标官方版本、localized release、输出文件、hash、size、byte delta、TextUnit/provider/review trace 和回滚信息。
2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作。
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果。
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`rollback 按 manifest 恢复上一 release。
5. 成功后发布状态从 `not_localized` 切到 `localized``localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release。
验收:
1. Patch 失败不影响 `bat-resources/current`
2. 汉化 release 保留官方相对目录结构。
3. `localized` 状态能证明原版和汉化两套资源都已发布,且 patch manifest 可验证。
---
## 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 样本和独立二进制格式诊断。
2. 继续补充 TypeTree 字段 reader、MonoBehaviour/ScriptableObject 遍历和真实版本差异。
3. 基于 `translation.worker.run` 推进翻译记忆和通用 manifest Patch 构建。
4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引。
5. 在通用 Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包和通用
Patch 发布流程统一,保留当前受支持 localized patch 发布/rollback 链路。
+109 -39
View File
@@ -6,6 +6,12 @@
这个后端只处理 **日服官方资源**,只接受官方 `.jp/.com` 域名下的资源链路。
**Release 布局、URL→磁盘映射、seed 模板与 bat-api 分发 path 的冻结契约**见:
- `docs/architecture/resource-release-layout.md`
---
明确排除:
- `bluearchive.cafe`
@@ -31,7 +37,7 @@
| 清单层 | 解析 `BundlePackingInfo.bytes``TableCatalog.bytes``MediaCatalog.bytes` | 得到完整文件清单 |
| 计划层 | 合并 discovery + inventory,去重并保序 | 得到全量 pull plan |
| 下载层 | 校验官方 URL,调用下载器,落盘并记录字节数 | 得到本地资源副本 |
| 导入层 | 将 bundle 写入 CAS 和 ResourceRepository | 得到可查询的资源索引 |
| 导入层 | 可配置将已校验官方 release 写入 CAS 和 ResourceRepository | 得到可查询的资源索引 |
| 同步层 | 比较当前快照和历史快照 | 决定下载、校验、发布 |
| 更新层 | 保存上次官方 snapshot,定期执行 discovery + diff + pull | 形成自动更新闭环 |
@@ -48,7 +54,7 @@
3. 不要求把生产环境当作客户端安装目录。
4. 可以显式执行 official metadata discovery 自动发现 `server-info` URL、`connection-group``app-version`
5. 也可以通过配置、调度状态或已审计 metadata snapshot 显式提供这些值。
6. `--auto-discover` 只允许通过官方 HTTP metadata 和临时目录解析 `GameMainConfig`launcher metadata 未变时必须复用缓存,metadata 变化时才按 manifest 重新下载必要 `resources.assets` 或旧版官方 game zip。
6. `--auto-discover` 只允许通过官方 HTTP metadata 和临时目录解析 `GameMainConfig`launcher metadata 与 remote manifest 文件列表 digest 均未变时必须复用缓存,任一变化时才按 manifest 重新下载必要 `resources.assets` 或旧版官方 game zip。
### 3.1 发现官方资源根
@@ -68,7 +74,7 @@
### 3.2 枚举完整资源清单
资源清单不是“猜几个文件”,而是从官方 catalog 字节里提取完整文件名列表。
资源清单不是“猜几个文件”,而是从官方 catalog 字节里提取完整文件名或相对路径列表。
当前做法:
@@ -77,7 +83,7 @@
3. 读取 `TableCatalog.bytes`
4. 提取所有表资源名,例如 `ExcelDB.db`
5. 读取 `MediaCatalog.bytes`
6. 提取所有媒体资源名,例如 `JP_Airi.zip`
6. 提取所有媒体下载相对路径,例如 `GameData/Audio/VOC_JP/JP_Airi.zip``Prologue/Scenario/Event/10000_Title_Sound.ogg`
然后对 verified platforms 生成完整 URL 集:
@@ -129,16 +135,23 @@
6. `TableCatalog.bytes``BundlePackingInfo.bytes``MediaCatalog.bytes` 总是刷新并用官方 `.hash` 强校验;该 `.hash``xxHash32(seed=0)` 的十进制文本。
7. `catalog_*.hash` 当前只作为 Addressables catalog 变更标记,不作为 zip/JSON 内容校验算法;Unity Addressables/SBP builder 对 JSON/bin catalog 使用 `HashingMethods.Calculate` 生成 `Hash128` 文本,运行时用它判断 remote catalog cache 是否过期,它不能套用 seed catalog 的 `xxHash32` 规则。
8. 官方 seed `.hash` 校验失败会让当前下载失败,并移除对应 data/hash URL 的本地 manifest 条目,避免失败产物在下一轮被本地 BLAKE3 audit 误判为健康缓存。
9. 存在 `.part` 临时文件时通过 `curl --continue-at -` 尝试断点续传
10. 新下载写入 `.part`,成功并通过必要校验后原子 rename 到 staging 内最终路径;断点续传后的 `.zip` 如果结构无效,会删除 `.part` 并重新全量下载
11. 成功下载后更新本地下载清单
12. 上一轮失败或中断留下的 staging 只有在 `official-version-state.json` 中存在同一 app version、bundle version 和 Addressables root 的失败记录,且 `<output>/.staging/<id>` 仍安全存在、`versions/<id>` 尚未发布时才会复用;复用后仍按 manifest、BLAKE3、ZIP 结构和官方 `.hash` 逐 URL 校验,不信任散落文件
13. curl 默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量,保留 `NO_PROXY`;带凭据的代理推荐用这些环境变量配置。CLI 也可用 `--proxy <URL>` 显式指定代理,或用 `--no-proxy` 强制直连。代理决策会进入 progress log、daemon log 和 `doctor` 诊断输出。代理凭据全程不落世界可读位置:日志与 `status` 输出脱敏;传给 curl 子进程时经 `ALL_PROXY` 环境变量而非 `--proxy` 参数,不进 curl 的 `/proc/<pid>/cmdline``--daemon` 模式下经环境变量下传后台子进程,不进子进程 argv 或 `bat-status.json`,复用凭据单独存于 `bat-proxy.secret``0600`),`clean-stable` 会在后台停止后清除
14. curl 失败按 HTTP/网络类型分类:403/404/普通 4xx 不重试,5xx、429、DNS、连接、超时、中断和网络类错误按尝试次数重试
15. 单个 URL 最终失败时写入 `official-download-quarantine.json`,发出 Failed progress,并阻止发布不完整资源
16. 旧 launcher 包或 `resources.assets` 下载使用官方 launcher CDN 配置,primary CDN 失败后切换 official backup CDN;资源 patch host 不猜测非官方镜像
17. 记录最终文件大小、本次传输字节数、官方 hash 校验数和执行状态
18. 非官方 URL 直接拒绝
9. 官方启动器/server-info 先于 client-patch CDN 开放是合法上游状态。若 seed marker 或必需 seed catalog 在进入 staging 前返回 403/404/普通 4xx,更新服务返回 `waiting_for_official_resources``unavailable_endpoints`,保留现有 `current`,不创建失败 staging,不写入 `failed_versions`watch/daemon 使用 `waiting` 状态按错误重试间隔继续探测
10. 存在 `.part` 临时文件时通过 `curl --continue-at -` 尝试断点续传
11. 新下载写入 `.part`,成功并通过必要校验后原子 rename 到 staging 内最终路径;断点续传后的 `.zip` 如果结构无效,会删除 `.part` 并重新全量下载
12. 成功下载后更新本地下载清单
13. 上一轮失败或中断留下的 staging 只有在 `official-version-state.json` 中存在同一 app version、bundle version 和 Addressables root 的失败记录,且 `<output>/.staging/<id>` 仍安全存在、`versions/<id>` 尚未发布时才会复用;复用后仍按 manifest、BLAKE3、ZIP 结构和官方 `.hash` 逐 URL 校验,不信任散落文件
14. curl 默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量,保留 `NO_PROXY`;带凭据的代理推荐用这些环境变量配置。CLI 也可用 `--proxy <URL>` 显式指定代理,或用 `--no-proxy` 强制直连。代理决策会进入 progress log、daemon log 和 `doctor` 诊断输出。代理凭据全程不落世界可读位置:日志与 `status` 输出脱敏;传给 curl 子进程时经 `ALL_PROXY` 环境变量而非 `--proxy` 参数,不进 curl 的 `/proc/<pid>/cmdline``--daemon` 模式下经环境变量下传后台子进程,不进子进程 argv 或 `bat-status.json`,复用凭据单独存于 `bat-proxy.secret``0600`),`clean-stable` 会在后台停止后清除
15. curl 失败按 HTTP/网络类型分类:403/404/普通 4xx 不重试,5xx、429、DNS、连接、超时、中断和网络类错误按尝试次数重试
16. 单个 URL 最终失败时写入 `official-download-quarantine.json`,发出 Failed progress,并阻止发布不完整资源
17. 旧 launcher 包或 `resources.assets` 下载使用官方 launcher CDN 配置,primary CDN 失败后切换 official backup CDN;资源 patch host 不猜测非官方镜像
18. 记录最终文件大小、本次传输字节数、官方 hash 校验数和执行状态
19. 非官方 URL 直接拒绝。
20. 下载调度默认并发数为 `8`,允许范围是 `1..=256`,由
`--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置。worker 从共享
plan 队列逐项领取任务,单个任务完成后立即领取下一个,不等待其他 worker
的当前任务;完成结果在协调线程即时更新 manifest、hash 事件和进度计数。
最终 `OfficialResourcePullReport.items` 仍按 `OfficialResourcePullPlan`
顺序排列,避免并发完成顺序泄露到发布和 API 读侧。
路径映射时会做分段清理,并在写入前做输出目录安全校验、相对路径归属校验和现有路径组件 symlink 检查,避免把不安全路径写进输出目录或通过 symlink 跳出输出目录。
@@ -148,14 +161,31 @@
### 3.5 导入到 CAS 和资源仓储
资源下载后,导入层会:
官方同步下载、校验并发布 release 后,可以通过 `--import-repository`
`.env``BAT_IMPORT_REPOSITORY=1` 自动触发 CAS + `ResourceRepository`
导入:
1. 把 bundle 原始字节写入 CAS
2. 解析 UnityFS 基础摘要
3. 把资源条目写入 `ResourceRepository`
4. 记录资源路径、hash、大小和解析摘要
1. 读取已发布 release 下的 `official-download-manifest.json`
2. 逐条按 manifest 的相对路径、size 和 BLAKE3 重新校验本地文件
3. 把已校验字节写入 CAS;默认 CAS 根目录是 `<output>/.cas`,也可用
`--import-cas-root` / `BAT_IMPORT_CAS_ROOT` 覆盖
4. 将资源条目写入 SQLite `ResourceRepository`;默认索引路径是
`<output>/resources.sqlite`,也可用 `--import-resource-db` /
`BAT_IMPORT_RESOURCE_DB` 覆盖。
5. AssetBundle、TextAsset、TableBundle、Media、Manifest/Other 会按资源类型分类;资源 metadata 会通过 `metadata_json` 保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式。
6. 当前 release 的单条 TextUnit 明细和解析错误会写入 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` RPC 和 `parse-text-units` / `parse-errors` CLI 只读查询。
这层的意义是把“下载到磁盘的文件”变成“可查询、可复用、可去重”的资源对象。
`resource.index` RPC / CLI 只读查询现有 SQLite 索引;索引不存在时返回
`available=false`,不会因为查询创建空库。发布后的 TextUnit 队列还会在当前
release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrations`
管理 queued/running/failed/completed/skipped、provider run、lease、失败分类、
重试计划和 TextUnit 级译文结果。`translation.tasks` 优先查询这份状态库,
`translation.worker.run` 由 Rust worker 回写状态;`translation.task.update` 仍供外部 provider 流程回写状态;
没有状态库的旧 release 才回退到 immutable JSON 队列。`bat doctor cas`
已提供只读 CAS 根目录、对象目录、元数据库文件和对象统计诊断;`resource.index`
已把 release、平台、bundle path 和常用数组 metadata 过滤下推到 SQLite。G-011
剩余工作是更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。
对应实现主要在:
@@ -189,31 +219,44 @@
集成边界:
1. 当前生产和 Go CLI 默认集成路径是运行 `bat --json` 并消费结构化 report。
1. 当前生产集成路径是 Rust `bat --watch` / `bat --daemon` 持久运行;Go `bat-api`
通过 `internal/backendrpc` 调用 daemon RPC,读取已发布 release 和状态,不运行
另一套同步器。`bat --json` 只表示 Rust CLI 的机器输出形态。
2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。
3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。
流程是:
1. 显式执行 `--auto-discover` 或读取已审计 `server-info` 输入。
2. `--auto-discover` 先抓官方 launcher metadatametadata 未变时复用 `official-bootstrap-cache.json` 中的 `GameMainConfig` 摘要,metadata 变化时按 manifest 临时下载 `resources.assets` 或旧版官方 game zip 并重新解析。
3. 生成当前 v2 snapshot,记录 `app_version``connection_group``bundle_version``addressables_root`、endpoint URL、seed `.hash` 内容、`catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。
2. `--auto-discover` 先抓官方 launcher metadata、launcher CDN config 和 remote manifestmetadata 与 remote manifest 文件列表 digest 均未变时复用 `official-bootstrap-cache.json` 中的 `GameMainConfig` 摘要,任一变化时按 manifest 临时下载 `resources.assets` 或旧版官方 game zip 并重新解析。
3. 生成当前 v2 snapshot,记录 `app_version``connection_group``bundle_version``addressables_root`、endpoint URL、seed `.hash` 内容、`catalog_*.hash` marker、launcher metadata 摘要、remote manifest 文件列表 digest `GameMainConfig` 摘要。
4. 读取上一次成功同步写出的 snapshot。
5. 使用 `OfficialSyncPlan` 和扩展 snapshot diff 判断是否需要下载;URL 未变但 `.hash` / marker 内容变化也会触发更新。
6. 每轮都会基于最新 seed catalog 构建当前 pull plan,并检查输出目录是否已有当前 plan 的 manifest 条目或目标文件。
7. 如果远端 snapshot 未变化但输出目录没有任何当前 plan 的本地资源,仍按首次运行处理并执行全量拉取。
8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。
9. 远端变化、本地 audit 发现 repair_needed,首次空目录运行,或缺少 `current` 原子发布指针时,进入下载/发布流程。
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot。
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。新 staging 还会扫描已发布 release 的下载 manifest,按规范化 destination 查找候选并重新验证 size、BLAKE3 和 ZIP 结构;硬链接失败时回退到临时文件复制和原子 rename,历史 release 保持不可变。
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot,并在 staging 中写入 `official-launcher-bootstrap.json`(若本轮启用 `--auto-discover`
12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/current` symlink 指向该 versioned 目录。
13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json``crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。
14. 随后刷新 active release 下的 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。
15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。历史 release 候选失效时,已有 CAS 对象会先经过完整性和元数据校验,再增加 release 引用并原子物化;当前 release 在 `official-cas-reuse-references.json` 中记录引用,staging/release 清理时递减,失败则回退网络并保留诊断。
16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;UnityFS TextAsset patch 发布成功并通过 `localized-patch-manifest.json`、current symlink 和 release ID 校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。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` 优先走 RPC`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,`restart` 才负责重启进程或替换启动参数;显式 `--proxy` / `--no-proxy` 会作为启动参数保存并在后台重启时复用。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 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 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定
维护期特殊分支:如果官方 launcher/server-info 已经指向新资源根,但 client-patch seed marker 或必需 seed catalog 仍返回 403/404 等未开放状态,`bat` 返回 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging;若本轮启用 `--auto-discover`,会在 `<output>/official-launcher-bootstrap.pending.json` 写入待处理 launcher bootstrap 证据,供后续排障和自研客户端开发使用
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。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``restart``logs``reload`、默认形态的 `refresh` 和默认形态的 `repair` 优先走 RPC`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,默认 `repair` 会通过 `resource.repair` 入队本地 manifest 审计+修复任务,live RPC `restart` 会启动 Rust lifecycle controller 并复用 CLI 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 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
对应实现主要在:
- `infrastructure/src/official_update.rs`
- `infrastructure/src/bin/bat_official_sync.rs`
- `infrastructure/src/bin/bat_official_sync.rs`(薄入口)
- `infrastructure/src/bin/bat/app.rs`(控制面组合)
- `infrastructure/src/bin/bat/report_output.rs``terminal_output.rs`(前台报告和终端输出)
- `infrastructure/src/bin/bat/task_registry.rs`(任务注册表、持久化和 worker
- `infrastructure/src/bin/bat/readonly_query.rs``translation_query.rs`(只读查询)
- `infrastructure/src/bin/bat/patch_commands.rs`patch 命令)
- `infrastructure/examples/official_update_check.rs`(历史/开发入口)
## 4. 官方 bootstrap 与用户流程
@@ -246,10 +289,11 @@ Linux 生产路径:
- pull plan 会同时包含 discovery URLs 和 content URLs
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress logstdout 默认输出人类可读摘要;progress log 覆盖代理决策、总体下载进度、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status``stop``restart``reload``logs``refresh --force``verify``repair``doctor``clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging``versions``current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress logstdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC live control/backend`daemon.status/logs/stop/restart/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``translation.tasks/handoff/task.update``localized.status``catalog.*``task.*`、文件级 `patch.apply` / `unityfs.patch_*`);`restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging``versions``current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态
- curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN
- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因
- 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类
- 资源导入链路已覆盖可配置 CAS 写入、`ResourceRepository` 索引、`metadata_json` release/平台/bundle/TextAsset/TextUnit 摘要,以及 TextAsset/Table/Media 分类`resource.index` 可只读查询现有索引,常用 metadata 过滤已下推到 SQLite`bat doctor cas` 可只读诊断既有 CAS 目录和对象统计
- 官方 release 发布后会生成 `official-resource-changes.json``crowdin-translation-handoff.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`,为后续增量解析和 Crowdin worker 预留稳定输入
- 离线回归样本已覆盖当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和 seed hash mismatch
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
- 下载层能在本地文件 size/BLAKE3/path、ZIP 结构或 manifest 不匹配时重新下载
@@ -280,6 +324,7 @@ Linux 生产路径:
daemon`bat --daemon`)在 `<state-dir>/bat.sock` 上提供 Unix socket
JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边界**。
稳定方法、schema 和错误语义以 `docs/reference/rpc-backend-api.md` 为准。
### 7.1 协议契约
@@ -289,26 +334,51 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
- `error` 为统一 `ApiError``code``BAT-ERR-<6 位>`)、`kind`
`domain``location``message``retryable`。码表以
`core/src/error_code.rs` 为准。
- 长任务(`resource.sync` / `resource.verify` / `catalog.refresh`
- 长任务(`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh`
入队即返回 `task_id`,经 `task.status` / `task.list` / `task.logs`
轮询,`task.cancel` 协作式取消。任务执行器是单 worker FIFO,与
watch 循环经进程内锁互斥。任务历史持久化于 `<state-dir>/bat-tasks.json`
(版本化、`0600` 原子写,生命周期转换时落盘),daemon 重启后历史任务
仍可经 `task.*` 查询,中断任务标记 `task_interrupted`700005)。
- 方法命名空间与实现状态、请求/响应示例见 `USERGUIDE.md` §6
`daemon.*` / `resource.*` / `catalog.*` / `task.*` 已实现;
`patch.*` / `unityfs.*` 待引擎;`task.create` / `resource.repair`
按设计暂缓。
- 方法命名空间与实现状态、请求/响应示例见
`docs/reference/rpc-backend-api.md``daemon.status/logs/stop/restart/reload/refresh/doctor`
`resource.state/sync/verify/repair/manifest/list/index``parse.status/text_units/errors`
`translation.tasks/handoff/task.update/proofread``localized.status``catalog.*`
`task.status/list/cancel/logs` 已实现;文件级 `patch.apply` / `unityfs.patch_*`
已实现,发布级 patch 与复杂 UnityFS 语义编辑待引擎;
`task.create` 按设计暂不开放通用任务入口;
`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径;
`daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
### 7.2 Go 层职责边界
- Go 层负责:BlueArchive 客户端请求处理、HTTP API、鉴权、内容分发,
以及作为 RPC client 调用本机 daemon(连接 `bat.sock`,每行一个
JSON-RPC 请求/响应)
- Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查
- Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 是资源读侧、
bootstrap 和 HTTP 分发入口。二者之间的稳定边界是 `bat.sock` RPC 和
`resource_root` 中已发布的只读文件
- Go 层负责:资源 bootstrap、资源内容分发(`cmd/bat-api`)、HTTP API 进程配置
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon(连接
`bat.sock`,每行一个 JSON-RPC 请求/响应)。`cmd/bat` 仍是试验骨架,不是产品级用户 CLI。
- **`bat-api`(资源分发)**
- 提供 `/v1/bootstrap`,把 `bat` 的 RPC 健康、release 摘要、server-info URL、
client-patch base 和改写后的 Addressables root 组织成启动前资源发现响应。
- 提供 `/healthz` 作为 liveness + 最近一次 RPC refresh 诊断,提供 `/readyz`
作为 release readiness;当前无可分发 release 时 `/readyz` 返回 `503`
- 只读提供 Rust `bat` 已发布 release 中的资源字节(官方 CDN host/path 形态)。
- CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再
`catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。
- 支持 `.env` / 环境变量配置监听端口、public base URL、RPC socket 和 RPC
刷新周期,并预留 database/redis 键供后续 API 持久化;**不**负责资源自动拉取。
- 可选改写 server-info 中的 `AddressablesCatalogUrlRoot` 指向自身;不伪装
完整游戏业务 API。启动前资源 metadata 兼容属于资源 bootstrap;账号、登录、
Gateway、游戏业务 `ApiUrl` 和鉴权全链非本服务关闭条件。
- Rust `bat` / daemon 负责:官方资源自动发现与拉取、校验、catalog 更新检查、
版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。
- Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源
目录内部结构;跨语言交互只经 RPC 契约。
- Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件;跨语言控制面
只经 RPC 契约。生产文件字节从 RPC 给出的 `resource_root` 读取,`bat-api`
与 daemon 同服务器、同容器或同一共享文件系统部署;显式 `--resource-root`
只用于 fixture、本地开发或 RPC 不可用时的应急只读诊断。
### 7.3 FFI 的定位(降级说明)
@@ -0,0 +1,371 @@
# 官方资源 Release 布局与资源侧契约
- **更新时间**2026-09-04
- **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `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 后端 |
| 资源 bootstrap / 只读分发 | Go `bat-api` | 同环境经 `bat.sock` 发现已发布版本和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写和官方 CDN path 字节 |
| 试验 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-textunit-index.json # TextUnit 明细与解析错误索引,不是汉化产物
official-textunit-tasks.json # 翻译任务候选派生队列,不发 Crowdin 网络请求
crowdin-textunit-queue.json # Crowdin worker 离线输入队列
official-sync-snapshot.json # 常在 active root / current 下
official-launcher-bootstrap.json # 官方 launcher 引导链版本化产物
official-cas-reuse-references.json # 当前 release 获取的 CAS 引用
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 缓存
official-launcher-bootstrap.pending.json # 维护期 launcher 已前进但资源未开放时的待处理证据
<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 | RPC 给出的 `version.resource_root`;通常等价于 `current` 解析后的 versioned 目录 |
每个 release 的 `official-download-manifest.json` 是历史复用的索引。新 staging
按规范化 destination 查找候选,并重新验证 manifest 中的 size、BLAKE3 和 ZIP
结构;URL、CDN 根或 release ID 变化本身不构成失效条件。复用文件先尝试硬链接,
跨文件系统时复制到 staging 内的临时文件并原子 rename,旧 `versions/<id>` 目录
保持不可变。
从 CAS 物化资源时,`official-cas-reuse-references.json` 记录每个获取的对象引用,
文件带版本字段且允许重复 object ID。孤儿 staging 或显式 release 清理必须先按
清单减少 CAS 引用,再删除目录;CAS 对象损坏、缺失或元数据不一致时只产生诊断,
回退网络下载,不发布未经校验的文件。
---
## 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 主体。Rust `bat` 会把启动器链中与资源发现相关的 launcher metadata、CDN config、remote manifest 文件列表、选中的 `resources.assets` 来源和 `GameMainConfig` 摘要写入 `official-launcher-bootstrap.json`,供后续 `bat-api` / 自研客户端在 Rust 侧完成前继续以 versioned release 为权威来源。)
### 3.2 bat-api 对外 path1:1
```text
GET {public-base-url}/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
≡ 磁盘 <resource_root>/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
```
默认仅服务 **download manifest 索引内且 Present + size 匹配** 的文件。
### 3.3 launcher 资源引导兼容
只读分析本机样本时可见两类启动器形态:
| 目录形态 | 说明 |
|---|---|
| `AllResources/YostarGames/BlueArchive_JP_Gamelauncher` | 官方 Electron 启动器目录 |
| `Localized` / `Localized_Official` | 汉化或改造启动器目录 |
| `AllResources/YostarGames/BlueArchive_JP` | 官方安装后的游戏客户端目录,含 `game-launcher-config.json``manifest.json``BlueArchive_Data/StreamingAssets/catalog_Remote.*` |
这些目录只作为开发期样本;生产链路不得依赖 `/home/wanye/D/BlueArchive` 或任何已安装客户端目录。
官方启动器样本中与资源发现相关的 HTTP path:
| Host / path | 资源侧意义 |
|---|---|
| `https://api-launcher-jp.yo-star.com/api/launcher/game/config` | 返回 launcher 观察到的最新客户端版本和包路径 |
| `https://api-launcher-jp.yo-star.com/api/launcher/game/config/json?version=...&file_path=...` | 返回远端 package manifest URL |
| `https://api-launcher-jp.yo-star.com/api/launcher/advanced/game/download/cdn` | 返回 launcher package CDN primary / backup |
`bat-api` 的兼容范围是**资源引导**,不是完整启动器更新服务:
| bat-api path | 行为 |
|---|---|
| `/v1/launcher/bootstrap` | 返回资源引导聚合视图:已发布 release、launcher metadata、GameMainConfig 摘要、server-info URL、client-patch base、改写后的 Addressables root |
| `/api/launcher/game/config` | 返回 `{code,message,data}` envelope,字段来自 Rust `bat` snapshot/RPC 中的 `launcher_metadata`,并附带 `resource_bootstrap_url` |
| `/api/launcher/game/config/json` | 返回指向 `/api-launcher-jp.yo-star.com/api/launcher/resource/bootstrap.json` 的资源引导 JSON URL,显式标记 `package_update_manifest=false` |
| `/api/launcher/advanced/game/download/cdn` | 返回 `public-base-url` 作为资源引导 CDN 根,显式标记 `package_update_manifest=false` |
| `/api-launcher-jp.yo-star.com/...` | 与上面裸 path 等价,便于反向代理或 hosts 映射保持官方 host 形状 |
数据来源只能是 Rust `bat` 已发布状态;当前 Go `bat-api` 仍主要消费 snapshot/RPC 摘要,后续字段统一与联调时应把 versioned launcher artifact 纳入 contract fixture
1. `catalog.status` / `official-sync-snapshot.json` 中的 `launcher_metadata`
2. `catalog.status` / `official-sync-snapshot.json` 中的 `game_main_config_bootstrap`
3. `official-launcher-bootstrap.json` 中的官方 launcher bootstrap versioned artifact。
4. `resource.manifest` 和磁盘 Present/size 检查得到的当前 release 索引。
`bat-api` 不下载 launcher 包、不生成官方 PC package update manifest、不执行启动器签名/鉴权链、不仿造登录、账号、网关或游戏业务 API。需要真实资源拉取时,仍由 Rust `bat --auto-discover` 在隔离 staging 中通过官方 HTTP metadata 完成,并把已发布结果通过 RPC 暴露给 `bat-api`
---
## 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 行为 |
|---|---|---|
| 启动前资源发现 | 客户端/补丁器需要知道当前资源版本、server-info 和 client-patch 根 | `GET /v1/bootstrap` 返回 `bat` RPC 健康、release 摘要、server-info URL、client-patch base 和改写后的 Addressables root |
| 服务就绪 | 运维需要区分进程存活和 release 是否可分发 | `GET /healthz` 返回 liveness + RPC refresh 诊断;`GET/HEAD /readyz` 无可分发 release 时返回 `503` |
| client-patch 内容 | GET 官方 path;无业务鉴权头(资源 CDN) | `GET/HEAD /prod-clientpatch.../...` 原样字节,支持 Range |
| server-info | GET JSON;字段 PascalCase`ConnectionGroups` 等) | 可选加载并**只改** `AddressablesCatalogUrlRoot` 指向 `{public-base}/prod-clientpatch.../{token}` |
| launcher bootstrap | 启动器链会先查 launcher metadata,再找到 server-info / Addressables root | `/v1/launcher/bootstrap``/api/launcher/...` 只输出资源引导兼容信息,来源是 Rust snapshot/RPC |
| seed `.hash` | 纯文本十进制(可含空白) | 原样分发 |
| Range / 断点 | 官方客户端下载器用 Range;bat 用 curl `.part` | `ServeContent` 支持 Range / `206` / `416` / `If-Range` |
| 缓存 / 条件请求 | 资源位于 versioned rootmanifest 有 BLAKE3 | ETag 优先使用 manifest BLAKE3;返回 Last-Modified、Accept-Ranges、长期 Cache-Control |
| 业务 ApiUrl/Gateway | 游戏协议 | **不改写、不仿造** |
Addressables 改写后客户端拼接:
```text
{rewritten_root}/TableBundles/TableCatalog.bytes
≡ {public-base}/prod-clientpatch.../{token}/TableBundles/TableCatalog.bytes
```
与磁盘映射一致。
---
## 8. RPC 与分发发现顺序
`bat-api`(及任何 Go 服务层)发现当前 release,并由 `/v1/bootstrap` 组织为启动前资源入口:
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``bat-api``bat` 在同服务器、同容器或同共享文件系统环境内运行。`--resource-root` 只用于本地 fixture 或应急只读诊断,不作为生产资源根配置。`BAT_API_REFRESH_INTERVAL` 控制 bat-api 周期重读 RPC,以跟随 Rust `bat` 发布新 release。见 `cmd/bat-api/.env.example`
---
## 9. 真实资源样本索引
在服务器 release 上优先采集到 `/tmp` 隔离目录(**不入库大文件**):
| 用途 | 建议路径模式 |
|---|---|
| Addressables | `{PatchDir}/catalog_*.zip` 解压后的 JSON/bin + 旁路 `.hash` |
| UnityFS | `FullPatch_*.zip` 内抽样 `.bundle`,或已解包 bundle |
| seed 加固(R2 | 各平台 `TableCatalog` / `BundlePackingInfo` / `MediaCatalog``.bytes`+`.hash` |
字段目标(已有 `m_Crc` 部分):继续扩大 hash/size/CRC/依赖等可校验字段覆盖。
结构目标: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-005 / G-007 / G-009
---
## 12. 变更纪律
1. 改 URL 模板或落盘规则 → **必须**同步本文 + 相关单测。
2. 改 inventory 启发式 → 说明覆盖的真实风险并补 fixture。
3. 真机实勘若发现与本文冲突 → **以真机为准** 修代码与本文,禁止静默分叉。
+20 -19
View File
@@ -1,6 +1,6 @@
# 稳定工程基线指南
- **更新时间**2026-07-06
- **更新时间**2026-09-04
- **目标**:让工作区处于可继续开发核心功能的可信状态。
---
@@ -13,7 +13,7 @@
2. 根目录只保留入口文档和工程配置。
3. 旧报告归档,且不再和当前状态混淆。
4. Rust workspace 成员显式列出。
5. Go 尚未实现时,Makefile 不误报失败
5. Go 正式入口为 `bat-api` 资源 bootstrap/分发服务;Makefile 不把实验性 CLI 骨架误报为完整产品
6. 当前缺口有集中清单和关闭顺序。
7. 架构边界有 ADR 记录。
8. 基础验证命令通过。
@@ -36,27 +36,27 @@ make lint
```bash
cargo test --workspace
cargo check --workspace
cargo clippy --workspace -- -D warnings
cargo clippy --workspace --all-targets -- -D warnings
go test ./internal/api/... ./internal/backendrpc/...
go vet ./...
```
说明:
1. 当前没有 Go 产品入口,因此 Go build/test/check/fmt/lint 会在空 Go 阶段明确跳过
2. 如果后续新增 Go package,必须让 `go test ./...``go vet ./...` 纳入硬性验证
3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入 CI
1. 默认 Go 测试只覆盖正式 `bat-api` 依赖的纯 Go 包:`internal/api``internal/backendrpc``make test-go-ffi` / `make test-go-all` 才会包含 FFI 和试验 CLI
2. `make check` 当前直接执行 `go vet ./...`,因此会检查所有已存在的 Go 包;新增 Go 产品 package 后,必须同时纳入默认测试门禁
3. `golangci-lint` 当前仍是可选补充门禁;Go 的硬性验证是默认 API 测试、全量 `go vet``bat-api` 构建
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`
如果构建环境的默认 Go cache 不可写,可将 `GOCACHE` 指向工作区外的临时目录,例如
`GOCACHE=/tmp/bat-go-cache`
---
## 3. Git 基线
当前工作区`.git/` 是空目录,无法恢复原历史。本基线采用新初始化仓库,并以首次提交作为后续开发起点。
首次提交信息:
```text
chore: establish development baseline
```
当前工作区以现有 Git 分支和提交为基线;原项目历史未恢复。提交前应确认工作区
只包含本次有意修改,并核对文档、源码和测试状态。
提交前检查:
@@ -84,14 +84,15 @@ git check-ignore -v Cargo.lock CLAUDE.md AGENTS.md CONTRIBUTING.md
---
## 5. 下一阶段入口
## 5. 当前开发入口
CAS V1 和 Rust 官方同步闭环完成后,下一阶段优先推进:
当前开发优先推进:
1. Go CLI 的 `doctor` 和基础命令框架
2. `docs/guides/official-full-pull-smoke.md` 执行真实官方网络全量下载 smoke,并保留隔离目录报告
3. 官方同步结果接入 CAS + ResourceRepository。
4. AssetBundle UnityFS 解析
1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包
2. 基于 `translation.worker.run` 推进翻译记忆和通用 manifest Patch 构建
3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图
4. `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告
5. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
优先阅读:
+40
View File
@@ -0,0 +1,40 @@
# bat-api 同机 live 联调
## 目的
该 runbook 验证生产拓扑的本地形态:Rust `bat` 与 Go `bat-api` 在同一主机上运行,二者通过同一个 `bat.sock` Unix socket 和同一个已发布资源文件系统协作。
测试使用仓库内完整 release fixture,并把所有 daemon、HTTP 服务、release 目录和报告写入一个新的 `/tmp` 隔离目录。它不访问官方网络,不读取现有客户端目录,也不写入开发机生产资源目录。
## 命令
```bash
make bat-api-local-live-smoke
```
脚本会按需构建 `bat``bat-api`,然后在同一临时目录中:
1. 创建版本化 release、`official-version-state.json``current` symlink。
2. 启动真实 Rust `bat --daemon`,验证 live `bat.sock` RPC。
3. 启动 Go `bat-api`,通过 RPC 发现 `resource_root` 和 manifestGo 不读取 daemon 状态文件。
4. 验证 `/healthz``/readyz``/v1/bootstrap`、server-info 和 launcher resource bootstrap。
5. 验证 CDN `GET``HEAD``Range`、ETag、Last-Modified、缓存头、未索引路径和编码 dot-segment 越界路径。
6. 切换 `current` 到下一个已发布版本,确认 API 索引跟随 RPC 返回的版本变化。
7. 清空已发布版本,确认旧索引不会继续分发,`/readyz` 返回 `503`
8. 停止并重启 Rust daemon,确认 RPC 断开时 API 返回未 ready,重连后恢复 ready。
成功时脚本输出 `LOCAL_BAT_API_LIVE_SMOKE_OK`,并打印类似以下报告路径:
```text
/tmp/bat-api-local-live-<UTC timestamp>/report/SMOKE_REPORT.md
```
报告目录不提交 Git;需要审阅时应保存该次命令输出和报告目录位置。
## 生产边界
- Rust `bat` 负责官方发现、下载、校验、发布、版本状态和 `bat.sock` RPC。
- Go `bat-api` 只通过 RPC 发现已发布 `resource_root` 和 manifest,并提供 HTTP bootstrap/CDN 读服务。
- 生产中两者必须使用同一主机、同一容器或同一共享文件系统;`bat.sock` 不应暴露到公网。
- `--resource-root` / `BAT_API_RESOURCE_ROOT` 只用于 fixture 或应急只读诊断,不能替代生产 RPC 发现。
- `make official-smoke` 是独立的官方网络全量拉取 runbook;本文件的本地 fixture smoke 不证明官方网络可达或官方全量资源下载成功。
+342
View File
@@ -0,0 +1,342 @@
# Rust bat 工作流命令
Rust `bat` 的工作流入口按三个一级命令组织:
- `res`:官方资源拉取、校验、修复和拉取计划。
- `parse`:当前官方 release 的解析和 UnityFS 重打包。
- `i18n`:离线翻译工作台、人工文本修改和汉化 release 发布。
`resource``resources``translation``translate` 仍作为长别名接受,但文档示例统一使用 `res``i18n`;例如 `resource status``resource schedule``translation tasks``translation handoff``translation status` 都会落到同一组已实现命令。
## 资源拉取
单次拉取:
```bash
bat res pull --auto-discover --output /tmp/bat-resources
```
同一进程内限定次数执行。第二轮及以后必须显式给出间隔:
```bash
bat res pull --auto-discover \
--run-count 3 \
--interval 1h \
--output /tmp/bat-resources
```
无限周期执行使用 `--watch`
```bash
bat res pull --auto-discover --watch --interval 1h \
--output /tmp/bat-resources
```
资源下载默认使用 8 个独立 worker,允许范围为 `1..=256`。worker 完成当前 URL 后立即领取共享队列中的下一个任务,进度按完成顺序统计,最终报告仍按计划顺序输出。
## 解析与重打包
解析当前已发布 release
```bash
bat parse run --output /tmp/bat-resources
```
也可以显式指定隔离的已发布 release 根目录:
```bash
bat parse run \
--resource-root /tmp/bat-resources/versions/<release-id> \
--force
```
解析结果会刷新 `official-parse-cache.json``official-textunit-index.json` 和翻译队列。`--force` 忽略已有解析缓存,但仍要求输入 release 已通过官方下载 manifest 校验。
清理当前 release 的可再生解析缓存和离线翻译队列:
```bash
bat parse clear-cache \
--resource-root /tmp/bat-resources/versions/<release-id> \
--force
```
该命令不会删除 `translation-tasks.sqlite`;worker 状态必须通过任务接口单独维护。
批量 UnityFS 重打包使用 JSON spec。spec 的 `schema_version` 当前为 `1`,支持 `text_asset``string_field` 和受支持的语义 `field` 操作:
```bash
bat parse repack --repack-spec /tmp/bat-repack.json
```
重打包写入独立的 `target_bundle`,逐个操作后由底层 UnityFS patch 实现重建并校验,不允许 source 和 target 相同。
## 翻译工作台与发布
导出可人工编辑的工作台:
```bash
bat i18n export \
--output /tmp/bat-resources \
--translation-file /tmp/bat-workbench.json
```
修改一个条目:
```bash
bat i18n set \
--translation-file /tmp/bat-workbench.json \
--translation-id <text-unit-id> \
--translated-text '中文文本'
```
也可以使用 `--translated-file` 读取 UTF-8 文本。需要复核单条内容时:
```bash
bat i18n get \
--translation-file /tmp/bat-workbench.json \
--translation-id <text-unit-id> \
--json
```
需要把某条译文恢复为未审核状态时:
```bash
bat i18n unset \
--translation-file /tmp/bat-workbench.json \
--translation-id <text-unit-id>
```
这些工作台操作也可以写成 `bat i18n workbench set|get|unset|validate ...`
`--translation-file` 也可简写为 `--workbench`
工作台会保存 source text、release ID、TextUnit 目标和人工译文;发布前会重新读取当前
TextUnit 索引,拒绝过期 release、source text 或 patch 目标。
发布前可只做工作台审计:
```bash
bat i18n validate \
--resource-root /tmp/bat-resources/versions/<release-id> \
--translation-file /tmp/bat-workbench.json
```
报告会区分未审核、原文未变化、可直接 `i18n publish` 的受支持 TextAsset/
TypeTree string field 条目,以及需要先经过独立 repack 流程的 ZIP 内或其他不支持条目。
发布汉化 release
```bash
bat i18n publish \
--output /tmp/bat-resources \
--localized-output /tmp/bat-localized \
--translation-file /tmp/bat-workbench.json
```
发布接受当前实现支持的直接 TextAsset、TypeTree string field 和 managed-reference
string field 条目;zip 内 bundle 和其他不支持条目使用 `parse repack` 的 spec
单独处理。`--force` 不覆盖已有目录,而是生成独立的
`<official-release>-manual-<unix-seconds>` 汉化 release ID;也可以用
`--localized-release-id` 显式指定新 ID。因此强制发布仍保留旧 release 和 rollback
信息。
当前 `i18n run` 是离线工作流:刷新 TextUnit 队列,并可用 `--translation-file` 导出工作台;真实 provider 由单独的 worker 命令消费 `translation-tasks.sqlite`
运行一次 mock provider worker
```bash
bat i18n worker run \
--output /tmp/bat-resources \
--provider mock \
--worker-concurrency 8
```
`--provider` 支持 `mock``crowdin``mock` 可通过 `--translation-fixture`
读取本地 JSON fixture`crowdin` 从环境变量 `CROWDIN_PROJECT_ID`
`CROWDIN_LANGUAGE_ID``CROWDIN_API_TOKEN` 读取配置。worker 默认并发为 8,
范围 `1..=256`;每个 worker 完成当前任务后立即从 SQLite 队列领取下一项,
不会等待当前一批 worker 全部结束后再重新分配。
可用参数包括 `--worker-max-attempts``--worker-lease-seconds`
`--worker-retry-backoff` / `--worker-retry-backoff-seconds`
`--worker-max-tasks``--worker-id`。worker 支持 `--run-count`
`--watch`,因此可以单次、限定次数或周期执行;`--run-count > 1` 时仍必须
显式指定 `--interval`
外部 provider 或人工流程也可以用 `i18n task update` 回写当前 release 的任务状态:
```bash
bat i18n task update \
--state-dir /tmp/bat-state \
--task-id textunit/v-current/TextAssets/Scenario.json \
--task-status failed \
--failure-reason "provider rejected payload" \
--provider-run-id provider-run-1 \
--json
```
`--task-status` 支持 Rust contract 中的 `queued``running``failed`
`completed``skipped`;命令只更新当前 release 的
`translation-tasks.sqlite`,不会创建任意翻译任务。
任务查询和交接查询可以用 `bat i18n tasks` / `bat i18n handoff`
汉化发布状态可以用 `bat i18n status`。这些只读入口也可以写成
`bat translation tasks|handoff|status`,其中 `translation` / `translate`
是一级命令长别名。
需要把当前汉化 workflow 切到人工校对中时,可用:
```bash
bat i18n proofread \
--output /tmp/bat-resources \
--localized-output /tmp/bat-localized
```
该命令只改写 `localized-version-state.json` 中的工作流标记,不会改动已发布的
汉化 release 指针;如果自动汉化已经发布,后续仍可继续正常发布汉化资源。
## 持久化调度
每个一级工作流都可以管理自己的 schedule。调度计划保存在 `--state-dir/bat-schedules.json`,计划记录包含动作、参数、下一次执行时间、周期、剩余次数、启用状态和最近错误。
`parse schedule``res schedule` / `i18n schedule` 共用同一份计划库,
`--id` / `--action` 分别是 `--schedule-id` / `--schedule-action` 的简写。
新增一个每天执行的资源拉取计划:
```bash
bat res schedule add \
--state-dir /tmp/bat-schedule \
--schedule-id daily-pull \
--schedule-action pull \
--schedule-delay 1s \
--schedule-every 24h \
--schedule-arg --auto-discover \
--schedule-arg --output \
--schedule-arg /tmp/bat-resources
```
计划操作:
```bash
bat res schedule list --state-dir /tmp/bat-schedule
bat res schedule update --state-dir /tmp/bat-schedule --schedule-id daily-pull --schedule-every 12h
bat res schedule remove --state-dir /tmp/bat-schedule --schedule-id daily-pull
bat res schedule run --state-dir /tmp/bat-schedule
bat parse schedule list --state-dir /tmp/bat-schedule
```
`parse schedule add` 默认动作是 `run``i18n schedule add` 默认动作也是 `run`;可以用 `--schedule-action repack``--schedule-action publish` 选择对应动作。`--schedule-count` 限定执行次数,省略表示周期无限执行;没有 `--schedule-every` 的计划执行一次后自动停用。
`res/parse/i18n schedule list` 默认只显示对应一级命令的计划;也可以用
`--schedule-id``--schedule-enabled``--schedule-disabled` 过滤。计划删除和执行
会校验一级命令作用域,避免误操作其他工作流。`schedule update` 可以用
`--schedule-clear-every` 将周期计划改为单次计划;`schedule remove` 会删除计划。
`schedule run --force` 会忽略到期时间立即执行指定计划,`--schedule-max-runs N`
限制本轮最多执行 N 个到期计划。
## bat-api 调度与 dashboard 接口
内嵌 dashboard 由 `bat-api` 直接服务于 `GET /admin/dashboard/`。页面静态资产免
token 读取,但资源、调度、任务、日志、解析和翻译控制都通过 `bat-api` 转发到
Rust `bat.sock`,不维护第二份计划状态或翻译状态。Rust RPC 方法为:
- `schedule.list`
- `schedule.add`
- `schedule.update`
- `schedule.remove`
- `schedule.run`
- `task.list`
- `task.status`
- `task.logs`
- `task.cancel`
- `daemon.logs`
- `daemon.doctor`
- `parse.status`
- `parse.text_units`
- `parse.errors`
`bat-api` 对应接口为 `GET /admin/schedules`
`POST /admin/control/schedule-add|schedule-update|schedule-remove|schedule-run`
均要求配置 `BAT_API_AUTH_TOKEN` 并携带管理 token。列表接口支持 `id``group`
`enabled` query 过滤;请求字段沿用 Rust
contract`id``group``action``args``next_run_unix_seconds`
`delay_seconds``every_seconds``count``clear_args``clear_every`
`enabled``schedule.list` 额外接受 `id``group``enabled` 过滤,
`schedule.run` 额外接受 `group``force``max_runs`
任务和诊断接口同样要求管理 token:`GET /admin/diagnostics` 转发
`daemon.doctor``GET /admin/logs?tail=200` 转发 `daemon.logs`
`GET /admin/tasks``GET /admin/tasks/status?task_id=...`
`GET /admin/tasks/logs?task_id=...` 转发 `task.*` 查询。取消任务使用
`POST /admin/control/task-cancel`,请求字段为 `task_id`
解析查询接口为 `GET /admin/parse/status`
`GET /admin/parse/text-units``GET /admin/parse/errors`,均只读转发当前
Rust release 的 `parse.*` 数据。`text-units``errors` 支持 `offset`
`limit``destination``path_pattern``archive_entry``path_id``class_id`
`field_path``format` query`limit` 范围为 `1..=1000`
翻译任务状态可由已鉴权的 dashboard 通过 `GET /admin/translation/tasks`
查询,query 过滤项包括 `offset``limit``task_id``release_id`
`destination``path_pattern``archive_entry``status``worker_status`
`parse_status``format``has_reason``has_failure_reason`。完整 provider
run 交接视图通过 `GET /admin/translation/handoff` 查询。两个查询接口都只转发
Rust `translation.tasks` / `translation.handoff`,不在 Go 侧维护状态。
翻译任务状态也可由已鉴权的 dashboard 通过
`POST /admin/control/translation-task-update` 回写,请求字段为
`task_id``status`,以及可选的 `failure_reason``provider`
`provider_run_id``translation_results`;该接口只转发
`translation.task.update`。人工校对流程提交译文时必须使用 `status=completed`
并为每个 `translation_results[]` 提供 `unit_id``source_text`
`translated_text`Rust 会用当前 `official-textunit-index.json` 校验 unit、
source text、destination 和 archive entry 后再落库。
`POST /admin/control/translation-worker-run` 会触发 Rust 侧
`translation.worker.run`,请求字段为 `provider``fixture_path`
`concurrency``max_attempts``lease_seconds``retry_backoff_seconds`
`max_tasks``worker_id`。bat-api 只做鉴权、JSON 解码和基础范围校验;
任务状态、lease、重试和 provider 结果仍由 Rust 持久化。
`POST /admin/control/translation-proofread` 会把当前汉化 workflow 标记为人工校对中;
该接口只转发 `translation.proofread`,不会改动已发布汉化 release 指针。
## localized patch 发布与回滚
`i18n publish` 会在独立的 `.staging/<localized-release-id>` 中复制当前官方
release,校验工作台与当前 TextUnit 索引的 source/location 一致后,写入已有支持
范围内的 TextAsset、TypeTree string field 和 managed-reference string field
patch。校验通过后才原子切换 `localized/current`,并在 release manifest 中记录
源/目标 BLAKE3、字节数、patch kind、TextUnit、provider、review 和 rollback
信息。ZIP 内 bundle 不会被静默改写。
使用人工编辑的工作台发布:
```bash
bat i18n publish \
--translation-file /tmp/bat-workbench.json \
--localized-release-id release-manual-1
```
使用已完成 provider worker 的译文结果发布:
```bash
bat i18n publish \
--from-worker \
--localized-release-id release-worker-1
```
发布失败会清理 staging,不切换 `current`。当前 release 的 rollback 目标由
manifest 记录,执行后删除本次版本目录并恢复上一版本;没有上一版本时移除
`current`
```bash
bat i18n rollback --localized-release-id release-worker-1
```
Rust RPC 方法为 `localized.publish``localized.rollback`bat-api 对应为
`POST /admin/control/localized-publish``POST /admin/control/localized-rollback`
以及鉴权的 `GET /admin/translation/status`。发布请求必须且只能包含
`translation_file``from_worker=true`rollback 可省略 release ID 以操作当前
release。Go 只做鉴权、参数校验和转发,状态与产物仍由 Rust 持有。
## 边界
解析器新增类型覆盖和新的解析格式当前按路线图推进;新增覆盖仍需通过真实 fixture、回归测试和文档同步验收,不要只靠合成样本宣称能力。
+227 -125
View File
@@ -4,103 +4,84 @@
BlueArchive Toolkit 的部署文档分为当前可用模式和目标模式:
1. **本地开发模式**代码在本地,连接本地或远程数据库。
2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch`
3. **完整单机/分布式部署**:尚未提供。API Server、数据库迁移和 Web 未实现前,不把它作为可执行部署方案
1. **本地开发模式**当前 Rust `bat` 和 Go `bat-api` 不依赖 PostgreSQL/Redis
本地资源状态使用文件和 SQLite
2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch` 或 RPC/daemon 模式
3. **bat-api 资源 bootstrap / 分发服务**:当前可用,和 Rust `bat` 在同一服务器/容器环境运行,经 `bat.sock` RPC 获取当前 `resource_root`
4. **可选数据库开发环境**PostgreSQL/Redis 只服务于未来的 Go 服务层、Translation
Memory、Glossary 和完整 Provider 扩展,不是当前 `bat` / `bat-api` 的生产运行依赖。
5. **完整单机/分布式部署**:尚未提供。完整游戏业务 API、数据库迁移和 Web 未实现前,不把它作为可执行部署方案。
---
## 模式 1:本地开发 + 远程数据库
## 模式 1:本地开发(当前推荐)
适用场景:本地开发,数据库部署在有公网 IP 的远程服务器
### 步骤
#### 1. 在远程服务器上部署数据库
当前实现不要求启动 PostgreSQL 或 Redis。建议先运行 Rust/Go 自身的门禁:
```bash
# SSH 登录到服务器
ssh user@your.server.com
# 创建部署目录
mkdir -p ~/bat/deployments
cd ~/bat/deployments
# 上传配置文件(在本地执行)
scp -r deployments/* user@your.server.com:~/bat/deployments/
# 配置环境变量
cp .env.example .env
nano .env # 设置强密码
# 启动数据库
docker compose -f docker-compose.remote-db.yml up -d
# 查看状态
docker compose -f docker-compose.remote-db.yml ps
cargo check --workspace --locked
make test-go-api
make build-go-api
make check-docs
```
#### 2. 配置防火墙
只有在开发未来 Go 服务层或目标数据库适配时,才需要启动可选的本地数据库:
```bash
# 开放 PostgreSQL 端口
sudo ufw allow 5432/tcp
# 开放 Redis 端口
sudo ufw allow 6379/tcp
# 查看状态
sudo ufw status
docker compose -f deployments/docker-compose.dev.yml --profile local-db up -d
```
#### 3. 本地连接配置
本地数据库端口默认只绑定 `127.0.0.1`,不应改为 `0.0.0.0`
在本地项目根目录创建 `.env`
---
## 模式 2:可选数据库开发环境(目标能力)
PostgreSQL 和 Redis 不是当前 `bat` / `bat-api` 的生产运行依赖。本模式只用于未来
服务层、Translation Memory、Glossary 或 Provider 扩展的开发验证,不能作为当前
资源同步或资源分发的部署前置条件。
### 远程开发连接
远程开发默认使用私网地址、VPN 或 SSH tunnel。不要为开发方便把 PostgreSQL
`5432` 或 Redis `6379` 暴露到公网;尤其不得把 Redis 公网暴露作为推荐方案。
在远程主机上启动可选数据库后,优先通过 SSH tunnel 连接:
```bash
ssh -N \
-L 15432:127.0.0.1:5432 \
-L 16379:127.0.0.1:6379 \
user@db-host
```
本地开发进程只连接 tunnel 的回环端口:
```env
DB_HOST=your.server.ip.address
DB_PORT=5432
DB_USER=bat_user
DB_PASSWORD=your_secure_password
DB_NAME=bluearchive_toolkit
REDIS_HOST=your.server.ip.address
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password
DB_HOST=127.0.0.1
DB_PORT=15432
REDIS_HOST=127.0.0.1
REDIS_PORT=16379
```
#### 4. 测试连接
如果使用 VPN 或私网直连,应限制数据库服务仅监听明确的私网接口和允许的来源
网段,并继续使用认证与 TLS。不要添加面向全网的 `5432` / `6379` 防火墙放行规则。
远程主机上的可选 Compose 服务:
```bash
# 测试 PostgreSQL 连接
psql -h your.server.ip.address -U bat_user -d bluearchive_toolkit
# 测试 Redis 连接
redis-cli -h your.server.ip.address -p 6379 -a your_redis_password ping
docker compose -f deployments/docker-compose.remote-db.yml up -d
docker compose -f deployments/docker-compose.remote-db.yml ps
```
---
## 模式 2:本地数据库(开发)
适用场景:完全本地开发,不需要远程服务器
```bash
# 启动本地数据库
docker compose -f deployments/docker-compose.dev.yml --profile local-db up -d
# 配置 .env
DB_HOST=localhost
DB_PORT=5432
REDIS_HOST=localhost
REDIS_PORT=6379
```
该 Compose 配置默认仅在远程主机回环地址发布端口,远程访问应通过 SSH tunnel、
VPN 或受控私网,不通过公网端口直连。
---
## 模式 3:官方资源同步生产任务
当前可部署的生产任务是 Rust 官方资源同步 binary。API Server 和 Web 尚未实现,不能按完整服务端产品部署。
当前可部署的生产同步任务是 Rust 官方资源同步 binary。`bat-api` 资源 bootstrap / 分发服务见模式 4;完整游戏业务 API 和 Web 尚未实现,不能按完整服务端产品部署。
### 构建 release binary
@@ -198,14 +179,14 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat \
--no-progress
```
### 推荐模式:systemd 托管 `--watch`
### 推荐模式:纯同步时 systemd 托管 `--watch`
生产推荐让 systemd 直接托管前台 `--watch` 进程,而不是在 systemd 里再启动 `--daemon`。原因:
只需要远程长期同步资源、暂不部署 `bat-api` 时,推荐让 systemd 直接托管前台 `--watch` 进程,而不是在 systemd 里再启动 `--daemon`。原因:
- systemd 能直接追踪主进程、退出码、重启次数和 stop 信号。
- 日志进入 journald,用 `journalctl` 管理,不依赖 `bat-daemon.log`
- Rust 内部已经负责 1 小时间隔、北京时间固定强制刷新和失败快速重试,systemd 不需要 timer。
- `bat --daemon` 的 Unix socket RPC 适合没有进程管理器的 shell/container 场景systemd 场景下用 `systemctl``journalctl``bat verify/doctor` 运维即可。
- `bat --daemon` 的 Unix socket RPC 适合 shell/container 场景,也适合给同环境运行的 `bat-api` 提供 release 发现;纯同步 systemd 场景下用 `systemctl``journalctl``bat verify/doctor` 运维即可。
安装 unit 和可选环境文件:
@@ -227,10 +208,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 +222,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
@@ -250,10 +233,12 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat reload --state-dir /var/lib/bluearc
sudo -u bat /opt/bluearchive-toolkit/bin/bat stop --state-dir /var/lib/bluearchive-toolkit/daemon-state
```
`--daemon` 会在 `--state-dir` 下创建 `bat.sock``bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl` 和短生命周期的 `bat-control.lock``bat.sock` 是 Unix socket JSON-RPC 控制通道;`status``stop``logs``reload`默认形态的 `refresh` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-status.json` 会暴露最后成功时间、下次检查时间、最后错误摘要和当前下载进度;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`
`--daemon` 会在 `--state-dir` 下创建 `bat.sock``bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl` 和短生命周期的 `bat-control.lock``bat.sock` 是 Unix socket JSON-RPC 控制通道;`status``stop``restart``logs``reload`默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-status.json` 会暴露最后成功时间、下次检查时间、最后错误摘要和当前下载进度;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`restart` 会通过 Rust lifecycle controller 复用 CLI restart 路径替换后台进程;`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`
不要同时运行 systemd `--watch` 和 standalone `--daemon` 指向同一个 `--output`。二者都会被资源锁和 live daemon 互斥保护,但生产运维上应保持单一 owner。
如果同一台服务器还要运行 `bat-api`,必须让 Rust `bat` 以能提供 `bat.sock` 的 RPC 形态运行,并让 `bat-api` 通过该 socket 获取当前 `resource_root`。这种部署见模式 4;不要把 `BAT_API_RESOURCE_ROOT` 当作生产主配置。
### 日志和状态路径
systemd 模式:
@@ -262,9 +247,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`
- 汉化 releasePatch 发布后):`/var/lib/bluearchive-toolkit/localized/current`
- 运行期目录:`/run/bluearchive-toolkit/`
standalone `--daemon` 模式:
@@ -279,15 +266,15 @@ 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
```
如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套同步参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程;带 `--output`、server-info、connection-group、app-version、platforms、snapshot、curlunzip 等显式参数时,`refresh` 会作为一次性前台同步运行,但不能写入 live daemon 正在管理的同一资源目录,否则会返回 locked。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`repair` 会走官方同步链路重新下载必要文件,但同样不能和 live daemon 并行写同一资源目录;`clean-stable` 只清理 `.part``.tmp`、失效或损坏的 PID/socket/锁,不删除正式资源。
如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套同步参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程;默认形态的 `repair` 会通过 `resource.repair` RPC 入队本地 manifest 审计+修复任务并返回 `task_id``--output`、server-info、connection-group、app-version、platforms、snapshot、curlunzip 或其它显式同步参数时,`refresh` / `repair` 会作为一次性前台命令运行,但不能写入 live daemon 正在管理的同一资源目录,否则会返回 locked。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`clean-stable` 只清理 `.part``.tmp`、失效或损坏的 PID/socket/锁,不删除正式资源。
### 升级
@@ -347,77 +334,192 @@ sudo -u bat tar -C /var/lib/bluearchive-toolkit/official \
---
## 模式 4完整生产环境部署
## 模式 4bat-api 资源 bootstrap / 分发服务
当前不可用。API Server、数据库迁移、Web 管理后台和发布编排尚未实现;不要按完整服务端产品部署本仓库
适用场景:真实 Rust `bat` 长期运行在生产主机,并且同一主机/容器环境内运行 Go `bat-api`,给客户端、补丁器或上层工具提供启动前资源入口和 CDN path 只读分发
---
核心约束:
## 数据库备份
1. `bat-api` 与 Rust `bat` 同环境部署,至少要能访问同一个 Unix socket 和同一个已发布资源文件系统。
2. 当前资源目录由 `bat.sock` RPC 返回的 `resource_root` 决定;生产不要在 `bat-api` 配置里写死 `BAT_API_RESOURCE_ROOT`
3. `BAT_API_RESOURCE_ROOT` 只用于本地 fixture、临时只读诊断或 RPC 不可用时的应急验证。
4. `bat.sock` 只在服务器本机使用,不通过公网暴露;对外只发布 HTTP `bat-api`,生产建议放在反向代理和 TLS 后面。
5. 本地开发环境不需要官方全量下载;使用 Go 单测、fixture release 和 `make bat-api-local-live-smoke`。该 smoke 在本地 `/tmp` 隔离目录启动真实 Rust daemon,不连接远程服务器。
### 手动备份
### 构建和安装 bat-api
```bash
# PostgreSQL
pg_dump -h your.server.com -U bat_user -d bluearchive_toolkit > backup.sql
make build-go-api
# Redis
redis-cli -h your.server.com -p 6379 -a password BGSAVE
VERSION="$(git rev-parse --short HEAD)"
sudo install -d -o root -g root -m 0755 \
/opt/bluearchive-toolkit/releases/"${VERSION}" \
/opt/bluearchive-toolkit/bin
sudo install -o root -g root -m 0755 \
bin/bat-api \
/opt/bluearchive-toolkit/releases/"${VERSION}"/bat-api
sudo ln -sfn \
/opt/bluearchive-toolkit/releases/"${VERSION}"/bat-api \
/opt/bluearchive-toolkit/bin/bat-api
/opt/bluearchive-toolkit/bin/bat-api --help
```
### 自动备份
如果 Rust `bat` 和 Go `bat-api` 使用同一个 release 目录发布,也可以把二者放在同一个 `<version-or-git-sha>` 目录下,分别通过 `/opt/bluearchive-toolkit/bin/bat``/opt/bluearchive-toolkit/bin/bat-api` 暴露稳定 symlink。
启动备份服务:
```bash
docker compose -f deployments/docker-compose.remote-db.yml --profile backup up -d
```
### bat 侧前置条件
备份文件位置:`deployments/backups/`
---
## 监控
### 查看日志
`bat-api` 依赖 live RPC,而不是直接读取 daemon 状态文件。部署 `bat-api` 前,部署所在生产主机上应已有 socket 形态的 Rust `bat`
```bash
# 数据库日志
docker logs bat-postgres
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
# Redis 日志
docker logs bat-redis
sudo -u bat /opt/bluearchive-toolkit/bin/bat status \
--state-dir /var/lib/bluearchive-toolkit/daemon-state
```
确认 socket 存在:
```bash
sudo -u bat test -S /var/lib/bluearchive-toolkit/daemon-state/bat.sock
```
不要同时再运行一个 `--watch` service 指向 `/var/lib/bluearchive-toolkit/official`。如果当前服务器已经部署了 `bluearchive-toolkit-official-sync.service` 的纯同步 `--watch` 模式,需要先切换为 socket/RPC 形态,再启用 `bat-api`
### 安装 bat-api systemd unit
```bash
sudo install -o root -g root -m 0644 \
deployments/systemd/bluearchive-toolkit-bat-api.service \
/etc/systemd/system/bluearchive-toolkit-bat-api.service
sudo install -o root -g root -m 0644 \
deployments/systemd/bat-api.env.example \
/etc/bluearchive-toolkit/bat-api.env
sudo systemctl daemon-reload
sudo systemctl enable --now bluearchive-toolkit-bat-api.service
```
默认配置只监听本机:
```env
BAT_API_LISTEN=127.0.0.1:18080
BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080
BAT_API_SOCKET=/var/lib/bluearchive-toolkit/daemon-state/bat.sock
BAT_API_REFRESH_INTERVAL=1m
BAT_API_ACCESS_LOG=true
BAT_API_RATE_LIMIT_RPS=30
BAT_API_RATE_LIMIT_BURST=120
```
生产反向代理公开后,把 `BAT_API_PUBLIC_BASE_URL` 改成客户端实际访问的 HTTPS 根,例如:
```env
BAT_API_PUBLIC_BASE_URL=https://assets.example.com
```
面对玩家分发时还应通过 secret manager 或 systemd credential 注入:
```env
BAT_API_AUTH_TOKEN=<secret>
BAT_API_AUTH_QUERY_PARAM=bat_token
BAT_API_AUTH_EXEMPT_PATHS=/healthz,/readyz
BAT_API_MAX_RESOURCE_LIMIT=1000
```
反代必须强制 HTTPS,并在转发到 `bat-api` 前清洗客户端提交的 `X-Forwarded-For` / `X-Real-IP`。只有确认反代会覆盖这些 header 时,才设置:
```env
BAT_API_TRUST_PROXY_HEADERS=true
```
否则保持默认 `false``bat-api` 会按 TCP peer IP 做限流和日志归因。应用层访问日志只记录 path,不记录 query string,避免 query token 进入日志。动态 JSON 响应使用 `Cache-Control: no-store`;CDN 字节路径仍使用长期 immutable 缓存。
不要在生产 env 里设置 `BAT_API_RESOURCE_ROOT``bat-api` 会按 `BAT_API_REFRESH_INTERVAL` 周期通过 RPC 重新读取 `catalog.status` / `resource.manifest`,从而跟随 Rust `bat` 切换 `current -> versions/<id>`
### 健康检查
```bash
# 检查容器状态
docker compose -f deployments/docker-compose.remote-db.yml ps
# 检查 PostgreSQL
docker exec bat-postgres pg_isready -U bat_user
# 检查 Redis
docker exec bat-redis redis-cli ping
systemctl status bluearchive-toolkit-bat-api.service
journalctl -u bluearchive-toolkit-bat-api.service -f
curl -fsS http://127.0.0.1:18080/healthz
curl -fsS http://127.0.0.1:18080/readyz
curl -fsS http://127.0.0.1:18080/v1/bootstrap
curl -fsS http://127.0.0.1:18080/v1/launcher/bootstrap
curl -fsS http://127.0.0.1:18080/api-launcher-jp.yo-star.com/api/launcher/game/config
curl -fsS http://127.0.0.1:18080/openapi.yaml
curl -fsS http://127.0.0.1:18080/admin/
```
`/healthz` 是 liveness,固定返回服务存活状态,并包含最近一次 RPC refresh 的开始时间、成功时间、耗时、warning 和错误摘要。`/readyz` 是 readiness,当前没有可分发 release 时返回 `503``rpc_available=true``ready=true` 表示 `bat-api` 已经通过 RPC 发现可分发 release`ready=false` 时,先检查 `bat.sock`、Rust `bat status``resource_root` 是否存在,以及 `official-download-manifest.json` 中的文件是否仍在磁盘上。
`/v1/launcher/bootstrap``/api-launcher-jp.yo-star.com/api/launcher/...` 只用于 launcher 资源 metadata / GameMainConfig 引导兼容。它们从 Rust `bat` 的已发布 snapshot/RPC 派生响应,显式标记不是完整 package update manifest;生产排障时应确认这些响应中的 `scope=resource_bootstrap_only``resource_bootstrap_url`、server-info URL 和 client-patch base 是否指向当前 `BAT_API_PUBLIC_BASE_URL`
### 本地开发限制
开发机不能本地全量运行 `bat` 时,不需要伪造生产资源目录。Go 侧改动用单测和 fixture 验证:
```bash
make test-go-api
make build-go-api
BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
--listen 127.0.0.1:18080 \
--public-base-url http://127.0.0.1:18080 \
--resource-root internal/api/testdata/release \
--refresh-interval 0
```
这条本地命令只验证 HTTP 形态、server-info 改写、CDN path、Range/缓存语义和管理接口;同机 live 联调使用 `make bat-api-local-live-smoke`,真实官方网络下载则使用独立的 `make official-smoke`
---
## 故障排查
## 模式 5:完整生产环境部署
### 无法连接数据库
完整游戏业务生产环境当前不可用。`bat-api` 资源 bootstrap/分发服务和 Rust
官方资源同步任务已经可以按模式 3/4 部署;数据库迁移、Web 管理后台、发布编排
以及完整游戏业务 API 尚未实现,因此不要按完整服务端产品部署本仓库。
1. 检查防火墙是否开放端口
2. 检查 `pg_hba.conf` 配置
3. 检查密码是否正确
4. 检查数据库是否启动
---
### 性能问题
## 可选数据库环境的备份与监控
1. 查看数据库连接数
2. 检查慢查询日志
3. 优化索引
4. 调整数据库参数
以下内容只适用于未来服务层使用的可选 PostgreSQL/Redis 环境,不属于当前
`bat` / `bat-api` 生产部署步骤。
### 备份
备份应在数据库主机或受控私网内执行,也可以通过 SSH 在远程主机上运行容器内工具:
```bash
ssh user@db-host \
'docker exec bat-postgres pg_dump -U bat_user bluearchive_toolkit' \
> backup.sql
docker compose -f deployments/docker-compose.remote-db.yml --profile backup up -d
```
Redis 备份使用数据库主机或容器内的受控备份工具。不要在脚本或文档中使用带公网
主机名的 `redis-cli -h ... -p 6379` 连接,也不要把密码放进公开命令行参数或提交文件。
### 监控
```bash
ssh user@db-host 'docker compose -f deployments/docker-compose.remote-db.yml ps'
ssh user@db-host 'docker logs bat-postgres'
ssh user@db-host 'docker logs bat-redis'
```
### 故障排查
当前 `bat` / `bat-api` 无需数据库连接;资源同步故障应先检查 `bat.sock`、发布目录、
SQLite 索引和 Rust daemon 状态。未来服务层出现数据库连接问题时,按以下顺序检查:
1. 私网、VPN 或 SSH tunnel 是否可用;
2. 本地连接端口是否为 tunnel 映射或受控私网端口;
3. 数据库认证、TLS 和允许网段配置;
4. 数据库容器是否运行。
---
+208 -6
View File
@@ -8,7 +8,7 @@
#### Go
```bash
# 安装 Go 1.22+
# 安装 Go 1.26.4+
# 参考:https://golang.org/doc/install
go version # 验证安装
@@ -23,6 +23,21 @@ rustc --version # 验证安装
cargo --version
```
#### 自托管 Gitea runner
`.gitea/workflows/bat.yml` 使用 `runs-on: linux`,并且不依赖 `actions/checkout``dtolnay/rust-toolchain` 等外部 GitHub Action。runner 需要在执行环境中预装以下命令:
```bash
git --version
rustc --version
cargo --version
rustfmt --version
cargo clippy --version
go version
```
该 workflow 会用 `GITHUB_SERVER_URL``GITHUB_REPOSITORY``GITHUB_REF``GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的格式化、检查、构建、clippy 和测试,以及 Go API 门禁和文档状态门禁。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。
#### Docker
```bash
# 安装 Docker 和 Docker Compose
@@ -103,6 +118,10 @@ git push origin feature/your-feature-name
禁止使用 demo、临时实现、硬编码路径或只为当前测试通过的伪实现。确实未完成的能力应写入当前缺口文档,而不是用 `TODO``FIXME` 隐藏。
### 解析模块状态
UnityFS / AssetBundle / Addressables / TypeTree 解析当前按路线图继续推进。新增解析类型、扩大解析覆盖和写入型解析 RPC/CLI 仍需遵守现有接口边界、真实 fixture 和回归验收要求。
### Go
- 遵循 [Effective Go](https://golang.org/doc/effective_go)
- 使用 `gofmt` 格式化
@@ -124,12 +143,38 @@ git push origin feature/your-feature-name
### 合并前通用门禁
```bash
cargo fmt --check
cargo fmt --all -- --check
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
make test-go-api
make build-go-api
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
make check-docs
```
Go CLI 尚未实现时,`go test ./...` 可能没有产品级 package 可运行;Makefile 会在空 Go 阶段清晰跳过。
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
- **同步/运维命令行** = Rust `bat`(近乎全自动)
- **资源 bootstrap/分发服务与内嵌 dashboard** = `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`
`bat-api` 与 Rust `bat` 的生产拓扑是同一主机、同一容器或同一共享文件系统。开发时优先使用隔离 fixture 和本地 `bat.sock` live smoke,不连接远程服务器,也不读取现有客户端目录:
```bash
make test-go-api
make bat-api-local-live-smoke
BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
--listen 127.0.0.1:18080 \
--public-base-url http://127.0.0.1:18080 \
--resource-root internal/api/testdata/release \
--refresh-interval 0
```
其中 `make bat-api-local-live-smoke` 会在 `/tmp` 中启动真实 Rust daemon 和 Go API,覆盖 release 切换、清单不完整、无 release、RPC 断开、server-info、CDN Range/缓存和路径越界;报告保留在该次 smoke 的临时目录。单独的 `--resource-root` 命令只验证 fixture HTTP 形态,不替代 live socket 联调。浏览器检查内嵌 dashboard 时打开 `http://127.0.0.1:18080/admin/dashboard/`,再在页面内填入管理 token。
生产默认路径仍是 `--socket` / `BAT_API_SOCKET`,资源根由 Rust `bat` RPC 返回;`--resource-root` 只用于上述 fixture 或应急只读诊断。
### 常用聚焦命令
@@ -137,6 +182,7 @@ Go CLI 尚未实现时,`go test ./...` 可能没有产品级 package 可运行
cargo test -p bat-core -- --nocapture
cargo test -p bat-adapters -- --nocapture
cargo test -p bat-ffi -- --nocapture
cargo test -p bat-patch -- --nocapture
cargo test -p bat-infrastructure -- --nocapture
cargo test -p bat-infrastructure --bin bat -- --nocapture
cargo clippy -p bat-core -p bat-adapters -p bat-infrastructure --all-targets -- -D warnings
@@ -144,7 +190,9 @@ 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 CLI 和生产同步默认应通过 `bat --json` 进程边界集成
`bat-ffi` 只是可选无状态 C ABI 兼容层。修改 FFI 时必须运行 `cargo test -p bat-ffi -- --nocapture`Go 服务层默认经 `internal/backendrpc` 调 daemon;同步任务由 Rust `bat` 执行,不由 Go 试验 CLI 承担
Rust `bat` 的资源拉取、解析、翻译工作流、重打包、汉化发布和持久化调度命令见 [`docs/guides/bat-workflows.md`](bat-workflows.md)。推荐使用 `res``parse``i18n` 三个一级命令。
### 集成测试
@@ -170,7 +218,158 @@ 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` 或其他隔离目录,不要写入现有资源目录,也不要把汉化输出覆盖到官方原版资源目录
官方 release 拉取并校验完成后会在当前 release 根目录维护
`official-resource-changes.json``crowdin-translation-handoff.json`
`official-parse-cache.json``official-textunit-index.json`,随后从
Added/Modified 资源、parse cache 与 TextUnit 明细索引派生
`official-textunit-tasks.json``crowdin-textunit-queue.json`
`translation-tasks.sqlite``translation-handoff.json`。本地已有旧完整
版本时,新版本发布后会先按 manifest destination 对比旧/新 release,只把新增和
内容变更的资源写入解析与 Crowdin handoff;删除资源只记录差异,不进入翻译队列。
up-to-date 轮询发现本地文件、解析缓存、TextUnit 明细索引和 TextUnit 队列未变时不会重复解析。
Crowdin 队列当前只落本地文件,不发网络请求。
官方下载服务默认使用 8 个有界 worker,`--download-concurrency` /
`BAT_DOWNLOAD_CONCURRENCY` 只接受 `1..=256`。worker 完成一个 URL 后立即从共享
队列领取下一个任务;finished 进度按实际完成顺序即时上报,完成计数单调递增,
最终 report 的资源列表仍按 pull plan 顺序。需要验证顺序模式时显式使用
`--download-concurrency 1`。本文档中的真实资源命令仅是隔离 runbook;本地轻量
验证应使用 fake-curl/fixture,不要在开发机执行真实下载或 smoke run。
新 release 会在网络下载前扫描已发布 release 的下载 manifest,按规范化
destination 查找候选,并重新验证 size、BLAKE3 和 ZIP 结构。命中后优先硬链接,
跨文件系统时回退为 staging 内临时文件复制和原子 rename;历史 release 保持不可变。
历史候选不满足校验时才尝试既有 CAS 对象。CAS 复用会记录
`official-cas-reuse-references.json`,清理 staging/release 时递减引用;损坏、缺失
或元数据不一致会记录诊断并回退网络。报告和进度分别暴露
`release_reused_count``cas_reused_count``reused_bytes`
`transferred_bytes` 以及 `release_reused` / `cas_reused` / `downloaded` 状态。
需要把已校验官方 release 导入 CAS + `ResourceRepository` 时,显式启用:
```bash
cargo run -p bat-infrastructure --bin bat -- \
--auto-discover \
--import-repository \
--import-cas-root /tmp/bat-test.cas \
--import-resource-db /tmp/bat-test-resources.sqlite
```
对应 `.env` / 环境变量键为 `BAT_IMPORT_REPOSITORY`
`BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB`。只读查询命令:
```bash
cargo run -p bat-infrastructure --bin bat -- parse-status
cargo run -p bat-infrastructure --bin bat -- parse-text-units --limit 50
cargo run -p bat-infrastructure --bin bat -- parse-errors --limit 50
cargo run -p bat-infrastructure --bin bat -- translation-tasks --task-status skipped_parse_failed --has-reason --limit 50
cargo run -p bat-infrastructure --bin bat -- translation-tasks --worker-status failed --has-failure-reason --limit 50
cargo run -p bat-infrastructure --bin bat -- translation-handoff
cargo run -p bat-infrastructure --bin bat -- i18n worker run --provider mock --worker-concurrency 8 --worker-max-tasks 10
cargo run -p bat-infrastructure --bin bat -- localized-status
cargo run -p bat-infrastructure --bin bat -- resource-index --limit 50
cargo run -p bat-infrastructure --bin bat -- resource-index --release-id <ID> --platform windows --archive-entry <PATH> --format json --limit 50
```
历史 release/CAS 复用的隔离回归测试:
```bash
cargo test -p bat-infrastructure official_download::tests::reuses_verified_historical_release_when_cdn_root_changes -- --nocapture
cargo test -p bat-infrastructure official_download::tests::falls_back_to_cas_after_corrupt_historical_release -- --nocapture
cargo test -p bat-infrastructure official_download::tests::corrupted_cas_falls_back_to_network_with_diagnostic -- --nocapture
```
`parse-status` 会额外显示 TextUnit 明细索引和队列摘要;`parse-text-units` /
`parse-errors` 可按 destination、archive entry、path id、class id、field path
和 format 分页查询当前官方 release 的 TextUnit 明细与解析错误;
`translation-tasks` 可按 release、destination、archive entry、队列任务状态、provider
worker 状态、parse status、TextUnit format、队列 reason 和 provider failure reason
查询离线 TextUnit 翻译任务状态与跳过/失败原因;发布后的状态保存在当前 release
根目录的 `translation-tasks.sqlite`,旧 release 没有状态库时回退到 JSON 队列;
`i18n worker run` / `translation.worker.run` 会由 Rust provider worker 独立 claim
下一项任务并落库 lease、失败分类、重试计划和 TextUnit 级译文结果,默认并发为 8,
范围 `1..=256`
`translation-handoff` / `translation.handoff` 会动态合并版本化
`translation-handoff.json` 与 SQLite 状态,返回 job、unit、provider run 的完整交接
视图;
`resource-index` 返回的资源 JSON 包含 release、平台、bundle path、TextAsset 和 TextUnit metadata
以及 Addressables entry 的 provider ID、bundle name、hash、size、CRC 和依赖关系;
并可按 release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 做资源级过滤;
`localized-status` 只有在 `localized-version-state.json``current` symlink 和
`localized-patch-manifest.json` 都匹配当前官方 release 时才返回 `localized`
当 workflow 被 `translation.proofread` 标记为人工校对中时,会额外返回
`translation_workflow_status=manual_proofreading`
`translation_workflow_status_code=translation.manual_proofreading`,但不会遮蔽已发布的汉化 release。
文件级写入命令只处理显式输入/输出文件,不切换官方或汉化 release:
```bash
cargo run -p bat-infrastructure --bin bat -- patch-apply \
--patch-kind text \
--source-file /tmp/bat-source.txt \
--patch-file /tmp/bat-source.text-patch.json \
--target-file /tmp/bat-target.txt
cargo run -p bat-infrastructure --bin bat -- unityfs-patch-text-asset \
--bundle-file /tmp/source.bundle \
--serialized-file CAB-Example \
--object-path-id 1 \
--replacement-file /tmp/replacement.bytes \
--target-file /tmp/target.bundle
cargo run -p bat-infrastructure --bin bat -- unityfs-patch-string-field \
--bundle-file /tmp/source.bundle \
--serialized-file CAB-Example \
--object-path-id 1 \
--string-field-path message \
--replacement-text "老师" \
--target-file /tmp/target.bundle
cargo run -p bat-infrastructure --bin bat -- unityfs-patch-field \
--bundle-file /tmp/source.bundle \
--serialized-file CAB-Example \
--object-path-id 1 \
--field-path scores[1] \
--expected-json '{"kind":"signed","value":20}' \
--replacement-json '{"kind":"signed","value":42}' \
--target-file /tmp/target.bundle
cargo run -p bat-infrastructure --bin bat -- unityfs-patch-field \
--bundle-file /tmp/source.bundle \
--serialized-file CAB-Example \
--object-path-id 1 \
--field-path difficulty \
--expected-json '{"kind":"enum","value":{"type_name":"ScenarioDifficulty","storage_type":"int","value":2}}' \
--replacement-json '{"kind":"enum","value":{"type_name":"ScenarioDifficulty","storage_type":"int","value":3}}' \
--target-file /tmp/target.bundle
cargo run -p bat-infrastructure --bin bat -- unityfs-patch-field \
--bundle-file /tmp/source.bundle \
--serialized-file CAB-Example \
--object-path-id 1 \
--field-path target_layers \
--expected-json '{"kind":"bit_field","value":{"type_name":"LayerMask","storage_type":"UInt32","bits":5}}' \
--replacement-json '{"kind":"bit_field","value":{"type_name":"LayerMask","storage_type":"UInt32","bits":9}}' \
--target-file /tmp/target.bundle
cargo run -p bat-infrastructure --bin bat -- unityfs-patch-field \
--bundle-file /tmp/source.bundle \
--serialized-file CAB-Example \
--object-path-id 1 \
--field-path messages \
--replacement-json '{"kind":"array","value":[{"kind":"string","value":"你好"},{"kind":"string","value":"老师"}]}' \
--target-file /tmp/target.bundle
cargo run -p bat-infrastructure --bin bat -- unityfs-patch-field \
--bundle-file /tmp/source.bundle \
--serialized-file CAB-Example \
--object-path-id 1 \
--field-path texts \
--replacement-json '{"kind":"map","value":[{"kind":"object","value":[{"name":"first","value":{"kind":"string","value":"jp"}},{"name":"second","value":{"kind":"string","value":"你好"}}]}]}' \
--target-file /tmp/target.bundle
```
生产或 CI 环境不得依赖安装官方启动器。需要启动器信息时,只能分析启动器资源、官方 manifest 或公开更新数据,并将解析结果固化为可验证流程。
@@ -215,7 +414,10 @@ cargo fetch
### 3. FFI 兼容层问题
`bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。默认 Go CLI 集成优先运行 Rust `bat --json`
`bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。当前 Go 正式产品入口是
`bat-api` 资源 bootstrap/分发服务,默认通过 `internal/backendrpc` 调用 daemon RPC
`cmd/bat` 仍是试验 CLI。新的 Go 集成优先使用 Rust `bat.sock` RPC 或稳定 SDK
`bat --json` 仅是 Rust CLI 的机器输出形态。
重新构建兼容库:
```bash
+3 -3
View File
@@ -69,10 +69,10 @@ cargo build --release -p bat-infrastructure --bin bat
脚本会在关键步骤后自动检查:
- 首次全量拉取 stderr log 包含总体下载进度、单文件进度和校验结果。
- 首次全量拉取 stderr log 包含下载已完成计数、单文件进度和校验结果。
- 二次运行 stdout JSON 包含 `update_status=up_to_date`
- repair stdout JSON 包含 `command=repair``status=completed`
- repair stderr log 包含总体下载进度、单文件进度和校验结果。
- repair stderr log 包含下载已完成计数、单文件进度和校验结果。
- repair 后 verify stdout JSON 包含 `healthy=true`
## 环境变量
@@ -96,4 +96,4 @@ cargo build --release -p bat-infrastructure --bin bat
- `03-second-up-to-date.stdout.json``update_status``up_to_date`
- `04-repair-after-damage.stdout.json` 中 repair 完成,且有重新下载或修复行为。
- `05-verify-after-repair.stdout.json``healthy``true`
- `02-first-full-pull.stderr.log``04-repair-after-damage.stderr.log` 中包含下载总体进度、单文件进度和校验结果日志。
- `02-first-full-pull.stderr.log``04-repair-after-damage.stderr.log` 中包含下载已完成计数、单文件进度和校验结果日志。
+28 -12
View File
@@ -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` 等控制命令。生产资源输出目录必须是独立目录;需要覆盖时用 `--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` 权限创建。
@@ -97,9 +97,9 @@ Linux 生产运行时链路只走官方日服 HTTP 资源,不安装、不启
2. 请求官方 `server-info`
3. 生成 Windows + Android 的官方资源 discovery 端点。
4. 拉取 seed catalog,生成完整官方 pull plan。
5. dry-run 只输出 URL;非 dry-run 下载全部官方 URL 到 staging,验收完成后原子发布到 `current`
5. dry-run 只输出 URL;非 dry-run 下载全部官方 URL 到 staging,验收完成后原子发布到 `current`,并在 release 中写入 `official-launcher-bootstrap.json`
`--auto-discover` 会下载官方 metadata,并按官方 manifest 临时获取 `resources.assets` 解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装官方启动器,也不会执行官方启动器进程。`--launcher-bootstrap` 只是旧命名兼容别名,新流程不要再推荐使用。
`--auto-discover` 会下载官方 metadata记录 launcher API 返回的 game config、CDN config、remote manifest 文件列表和选中的 `resources.assets` 来源,并按官方 manifest 临时获取 `resources.assets` 解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装官方启动器,也不会执行官方启动器进程。`--launcher-bootstrap` 只是旧命名兼容别名,新流程不要再推荐使用。
## 2. 可选 metadata 审计
@@ -184,6 +184,8 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
- 存在 `.part` 临时文件时会尝试断点续传
- 新下载先写 `.part`,成功并通过必要校验后再替换为最终文件;如果断点续传后的 `.zip` 结构校验失败,会删除 `.part` 并重新全量下载
- 如果上一轮非 dry-run 已进入 staging 但未发布成功,下一轮会优先查找 `<output>/official-version-state.json` 中同一 app version、bundle version 和 Addressables root 的失败版本;只有对应 `<output>/.staging/<id>` 仍存在、路径安全且 `versions/<id>` 尚未发布时,才复用该 staging,并继续按 manifest 校验复用或重下单个 URL
- 新 release 的 staging 在访问网络前会扫描已发布 release 的 `official-download-manifest.json`。候选必须同时满足 manifest 记录的 destination、size、BLAKE3 和适用的 ZIP 结构校验;URL、CDN 根和 release ID 的变化本身不会阻止复用。命中后优先用硬链接,跨文件系统时回退为临时文件复制并原子 rename,旧 release 不会被修改
- 历史 release 候选失效时,如果配置的 CAS 根已有对应 BLAKE3 对象,会先通过 CAS 读取完整性和元数据,再增加当前 release 的引用并原子物化;当前 release 会写 `official-cas-reuse-references.json`,清理孤儿 staging 或显式清理 release 时递减这些引用。CAS 损坏、缺对象或元数据不一致会写入复用诊断并继续走网络下载,不会静默使用缓存
- 把结果发布到 `--output/current`
## 5. 自动更新检查
@@ -198,25 +200,39 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
- 官方 seed `.hash` 校验失败会让本轮失败,并清理对应本地 manifest 条目;下一轮会继续把这类文件视为需要 repair,而不是把失败产物当作健康缓存复用。
- curl 默认自动检测本地代理环境;也可以用 `--proxy <URL>` 显式指定代理,或用 `--no-proxy` 强制直连。代理决策会进入 progress log、daemon log 和 `doctor` 诊断输出。
- curl 失败会按类型分类:403/404/普通 4xx 不重试,5xx、429、DNS、连接、超时、中断和网络类错误按尝试次数重试。
- 官方维护或大版本发布窗口可能出现启动器/server-info 已经给出新版本和新 `AddressablesCatalogUrlRoot`,但 client-patch CDN 的 seed marker 或必需 seed catalog 尚未开放的状态。此时单次运行会输出 `update_status=waiting_for_official_resources``waiting_for_official_resources=true``unavailable_endpoints`;不会进入 staging、不会写入 `failed_versions`、不会切换 `current`。watch/daemon 会把状态置为 `waiting`,按 `--error-retry` / `BAT_ERROR_RETRY_SECONDS`(默认 60 秒)继续探测。
- 单个 URL 最终失败后会写入 `official-download-quarantine.json`progress log、daemon status 和 `bat-events.jsonl` 会记录失败类型、HTTP 状态、是否可重试、尝试次数和 quarantine 状态。
- quarantine 项会跳过本轮发布并让同步失败,避免把不完整 staging 发布到 `current`;下一轮 repair/refresh 成功后会清理对应 quarantine 条目。
- 失败或中断后的 staging 不会无条件丢弃:如果 version-state 记录的失败版本和本轮远端元数据匹配,且 staging 目录仍安全存在,下一轮会复用该 staging;已通过 manifest 校验的文件会跳过,缺失、损坏、无 manifest 或官方 seed `.hash` 需要刷新的 URL 会重新下载。
- 旧 launcher 包或 `resources.assets` 下载路径使用官方 launcher CDN 配置,primary CDN 失败后会切换官方 backup CDN;资源 patch host 当前只使用 server-info 返回的官方 client-patch host,不猜测非官方镜像。
- 远端和本地都一致:单次模式输出 `update_status=up_to_date`,watch 模式默认静默并等待下次检查。
- 远端 metadata 已更新但资源端尚未开放:单次模式输出 `update_status=waiting_for_official_resources`watch/daemon 模式保留现有资源并短间隔重试。
- 有远端变化或本地 repair:生成 pull plan,下载完整官方资源到 staging,成功后更新 snapshot 并原子发布到 `current`
- 非 dry-run 会维护 `<output>/official-version-state.json`:开始下载后写入 `in_progress_version`,发布成功后写入 `current_completed_version``previous_available_version`,失败或中断后写入 `failed_versions`。同一 app version、bundle version 和 Addressables root 的失败只保留最新一条;同一版本开始重新拉取或后续发布成功时会清理对应失败记录。重新拉取同一失败版本时会复用安全存在的失败 staging,不会因为 `publish_id` 变化从空目录重新开始。
- `--dry-run`:只报告本次是否会下载,不写 snapshot;如果 cache miss,也不会写入新的 bootstrap cache。
- `--dry-run --plan`:除更新判断外,还会解析 seed catalog 并打印完整下载 URL。
- 真实更新会输出 `downloaded_count``resumed_count``skipped_count``transferred_bytes``official_seed_hash_verified_count`
- 复用统计还包括 `release_reused_count``cas_reused_count``reused_bytes``reuse_warnings`;单文件 progress 状态区分 `release_reused``cas_reused``downloaded``transferred_bytes` 不包含复用文件。
- 校验报告分层输出 `official_seed_hash_verified_count``local_manifest_verified_count``addressables_marker_checked_count``unverified_marker_count`
- 下载阶段复用同一套本地清单、ZIP 结构校验和 `.part` 续传逻辑;没有清单或校验不匹配的文件会重新下载。
- 非 dry-run 且启用 `--auto-discover` 时,成功发布的 release 会包含 `official-launcher-bootstrap.json`up-to-date 轮询发现当前 release 缺少该文件时会补写。官方 launcher/server-info 已更新但 client-patch 资源尚未开放时,不切换 `current`,只在输出根写入 `official-launcher-bootstrap.pending.json` 作为维护期证据。
- 校验和发布完成后会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,写出 `<output>/current/official-resource-changes.json``<output>/current/crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 改变才算 modified;仅 URL/CDN 根变化但内容一致不会触发解析/翻译候选。新增+变更资源进入解析和 Crowdin 翻译 handoff,删除资源只进入差异记录;当前不会直接调用 Crowdin API。
- 随后会刷新 `<output>/current/official-parse-cache.json`。解析缓存从 `official-download-manifest.json` 的全部条目出发,处理直接 UnityFS bundle 和 zip 内 UnityFS 条目;catalog、hash、媒体等非 UnityFS 文件记录为不支持,不视为同步失败。新 release 会刷新解析缓存;远端和本地都 up-to-date 且已有有效解析缓存时只读取摘要,不重复解析。
- 需要将已校验官方 release 导入 CAS + SQLite ResourceRepository 时,使用 `--import-repository``.env``BAT_IMPORT_REPOSITORY=1`;默认 CAS 为 `<output>/.cas`,默认索引为 `<output>/resources.sqlite`,可用 `--import-cas-root` / `BAT_IMPORT_CAS_ROOT``--import-resource-db` / `BAT_IMPORT_RESOURCE_DB` 覆盖。`resource.index` RPC 可查询现有索引,索引不存在时返回 `available=false`,不会创建空库;`bat doctor cas --output <output>``bat doctor cas --import-cas-root <path>` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计。
- 官方同步报告中的 `localized_release_status=not_localized` 表示原版资源已发布、汉化资源未发布,这是当前官方同步阶段的正常完成状态;后续 Patch 发布完成后才应切换为 `localized`,表示原版和汉化两套资源都已发布。
资源同步状态文件默认分布如下:
- `<output>/current/official-sync-snapshot.json`:上一次成功同步的 v2 snapshot,包含 app version、connection group、bundle version、addressables root、endpoint URL、官方 seed `.hash` 内容、Addressables `catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。
- `<output>/official-bootstrap-cache.json``--auto-discover``GameMainConfig` 解析缓存。launcher metadata 未变时复用缓存;metadata 变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析
- `<output>/current/official-sync-snapshot.json`:上一次成功同步的 v2 snapshot,包含 app version、connection group、bundle version、addressables root、endpoint URL、官方 seed `.hash` 内容、Addressables `catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要launcher metadata 额外包含 remote manifest 文件列表 digest,用于发现同文件数但内容变化的 launcher manifest
- `<output>/current/official-launcher-bootstrap.json`随已发布 release versioned 保存的官方 launcher bootstrap 产物,包含 launcher metadata、launcher CDN config、remote manifest 文件列表、选中的 `resources.assets` 来源、`GameMainConfig` 摘要和当前资源上下文
- `<output>/official-launcher-bootstrap.pending.json`:官方 launcher/server-info 已前进但 client-patch seed marker 或必需 seed catalog 尚未开放时写入的待处理 bootstrap 证据;它不代表资源已发布,也不会改变 `current`
- `<output>/official-bootstrap-cache.json``--auto-discover``GameMainConfig` 解析缓存。launcher metadata 与 remote manifest 文件列表 digest 都未变时复用缓存;任一变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
- `<output>/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。
- `<output>/current/official-cas-reuse-references.json`:当前 release 获取的 CAS 引用清单;每个复用项占一条记录,release 清理或孤儿 staging GC 时据此递减引用。
- `<output>/current/official-resource-changes.json`:当前 release 相对上一完整 release 的资源差异,记录新增、变更、删除以及解析/翻译候选计数。
- `<output>/current/crowdin-translation-handoff.json`:为后续 Crowdin worker 预留的本地队列,只包含新增+变更资源;它不是 Crowdin API 调用结果。
- `<output>/current/official-parse-cache.json`:官方资源发布后的派生解析缓存,记录 bundle/zip 条目解析摘要和缓存复用情况;它不是汉化产物。
- `<output>/current/official-download-quarantine.json` 或当前 staging 下同名文件:下载最终失败的 URL 诊断记录,包含失败类型、HTTP 状态、是否可重试、尝试次数和最后错误。
先 dry-run
@@ -251,7 +267,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 -- \
@@ -265,7 +281,7 @@ cargo run -p bat-infrastructure --bin bat -- reload
cargo run -p bat-infrastructure --bin bat -- stop
```
`status``stop``logs``reload`默认形态的 `refresh` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status``stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy``--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`
`status``stop``restart``logs``reload`默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status``stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart`通过 Rust lifecycle controller 复用 CLI restart 路径停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy``--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`
如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>`
@@ -308,9 +324,9 @@ cargo run -p bat-infrastructure --bin bat -- \
--error-retry 60s
```
默认平台是 `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 errorwatch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75``verify``doctor` 发现问题也返回非 0。
默认平台是 `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 阶段会输出已完成计数和单文件开始/完成状态,worker 从共享队列独立领取任务并在完成后立即领取下一项,完成计数保持单调不倒退,最终 report 的 `items` 仍按 pull plan 顺序排列,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON errorwatch 模式下错误 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 自动更新的唯一方式。下载默认并发 8,可用 `--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`worker 动态领取共享 plan,finished 进度即时按完成数统计,发布 report 仍按 plan 顺序。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产官方资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取原版资源时应读取 `--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`,但生产同步默认应保持开启。
@@ -325,9 +341,9 @@ scripts/official-full-pull-smoke.sh
make official-smoke
```
默认输出在 `/tmp/bat-official-smoke-<UTC timestamp>/`,脚本会执行 dry-run plan、首次全量拉取、二次 `up_to_date`、本地文件破坏后的 `repair`、repair 后 `verify`,并检查 stderr progress log 中存在总体下载进度、单文件进度和校验结果摘要。完整说明见 `docs/guides/official-full-pull-smoke.md`
默认输出在 `/tmp/bat-official-smoke-<UTC timestamp>/`,脚本会执行 dry-run plan、首次全量拉取、二次 `up_to_date`、本地文件破坏后的 `repair`、repair 后 `verify`,并检查 stderr progress log 中存在下载已完成计数、单文件进度和校验结果摘要。完整说明见 `docs/guides/official-full-pull-smoke.md`
## 7. 例外输入
## 7. 输入模式
可接受的 `server-info` 输入是:
@@ -344,7 +360,7 @@ make official-smoke
- `infrastructure/examples/official_launcher_bootstrap.rs`
- `infrastructure/examples/official_pull_plan.rs`
- `infrastructure/src/bin/bat_official_sync.rs`
- `infrastructure/src/bin/bat_official_sync.rs`(薄入口;控制面实现位于同目录 `bat/`
- `infrastructure/examples/official_update_check.rs`(历史/开发入口;生产优先使用 `bat`
- `adapters/examples/yostar_jp_client_bootstrap.rs`
- `adapters/examples/yostar_jp_discovery.rs`
+500
View File
@@ -0,0 +1,500 @@
# Rust Resource Backend RPC API
本文档冻结本机 Rust Resource Backend API 的稳定调用边界。Go 项目
`bat-api`、Go 服务层、运维脚本和 `bat` CLI 都应以这里的 JSON-RPC
contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主路径。
## 传输
- 传输:Unix domain socket。
- 默认 socket`/tmp/bat-pid/bat.sock`
- 协议:JSON-RPC 2.0,每行一个 request,每行一个 response。
- 编码:UTF-8 JSON。
- 访问控制:依赖本机文件权限和状态目录权限;不要把 socket 暴露到公网。
请求:
```json
{"jsonrpc":"2.0","id":1,"method":"resource.repair","params":null}
```
成功响应的 JSON-RPC 顶层 `result` 一律是应用层 envelope
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"ok": true,
"status": "accepted",
"data": {"task_id": "task-1234-1", "kind": "resource.repair"},
"request_id": "req-1234-1"
}
}
```
应用层失败也放在 `result` 的 envelope 中:
```json
{
"ok": false,
"status": "error",
"error": {
"code": "BAT-ERR-700003",
"kind": "not_implemented",
"domain": "rpc",
"location": "rpc.dispatch",
"message": "方法尚未实现:daemon.clean-stable",
"retryable": false
},
"request_id": "req-1234-2"
}
```
只有 JSON 解析失败等传输层错误使用 JSON-RPC 顶层 `error`
## Envelope
| 字段 | 类型 | 说明 |
|---|---|---|
| `ok` | bool | 应用层是否成功。 |
| `status` | string | `ok``accepted``error`。 |
| `data` | object/null | 成功结果。失败时省略。 |
| `error` | object/null | `ApiError`。成功时省略。 |
| `request_id` | string | daemon 进程内请求 ID,用于日志关联。 |
`ApiError` 结构以 `core/src/error_code.rs` 码表为准:
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | string | `BAT-ERR-<6位>`。 |
| `kind` | string | 错误类别。 |
| `domain` | string | 错误域。 |
| `location` | string | Rust 侧出错位置。 |
| `message` | string | 可诊断错误信息。 |
| `retryable` | bool | 调用方是否可以按策略重试。 |
## 方法
### daemon
| 方法 | 状态 | params | data |
|---|---|---|---|
| `daemon.status` | 已实现 | `null` | 后台状态报告。 |
| `daemon.logs` | 已实现 | `{ "tail": 200 }` | 日志尾部报告。 |
| `daemon.stop` | 已实现 | `null` | accepted ack。 |
| `daemon.restart` | 已实现 | `null` | accepted ack;启动 Rust lifecycle controller,并在响应后停止当前 daemon。 |
| `daemon.reload` | 已实现 | `null` | accepted ack。 |
| `daemon.refresh` | 已实现 | `{ "force": false }` | accepted ack。 |
| `daemon.doctor` | 已实现 | `null` | 只读诊断报告。 |
| `daemon.clean-stable` | 保留 | `null` | live RPC 不执行;由 CLI 离线清理入口处理。 |
`daemon.restart` 不在 daemon 线程内手写第二套启动流程;它启动本机 Rust
`bat restart --state-dir ...` lifecycle controller,由既有 CLI restart 路径复用
保存的启动参数、代理凭据、PID/socket 替换和控制锁。
`bat.status``bat.stop``bat.restart``bat.reload``bat.refresh``bat.logs`
`bat.doctor``bat.clean-stable` 是兼容别名;新代码应使用 `daemon.*`
### resource
| 方法 | 状态 | params | data |
|---|---|---|---|
| `resource.state` | 已实现 | `null` | 资源发布根、版本状态、上次同步结果。 |
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
| `resource.manifest` | 已实现 | `{ "offset": 0, "limit": 100 }` | 当前 download manifest 分页。 |
| `resource.list` | 已实现 | `{ "offset": 0, "limit": 100 }` | `resource.manifest` 的兼容别名。 |
| `resource.index` | 已实现 | `{ "offset": 0, "limit": 100, "type": "asset_bundle", "hash": "...", "path_pattern": "*", "release_id": "...", "platform": "windows", "destination": "...", "archive_entry": "...", "parse_status": "parsed", "format": "json" }` | 当前 `ResourceRepository` 分页/过滤查询。 |
`resource.repair` 会开启本地 manifest audit + repair,不继承 `force`
`resource.manifest` / `resource.list` 查询当前已发布 release 的
`official-download-manifest.json``resource.index` 查询可选导入产生的
SQLite `ResourceRepository`,索引不存在时返回 `ok=true`
`data.available=false`,不会隐式创建数据库。`resource.index` 可按
`resource_type`/`type``hash``path_pattern``official_release_id`/`release_id`
`platform``destination``bundle_path``archive_entry``parse_status`
`text_unit_format`/`format` 过滤;`path_id``class_id``field_path`
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
`1..=1000`,非法参数返回 `BAT-ERR-700002`
`resource.index``entries[]``Resource` JSON,除 `id``local_path`
`entry` 外会包含 `metadata``official_release_id``platform`
`bundle_path``archive_entries``parse_statuses``unity_versions`
`text_assets``text_unit_count``text_unit_formats`
`text_unit_error_count` 等字段。`entry` 还会保留 Addressables 的
`provider_id``bundle_name``hash``size``crc``dependencies`
旧索引库会通过 `metadata_json` 以及资源字段兼容迁移得到默认空值。
`resource.state``catalog.status``parse.status``localized.status`
都会返回当前观察面的短状态 `status` 与稳定状态码 `status_code``status_code`
使用命名空间格式,例如 `official.up_to_date``official.published`
`parse.completed``translation.queued_offline``localized.published`
`distribution.ready`。这些状态码描述资源/解析/翻译 handoff/汉化/分发生命周期;
失败原因仍使用 `BAT-ERR-*` 错误码,二者不混用。响应还会包含
`status_phase``status_terminal``status_retryable`,供 `bat-api` 等读侧
决定展示、重试或 readiness。
官方资源完整新版本发布后,Rust 侧会先比较上一完整 release 与当前 release
的 download manifest,并在当前 release 根目录写出:
- `official-resource-changes.json`:记录 added / modified / removed 资源。
同一 destination 只有 size 或 BLAKE3 变化才算 modifiedURL 或 CDN root
变化但内容一致时不进入解析/翻译候选。
- `crowdin-translation-handoff.json`:只包含 added + modified 资源,作为后续
Crowdin worker 的稳定本地队列输入;当前 RPC 不直接调用 Crowdin API。
- `official-parse-cache.json`:解析缓存。up-to-date 轮询发现本地文件未变且缓存
有效时只读取摘要,不重复解析。
- `official-textunit-index.json`:TextUnit 明细和解析错误索引。up-to-date 轮询发现
本地文件未变且索引有效时复用,不重复解析。
- `official-textunit-tasks.json`:只由 added + modified 资源、parse cache 和
TextUnit 明细索引派生,记录 TextUnit 任务、跳过原因和解析诊断。
- `crowdin-textunit-queue.json`:只包含已产生 TextUnit 的离线任务;官方同步阶段
不发出 provider 网络请求。
- `translation-tasks.sqlite`:当前 release 的可变 worker 状态库,记录
queued / running / failed / completed / skipped、attempt count、provider run
ID、provider、TextUnit 级译文结果、lease、失败分类、可重试标记和
next attemptschema 由 `schema_migrations` 版本表管理。
- `translation-handoff.json`:当前 release 的版本化 job/unit/provider run 交接
快照;worker 更新后的实时状态仍以 `translation-tasks.sqlite` 为准。
删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。
### schedule
调度计划由 Rust `bat` 持有,状态文件为 daemon `state_dir` 下的
`bat-schedules.json`。CLI、RPC 和 `bat-api` dashboard 都调用同一组原子
读改写逻辑,不在 Go 侧复制计划状态。
| 方法 | 状态 | params | data |
|---|---|---|---|
| `schedule.list` | 已实现 | `null``{ "id": "...", "group": "res", "enabled": true }` | `{ "command": "schedule-list", "query": {...}, "schedules": [...] }`。 |
| `schedule.add` | 已实现 | 调度 mutation | 新建 schedule report。 |
| `schedule.update` | 已实现 | 调度 mutation,必须有 `id` | 更新后的 schedule report。 |
| `schedule.remove` | 已实现 | `{ "id": "daily-pull" }` | 删除报告。 |
| `schedule.run` | 已实现 | `{ "id": "daily-pull", "group": "res", "force": true, "max_runs": 1 }`,字段可省略 | 到期或强制执行报告;省略 `id` 执行指定 group 的到期计划。 |
调度 mutation 字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 计划 IDadd 必填,update/remove 用于定位。 |
| `group` | string | `res``parse``i18n`;对应一级工作流。 |
| `action` | string | `res``pull/refresh/verify/repair``parse``run/repack/clear-cache``i18n``run/export/validate/publish`。 |
| `args` | string[] | 目标工作流的 CLI 参数。 |
| `next_run_unix_seconds` | uint64 | 指定下一次执行时间;不能和 `delay_seconds` 同时使用。 |
| `delay_seconds` | uint64 | 从当前时间计算下一次执行时间。 |
| `every_seconds` | uint64 | 周期秒数;必须大于 0。 |
| `count` | uint64 | 最大执行次数;省略周期无限执行,非周期计划默认执行一次。 |
| `clear_args` | bool | update 时清空工作流参数。 |
| `clear_every` | bool | update 时清除周期并转为单次计划。 |
| `enabled` | bool | 启用或停用计划。 |
`schedule.run.max_runs` 必须大于 0,用于限制一次轮询最多领取的到期计划数。
`count > 1` 必须和周期同时存在;`schedule.run``force=true` 只忽略
到期时间,不会绕过 `enabled=false`。每次执行前先持久化下一次状态,执行后
再持久化成功/失败和错误信息,避免进程中断后重复领取同一计划。
### parse
| 方法 | 状态 | params | data |
|---|---|---|---|
| `parse.status` | 已实现 | `null` | 当前官方 release 的解析缓存状态。 |
| `parse.text_units` | 已实现 | `{ "offset": 0, "limit": 100, "destination": "*Table*", "archive_entry": "*.bytes", "path_id": 1, "class_id": 114, "field_path": "*Text*", "format": "json" }` | 当前官方 release 的 TextUnit 明细分页。 |
| `parse.errors` | 已实现 | `{ "offset": 0, "limit": 100, "destination": "*Table*", "archive_entry": "*.bytes", "path_id": 1, "class_id": 114, "field_path": "*Text*", "format": "json" }` | 当前官方 release 的解析错误分页。 |
| `translation.tasks` | 已实现 | `{ "offset": 0, "limit": 100, "task_id": "...", "release_id": "...", "destination": "...", "archive_entry": "...", "status": "skipped_parse_failed", "parse_status": "failed", "format": "json", "has_reason": true }` | 当前官方 release 的离线 TextUnit 翻译任务状态分页。 |
| `translation.handoff` | 已实现 | `null` | 当前官方 release 的 job、unit、provider run 交接视图;动态合并队列和 SQLite worker 状态。 |
| `translation.task.update` | 已实现 | `{ "task_id": "...", "status": "failed", "failure_reason": "...", "provider_run_id": "..." }` | 写入当前 release 的 provider worker 状态,返回可回查任务记录。 |
| `translation.worker.run` | 已实现 | provider worker 参数 | 异步触发 Rust provider worker,返回 `{ "task_id": "...", "kind": "translation.worker.run", "worker": {...} }`。 |
| `translation.proofread` | 已实现 | `null` | 将当前汉化 workflow 标记为人工校对中,返回工作流状态报告。 |
`parse.status` 是只读查询;没有当前 release 或没有解析缓存时返回
`ok=true``data.available=false`。解析缓存来自官方原版资源目录,不读取
汉化输出目录。存在 `official-textunit-index.json` 时,响应会包含
`textunit_index_available=true``textunit_index_path`
`textunit_index_summary`;存在 `official-textunit-tasks.json` 时,响应会包含
`textunit_queue_available=true``textunit_task_queue_path`
`textunit_task_summary`。当 TextUnit 队列存在且有离线任务时,
`translation_status_code=translation.queued_offline`provider worker 完成任务后,
同一查询面会返回已落库的 worker 状态和 TextUnit 级译文结果。
`parse.text_units` / `parse.errors` 是只读查询;没有当前 release 或没有
`official-textunit-index.json` 时返回 `ok=true``data.available=false`
分页参数 `offset` 默认 0`limit` 默认 100,范围是 `1..=1000`。过滤参数:
| 字段 | 类型 | 说明 |
|---|---|---|
| `destination` | string | official download manifest destination,支持 `*` 通配。 |
| `archive_entry` | string | zip 内条目,支持 `*` 通配;直接 bundle 通常为 `null`。 |
| `path_id` | integer | Unity object path id。 |
| `class_id` | integer | Unity class id。 |
| `field_path` | string | TypeTree/TextAsset 字段路径,支持 `*` 通配。 |
| `format` | string | TextUnit 格式,例如 `json``csv``tsv``plain``typetree_string`。 |
`parse.text_units``entries[]` 会包含 source text、source URL、
destination、archive entry、source kind、Unity version、serialized file、
path id、class id、field path、字段 offset/byte size、format、asset name 和
context。`parse.errors``entries[]` 会包含 source URL、destination、
archive entry、status、serialized file、path id、class id、field path、
offset 和 error。TypeTree-covered managed reference 字段会进入结构化字段遍历;
完整 managed reference registry 等暂不支持结构会进入解析错误,而不是静默降级为
低保真文本。
`translation.tasks` 优先查询当前 release 的 `translation-tasks.sqlite`,旧 release
没有该文件时回退到 `official-textunit-tasks.json`;用于查看离线 TextUnit
翻译任务候选和 provider worker 状态。没有当前 release 或没有任务队列时返回
`ok=true``data.available=false`。过滤参数包括 `task_id`
`official_release_id`/`release_id``destination``path_pattern`
`archive_entry``status`/`task_status``worker_status``parse_status`
`text_unit_format`/`format``has_reason``has_failure_reason`
`entries[]` 会包含 `official_release_id``destination``archive_entry`
`parse_status`、队列 `status``task_status``failure_reason``attempt_count`
`provider_run_id`、TextAsset/TextUnit 摘要和校验指纹。
`translation.task.update` 只更新当前 release 的 SQLite 状态库,不改写 immutable
队列文件,也不主动访问 Crowdin。`status` 支持 `queued``running``failed`
`completed``skipped`;进入 `running` 会增加 attempt count`completed`
记录完成时间,`failed` 可写入 `failure_reason`。人工校对流程可以在
`status=completed` 时额外提交 `provider``provider_run_id`
`translation_results[]`,每个结果必须包含 `unit_id``source_text`
`translated_text`Rust 会用当前 `official-textunit-index.json` 校验 unit、
source text、destination 和 archive entry 后再落库。因此 worker 或人工校对流程
消费 handoff 后,bat-api 可通过 `translation.tasks` 查询单项任务,也可通过
`translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff`
不会触发下载或 provider 网络请求;没有当前 release 或任务队列时返回
`data.available=false`
`translation.worker.run` 通过 daemon 任务队列异步启动 Rust provider worker。
provider worker 会先同步当前 release 的 TextUnit 队列到
`translation-tasks.sqlite`,回收过期 lease,然后由 `concurrency` 个独立 worker
循环 claim 下一项任务;任一 worker 完成当前任务后会立即领取下一项,不等待
其他 worker 完成本轮批次。默认并发为 8,范围 `1..=256`
provider worker 参数:
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `provider` | string | `mock` | `mock``crowdin`。 |
| `fixture_path` | string/null | `null` | mock provider fixture;别名为 `translation_fixture``provider_fixture``mock_fixture``fixture`。 |
| `concurrency` | uint | `8` | 独立 worker 数,范围 `1..=256`;别名为 `worker_concurrency``translation_concurrency`。 |
| `max_attempts` | uint | `3` | 单个任务最大 claim 次数,必须大于 0。 |
| `lease_seconds` | uint | `300` | claim lease 秒数,必须大于 0。 |
| `retry_backoff_seconds` | uint | `5` | 可重试 provider 失败的 next attempt 间隔,可为 0。 |
| `max_tasks` | uint/null | `null` | 本轮最多 claim 的任务数,设置时必须大于 0。 |
| `worker_id` | string | `bat-rpc-worker` | lease 诊断用 worker ID 前缀。 |
数字字段必须是 JSON number;字符串数字、负数和越界值会返回
`BAT-ERR-700002``mock` provider 在没有 fixture 时把 source text 写成可诊断的
mock 译文;`crowdin` provider 从 `CROWDIN_PROJECT_ID``CROWDIN_LANGUAGE_ID`
`CROWDIN_API_TOKEN` 读取配置,可选 `CROWDIN_API_BASE_URL``BAT_CURL`
token 不会进入报告、任务记录或调试输出。
### localized
| 方法 | 状态 | params | data |
|---|---|---|---|
| `localized.status` | 已实现 | `null` | 汉化发布状态、当前官方 release 匹配关系和汉化输出目录。 |
| `localized.publish` | 已实现 | `{ "translation_file": "...", "localized_release_id": "...", "force": false }``{ "from_worker": true, "localized_release_id": "...", "force": false }` | 已校验并发布的汉化 release、manifest 和完整性报告。 |
| `localized.rollback` | 已实现 | `{ "localized_release_id": "..." }`,可省略 | 删除当前 release、恢复 manifest 记录的上一 release 和新状态。 |
`localized.status` 严格按 daemon / `.env` 中的 `BAT_LOCALIZED_OUTPUT`
`--localized-output` 查询汉化产物目录,不把 `./bat-resources`
`./bat-localized` 混用。当前支持未汉化发布状态和已汉化发布状态的只读报告。
`status` / `status_code` 使用生命周期短状态和稳定状态码,例如
`pending` / `localized.pending``stale` / `localized.stale``published` /
`localized.published`;旧的 `localized` / `not_localized` 业务标签放在
`localized_release_status``translation_workflow_status` / `translation_workflow_status_code`
用于表示汉化工作流的人工校对状态,例如 `manual_proofreading` /
`translation.manual_proofreading`。返回 `localized_release_status=localized` 的条件是:
`localized-version-state.json` 的官方 release ID 匹配当前官方 release
`current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的
`localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回
`patch_manifest_path``patch_manifest_available`
`patch_manifest_matches_release``patch_file_count`
`patch_text_asset_operation_count``rollback_previous_current_target`
### catalog
| 方法 | 状态 | params | data |
|---|---|---|---|
| `catalog.status` | 已实现 | `null` | 当前已发布 catalog 概览。 |
| `catalog.versions` | 已实现 | `null` | current / in_progress / previous / failed。 |
| `catalog.diff` | 已实现 | `null` | 当前 snapshot 相对上一可用版本的差异。 |
| `catalog.refresh` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "catalog.refresh" }`。 |
只读查询在没有可用版本时返回 `ok=true``data.available=false`
`catalog.status` 可用时会返回 `status_code=official.published`,并用
`distribution_status_code=distribution.ready` 表示该官方 release 可被读侧分发;
不可用时对应 `official.unavailable` / `distribution.blocked`
### task
| 方法 | 状态 | params | data |
|---|---|---|---|
| `task.status` | 已实现 | `{ "task_id": "..." }` | 单个任务记录。 |
| `task.list` | 已实现 | `null` | `{ "tasks": [...] }`。 |
| `task.cancel` | 已实现 | `{ "task_id": "..." }` | cancel ack。 |
| `task.logs` | 已实现 | `{ "task_id": "..." }` | `{ "task_id": "...", "lines": [...] }`。 |
| `task.create` | 保留 | object | 不开放通用任务入口;由语义方法创建任务。 |
任务记录:
```json
{
"id": "task-1234-1",
"kind": "resource.repair",
"status": "queued",
"stage": null,
"message": null,
"created_at": 1780000000,
"updated_at": 1780000000,
"started_at": null,
"finished_at": null,
"error": null,
"result": null
}
```
`status` 取值:`queued``running``succeeded``failed``cancelled`
daemon 重启后仍处于 `queued``running` 的历史任务会被标记为
`failed`,错误码为 `BAT-ERR-700005`
### patch / unityfs
已开放的文件级写入方法:
- `patch.apply`:对显式 `source_path``patch_path``target_path` 执行
Binary/JSON/Text patch apply`kind` 取值为 `binary``json``text`
- `unityfs.patch_text_asset`:对显式 UnityFS `bundle_path` 中的
`serialized_file_path` / `path_id` TextAsset 应用 `replacement_path`,写入
`target_path`,可选 `expected_name`
- `unityfs.patch_string_field`:对显式 UnityFS `bundle_path` 中的
`serialized_file_path` / `path_id` / `field_path` TypeTree string 字段应用
`replacement_text` 或 UTF-8 `replacement_path`,写入 `target_path`,可选
`expected_value`
- `unityfs.patch_field`:对显式 UnityFS `bundle_path` 中的
`serialized_file_path` / `path_id` / `field_path` TypeTree 字段应用语义
`replacement` JSON,写入 `target_path`,可选 `expected_value``replacement`
使用 `{"kind":"signed","value":42}` 这类 tagged JSON;支持
`bool``signed``unsigned``float32``float64``string``bytes`
`enum``bit_field``p_ptr`、固定 Unity 叶子结构、object 字段组合和 TypeTree schema 支撑的 array/map 整体替换。enum 形如
`{"kind":"enum","value":{"type_name":"ScenarioDifficulty","storage_type":"int","value":3}}`
`type_name` 是 TypeTree enum 类型名,`storage_type` 是 backing integer 类型。`LayerMask` / `BitField` 形如
`{"kind":"bit_field","value":{"type_name":"LayerMask","storage_type":"UInt32","bits":9}}`
array 形如
`{"kind":"array","value":[{"kind":"string","value":"你好"}]}`map entry 用
object 表达,例如
`{"kind":"object","value":[{"name":"first","value":{"kind":"string","value":"jp"}}]}`
扩容时复用当前首个元素或 TypeTree data node 的编码 schemamap entry schema
变化、unknown 字段和未覆盖的 managed reference registry 变体仍会返回明确错误。
固定 Unity 叶子结构使用 raw bits/bytes 表达,例如
`{"kind":"float32_struct","value":{"type_name":"Vector3f","values":[1065353216,1073741824,1077936128]}}`
`{"kind":"fixed_bytes","value":{"type_name":"GUID","bytes":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]}}`
这些方法同步执行,不进入 `task.*` 队列;输出文件使用临时文件原子写入,响应
`data` 会返回 source / patch 或 replacement / target 的 size 与 BLAKE3。`target_path`
不能与输入文件相同。
仍关闭的范围:通用 manifest 驱动的发布级 `patch build` / `patch rollback`、复杂 UnityFS 语义编辑、
`unityfs.inspect`、通用 manifest 驱动 release 切换。调用这些规划方法仍返回
`BAT-ERR-700003`
CLI 对应关系:
| CLI | RPC |
|---|---|
| `bat patch-apply` | `patch.apply` |
| `bat unityfs-patch-text-asset` | `unityfs.patch_text_asset` |
| `bat unityfs-patch-string-field` | `unityfs.patch_string_field` |
| `bat unityfs-patch-field` | `unityfs.patch_field` |
## Go 调用边界
`bat-api` 应直接调用本 RPC contract,不通过 `exec` 调用 `bat` binary。
`bat` binary 是人类 CLI 和进程生命周期工具;默认 `refresh` / `repair`
在 daemon 可用时也会作为 RPC client 调用同一个 socket。`daemon.restart`
会启动 Rust lifecycle controller 复用同一套 CLI restart 路径,Go 层仍不直接
`exec` 或解析 `bat` stdout。
人类 CLI 的只读查询命令与 RPC 对应关系如下:
| CLI | RPC |
|---|---|
| `bat parse-status` | `parse.status` |
| `bat parse-text-units` | `parse.text_units` |
| `bat parse-errors` | `parse.errors` |
| `bat translation-tasks` | `translation.tasks` |
| `bat translation-handoff` | `translation.handoff` |
| `bat i18n task list` / `bat i18n task status` | `translation.tasks` |
| `bat i18n task update` | `translation.task.update` |
| `bat i18n worker run` | `translation.worker.run` |
| `bat i18n proofread` | `translation.proofread` |
| `bat localized-status` | `localized.status` |
| `bat resource-index` | `resource.index` |
`bat translation-tasks` / `bat i18n tasks``bat translation-handoff` / `bat i18n handoff`
`bat localized-status` / `bat i18n status` 都对应同一 RPC;这里列出的是推荐命令形态。
`bat resource-index` 支持 `--offset``--limit``--resource-type``--hash`
`--path-pattern``--release-id``--platform``--destination`
`--bundle-path``--archive-entry``--parse-status``--format`
这些常用 metadata 过滤在 SQLite `ResourceRepository` 中下推执行。`bat doctor cas`
是本地只读 CLI 诊断入口,不对应 live RPC 方法;它读取 CLI 指定的 CAS 根目录和
元数据库路径,报告缺失或对象文件异常,且不会创建空库。
`bat parse-text-units` / `bat parse-errors` 支持 `--offset``--limit`
`--destination``--path-pattern``--archive-entry``--path-id`
`--class-id``--field-path``--format``bat translation-tasks` 支持
`--offset``--limit``--task-id``--release-id``--destination`
`--path-pattern``--archive-entry``--task-status``--worker-status`
`--parse-status``--format``--has-reason``--has-failure-reason`。这些过滤参数不适用于
`parse-status``translation-handoff``localized-status`
### Go 客户端表面
`internal/backendrpc.Client` 是 Unix socket JSON-RPC 传输客户端:
- `Call` 可发送本文档中的任意已记录方法,并负责 JSON-RPC transport、
envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。
- typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、
`resource.state/sync/verify/repair/manifest/list``schedule.list/add/update/remove/run`
`catalog.*``parse.*`
`localized.status``localized.publish``localized.rollback`
`translation.tasks``translation.handoff``translation.task.update`
`translation.worker.run``translation.proofread`
`task.*` 和三个 `unityfs.patch_*` 方法。
- `resource.index``patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守
本契约的参数和响应定义。
`internal/api` 对 bat-api 生产路径进一步收窄接口:
| Go 接口 | 允许调用的 RPC | 用途 |
|---|---|---|
| `Backend` | `daemon.status``daemon.doctor``resource.state``catalog.status``resource.manifest` | 启动发现、周期刷新和资源分发 |
| `ControlBackend` | `daemon.restart``daemon.reload``daemon.refresh``resource.sync``resource.verify``resource.repair``catalog.refresh` | 鉴权后的管理控制白名单 |
| `ScheduleBackend` | `schedule.list``schedule.add``schedule.update``schedule.remove``schedule.run` | 鉴权后的 dashboard 调度计划控制 |
| `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 |
| `TaskBackend` | `task.list``task.status``task.logs``task.cancel` | 鉴权后的 daemon 任务查询和取消 |
| `ParseBackend` | `parse.status``parse.text_units``parse.errors` | 鉴权后的当前 release 解析状态、TextUnit 和解析错误只读查询 |
| `TranslationBackend` | `translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 |
| `LocalizedBackend` | `localized.status``localized.publish``localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 |
`daemon.stop``daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
`infrastructure/src/bin/bat/app.rs``internal/backendrpc/client.go`
`internal/api/rpc_release.go`;修改方法、字段或 allowlist 时必须同步更新本文档。
Go mirror contract fixture 固化在 `internal/api/testdata/contract/`,覆盖
`catalog.status` available/unavailable、`resource.manifest` page0 和对应
`official-sync-snapshot.json`。这些 fixture 由 Rust 输出归一化而来,只用于
schema / mirror 回归;live daemon socket 和完整 fixture release 切换由
`make bat-api-local-live-smoke` 在同机 `/tmp` 隔离环境中验证。该 smoke 不替代
`make official-smoke` 的官方网络全量下载验证。
禁止事项:
- Go 服务层不直接读写 `bat-status.json``bat-tasks.json` 等 daemon 内部状态文件。
- Go 服务层不扩展 `bat-ffi` 为主控制面。
- Go 服务层不通过 stdout 解析 `bat status --json` 作为常规调用路径。
@@ -0,0 +1,214 @@
# bat-api / Rust bat Contract Fixture Handoff
更新时间:2026-09-04
本文用于两个 Codex 窗口之间间接联调 `bat-api` 与 Rust `bat` 的跨语言 contract fixture。
仓库内归一化 fixture 已交付;本文保留生成、审核和后续扩展的协作协议。
2026-07-31 更新:已审核归一化 fixture 已落入
`internal/api/testdata/contract/`Go 侧通过
`internal/api/contract_fixture_test.go` 固化 mirror struct 验证。本文件保留为
后续重新生成或扩展 contract fixture 时的协作协议。
## 最小上下文包
另一个窗口不需要知道本窗口的完整对话,只需要遵守以下上下文:
- 本次联调对象是 Rust `bat` RPC / snapshot JSON 与 Go `bat-api` mirror struct 的 contract fixture。
- 联调不要求本地运行全量长期服务端 `bat`;允许 Rust 侧使用 fixture root 或临时目录走真实代码路径导出 JSON。
- fixture 审核前只能放在 `/tmp/bat-contract-fixture/`,不能直接提交到仓库。
- Go 侧已经实现 player-facing HTTP 鉴权、限流、访问日志、反代适配、OpenAPI 和
`/admin/` 控制入口;仓库内 contract fixture 和同机 live daemon socket / 完整
fixture release 切换联调均已完成,命令为 `make bat-api-local-live-smoke`
- Go 侧当前相关代码入口:
- `internal/api/rpc_release.go`
- `internal/api/release_index.go`
- `internal/api/responses.go`
- `internal/backendrpc/`
## 背景
- Rust `bat` 是资源同步、状态发布和 `bat.sock` RPC 的权威实现。
- Go `bat-api` 是只读 HTTP bootstrap / 分发服务,消费 Rust RPC 输出和已发布资源目录。
- contract fixture 不能由任一侧手写猜测;必须由 Rust 侧真实输出,经归一化和用户审核后,再由 Go 侧固化测试。
## 非目标
- 不引入真实玩家账号、登录、网关、鉴权绕过或游戏业务 API fixture。
- 不写入开发机绝对资源路径,例如 `/home/wanye/D/BlueArchive`
- 不把当前某个真实版本号、日期、远程目录或本地目录写成长期契约。
- 不让 Go fixture 反向约束 Rust 内部实现;只约束对外 JSON contract。
## 建议共享目录
联调前使用临时目录交换未审核产物:
```text
/tmp/bat-contract-fixture/
rust/
catalog-status.available.raw.json
catalog-status.unavailable.raw.json
resource-manifest.page0.raw.json
official-sync-snapshot.raw.json
normalized/
catalog-status.available.json
catalog-status.unavailable.json
resource-manifest.page0.json
official-sync-snapshot.json
notes.md
```
只有用户审核通过后,才允许把归一化 fixture 落入仓库,例如:
```text
internal/api/testdata/contract/
```
## Rust 侧需要产出
Rust 窗口请基于当前真实代码生成或导出以下 JSON:
1. `catalog.status` available=true 响应。
2. `catalog.status` available=false 响应。
3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。
4. 对应 release 的 `official-sync-snapshot.json`
输出应来自 Rust 代码路径,而不是手写 JSON。允许使用 fixture resource root 或临时目录,但不能依赖开发机真实资源目录。
## 归一化规则
归一化只允许处理环境相关值,不改变 schema:
- 绝对路径归一化为 `${RESOURCE_ROOT}``${STATE_DIR}`
- 版本 id 归一化为 `${VERSION_ID}`
- 时间戳可归一化为固定小整数或 `${COMPLETED_UNIX_SECONDS}`
- 真实 URL host 保留;路径中若含具体 release token,可归一化为 `{addressables-root}` / `{manifest-path}`
- 字段名、字段类型、字段层级、null / missing / array / number 语义不得修改。
## Go 侧验证范围
Go 窗口读取归一化后的 JSON,验证:
1. `parseCatalogStatus` 能解析 `available=true`,并正确映射:
- `app_version`
- `bundle_version`
- `connection_group_name`
- `addressables_root`
- `version.id`
- `version.completed_unix_seconds`
- `version.resource_root`
- `launcher_metadata`
- `game_main_config_bootstrap`
2. `parseCatalogStatus``available=false` 返回不可用而不是错误。
3. `resource.manifest` entry 字段能映射为 Go `ResourceManifestEntry`
- `url`
- `destination`
- `bytes`
- `blake3`
4. 本地 snapshot fixture 与 RPC `catalog.status` 均使用 `game_main_config_bootstrap`
5. `bat-api` bootstrap 和 launcher bootstrap 不泄露归一化前的开发机路径。
Go 侧审核通过后的落地建议:
- `internal/api/testdata/contract/catalog-status.available.json`
- `internal/api/testdata/contract/catalog-status.unavailable.json`
- `internal/api/testdata/contract/resource-manifest.page0.json`
- `internal/api/testdata/contract/official-sync-snapshot.json`
- `internal/api/contract_fixture_test.go`
测试不应依赖 `/tmp/bat-contract-fixture/`;该目录只用于两窗口交接未审核产物。
## 必须覆盖的 optional 语义
至少需要两组 Rust 输出或派生 fixture 覆盖:
1. optional 字段非空:
- `launcher_metadata.game_lowest_version`
- `launcher_metadata.game_start_exe_name`
- `launcher_metadata.manifest_source`
- `game_main_config_bootstrap.server_info_data_url`
- `game_main_config_bootstrap.default_connection_group`
2. optional 字段为 null 或缺省:
- Go mirror 不应崩溃。
- HTTP response 中按当前 Go struct `omitempty` 策略输出。
## 用户审核点
落仓库前请用户审核:
- 归一化是否过度改变 Rust 真实输出。
- fixture 是否意外绑定真实版本、日期、本机路径或私有部署路径。
- `game_main_config_bootstrap` 在 RPC / snapshot 中是否保持同一语义。
- optional 字段覆盖是否足够。
## notes.md 模板
Rust 侧生成 `/tmp/bat-contract-fixture/notes.md` 时建议使用以下结构:
```markdown
# bat contract fixture notes
## 生成命令
- catalog.status available=true: ...
- catalog.status available=false: ...
- resource.manifest page0: ...
- official-sync-snapshot: ...
## 原始输出来源
- Rust commit / working tree: ...
- 使用的 fixture root 或临时目录: ...
- 是否依赖真实开发机资源目录: 否
## 归一化
- `${RESOURCE_ROOT}`: ...
- `${STATE_DIR}`: ...
- `${VERSION_ID}`: ...
- `${COMPLETED_UNIX_SECONDS}`: ...
- URL 路径占位符: ...
## 需要用户审核
- ...
```
## 完成判定
contract fixture 工作只有在以下条件同时满足时才算完成:
1. Rust 侧原始 JSON 来自真实 Rust 代码路径。
2. 归一化 JSON 经过用户审核。
3. Go 侧测试读取归一化 fixture 并验证 mirror struct / launcher bootstrap 行为。
4. Go 测试不依赖开发机资源目录、远程长期运行 `bat``/tmp` 中的交接目录。
5. 文档记录 fixture 覆盖的风险和仍未覆盖的字段。
## 建议给另一个窗口的短指令
```text
请读取 docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md。
你负责 Rust bat 侧 contract fixture 原始输出:
1. catalog.status available=true
2. catalog.status available=false
3. resource.manifest page0
4. 对应 official-sync-snapshot.json
请输出到 /tmp/bat-contract-fixture/rust/,不要手写 JSON,不要引用开发机真实资源目录。
输出后在 /tmp/bat-contract-fixture/notes.md 说明生成命令、是否做过归一化、哪些字段需要用户审核。
```
## 当前状态
- Go `bat-api` 已具备消费 `launcher_metadata` / `game_main_config_bootstrap` 的 mirror struct。
- Go `bat-api` 已具备 player-facing HTTP 控制面、OpenAPI 和管理控制白名单。
- 已归一化的 Rust contract fixture 已落仓库:
- `internal/api/testdata/contract/catalog-status.available.json`
- `internal/api/testdata/contract/catalog-status.unavailable.json`
- `internal/api/testdata/contract/resource-manifest.page0.json`
- `internal/api/testdata/contract/official-sync-snapshot.json`
- 原始交接产物仍位于 `/tmp/bat-contract-fixture/`;仓库内归一化 fixture 用于 schema/mirror 回归,
同机 live socket 验证使用 `make bat-api-local-live-smoke`,不依赖该交接目录。
- Go contract 测试读取仓库内归一化 fixture,不依赖 `/tmp/bat-contract-fixture/`、开发机资源目录或远端长期运行的 `bat`
- 真实长期 daemon 的生产部署仍需由部署环境持续运行;仓库已在本地隔离环境通过真实
daemon socket 完成端到端调用、版本切换、无 release、RPC 断线和恢复验证。真实官方
网络全量下载仍由 `make official-smoke` 独立负责。
+95 -417
View File
@@ -1,472 +1,150 @@
# 当前实现缺口清单
- **更新时间**2026-07-17
- **用途**集中跟踪当前代码中的占位实现、设计缺口和下一步验收项
- **更新时间**2026-09-04
- **文档角色**只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态
- **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。
- **Go 进度**`GO_STATUS.md`
- **资源布局契约**`../architecture/resource-release-layout.md`
- **权威计划**`../../PROJECT_PLAN.md`
- **历史资料**`docs/archive/``docs/reports/historical/` 只用于追溯。
---
## 1. 当前工程缺口
## 1. 基线缺口
### G-005AssetBundle 复杂解析仍未完成
### G-001Git 元数据不可用
状态:**部分完成,继续推进**
状态:**已关闭,采用新初始化基线**
当前已具备 UnityFS 容器校验、directory 文件提取、serialized file
object/type table/TypeTree 元数据、TextAsset、基础 MonoBehaviour 和
ScriptableObject 字段读取、TextUnit 提取,以及受支持字段的文件级重建。
原现象
仍需完成
- `.git/` 是空目录。
- `git status``not a git repository`
- 用真实资源 fixture 覆盖更多 MonoBehaviour、ScriptableObject、Unity 版本差异、
复杂容器和 managed reference registry/map entry 变体
- 为未知字段补充结构语义;不能把低保真猜测当作已支持格式。
- 完成发布级复杂对象重打包,并把 bundle、serialized file、path id、class id、
field path、offset 和 byte size 的定位信息贯通到稳定发布流程。
处理结果:
现有证据:`crates/bat-assetbundle` 的单元/重建测试、隔离真实 UnityFS 回归和
`bat-infrastructure` 的解析缓存测试。新增格式覆盖必须同时补真实 fixture、回归测试
和文档。
- 已执行 `git init`
- 已将初始分支调整为 `main`
- 已配置当前路径为 Git safe directory。
- `git status --short --branch` 已可用。
- 本轮创建首次基线提交。
### G-006:通用 Patch 发布仍未完成
限制:
状态:**基础完成,发布流程部分完成**
- 原项目历史未恢复。
- 后续历史从当前基线提交开始。
`bat-patch` 已提供 Binary/JSON/Text Patch、manifest、BLAKE3/size 校验和
rollback 元数据;文件级 `patch.apply` 与受支持的 UnityFS TextAsset、TypeTree
string field、managed-reference string field 写入及 localized publish/rollback
已可用。
验收
仍需完成
- `git log --oneline -1` 能看到基线提交
- 通用 manifest 驱动的跨类型 patch build/apply/publish/rollback
- 复杂 AssetBundle 重打包和完整翻译文件集合构建。
- 原版 release 与 localized release 双发布后的查询、分发和清理策略。
### G-002CAS 有两套实现边界
所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变
已发布的 `bat-resources/current``bat-localized/current`
状态:**已关闭**
### G-007Addressables 完整兼容仍未完成
原现象:
状态:**当前 JSON/compact 目标字段完成,独立二进制格式待后续**
- `crates/bat-cas-engine/src/storage.rs` 有文件系统存储。
- `infrastructure/src/cas/filesystem.rs` 也实现了文件系统 CAS repository
当前 JSON/compact catalog 已覆盖 path、hash、size、address、dependencies、
provider、bundle name、resource type 和 CRC,并有 fixture/golden 回归
处理结果
仍需完成
- `crates/bat-cas-engine` 新增 `repository` 组合层,成为 CAS 核心实现
- `infrastructure/src/cas/filesystem.rs` 已改为 `bat-core::CasRepository` 适配层
- infrastructure 不再直接写对象文件,不再维护自己的引用计数逻辑。
- 更多 Windows/Android 真实 catalog 形态和失败诊断
- 独立二进制 catalog 入口;在未支持前必须明确拒绝,不得静默丢字段
验收证据:
### G-009`bat-api` 仍是资源服务,不是完整官方游戏 API
- `bat-cas-engine::repository::FileSystemCasRepository`
- `bat_infrastructure::FileSystemCasRepository`
- `cargo test --workspace`
状态:**资源 bootstrap/分发和管理控制面已可用,业务 API 未完成**
### G-003CAS 引用计数和 GC 未实现
当前 `cmd/bat-api` 通过 `bat.sock` 读取 Rust 已发布 release,提供 bootstrap、
launcher 资源引导兼容、只读 CDN path、readiness、OpenAPI、鉴权管理入口和内嵌
dashboard。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。
状态:**已关闭**
仍需完成:
原现象:
- 完整游戏业务 API、账号/登录/网关链和完整 launcher 安装包更新链。
- 更丰富的 Resource/TextUnit/翻译记忆查询面。
- 真实官方网络长期运行报告;运行使用 `make official-smoke`,产物留在隔离目录。
- `FileSystemCasRepository::add_reference` 返回固定 `1`
- `remove_reference` 返回固定 `0`
- `get_reference_count` 返回固定 `1`
- `gc` 返回固定 `0`
- `crates/bat-cas-engine/src/refcount.rs` 是占位。
`bat-api` 不得复制 Rust 下载器、CAS、AssetBundle 解析、Patch 核心算法或同步状态机
处理结果:
### G-010:完整 Web 协作后台仍未完成
- `crates/bat-cas-engine/src/refcount.rs` 使用 SQLite 保存对象元数据和引用计数。
- `store()` 会存储对象并增加引用计数。
- `add_reference()``remove_reference()``get_reference_count()` 已持久化。
- `gc()` 删除引用计数为 0 的对象和元数据。
- `gc_candidates()` 提供 dry-run 能力。
状态:**内嵌 dashboard MVP 已完成,完整后台未开始**
验收证据:
当前页面可以调用已有资源、调度、任务、解析、翻译和 localized 控制接口。
- 引用计数增减有持久化测试。
- GC 不删除仍被引用对象。
- 并发引用更新测试通过。
仍需完成:
---
- 独立登录、角色权限和协作式翻译审核。
- Glossary/术语管理、批量审核、搜索和完整历史版本视图。
- 构建型前端工程、浏览器 E2E 和完整错误态交互门禁。
## 2. 核心功能缺口
### G-011ResourceRepository 查询面仍不完整
### G-004:CAS 写入不是生产级原子流程
状态:**部分完成**
状态:**已关闭**
当前已支持 CAS + SQLite 导入、资源类型/release/平台/path/parse status/TextUnit
format 等资源级过滤,`parse.text_units` / `parse.errors` 和翻译任务查询也已可用。
原现象
仍需完成
- 当前写入直接写目标路径
- 缺少临时文件、fsync、原子 rename、并发冲突处理
- 更丰富的 TextUnit、翻译记忆和 Patch 发布资源视图
- 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物
- 更多 schema 迁移、权限、并发和损坏恢复场景验证。
处理结果:
### G-011D:双 release 的完整查询与发布策略仍未完成
- `FileSystemStorage::put()` 使用临时文件写入、文件 sync、原子 rename、目录 sync。
- 读取对象时强制 Hash 校验。
- 并发写入相同内容只保留一个对象,引用计数按调用次数递增。
- 损坏对象读取返回 `HashMismatch`
状态:**受支持范围完成,通用范围部分完成**
验收证据:
官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和
rollback`localized.status` 能校验当前官方 release 与 patch manifest 的一致性。
- 写入失败不会留下可见半成品对象
- 并发写入相同内容只产生一个对象。
- 读取时 Hash 不匹配会返回明确错误。
### G-005AssetBundle 解析器仍是占位
现象:
- `crates/bat-assetbundle/src/parser.rs` 只有 `Parser::name`
- `types.rs` 只有 `AssetType::TextAsset`
影响:
- 无法解析真实 UnityFS。
- 无法提取 TextAsset 或配置文本。
验收:
- 能解析结构化测试样本。
- 支持 UnityFS header、blocks、directory、metadata。
- 错误包含偏移和字段上下文。
### G-006Patch 引擎仍是占位
现象:
- `binary::apply_patch` 返回空 `Vec`
- `json::apply_json_patch` 返回空字符串。
影响:
- 无法生成或应用补丁。
- 回滚和完整性校验无法落地。
验收:
- Binary patch 能完成 diff/apply 往返。
- JSON patch 能应用 RFC 6902 patch。
- Patch manifest 包含 hash、版本和回滚信息。
### G-007Addressables Catalog 解析不完整
状态:**部分关闭**
现象:
- `AddressablesCatalogDriver` 已能解析当前真实形态 JSON catalog fixture/golden。
- 已输出 path、hash、size、resource_type、address、dependencies、metadata。
- 仍需覆盖更多官方 catalog 结构变体、二进制/压缩字段组合和更明确的失败诊断。
影响:
- 当前解析能力可以服务 Manifest inspect 和部分资源索引,但还不能宣称完整兼容所有 Unity Addressables/SBP catalog 形态。
验收:
- 能解析项目目标版本的真实 Catalog 样本集合。
- 解析结果包含资源 key、provider、dependency、hash、size、path。
- 对不支持的 catalog 结构返回明确错误,而不是静默丢字段。
---
## 3. 应用层缺口
### G-008Go CLI 尚未实现
现象:
- `cmd/bat` 已有 `main.go`,但只是通过 cgo 调用 `bat-ffi` 的最小骨架(doctor/manifest inspect/sync plan),不是产品级用户入口;且默认 Go/Rust 集成边界应是 `bat --json` 进程边界,而非 FFI。
- `internal/ffi/ffi.go` 已存在,但只是可选 CGO 兼容包装,不是用户可运行的产品 CLI,也不是默认集成边界。
- `go test ./...` 当前没有产品级 Go package 覆盖。
当前进展:
- 对接边界已就绪:Rust daemon 的 `bat.sock` Unix socket JSON-RPC Backend APIissue #1 主体已完成:统一 envelope、`BAT-ERR` 错误码模型、`daemon.*`/`resource.*`/`catalog.*`/`task.*` 方法集)与 `bat --json` 进程边界均可用。Go CLI 缺的是产品级入口本身,实现时应重写 `cmd/bat` 现有 cgo 骨架为 RPC/进程边界对接。
影响:
- 用户没有统一入口。
- 同步、提取、补丁流程无法从命令行串联。
验收:
- `bat doctor` 可运行。
- `bat --help` 命令结构稳定。
- 命令支持默认人类可读输出和 `--json` 机器输出。
- Go CLI 默认通过 Rust `bat --json` 进程边界获取同步 report;除非明确兼容需求,不依赖 FFI。
### G-009API Server 和 OpenAPI 尚未实现
现象:
- `api/` 只有目录结构。
- 无 handler、service、OpenAPI schema。
影响:
- Web 和第三方集成无服务端入口。
验收:
- `/api/v1/health` 可用。
- 统一错误结构落地。
- OpenAPI 与实际路由同步。
### G-010Web 管理后台尚未实现
现象:
- `web/` 只有目录结构。
影响:
- 翻译审核、术语管理、Dashboard 无 UI。
验收:
- 登录、权限、翻译审核、术语管理基础流程可用。
---
## 4. 数据与翻译缺口
### G-011Resource Repository 未持久化
状态:**部分关闭**
影响:
- `SqliteResourceRepository` 已存在,可按领域 repository 接口保存资源元数据。
- `ResourceImportService` 已能把 manifest 中有数据的资源写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会分类索引。
- 官方同步下载结果尚未作为用户级流程自动触发导入 CAS + ResourceRepository。
- 迁移、版本化 schema 和 CLI 查询入口仍需补齐。
验收:
- schema 和迁移可重复执行。
- 可按版本、类型、hash、路径查询资源。
- 官方同步后的资源可通过 CLI 查询并能追溯到 CAS 对象。
### G-011A:资源导入链路基础能力不足
状态:**已关闭**
历史现象:
- 资源导入链路只导入 AssetBundle。
- 非 AssetBundle manifest 条目只会被跳过。
- 导入报告只包含 UnityFS 基础摘要,不包含稳定分类统计。
处理结果:
- `ResourceImportService` 会把有数据的 manifest 条目导入 CAS 并写入 `ResourceRepository`
- AssetBundle 仍执行 UnityFS header/block/directory 摘要解析。
- TextAsset、TableBundle、Media 会按资源类型分类,缺少数据时记录为 skipped,便于渐进导入。
- `ResourceImportReport` 增加 `category_counts``ImportedResource` 增加 `resource_type``category` 和可选 UnityFS 摘要。
验收:
- `cargo test -p bat-infrastructure import::tests::`
- `cargo test -p bat-infrastructure --test synthetic_phase2_import`
### G-011B:官方版本状态管理不明确
状态:**已关闭**
历史现象:
- 当前可用版本主要靠 `current` symlink 和 release 内 snapshot 推断。
- 未显式保存“正在拉取版本”和“失败版本”。
- 上一个可用版本需要从目录状态间接判断。
处理结果:
- 新增 `<output>/official-version-state.json`
- 开始下载后写入 `in_progress_version`
- 发布成功后写入 `current_completed_version``previous_available_version`
- 失败或中断后写入 `failed_versions` 并清空 in-progress。
- `bat status` 会读取并展示版本状态摘要。
验收:
- `cargo test -p bat-infrastructure version_state`
- `cargo test -p bat-infrastructure --test official_game_main_config_bootstrap`
### G-011C:真实 fixture 与回归样本不足
状态:**已关闭当前阶段**
历史现象:
- 已有 Addressables real-shape fixture/golden,但缺少按问题类型命名的当前/上一版本/结构变化样本。
- 403/404 和 hash mismatch 主要依赖单测内联构造,不便于后续回归扩展。
处理结果:
- 新增 `adapters/tests/fixtures/addressables_regression/current_catalog.json`
- 新增 `adapters/tests/fixtures/addressables_regression/previous_catalog.json`
- 新增 `adapters/tests/fixtures/addressables_regression/structure_changed_catalog.json`
- 新增 `infrastructure/tests/fixtures/official_regression/http_403.json`
- 新增 `infrastructure/tests/fixtures/official_regression/http_404.json`
- 新增 `infrastructure/tests/fixtures/official_regression/hash_mismatch_catalog.json`
- 对应测试会解析这些 fixture,防止样本只存在但不参与验证。
验收:
- `cargo test -p bat-adapters --test addressables_regression`
- `cargo test -p bat-infrastructure regression_fixture`
仍需完成通用 patch 发布、复杂重打包、双 release 查询/分发视图和清理策略
### G-012Translation Memory 未实现
影响:
- 无法复用人工翻译和 AI 翻译历史。
验收:
- 精确匹配、模糊匹配、上下文匹配可用。
- 记录 Provider、模型、审核状态和历史版本。
需要支持精确、模糊和上下文匹配,并保留 provider、模型、审核状态和历史版本。
### G-013Glossary 未实现
影响:
需要支持术语优先级、别名、分类、冲突检测和审核。
- 无法保证术语一致性。
- AI 翻译无法强制遵守术语。
### G-014:完整 Provider 扩展体系未实现
验收:
当前已有 mock/Crowdin provider worker、lease、重试和 TextUnit 结果落库;仍需建立
可替换的 Provider 扩展体系,以及批处理、限流、成本统计和质量检查。
- 术语优先级高于 AI。
- 支持别名、分类、冲突检测、审核。
## 2. 已确定的架构边界
### G-014AI Provider 抽象未实现
以下内容不是待实现的重复任务:
影响:
1. 正式资源同步和运维命令行是 Rust `bat`;不另做产品级 Go 同步 CLI。
2. Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 只读取已发布资源,
通过 `bat.sock` 提供 bootstrap、分发和受限管理入口。
3. `bat-api` 是资源 bootstrap/分发服务,**不是完整官方游戏 API**。
4. `bat-ffi` 只保留无状态兼容 helper,不承载 daemon、下载器、CAS handle 或主控制面。
5. 官方原版 release 和 localized release 使用独立目录、staging、manifest、current
和 rollback 生命周期。
6. `daemon.clean-stable` 是 CLI 生命周期清理入口,不在 live RPC 内执行在线清理;
`task.create` 也不作为通用 RPC 入口开放。
7. `status` / `status_code` 描述生命周期,`BAT-ERR-*` 描述错误;两者不混用。
- 无法接入 DeepL/OpenAI/Anthropic/Google/Azure
详细阶段报告仍保留在 `docs/reports/historical/`,不作为当前实现依据
验收:
## 3. 后续推进顺序
- Provider 可替换
- 支持批处理、限流、重试、成本统计和质量检查
---
## 5. 文档与发布缺口
### G-015README 与当前真实状态不完全一致
状态:**已关闭**
现象:
- 旧 README 描述了最终架构,但部分功能尚未实现。
验收:
- README 明确区分已实现、开发中、规划中。
处理结果:
- README 已明确区分当前可用能力、未完成模块、官方同步运行命令和近期优先级。
### G-016:架构文档需要更新为当前路线图
状态:**已关闭当前阶段**
现象:
- 旧 `docs/architecture/README.md` 偏目标架构,容易让读者误以为 Go 同步器和 API/Web 已经可用。
验收:
- 增加 ADR 或架构决策记录。
- 明确 Rust/Go/DB/Plugin 边界。
处理结果:
- 架构 README 已明确当前 Rust 官方同步入口、Go 计划边界和目标架构差异。
- 官方资源后端说明由 `docs/architecture/official-resource-backend.md` 承载。
- `bat-ffi` 已降级为可选无状态兼容层,主集成边界明确为 `bat --json` 进程边界或未来稳定 SDK。
### G-017CI 未落地
状态:**已关闭(决策:不引入托管 CI)**
原现象:
- 仓库没有 GitHub Workflows 或等价托管 CI,质量门槛无自动远端执行。
处理结果:
- 明确决策:本项目不加入 GitHub Workflows,也不引入其他托管 CI。
- 质量门禁由本地默认验证命令承担:提交前执行 `cargo fmt` / `cargo clippy --workspace --all-targets -- -D warnings` / `cargo test --workspace`(见 `docs/guides/development.md``docs/guides/baseline.md`)。
- 发布类检查(build、smoke)由 `Makefile``scripts/` 下的可重复脚本承担(如 `make official-smoke`)。
限制:
- 门禁执行依赖提交者本地自觉,无远端强制拦截;若未来出现多人协作或外部贡献需求,可重新评估本决策。
### G-018:真实官方网络全量下载 smoke test 已固化为可重复命令
状态:**已关闭(已固化可重复 smoke 命令;真实下载产物不纳入 Git)**
历史现象:
- 本地测试覆盖 mock、fixture、synthetic import 和 CLI 参数。
- 曾缺少真实官方网络全量下载的固定 runbook 和可重复命令。
处理结果:
- 新增 `scripts/official-full-pull-smoke.sh`,默认在 `/tmp/bat-official-smoke-<UTC timestamp>/` 下创建隔离资源目录、状态目录和报告目录。
- 新增 `make official-smoke` 统一入口。
- 新增 `docs/guides/official-full-pull-smoke.md`,记录目标、命令、输出结构、环境变量、安全边界和成功判定。
- smoke 流程覆盖 dry-run plan、首次全量拉取、二次 `up_to_date`、人工破坏 active release 文件后的 `repair`、repair 后 `verify`
- 脚本会检查二次 `up_to_date`、repair 完成、verify `healthy=true`,并检查首次拉取和 repair 的 stderr log 中存在总体下载进度、单文件进度和校验结果日志。
- 运行报告 `SMOKE_REPORT.md` 记录实际输出目录、active release、文件数量、release 大小和被破坏文件;大型官方资源文件保留在隔离输出目录,不纳入 Git。
验收:
- 使用 `scripts/official-full-pull-smoke.sh``make official-smoke`
- 默认输出目录必须是独立 `/tmp` 目录;非 `/tmp` 路径需要显式设置 `BAT_SMOKE_ALLOW_NON_TMP=1`,且输出目录必须为空。
- 脚本退出码为 0 即表示 runbook 验收通过。
- 真实网络执行需要外部网络和足够磁盘空间;本仓库只保存 runbook、脚本和测试,不保存官方大文件。
后续跟踪(非阻塞):
- 官方同步长期运行测试正在进行,运行报告将在后续提供。
### G-019:下载失败重试策略不够精细
状态:**已关闭**
历史现象:
- curl 失败只按固定次数重试,错误信息主要保留最后一次 stderr。
- 403/404 和 5xx 没有不同处理。
- 单个 URL 长期失败时缺少可查询的 quarantine 诊断状态。
- 旧 launcher 包下载虽然有 primary/backup CDN 路径,但失败信息没有统一分类。
处理结果:
- 新增统一 curl 失败分类:`http_forbidden``http_not_found``http_client_error``http_too_many_requests``http_server_error``dns``connect``timeout``tls``interrupted``network` 等。
- 403/404/普通 4xx 视为不可重试并提前停止;5xx、429、DNS、连接、超时、中断和网络类错误按尝试次数重试。
- 单个资源 URL 最终失败会写入 `official-download-quarantine.json`,记录失败类型、HTTP 状态、是否可重试、尝试次数和最后错误。
- 失败 URL 会发出 Failed progressdaemon status 和 `bat-events.jsonl` 暴露失败类型、HTTP 状态、重试属性和 quarantine 状态。
- quarantine 会中断同步并阻止发布不完整 staging;下一轮成功下载或复用后清理对应 quarantine 条目。
- 旧 launcher 包或 `resources.assets` 下载在 primary CDN 失败后会切换官方 backup CDN。
验收:
- HTTP 404 不重试,写入 quarantinemanifest 不写失败项。
- HTTP 5xx 重试到上限后写入 quarantine。
- launcher primary CDN 失败后会尝试官方 backup CDN。
---
## 6. 当前关闭顺序建议
1. G-008
2. G-011
3. G-005
4. G-007
5. G-012
6. G-006
这个顺序优先补齐用户入口和官方同步结果的资源索引编排,再推进解析、翻译和补丁。G-018 已固化为可重复 smoke 命令并关闭;G-017 已按"不引入托管 CI"决策关闭。
1. 继续 G-005:真实 AssetBundle 样本、复杂字段解析和发布级重打包
2. 继续 G-006/G-011D:通用 manifest Patch 和双 release 查询/清理策略
3. 继续 G-011/G-012/G-013/G-014:资源查询、Translation Memory、Glossary 和 Provider
扩展体系。
4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。
5. 最后推进完整 Web 协作后台和完整游戏业务 API。
+151
View File
@@ -0,0 +1,151 @@
# Go 侧进度与边界(权威)
- **更新时间**2026-09-04
- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。
- **关联缺口**G-009(资源 bootstrap/分发);相关契约见 `docs/architecture/official-resource-backend.md` §7 和 `docs/guides/bat-api-local-live-smoke.md`
---
## 1. 三个入口分别是什么
| 名称 | 路径 / 产物 | 角色 | 是否产品入口 |
|---|---|---|---|
| **Rust `bat`** | `infrastructure` bin → 正式同步二进制 | 官方资源**自动**发现 / 拉取 / 校验 / 发布 / watch·daemon / 运维子命令 | **是(同步与运维命令行)** |
| **Go `bat-api`** | `cmd/bat-api``bin/bat-api` | **资源 bootstrap + 分发 HTTP 服务**(官方 CDN path 形态)+ release 观察 API + 内嵌 dashboard | **是(bootstrap/分发服务与管理入口)** |
| **Go 试验 CLI** | `cmd/bat``bin/bat-go`(不得再叫 `bin/bat` | FFI 演示骨架 | **否** |
### 1.1 「同步命令行 = Rust `bat`」的含义
人类做资源同步与运维时,正式命令行是 **Rust 编译的 `bat`**(近乎全自动:`--auto-discover``--watch` / `--daemon` 后只需偶发 `status` / `refresh` / `repair`,不需要持久手操维护)。
这**不是**说整个项目只有 Rust,也**不是**取消 Go 入口:
- Go 的正式产品入口是 **`bat-api` 服务进程**(给客户端/工具提供启动前资源 bootstrap、server-info 改写、已发布资源字节和内嵌管理 dashboard),不是再做一套同步 CLI。
- Go `cmd/bat` 仅试验,禁止与 Rust `bat` 二进制重名。
### 1.2 `bat``bat-api` 的关系
`bat` 是资源生产者和状态拥有者;`bat-api` 是资源读侧和 HTTP 入口。
| 关系面 | Rust `bat` / daemon | Go `bat-api` |
|---|---|---|
| 资源发现 | 读取官方 launcher/resource metadata,解析 `GameMainConfig`、server-info 和 Addressables root | 通过 `bat.sock` 读取已发布版本摘要,不重新探测官方 metadata |
| 下载与发布 | 下载、校验、staging、原子发布 `current -> versions/<id>`,维护 manifest/snapshot/version-state | 不下载、不写 staging、不改 version-state;生产资源根来自 RPC 返回的 `resource_root` |
| 启动前资源入口 | 暴露 `catalog.status` / `resource.manifest` 等 RPC 数据 | 提供 `/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、`/v1/server-info`、CDN path 和 `/admin/dashboard/`,组织给客户端/补丁器/维护者使用 |
| 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引;认证 Web 控制面仅白名单转发 reload/refresh/restart/sync/verify/repair/catalog-refresh、schedule/task/log/parse/translation/localized 方法,不持有或写入同步状态 |
这条边界允许 `bat-api` 做资源 bootstrap 兼容,但不允许它复制 Rust 下载器或伪装完整游戏业务服务。
### 1.3 决策(已核验)
1. **Go 同步 CLI 边界已确定**:不另做产品级 Go 同步/运维 CLI,正式入口是 Rust `bat`
2. **G-009**:资源 bootstrap/分发 MVP 部分完成;非完整游戏业务 API。
3. **USERGUIDE 的 bat-api 基础章节已补**;同机 live 联调 runbook 和内嵌 dashboard MVP 已补,真实官方网络下载仍由独立 smoke 负责。
---
## 2. 既有约定核对表(不可丢)
### 职责
| ID | 约定 |
|---|---|
| A | **自动发现 / 下载 / 校验 / 发布 / watch·daemon** 只在 **Rust `bat`** |
| B | **`bat-api` 只读分发**已发布 release,不实现下载器,不写 staging/version-state |
| C | 仿真范围 = **资源拉取相关**resource bootstrap + CDN path + 可选 server-info);**不是**完整游戏业务 API |
| D | launcher 资源 metadata 可作为 bootstrap 输入/输出兼容;账号、登录、网关和鉴权全链 **非 G-009 关闭条件** |
| E | USERGUIDE bat-api 基础章节和同机 live smoke 实战样例已补 |
### 发现与数据
| ID | 约定 |
|---|---|
| F | 版本/清单经 **`bat.sock` JSON-RPC**`--socket`);不读 daemon 内部状态文件 |
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再 catalog/manifest |
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
| I | 生产中 Rust `bat``bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 |
| J | 索引以 **manifest + 磁盘 Present/size** 为准 |
| J2 | RPC 状态以 Rust 返回的 `status` / `status_code` 为准;`bat-api` 只读消费,不自行推导同步状态 |
### 进程配置
| ID | 约定 |
|---|---|
| K | `.env` / 环境变量 / CLI:端口、public base、RPC socket、RPC 刷新周期;**预留** database/redis |
| L | 管理面 / bootstrap`/healthz``/readyz``/v1/bootstrap``/v1/release``/v1/resources``/openapi.yaml``/admin/` 控制入口 |
| M | CDN`GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...`,支持 Range、ETag、Last-Modified、长期缓存头 |
| N | server-info 可选;**只改 AddressablesCatalogUrlRoot** |
| N2 | launcher 兼容仅限资源引导:`/v1/launcher/bootstrap``/api/launcher/...` 形状端点输出已发布 release、launcher metadata 和 GameMainConfig 摘要;不下载 launcher 包、不生成完整 PC package update manifest、不仿造登录/网关 |
| N3 | 玩家-facing HTTP 控制面:可配置 token 鉴权、进程内限流、访问日志、反代 IP 适配、动态 JSON `no-store``/v1/resources` 分页上限 |
| N4 | `/admin/control/{action}` 白名单控制面;`restart` 通过 Rust live RPC 启动 lifecycle controllerGo 不直接执行 `bat` binary |
### 工程
| ID | 约定 |
|---|---|
| O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 |
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
| Q | 空目录标明 reserved empty |
| R | 默认门禁:`make test-go-api` + `make build-go-api` + `make check-docs`(无 FFI |
---
## 3. 组件进度
| 组件 | 路径 | 状态 | 说明 |
|---|---|---|---|
| Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``catalog.*``parse.*``localized.status/publish/rollback``task.*``translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread` 和文件级 UnityFS patch 调用;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + 内嵌 dashboard + `.env` |
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI |
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
| 空骨架 | `api/``pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
| Web | `web/` | **内嵌 dashboard MVP** | 完整协作后台、登录/角色和术语管理仍属 G-010 剩余 |
`go list ./...` 当前包:
- `bat-api/cmd/bat-api`
- `bat-api/cmd/bat`
- `bat-api/internal/api`
- `bat-api/internal/backendrpc`
- `bat-api/internal/ffi`
---
## 4. 验证门禁
```bash
# 默认(提交前 / CI 建议)
make test-go-api
make build-go-api
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
make check-docs
# 可选:改 FFI 或试验 CLI 时
make build-ffi
make test-go-ffi
make build-go-cli # 产出 bin/bat-go
```
---
## 5. 与缺口对应
| 项 | 状态 |
|---|---|
| Go 同步 CLI | **边界已确定**(正式同步 CLI = Rust `bat` |
| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*``task.*``parse.*`、翻译状态回写代理、内嵌 dashboard、同机 live smoke 和部署模板;持久化仍另议 |
| G-010 Web | 内嵌 dashboard MVP 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 |
---
## 6. 资源布局与逆向
- **Release / URL / 分发契约(权威)**`docs/architecture/resource-release-layout.md`
- 真机全量实勘、seed inventory diff 和样本采集按该文档 §9–§10 执行
## 7. 后续(不在进度统一范围内)
1. 预留 database/redis 的接入时机另议
2. 真实官方网络全量下载长期运行报告(使用 `make official-smoke`,与同机 live 联调独立)
3. launcher 完整安装包更新链 / 登录网关链(如需推进,应另立范围明确的后续需求)
+91
View File
@@ -0,0 +1,91 @@
# 解析模块维护冻结(历史记录)
**状态**:历史记录,已解除
**生效时间**2026-07-30
**解除时间**2026-09-04
本文保留解析模块维护冻结期间的原始规则和例外说明。冻结已于
2026-09-04 解除,以下内容不构成当前开发约束;当前解析开发以源码、测试、
`CURRENT_STATUS.md``docs/architecture/assetbundle.md` 为准。
---
## 原冻结记录
原记录发布时状态:**生效中**
冻结目标:停止继续扩大 UnityFS / AssetBundle / Addressables / TypeTree 解析能力,把当前工作重心切换到运行稳定性、代码审核问题、文档一致性和发布链路可靠性。
## 冻结范围
冻结覆盖以下 Rust 解析相关模块和对外入口:
- `crates/bat-assetbundle`
- `adapters/src/unity*`
- `infrastructure/src/official_parse.rs`
- `infrastructure/src/resources.rs` 中解析缓存、TextUnit 索引和解析状态相关逻辑
- `unityfs.*``parse.*``text.*` 相关 RPC / CLI 契约
- Addressables catalog、UnityFS、serialized file、TypeTree、TextUnit、AssetBundle patch 相关文档声明
## 允许变更
冻结期只允许以下解析相关变更:
- 修复编译失败、格式化失败、clippy 报错和测试失败。
- 修复真实运行中已经复现的 panic、错误状态污染、重复解析、缓存失效、状态不一致或诊断误导。
- 补充回归测试,前提是测试覆盖的是已存在能力的稳定性问题,不宣称新增解析能力。
- 修正文档、CLI 帮助、RPC 参考和状态文件中与当前实现不一致的解析能力声明。
- 改善错误信息、日志字段、状态记录和失败恢复,但不得改变解析输出契约,除非是修复错误契约且同步迁移说明。
## issue 43 的明确例外
本次 issue 43 经用户明确授权,允许新增 `bat` 的工作流编排入口:
- `parse run` 只刷新已有解析输出、TextUnit 索引和翻译队列;
- `parse repack` 只调用已有 TextAsset、TypeTree string 和受支持语义字段 patch 实现;
- `i18n` 工作台和 `publish` 只消费已有 TextUnit 输出,并发布独立汉化 release。
该例外不解冻解析器,不新增 UnityFS/AssetBundle/Addressables/TypeTree 解析类型、字段覆盖、catalog 结构或合成 fixture 能力。后续任何扩大解析覆盖的变更仍需单独解冻授权。
## issue 2/3 的开发例外
授权时间:2026-08-19
用户明确授权将 issue #2/#3 作为解析开发进展继续推进。本次例外允许:
- Addressables JSON/compact catalog 的 provider、bundle name、hash、size、CRC、资源类型和依赖关系字段补全,以及对应 SQLite/fixture 回归;
- UnityFS 已有 header、block、directory、压缩和 alignment 能力的校验加固,以及隔离真实 bundle 回归;
- 更新解析路线图、状态和 RPC/CLI 资源索引字段说明。
本次例外不包含发布级复杂对象重打包、完整 Unity 版本兼容承诺或新的汉化发布控制面;这些仍按后续 Patch/发布路线单独验收。
## 禁止变更
冻结期禁止以下解析相关变更:
- 新增 TypeTree 语义类型、字段族、managed reference 变体、Unity 内建结构体覆盖或 Addressables catalog 结构覆盖。
- 用纯合成 fixture 推进“完整解析”并把它记录为已支持能力。
- 开放新的写入型 `unityfs.*` / `patch.*` RPC 或 CLI。
- 修改解析结果 schema、TextUnit schema、patch field JSON 语义或缓存状态格式,除非它是阻断级 bug 修复并附带兼容策略。
- 将解析器和官方同步、汉化发布、Go API、Crowdin 或客户端流程进一步耦合。
## 解冻条件
解析扩展重新启动前必须同时满足:
- Rust `bat` 官方同步、daemon、status、校验、断点续传、增量更新和解析缓存链路稳定。
- 当前 P0/P1 维护 issue 已关闭或被明确降级。
- `bat-api` 与 Rust RPC / CLI 契约完成字段统一和联调验证。
- 真实资源 fixture、验证命令和验收标准已写入文档,不能只依赖合成样本。
## 冻结期验证
解析相关维护变更至少运行:
```bash
cargo fmt --check
cargo test -p bat-assetbundle --locked
cargo clippy -p bat-assetbundle --all-targets --locked -- -D warnings
```
如果变更影响 `bat` CLI、RPC、官方解析缓存或 TextUnit 索引,还必须补充对应 `bat-infrastructure` 测试或说明未运行原因。
+4 -1
View File
@@ -1,6 +1,8 @@
# 历史报告归档说明
本目录只保存追溯资料,不代表当前项目状态。当前状态以根目录 `CURRENT_STATUS.md``PROJECT_PLAN.md``DOCS_INDEX.md``docs/reports/CURRENT_GAPS.md` 为准。
本目录只保存追溯资料,不代表当前项目状态。当前实现以源码、测试、根目录
`CURRENT_STATUS.md` 和对应专项状态文档为准;`PROJECT_PLAN.md` 只描述目标和路线图,
`DOCS_INDEX.md` 只负责文档分类,`docs/reports/CURRENT_GAPS.md` 只记录当前缺口。
归档分类:
@@ -10,5 +12,6 @@
- `quality/`:早期质量状态报告。
- `build-logs/`:历史构建、测试和 Clippy 输出。
- `nested-docs/`:从误嵌套 `docs/docs` 移出的历史报告。
- `PARSER_FREEZE.md`2026-07-30 生效、2026-09-04 解除的解析模块维护冻结记录。
新增运行产物、smoke 输出、质量扫描输出和本地分析报告不要放入本目录;这些文件应写入 `/tmp`、显式的隔离输出目录,或被 `.gitignore` 覆盖的本地生成报告目录。
+4 -4
View File
@@ -70,12 +70,12 @@
## 📚 重要文档索引
### 架构和设计
- `docs/ARCHITECTURE_REVIEW.md` - 完整架构审查(1903行)
- `docs/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md` - 技术分析
- `docs/CODE_QUALITY_IMPROVEMENT.md` - 代码质量优化详情
- `docs/archive/ARCHITECTURE_REVIEW.md` - 完整架构审查(1903行)
- `docs/archive/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md` - 技术分析
- `docs/reports/historical/nested-docs/CODE_QUALITY_IMPROVEMENT.md` - 代码质量优化详情
### 进度报告
- `docs/PHASE_1_WEEK_1_COMPLETE.md` - Week 1 详细报告
- `docs/reports/historical/nested-docs/PHASE_1_WEEK_1_COMPLETE.md` - Week 1 详细报告
- `PHASE_1_WEEK_1_FINAL_REPORT.md` - Week 1 最终报告
### 代码质量
@@ -109,11 +109,11 @@ BlueArchiveToolkit/
## 📚 创建的文档
1. ✅ [ARCHITECTURE_REVIEW.md](./docs/ARCHITECTURE_REVIEW.md) - 完整架构审查(1903 行)
2. ✅ [BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md](./docs/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md) - 技术分析报告
3. ✅ [PHASE_0.5_REPORT.md](./docs/PHASE_0.5_REPORT.md) - 深度验证报告
4. ✅ [PHASE_1_WEEK_1_COMPLETE.md](./docs/PHASE_1_WEEK_1_COMPLETE.md) - Week 1 详细报告
5. ✅ [WEEK_1_VERIFIED.md](./WEEK_1_VERIFIED.md) - 最终验证报告
1. ✅ [ARCHITECTURE_REVIEW.md](../../../archive/ARCHITECTURE_REVIEW.md) - 完整架构审查(1903 行)
2. ✅ [BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md](../../../archive/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md) - 技术分析报告
3. ✅ [PHASE_0.5_REPORT.md](../nested-docs/PHASE_0.5_REPORT.md) - 深度验证报告
4. ✅ [PHASE_1_WEEK_1_COMPLETE.md](../nested-docs/PHASE_1_WEEK_1_COMPLETE.md) - Week 1 详细报告
5. `WEEK_1_VERIFIED.md` - 原报告未纳入当前归档。
---
+1 -1
View File
@@ -1,3 +1,3 @@
module bluearchive-toolkit
module bat-api
go 1.26.4
+2
View File
@@ -12,7 +12,9 @@ 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" }
bat-patch = { path = "../crates/bat-patch" }
anyhow.workspace = true
thiserror.workspace = true
serde.workspace = true
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,201 @@
use super::report_output::print_report;
pub(super) fn run_write_patch_command(options: &CliOptions) -> anyhow::Result<()> {
match options.command {
CliCommand::PatchApply => {
let params = patch_apply_params_from_options(options)?;
let report = apply_patch_file(&params)?;
print_report(options.output_format, &report)
}
CliCommand::UnityFsPatchTextAsset => {
let params = unityfs_text_asset_params_from_options(options)?;
let report = apply_unityfs_text_asset_patch_file(&params)?;
print_report(options.output_format, &report)
}
CliCommand::UnityFsPatchStringField => {
let params = unityfs_string_field_params_from_options(options)?;
let report = apply_unityfs_string_field_patch_file(&params)?;
print_report(options.output_format, &report)
}
CliCommand::UnityFsPatchField => {
let params = unityfs_field_params_from_options(options)?;
let report = apply_unityfs_field_patch_file(&params)?;
print_report(options.output_format, &report)
}
_ => Err(anyhow::anyhow!("不是写入 patch 命令")),
}
}
pub(super) fn is_write_patch_command(command: CliCommand) -> bool {
matches!(
command,
CliCommand::PatchApply
| CliCommand::UnityFsPatchTextAsset
| CliCommand::UnityFsPatchStringField
| CliCommand::UnityFsPatchField
)
}
pub(super) fn validate_write_patch_options(options: &CliOptions) -> anyhow::Result<()> {
match options.command {
CliCommand::PatchApply => {
let _ = patch_apply_params_from_options(options)?;
reject_unityfs_write_options(options, "patch-apply")?;
}
CliCommand::UnityFsPatchTextAsset => {
let _ = unityfs_text_asset_params_from_options(options)?;
reject_patch_apply_options(options, "unityfs-patch-text-asset")?;
if options.unityfs_field_path.is_some()
|| options.unityfs_replacement_text.is_some()
|| options.unityfs_expected_value.is_some()
{
return Err(anyhow::anyhow!(
"unityfs-patch-text-asset 不接受 --field-path、--string-field-path、--replacement-text 或 --expected-value"
));
}
}
CliCommand::UnityFsPatchStringField => {
let _ = unityfs_string_field_params_from_options(options)?;
reject_patch_apply_options(options, "unityfs-patch-string-field")?;
if options.unityfs_expected_name.is_some() {
return Err(anyhow::anyhow!(
"unityfs-patch-string-field 不接受 --expected-name"
));
}
}
CliCommand::UnityFsPatchField => {
let _ = unityfs_field_params_from_options(options)?;
reject_patch_apply_options(options, "unityfs-patch-field")?;
if options.unityfs_expected_name.is_some()
|| options.unityfs_replacement_text.is_some()
|| options.unityfs_expected_value.is_some()
{
return Err(anyhow::anyhow!(
"unityfs-patch-field 不接受 --expected-name、--replacement-text 或 --expected-value;请使用 --replacement-json / --expected-json"
));
}
}
_ => {}
}
Ok(())
}
fn patch_apply_params_from_options(options: &CliOptions) -> anyhow::Result<PatchApplyParams> {
Ok(PatchApplyParams {
kind: require_cli_option(options.patch_kind, "--patch-kind")?,
source_path: require_cli_option(options.patch_source_path.clone(), "--source-file")?,
patch_path: require_cli_option(options.patch_patch_path.clone(), "--patch-file")?,
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
})
}
fn unityfs_text_asset_params_from_options(
options: &CliOptions,
) -> anyhow::Result<UnityFsTextAssetPatchParams> {
Ok(UnityFsTextAssetPatchParams {
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
serialized_file_path: require_cli_option(
options.unityfs_serialized_file_path.clone(),
"--serialized-file",
)?,
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
replacement_path: require_cli_option(
options.unityfs_replacement_path.clone(),
"--replacement-file",
)?,
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
expected_name: options.unityfs_expected_name.clone(),
})
}
fn unityfs_string_field_params_from_options(
options: &CliOptions,
) -> anyhow::Result<UnityFsStringFieldPatchParams> {
let has_replacement_text = options.unityfs_replacement_text.is_some();
let has_replacement_path = options.unityfs_replacement_path.is_some();
if has_replacement_text == has_replacement_path {
return Err(anyhow::anyhow!(
"unityfs-patch-string-field 必须且只能指定 --replacement-text 或 --replacement-file 其中一个"
));
}
Ok(UnityFsStringFieldPatchParams {
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
serialized_file_path: require_cli_option(
options.unityfs_serialized_file_path.clone(),
"--serialized-file",
)?,
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
field_path: require_cli_option(
options.unityfs_field_path.clone(),
"--field-path/--string-field-path",
)?,
replacement_text: options.unityfs_replacement_text.clone(),
replacement_path: options.unityfs_replacement_path.clone(),
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
expected_value: options.unityfs_expected_value.clone(),
})
}
fn unityfs_field_params_from_options(
options: &CliOptions,
) -> anyhow::Result<UnityFsFieldPatchParams> {
if options.unityfs_replacement_path.is_some() {
return Err(anyhow::anyhow!(
"unityfs-patch-field 不接受 --replacement-file;请使用 --replacement-json"
));
}
Ok(UnityFsFieldPatchParams {
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
serialized_file_path: require_cli_option(
options.unityfs_serialized_file_path.clone(),
"--serialized-file",
)?,
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
field_path: require_cli_option(
options.unityfs_field_path.clone(),
"--field-path/--string-field-path",
)?,
replacement: require_cli_option(
options.unityfs_replacement_value.clone(),
"--replacement-json",
)?,
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
expected_value: options.unityfs_expected_semantic_value.clone(),
})
}
fn require_cli_option<T>(value: Option<T>, name: &str) -> anyhow::Result<T> {
value.ok_or_else(|| anyhow::anyhow!("缺少必要参数 {name}"))
}
fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Result<()> {
if options.patch_kind.is_some()
|| options.patch_source_path.is_some()
|| options.patch_patch_path.is_some()
{
return Err(anyhow::anyhow!(
"{command} 不接受 --patch-kind、--source-file 或 --patch-file"
));
}
Ok(())
}
fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::Result<()> {
if options.unityfs_bundle_path.is_some()
|| options.unityfs_serialized_file_path.is_some()
|| options.unityfs_path_id.is_some()
|| options.unityfs_field_path.is_some()
|| options.unityfs_replacement_path.is_some()
|| options.unityfs_replacement_text.is_some()
|| options.unityfs_expected_name.is_some()
|| options.unityfs_expected_value.is_some()
|| options.unityfs_replacement_value.is_some()
|| options.unityfs_expected_semantic_value.is_some()
{
return Err(anyhow::anyhow!(
"{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令"
));
}
Ok(())
}
use super::*;
@@ -0,0 +1,272 @@
use super::report_output::print_json_value;
use super::*;
pub(super) fn run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> {
run_readonly_query_command_with_rpc(options, daemon_rpc_available, daemon_rpc_call)
}
pub(super) fn run_readonly_query_command_with_rpc(
options: &CliOptions,
rpc_available: impl Fn(&Path) -> bool,
rpc_call: impl Fn(&Path, &str, Option<serde_json::Value>) -> anyhow::Result<serde_json::Value>,
) -> anyhow::Result<()> {
let method = readonly_query_rpc_method(options.command)
.ok_or_else(|| anyhow::anyhow!("不是只读查询命令"))?;
if rpc_available(&options.state_dir) && !readonly_query_requires_local_config(options) {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
let report = rpc_call(
&options.state_dir,
method,
readonly_query_rpc_params(options),
)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
let report = build_readonly_query_report(options, method)?;
print_json_value(options.output_format, &report)
}
fn readonly_query_rpc_method(command: CliCommand) -> Option<&'static str> {
match command {
CliCommand::ParseStatus => Some(RPC_METHOD_PARSE_STATUS),
CliCommand::ParseTextUnits => Some(RPC_METHOD_PARSE_TEXT_UNITS),
CliCommand::ParseErrors => Some(RPC_METHOD_PARSE_ERRORS),
CliCommand::TranslationTasks => Some(RPC_METHOD_TRANSLATION_TASKS),
CliCommand::TranslationHandoff => Some(RPC_METHOD_TRANSLATION_HANDOFF),
CliCommand::LocalizedStatus => Some(RPC_METHOD_LOCALIZED_STATUS),
CliCommand::ResourceIndex => Some(RPC_METHOD_RESOURCE_INDEX),
_ => None,
}
}
fn readonly_query_rpc_params(options: &CliOptions) -> Option<serde_json::Value> {
let mut params = serde_json::Map::new();
match options.command {
CliCommand::ResourceIndex
| CliCommand::ParseTextUnits
| CliCommand::ParseErrors
| CliCommand::TranslationTasks => {
params.insert(
"offset".to_string(),
serde_json::json!(options.query_offset),
);
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
}
_ => return None,
}
match options.command {
CliCommand::ResourceIndex => {
if let Some(resource_type) = options.query_resource_type {
params.insert(
"resource_type".to_string(),
serde_json::json!(resource_type_rpc_label(resource_type)),
);
}
if let Some(hash) = options.query_hash.as_ref() {
params.insert("hash".to_string(), serde_json::json!(hash));
}
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
}
if let Some(release_id) = options.query_official_release_id.as_ref() {
params.insert(
"official_release_id".to_string(),
serde_json::json!(release_id),
);
}
if let Some(platform) = options.query_platform.as_ref() {
params.insert("platform".to_string(), serde_json::json!(platform));
}
if let Some(destination) = options.query_destination.as_ref() {
params.insert("destination".to_string(), serde_json::json!(destination));
}
if let Some(bundle_path) = options.query_bundle_path.as_ref() {
params.insert("bundle_path".to_string(), serde_json::json!(bundle_path));
}
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
params.insert(
"archive_entry".to_string(),
serde_json::json!(archive_entry),
);
}
if let Some(parse_status) = options.query_parse_status.as_ref() {
params.insert("parse_status".to_string(), serde_json::json!(parse_status));
}
if let Some(format) = options.query_format.as_ref() {
params.insert("text_unit_format".to_string(), serde_json::json!(format));
}
}
CliCommand::ParseTextUnits | CliCommand::ParseErrors => {
if let Some(destination) = options.query_destination.as_ref() {
params.insert("destination".to_string(), serde_json::json!(destination));
}
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
}
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
params.insert(
"archive_entry".to_string(),
serde_json::json!(archive_entry),
);
}
if let Some(path_id) = options.query_path_id {
params.insert("path_id".to_string(), serde_json::json!(path_id));
}
if let Some(class_id) = options.query_class_id {
params.insert("class_id".to_string(), serde_json::json!(class_id));
}
if let Some(field_path) = options.query_field_path.as_ref() {
params.insert("field_path".to_string(), serde_json::json!(field_path));
}
if let Some(format) = options.query_format.as_ref() {
params.insert("format".to_string(), serde_json::json!(format));
}
}
CliCommand::TranslationTasks => {
if let Some(task_id) = options.query_task_id.as_ref() {
params.insert("task_id".to_string(), serde_json::json!(task_id));
}
if let Some(release_id) = options.query_official_release_id.as_ref() {
params.insert(
"official_release_id".to_string(),
serde_json::json!(release_id),
);
}
if let Some(destination) = options.query_destination.as_ref() {
params.insert("destination".to_string(), serde_json::json!(destination));
}
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
}
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
params.insert(
"archive_entry".to_string(),
serde_json::json!(archive_entry),
);
}
if let Some(status) = options.query_task_status.as_ref() {
params.insert("status".to_string(), serde_json::json!(status));
}
if let Some(status) = options.query_worker_status.as_ref() {
params.insert("worker_status".to_string(), serde_json::json!(status));
}
if let Some(parse_status) = options.query_parse_status.as_ref() {
params.insert("parse_status".to_string(), serde_json::json!(parse_status));
}
if let Some(format) = options.query_format.as_ref() {
params.insert("text_unit_format".to_string(), serde_json::json!(format));
}
if let Some(has_reason) = options.query_has_reason {
params.insert("has_reason".to_string(), serde_json::json!(has_reason));
}
if let Some(has_failure_reason) = options.query_has_failure_reason {
params.insert(
"has_failure_reason".to_string(),
serde_json::json!(has_failure_reason),
);
}
}
_ => {}
}
Some(serde_json::Value::Object(params))
}
fn readonly_query_requires_local_config(options: &CliOptions) -> bool {
matches!(options.command, CliCommand::ResourceIndex)
&& options.config.import_resource_repository_path.is_some()
}
fn build_readonly_query_report(
options: &CliOptions,
method: &str,
) -> anyhow::Result<serde_json::Value> {
match method {
RPC_METHOD_PARSE_STATUS => build_parse_status_report(&options.state_dir),
RPC_METHOD_PARSE_TEXT_UNITS => build_parse_text_units_report(
&options.state_dir,
textunit_query_from_options(options),
options.query_offset,
options.query_limit,
),
RPC_METHOD_PARSE_ERRORS => build_parse_errors_report(
&options.state_dir,
textunit_query_from_options(options),
options.query_offset,
options.query_limit,
),
RPC_METHOD_TRANSLATION_TASKS => build_translation_tasks_report(
&options.state_dir,
translation_task_query_from_options(options),
options.query_offset,
options.query_limit,
),
RPC_METHOD_TRANSLATION_HANDOFF => build_translation_handoff_report(&options.state_dir),
RPC_METHOD_LOCALIZED_STATUS => {
build_localized_status_report(&options.state_dir, &options.config)
}
RPC_METHOD_RESOURCE_INDEX => build_resource_index_report(
&options.state_dir,
&options.config,
resource_index_query_from_options(options),
options.query_offset,
options.query_limit,
),
_ => Err(anyhow::anyhow!("不支持的只读查询方法:{method}")),
}
}
pub(super) fn validate_readonly_query_options(options: &CliOptions) -> anyhow::Result<()> {
let has_resource_index_only_filter = options.query_resource_type.is_some()
|| options.query_hash.is_some()
|| options.query_platform.is_some()
|| options.query_bundle_path.is_some();
let has_parse_object_filter = options.query_path_id.is_some()
|| options.query_class_id.is_some()
|| options.query_field_path.is_some();
let has_translation_task_filter = options.query_task_id.is_some()
|| options.query_task_status.is_some()
|| options.query_worker_status.is_some()
|| options.query_has_reason.is_some()
|| options.query_has_failure_reason.is_some();
match options.command {
CliCommand::ResourceIndex => {
if has_parse_object_filter || has_translation_task_filter {
return Err(anyhow::anyhow!(
"--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks"
));
}
}
CliCommand::ParseTextUnits | CliCommand::ParseErrors => {
if has_resource_index_only_filter
|| has_translation_task_filter
|| options.query_official_release_id.is_some()
|| options.query_parse_status.is_some()
{
return Err(anyhow::anyhow!(
"--resource-type/--hash/--release-id/--platform/--bundle-path/--parse-status 只适用于 resource-index 或 translation-tasks--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks"
));
}
}
CliCommand::TranslationTasks => {
if has_resource_index_only_filter || has_parse_object_filter {
return Err(anyhow::anyhow!(
"--resource-type/--hash/--platform/--bundle-path 只适用于 resource-index--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors"
));
}
}
CliCommand::TranslationHandoff if options.query_option_explicit => {
return Err(anyhow::anyhow!(
"translation-handoff 不接受查询过滤参数;请使用 translation-tasks 查询单项任务"
));
}
CliCommand::ParseStatus | CliCommand::LocalizedStatus if options.query_option_explicit => {
return Err(anyhow::anyhow!(
"查询过滤参数只适用于 resource-index、parse-text-units、parse-errors 或 translation-tasks"
));
}
_ => {}
}
Ok(())
}
+850
View File
@@ -0,0 +1,850 @@
use super::*;
pub(super) trait HumanReport {
fn print_human(&self) -> anyhow::Result<()>;
}
pub(super) fn print_report<T>(format: OutputFormat, report: &T) -> anyhow::Result<()>
where
T: Serialize + HumanReport,
{
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
OutputFormat::Human => report.print_human()?,
}
Ok(())
}
pub(super) fn print_json_value(
format: OutputFormat,
value: &serde_json::Value,
) -> anyhow::Result<()> {
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?),
OutputFormat::Human => print_human_json_value(value)?,
}
Ok(())
}
impl HumanReport for serde_json::Value {
fn print_human(&self) -> anyhow::Result<()> {
print_human_json_value(self)
}
}
impl HumanReport for RepackReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("UnityFS 重打包");
print_field("命令", self.command);
print_field("状态", self.status);
print_path_field("源 bundle", &self.source_bundle);
print_path_field("目标 bundle", &self.target_bundle);
print_field("操作数", self.operation_count);
print_field("源字节", self.source_bytes);
print_field("目标字节", self.target_bytes);
print_field("源 BLAKE3", &self.source_blake3);
print_field("目标 BLAKE3", &self.target_blake3);
Ok(())
}
}
impl HumanReport for LocalizedPatchReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("汉化 release 发布");
print_path_field("版本目录", &self.version_path);
print_path_field("current", &self.current_path);
print_path_field("状态文件", &self.state_path);
print_path_field("patch manifest", &self.patch_manifest_path);
print_field("变更文件数", self.files.len());
print_field("TextAsset 操作数", self.manifest.text_asset_operation_count);
print_field("校验文件数", self.integrity.verified_changed_file_count);
Ok(())
}
}
impl HumanReport for LocalizedRollbackReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("汉化 release 回滚");
print_field("命令", self.command);
print_field("状态", self.status);
print_field("回滚 release", &self.rolled_back_release_id);
print_optional_field("恢复 release", self.restored_release_id.as_deref());
print_path_field("汉化输出目录", &self.localized_output_root);
print_path_field("current", &self.current_path);
print_path_field("状态文件", &self.state_path);
print_path_field("删除版本目录", &self.removed_version_path);
print_optional_path_field("恢复 current 目标", self.restored_current_target.as_ref());
print_field("新状态", &self.state.status);
print_optional_field("当前 release", self.state.current_release_id.as_deref());
Ok(())
}
}
impl HumanReport for bat_infrastructure::LocalizedTranslationWorkflowReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("汉化工作流状态");
print_field("命令", self.command);
print_field("状态", self.status);
print_field("官方 release", &self.official_release_id);
print_optional_field("汉化 release", self.current_release_id.as_deref());
print_field("汉化发布状态", &self.localized_release_status);
print_field("工作流状态", &self.translation_workflow_status);
print_field("工作流状态码", self.translation_workflow_status_code);
print_field("工作流标签", self.translation_workflow_label);
print_field("允许发布", format_bool(self.publish_allowed));
print_path_field("汉化输出目录", &self.localized_output_root);
print_path_field("状态文件", &self.state_path);
Ok(())
}
}
impl HumanReport for bat_infrastructure::TranslationWorkerReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("翻译 provider worker");
print_field("命令", self.command);
print_field("状态", self.status);
print_field("官方 release", &self.official_release_id);
print_field("provider", &self.provider);
print_field("回收 lease", self.recovered_lease_count);
print_field("领取任务", self.claimed_count);
print_field("完成任务", self.completed_count);
print_field("失败任务", self.failed_count);
print_field("已安排重试", self.retry_scheduled_count);
print_field("剩余任务", self.remaining_count);
for failure in &self.failures {
println!(
" - {} [{}] retryable={} {}",
failure.task_id,
failure.failure_class,
format_bool(failure.retryable),
failure.failure_reason
);
}
Ok(())
}
}
fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
if value.get("running").is_some() && value.get("state_dir").is_some() {
print_title("后台状态");
print_json_field(value, "status", "状态");
print_json_field(value, "message", "消息");
print_json_field(value, "running", "运行中");
print_json_field(value, "pid", "PID");
print_json_field(value, "daemon_state", "后台状态");
print_json_field(value, "rpc_available", "RPC 可用");
print_json_field(value, "stale_pid_file", "失效 PID");
print_json_field(value, "stale_socket", "失效 socket");
print_json_field(value, "last_update_status", "上次同步");
print_json_field(value, "last_success_unix_seconds", "最后成功时间");
print_json_field(value, "last_error", "上次错误");
print_json_field(value, "next_retry_seconds", "下次重试秒数");
print_json_field(value, "next_check_unix_seconds", "下次检查时间");
print_json_field(value, "current_stage", "当前阶段");
print_json_field(value, "current_message", "当前消息");
print_daemon_download_progress_json_summary(value.get("download_progress"));
print_json_field(value, "version_state_path", "版本状态路径");
print_daemon_version_state_json_summary(value.get("version_state"));
print_json_field(value, "resource_output_root", "资源目录");
print_json_field(value, "state_dir", "状态目录");
print_json_field(value, "socket_path", "socket");
print_json_field(value, "log_path", "日志");
print_json_field(value, "structured_log_path", "结构化日志");
print_json_field(value, "rotated_structured_log_paths", "轮转日志");
return Ok(());
}
if value.get("command").and_then(serde_json::Value::as_str) == Some("logs") {
print_title("后台日志");
print_json_field(value, "status", "状态");
print_json_field(value, "message", "消息");
print_json_field(value, "log_path", "日志");
print_json_field(value, "bytes", "字节");
print_json_field(value, "total_lines", "总行数");
print_json_field(value, "returned_lines", "返回行数");
if let Some(content) = value.get("content").and_then(serde_json::Value::as_str) {
if !content.is_empty() {
println!();
println!("{content}");
}
}
return Ok(());
}
if value.get("command").is_some() && value.get("status").is_some() {
print_title("后台命令");
print_json_field(value, "command", "命令");
print_json_field(value, "status", "状态");
print_json_field(value, "message", "消息");
print_json_field(value, "force", "force");
print_json_field(value, "state_dir", "状态目录");
print_json_field(value, "socket_path", "socket");
return Ok(());
}
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn print_daemon_download_progress_json_summary(value: Option<&serde_json::Value>) {
let Some(value) = value else {
return;
};
if value.is_null() {
return;
}
print_field(
"下载进度",
format_daemon_download_progress_json(value)
.unwrap_or_else(|error| format!("无法解析:{error}")),
);
}
pub(super) fn format_daemon_download_progress_json(
value: &serde_json::Value,
) -> Result<String, String> {
let progress = serde_json::from_value::<DaemonDownloadProgress>(value.clone())
.map_err(|error| error.to_string())?;
Ok(format_daemon_download_progress(&progress))
}
fn print_daemon_version_state_json_summary(value: Option<&serde_json::Value>) {
let Some(value) = value else {
return;
};
if value.is_null() {
return;
}
match serde_json::from_value::<OfficialVersionState>(value.clone()) {
Ok(version_state) => print_daemon_version_state_summary(&version_state),
Err(error) => print_field("版本状态", format!("无法解析:{error}")),
}
}
fn print_daemon_version_state_summary(version_state: &OfficialVersionState) {
print_optional_field(
"当前完成版本",
version_state
.current_completed_version
.as_ref()
.map(|version| version.id.as_str()),
);
print_optional_field(
"正在拉取版本",
version_state
.in_progress_version
.as_ref()
.map(|version| version.id.as_str()),
);
print_optional_field(
"上一个可用版本",
version_state
.previous_available_version
.as_ref()
.map(|version| version.id.as_str()),
);
let historical_failures = visible_historical_failed_versions(version_state);
print_field("历史失败版本数", historical_failures.len());
if let Some(failed) = historical_failures.last() {
print_field("最近历史失败版本", &failed.version.id);
print_field("最近历史失败时间", failed.failed_unix_seconds);
print_field("最近历史失败原因", &failed.error);
}
}
pub(super) fn visible_historical_failed_versions(
version_state: &OfficialVersionState,
) -> Vec<&OfficialFailedVersionRecord> {
let in_progress = version_state.in_progress_version.as_ref();
version_state
.failed_versions
.iter()
.filter(|failed| {
!in_progress.is_some_and(|version| version_matches_for_status(&failed.version, version))
})
.collect()
}
fn version_matches_for_status(left: &OfficialVersionRecord, right: &OfficialVersionRecord) -> bool {
left.app_version == right.app_version
&& left.bundle_version == right.bundle_version
&& left.addressables_root == right.addressables_root
}
fn print_json_field(value: &serde_json::Value, key: &str, label: &str) {
let Some(value) = value.get(key) else {
return;
};
if value.is_null() {
return;
}
if let Some(value) = value.as_str() {
print_field(label, value);
} else {
print_field(label, value);
}
}
fn print_title(title: &str) {
println!("{title}");
}
fn print_field(label: &str, value: impl std::fmt::Display) {
println!(" {label:<18} {value}");
}
fn print_optional_field<T>(label: &str, value: Option<T>)
where
T: std::fmt::Display,
{
if let Some(value) = value {
print_field(label, value);
}
}
fn print_path_field(label: &str, value: &Path) {
print_field(label, value.display());
}
fn print_optional_path_field(label: &str, value: Option<&PathBuf>) {
if let Some(value) = value {
print_path_field(label, value);
}
}
fn print_list(label: &str, values: &[String], limit: usize) {
if values.is_empty() {
return;
}
println!(" {label}:");
for value in values.iter().take(limit) {
println!(" - {value}");
}
if values.len() > limit {
println!(" ... 还有 {}", values.len() - limit);
}
}
fn format_daemon_download_progress(progress: &DaemonDownloadProgress) -> String {
let status = progress.status.as_deref().unwrap_or("running");
if let Some(hash) = progress.official_hash.as_ref() {
return format!(
"{}/{} official_hash algorithm={} expected={} actual={} data={} hash={}",
progress.index,
progress.total,
hash.algorithm.as_str(),
hash.expected,
hash.actual,
hash.data_url,
hash.hash_url
);
}
if status == "failed" {
return format!(
"{}/{} failed kind={} http={} retryable={} attempts={} quarantined={} {}",
progress.index,
progress.total,
progress.failure_kind.as_deref().unwrap_or("unknown"),
progress
.failure_http_status
.map(|status| status.to_string())
.unwrap_or_else(|| "none".to_string()),
progress
.failure_retryable
.map(|retryable| retryable.to_string())
.unwrap_or_else(|| "unknown".to_string()),
progress
.failure_attempts
.map(|attempts| attempts.to_string())
.unwrap_or_else(|| "0".to_string()),
progress
.quarantined
.map(|quarantined| quarantined.to_string())
.unwrap_or_else(|| "false".to_string()),
progress.url
);
}
if let Some(verification) = progress.verification.as_ref() {
return format!(
"{}/{} {} bytes={} blake3={} zip_checked={} zip_verified={} {}",
progress.index,
progress.total,
status,
verification.actual_bytes,
verification.actual_blake3,
verification.zip_checked,
verification.zip_structure_verified,
progress.url
);
}
format!(
"{}/{} {} {}",
progress.index, progress.total, status, progress.url
)
}
fn print_verification_summary(summary: &OfficialVerificationSummary) {
println!(" 校验摘要:");
for line in verification_summary_lines(summary) {
println!(" - {line}");
}
}
pub(super) fn verification_summary_lines(summary: &OfficialVerificationSummary) -> Vec<String> {
vec![
format!(
"官方 .hash 强校验: {} 对 ({})",
summary.official_hash_verified_count, summary.official_hash_scope
),
format!(
"本地 BLAKE3 复用校验: {} 项通过, {} 项需修复 ({})",
summary.local_manifest_blake3_verified_count,
summary.local_manifest_repair_needed_count,
summary.local_manifest_blake3_scope
),
format!(
"ZIP 结构校验: {} 个 ZIP 通过 ({})",
summary.zip_structure_verified_count, summary.zip_structure_scope
),
]
}
fn format_bool(value: bool) -> &'static str {
if value {
"yes"
} else {
"no"
}
}
fn format_bytes(value: u64) -> String {
const KIB: f64 = 1024.0;
const MIB: f64 = 1024.0 * 1024.0;
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
let value_f = value as f64;
if value_f >= GIB {
format!("{value_f:.2} GiB", value_f = value_f / GIB)
} else if value_f >= MIB {
format!("{value_f:.2} MiB", value_f = value_f / MIB)
} else if value_f >= KIB {
format!("{value_f:.2} KiB", value_f = value_f / KIB)
} else {
format!("{value} B")
}
}
fn platform_label(platform: PatchPlatform) -> &'static str {
match platform {
PatchPlatform::Windows => "Windows",
PatchPlatform::Android => "Android",
}
}
fn endpoint_kind_label_for_human(kind: YostarJpResourceEndpointKind) -> &'static str {
match kind {
YostarJpResourceEndpointKind::TableCatalog => "table_catalog",
YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash",
YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog",
YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash",
YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info",
YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash",
YostarJpResourceEndpointKind::MediaCatalog => "media_catalog",
YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash",
}
}
impl HumanReport for OfficialUpdateReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("官方资源同步");
print_field("状态", self.update_status.as_str());
print_field("状态码", self.status_code.as_str());
print_field("应用版本", &self.app_version);
print_optional_field("Bundle 版本", self.bundle_version.as_deref());
print_field("连接组", &self.connection_group);
print_field(
"平台",
self.platforms
.iter()
.map(|platform| platform_label(*platform))
.collect::<Vec<_>>()
.join(", "),
);
print_field("需要下载", format_bool(self.should_download));
print_field(
"等待官方资源",
format_bool(self.waiting_for_official_resources),
);
print_field("首次同步", format_bool(self.is_initial));
print_field("强制刷新", format_bool(self.force));
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.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);
print_optional_path_field("staging", self.staging_path.as_ref());
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.resource_change_set_path.as_ref());
print_optional_path_field("Crowdin handoff", self.crowdin_handoff_path.as_ref());
print_optional_path_field("解析缓存", self.parse_cache_path.as_ref());
print_optional_path_field("TextUnit 任务队列", self.textunit_task_queue_path.as_ref());
print_optional_path_field(
"Crowdin TextUnit 队列",
self.crowdin_textunit_queue_path.as_ref(),
);
print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref());
print_optional_path_field(
"启动器引导产物",
self.launcher_bootstrap_artifact_path.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));
print_optional_field("计划 URL 数", self.download_url_count);
print_optional_field("资源数", self.resource_count);
print_field("已下载", self.downloaded_count);
print_field("已续传", self.resumed_count);
print_field("当前 manifest 复用", self.skipped_count);
print_field("历史 release 复用", self.release_reused_count);
print_field("CAS 复用", self.cas_reused_count);
print_field("复用量", format_bytes(self.reused_bytes));
print_field("传输量", format_bytes(self.transferred_bytes));
print_field("复用诊断", self.reuse_warnings.len());
for warning in &self.reuse_warnings {
println!(
" - 复用回退 [{}] {} {}",
warning.source, warning.url, warning.message
);
}
print_field("最终大小", format_bytes(self.final_bytes));
print_field("本地校验通过", self.local_manifest_verified_count);
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.resource_change_summary.as_ref() {
print_field("新增资源", summary.added_count);
print_field("变更资源", summary.modified_count);
print_field("删除资源", summary.removed_count);
print_field("解析候选", summary.parse_candidate_count);
print_field("Crowdin 候选", summary.translation_candidate_count);
}
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("TextUnit", summary.text_unit_count);
print_field("二进制 TextAsset", summary.skipped_binary_text_asset_count);
print_field("TextUnit 诊断", summary.text_unit_error_count);
}
if let Some(summary) = self.textunit_task_summary.as_ref() {
print_field("TextUnit 资源候选", summary.resource_candidate_count);
print_field("TextUnit 解析条目", summary.parse_entry_count);
print_field("TextUnit 任务", summary.queued_task_count);
print_field("增量 TextUnit", summary.text_unit_count);
print_field("TextUnit 无解析", summary.skipped_no_parse_entry_count);
print_field("TextUnit 无文本", summary.skipped_no_text_unit_count);
print_field("TextUnit 解析失败", summary.skipped_parse_failed_count);
print_field("TextUnit 不支持", summary.skipped_unsupported_count);
}
print_field("catalog marker", self.addressables_marker_checked_count);
if !self.unavailable_endpoints.is_empty() {
let unavailable = self
.unavailable_endpoints
.iter()
.map(|endpoint| {
format!(
"{}{} kind={} http={} {}",
endpoint_kind_label_for_human(endpoint.kind),
endpoint
.platform
.map(|platform| format!(" ({})", platform_label(platform)))
.unwrap_or_default(),
endpoint.error_kind,
endpoint
.http_status
.map(|status| status.to_string())
.unwrap_or_else(|| "none".to_string()),
endpoint.url
)
})
.collect::<Vec<_>>();
print_list("不可用官方 endpoint", &unavailable, 8);
}
print_list("变更 endpoint", &self.changed_endpoint_urls, 8);
print_list("计划 URL", &self.download_urls, 8);
Ok(())
}
}
impl<T> HumanReport for CommandReport<T>
where
T: Serialize + HumanReport,
{
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
self.data.print_human()
}
}
impl HumanReport for PatchApplyReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("Patch 类型", self.kind.as_str());
print_path_field("源文件", &self.source_path);
print_path_field("Patch 文件", &self.patch_path);
print_path_field("目标文件", &self.target_path);
print_field("源字节", self.source_size);
print_field("Patch 字节", self.patch_size);
print_field("目标字节", self.target_size);
print_field("源 BLAKE3", &self.source_blake3);
print_field("Patch BLAKE3", &self.patch_blake3);
print_field("目标 BLAKE3", &self.target_blake3);
Ok(())
}
}
impl HumanReport for UnityFsPatchReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_path_field("源 bundle", &self.bundle_path);
print_field("Serialized 文件", &self.serialized_file_path);
print_field("Path ID", self.path_id);
print_optional_field("字段路径", self.field_path.as_deref());
print_path_field("目标 bundle", &self.target_path);
print_field("源字节", self.source_size);
print_field("替换字节", self.replacement_size);
print_field("目标字节", self.target_size);
print_field("源 BLAKE3", &self.source_blake3);
print_field("替换 BLAKE3", &self.replacement_blake3);
print_field("目标 BLAKE3", &self.target_blake3);
Ok(())
}
}
impl HumanReport for DaemonStartReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("状态", self.status);
print_field("PID", self.pid);
print_path_field("资源目录", &self.resource_output_root);
print_path_field("汉化目录", &self.localized_output_root);
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_path_field("日志", &self.log_path);
print_path_field("结构化日志", &self.structured_log_path);
Ok(())
}
}
impl HumanReport for DaemonStatusReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("后台状态");
print_field("状态", self.status);
print_field("消息", self.message);
print_field("运行中", format_bool(self.running));
print_optional_field("PID", self.pid);
print_optional_field("后台状态", self.daemon_state.as_deref());
print_field("RPC 可用", format_bool(self.rpc_available));
print_field("失效 PID", format_bool(self.stale_pid_file));
print_field("失效 socket", format_bool(self.stale_socket));
print_optional_field("上次同步", self.last_update_status.as_deref());
print_optional_field("最后成功时间", self.last_success_unix_seconds);
print_optional_field("上次错误", self.last_error.as_deref());
print_optional_field("下次重试秒数", self.next_retry_seconds);
print_optional_field("下次检查时间", self.next_check_unix_seconds);
print_optional_field("当前阶段", self.current_stage.as_deref());
print_optional_field("当前消息", self.current_message.as_deref());
if let Some(progress) = self.download_progress.as_ref() {
print_field("下载进度", format_daemon_download_progress(progress));
}
if let Some(version_state) = self.version_state.as_ref() {
print_daemon_version_state_summary(version_state);
}
print_optional_path_field("资源目录", self.resource_output_root.as_ref());
print_optional_path_field("汉化目录", self.localized_output_root.as_ref());
print_optional_path_field("版本状态", self.version_state_path.as_ref());
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_optional_path_field("日志", self.log_path.as_ref());
print_optional_path_field("结构化日志", self.structured_log_path.as_ref());
let rotated = self
.rotated_structured_log_paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>();
print_list("轮转日志", &rotated, 5);
Ok(())
}
}
impl HumanReport for DaemonStopReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("状态", self.status);
print_field("已停止", format_bool(self.stopped));
print_optional_field("PID", self.pid);
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
Ok(())
}
}
impl HumanReport for DaemonControlReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("策略", self.strategy);
print_optional_field("旧 PID", self.previous_pid);
print_field("PID", self.pid);
print_path_field("资源目录", &self.resource_output_root);
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_path_field("日志", &self.log_path);
Ok(())
}
}
impl HumanReport for VerifyCommandReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("健康", format_bool(self.healthy));
print_field("远端状态", &self.remote_update_status);
print_path_field("校验资源目录", &self.verified_resource_root);
print_optional_field("计划 URL 数", self.planned_url_count);
print_field("计划异常数", self.expected_plan_failure_count);
print_field("本地 manifest 项", self.local_manifest_entry_count);
print_field("本地校验通过", self.local_manifest_verified_count);
print_field("本地失败数", self.local_manifest_failure_count);
print_field("官方 hash 对", self.official_hash_pair_count);
print_field("官方 hash 通过", self.official_hash_verified_count);
print_verification_summary(&self.verification_summary);
if !self.official_hash_errors.is_empty() {
print_list("官方 hash 错误", &self.official_hash_errors, 8);
}
if !self.failures.is_empty() {
println!(" 失败项:");
for item in self.failures.iter().take(12) {
println!(" - {} -> {}", item.status, item.destination.display());
}
if self.failures.len() > 12 {
println!(" ... 还有 {}", self.failures.len() - 12);
}
}
Ok(())
}
}
impl HumanReport for LogsReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("状态", self.status);
print_path_field("日志", &self.log_path);
print_field("存在", format_bool(self.exists));
print_field("为空", format_bool(self.empty));
print_field("字节", self.bytes);
print_field("总行数", self.total_lines);
print_field("返回行数", self.returned_lines);
if !self.content.is_empty() {
println!();
println!("{}", self.content);
}
Ok(())
}
}
impl HumanReport for DoctorReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("健康", format_bool(self.healthy));
println!(" 检查:");
for check in &self.checks {
println!(
" [{}] {} - {}",
if check.ok { "OK" } else { "FAIL" },
check.name,
check.message
);
}
Ok(())
}
}
impl HumanReport for DoctorCasReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("健康", format_bool(self.healthy));
print_path_field("CAS 目录", &self.cas_root);
print_path_field("对象目录", &self.objects_dir);
print_path_field("元数据库", &self.metadata_db_path);
print_field("对象数", self.object_count);
print_field("总字节", self.total_size);
print_field("无效对象文件", self.invalid_object_count);
println!(" 检查:");
for check in &self.checks {
println!(
" [{}] {} - {}",
if check.ok { "OK" } else { "FAIL" },
check.name,
check.message
);
}
Ok(())
}
}
impl HumanReport for CleanStableReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_path_field("资源目录", &self.output_root);
print_path_field("状态目录", &self.state_dir);
if !self.removed_paths.is_empty() {
println!(" 已清理:");
for path in &self.removed_paths {
println!(" - {}", path.display());
}
}
if !self.skipped_paths.is_empty() {
println!(" 已跳过:");
for path in &self.skipped_paths {
println!(" - {}", path.display());
}
}
Ok(())
}
}
impl HumanReport for DaemonRpcAck {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_optional_field("force", self.force.map(format_bool));
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
Ok(())
}
}
@@ -0,0 +1,777 @@
use super::report_output::print_json_value;
use super::*;
const SCHEDULES_FILE_NAME: &str = "bat-schedules.json";
const SCHEDULE_LOCK_FILE_NAME: &str = "bat-schedule.lock";
const SCHEDULES_SCHEMA_VERSION: u32 = 1;
static SCHEDULE_FILE_LOCK: Mutex<()> = Mutex::new(());
#[derive(Debug)]
struct ScheduleFileLock {
path: PathBuf,
pid: u32,
}
impl ScheduleFileLock {
fn acquire(state_dir: &Path) -> anyhow::Result<Self> {
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
fs::create_dir_all(state_dir)?;
let path = state_dir.join(SCHEDULE_LOCK_FILE_NAME);
let pid = std::process::id();
for attempt in 0..=1 {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(PRIVATE_FILE_MODE);
match options.open(&path) {
Ok(mut file) => {
file.write_all(pid.to_string().as_bytes())?;
return Ok(Self { path, pid });
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
if attempt == 0 && remove_recoverable_pid_lock(&path)? {
continue;
}
return Err(anyhow::anyhow!(
"调度计划已被锁定:{}{}",
path.display(),
describe_pid_lock_owner(&path)?
));
}
Err(error) => {
return Err(anyhow::anyhow!(
"获取调度计划锁失败 {}:{error}",
path.display()
));
}
}
}
Err(anyhow::anyhow!("获取调度计划锁失败"))
}
}
impl Drop for ScheduleFileLock {
fn drop(&mut self) {
let expected = self.pid.to_string();
if fs::symlink_metadata(&self.path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return;
}
if fs::read_to_string(&self.path)
.map(|contents| contents.trim() == expected)
.unwrap_or(false)
{
let _ = fs::remove_file(&self.path);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct ScheduleFile {
pub(super) schema_version: u32,
pub(super) schedules: Vec<ScheduleEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct ScheduleEntry {
pub(super) id: String,
pub(super) group: String,
pub(super) action: String,
pub(super) args: Vec<String>,
pub(super) next_run_unix_seconds: u64,
pub(super) interval_seconds: Option<u64>,
pub(super) remaining_runs: Option<usize>,
pub(super) enabled: bool,
pub(super) created_unix_seconds: u64,
pub(super) updated_unix_seconds: u64,
pub(super) last_run_unix_seconds: Option<u64>,
pub(super) last_status: Option<String>,
pub(super) last_error: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(super) struct ScheduleMutationRequest {
#[serde(default, alias = "schedule_id")]
pub(super) id: Option<String>,
#[serde(default)]
pub(super) group: Option<String>,
#[serde(default)]
pub(super) action: Option<String>,
#[serde(default)]
pub(super) args: Vec<String>,
#[serde(default, alias = "at_unix_seconds", alias = "schedule_at_unix")]
pub(super) next_run_unix_seconds: Option<u64>,
#[serde(default, alias = "schedule_delay_seconds")]
pub(super) delay_seconds: Option<u64>,
#[serde(default, alias = "schedule_every_seconds")]
pub(super) every_seconds: Option<u64>,
#[serde(default, alias = "schedule_count")]
pub(super) count: Option<usize>,
#[serde(default)]
pub(super) clear_args: bool,
#[serde(default)]
pub(super) clear_every: bool,
#[serde(default)]
pub(super) enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(super) struct ScheduleListRequest {
#[serde(default, alias = "schedule_id")]
pub(super) id: Option<String>,
#[serde(default)]
pub(super) group: Option<String>,
#[serde(default)]
pub(super) enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(super) struct ScheduleRunRequest {
#[serde(default, alias = "schedule_id")]
pub(super) id: Option<String>,
#[serde(default)]
pub(super) group: Option<String>,
#[serde(default)]
pub(super) force: bool,
#[serde(default)]
pub(super) max_runs: Option<usize>,
}
pub(super) fn run_schedule_list(options: &CliOptions) -> anyhow::Result<()> {
let request = ScheduleListRequest {
id: options.schedule_id.clone(),
group: options.schedule_group.clone(),
enabled: options.schedule_enabled,
};
print_json_value(
options.output_format,
&schedule_list_report_with_request(&options.state_dir, request)?,
)
}
pub(super) fn run_schedule_add(options: &CliOptions) -> anyhow::Result<()> {
validate_schedule_command_options(options, false)?;
let request = schedule_request_from_options(options);
print_json_value(
options.output_format,
&schedule_add_report(&options.state_dir, request)?,
)
}
pub(super) fn run_schedule_update(options: &CliOptions) -> anyhow::Result<()> {
validate_schedule_command_options(options, true)?;
let request = schedule_request_from_options(options);
print_json_value(
options.output_format,
&schedule_update_report(&options.state_dir, request)?,
)
}
pub(super) fn run_schedule_remove(options: &CliOptions) -> anyhow::Result<()> {
validate_schedule_command_options(options, true)?;
let request = schedule_request_from_options(options);
print_json_value(
options.output_format,
&schedule_remove_report(&options.state_dir, request)?,
)
}
pub(super) fn run_schedule_run(options: &CliOptions) -> anyhow::Result<()> {
validate_schedule_command_options(options, true)?;
loop {
let request = ScheduleRunRequest {
id: options.schedule_id.clone(),
group: options.schedule_group.clone(),
force: options.config.force,
max_runs: options.schedule_max_runs,
};
print_json_value(
options.output_format,
&schedule_run_report(&options.state_dir, request)?,
)?;
if !options.watch {
return Ok(());
}
thread::sleep(options.interval);
}
}
pub(super) fn schedule_list_report_with_request(
state_dir: &Path,
request: ScheduleListRequest,
) -> anyhow::Result<serde_json::Value> {
let _guard = SCHEDULE_FILE_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
let file = read_schedule_file(state_dir)?;
let group = request
.group
.as_deref()
.map(normalize_schedule_group)
.transpose()?;
let schedules = file
.schedules
.iter()
.filter(|entry| request.id.as_deref().is_none_or(|id| id == entry.id))
.filter(|entry| group.as_deref().is_none_or(|group| group == entry.group))
.filter(|entry| {
request
.enabled
.is_none_or(|enabled| enabled == entry.enabled)
})
.collect::<Vec<_>>();
Ok(serde_json::json!({
"command": "schedule-list",
"status": "ok",
"state_file": schedule_file_path(state_dir),
"query": request,
"schedules": schedules,
}))
}
pub(super) fn schedule_add_report(
state_dir: &Path,
request: ScheduleMutationRequest,
) -> anyhow::Result<serde_json::Value> {
let _guard = SCHEDULE_FILE_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
let mut file = read_schedule_file(state_dir)?;
let id = request
.id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("schedule add 必须指定 --schedule-id"))?;
if file.schedules.iter().any(|entry| entry.id == id) {
return Err(anyhow::anyhow!("schedule 已存在:{id}"));
}
let now = unix_seconds_now();
let entry = build_schedule_entry(&request, now)?;
file.schedules.push(entry.clone());
write_schedule_file(state_dir, &file)?;
Ok(schedule_result_value(
state_dir,
"schedule-add",
"created",
&entry,
))
}
pub(super) fn schedule_update_report(
state_dir: &Path,
request: ScheduleMutationRequest,
) -> anyhow::Result<serde_json::Value> {
let _guard = SCHEDULE_FILE_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
validate_schedule_mutation(&request, true)?;
let id = request
.id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("schedule update 必须指定 --schedule-id"))?;
let mut file = read_schedule_file(state_dir)?;
let entry = file
.schedules
.iter_mut()
.find(|entry| entry.id == id)
.ok_or_else(|| anyhow::anyhow!("schedule 不存在:{id}"))?;
if let Some(group) = request
.group
.as_deref()
.map(normalize_schedule_group)
.transpose()?
{
if group != entry.group {
return Err(anyhow::anyhow!(
"schedule {} 属于 {},不能从 {} 二级命令更新",
id,
entry.group,
group
));
}
}
if let Some(action) = request.action.as_deref() {
validate_schedule_action(entry.group.as_str(), action)?;
entry.action = action.to_string();
}
if let Some(at) = request.next_run_unix_seconds {
entry.next_run_unix_seconds = at;
}
if let Some(delay) = request.delay_seconds {
entry.next_run_unix_seconds = unix_seconds_now().saturating_add(delay);
}
if let Some(every) = request.every_seconds {
entry.interval_seconds = Some(nonzero_seconds(
Duration::from_secs(every),
"--schedule-every",
)?);
}
if request.clear_every {
entry.interval_seconds = None;
}
if let Some(count) = request.count {
entry.remaining_runs = Some(count);
}
if request.clear_args {
entry.args.clear();
}
if !request.args.is_empty() {
validate_schedule_args(&request.args)?;
entry.args = request.args.clone();
}
if let Some(enabled) = request.enabled {
entry.enabled = enabled;
}
if entry.interval_seconds.is_none() && request.clear_every && request.count.is_none() {
entry.remaining_runs = Some(1);
}
validate_schedule_entry_shape(entry)?;
entry.updated_unix_seconds = unix_seconds_now();
let updated = entry.clone();
write_schedule_file(state_dir, &file)?;
Ok(schedule_result_value(
state_dir,
"schedule-update",
"updated",
&updated,
))
}
pub(super) fn schedule_remove_report(
state_dir: &Path,
request: ScheduleMutationRequest,
) -> anyhow::Result<serde_json::Value> {
let _guard = SCHEDULE_FILE_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
let id = request
.id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("schedule remove 必须指定 --schedule-id"))?;
let mut file = read_schedule_file(state_dir)?;
let group = request
.group
.as_deref()
.map(normalize_schedule_group)
.transpose()?;
let index = file
.schedules
.iter()
.position(|entry| entry.id == id)
.ok_or_else(|| anyhow::anyhow!("schedule 不存在:{id}"))?;
if let Some(group) = group {
if file.schedules[index].group != group {
return Err(anyhow::anyhow!(
"schedule {} 属于 {},不能从 {} 二级命令删除",
id,
file.schedules[index].group,
group
));
}
}
file.schedules.remove(index);
write_schedule_file(state_dir, &file)?;
Ok(serde_json::json!({
"command": "schedule-remove",
"status": "removed",
"id": id,
"state_file": schedule_file_path(state_dir),
}))
}
pub(super) fn schedule_run_report(
state_dir: &Path,
request: ScheduleRunRequest,
) -> anyhow::Result<serde_json::Value> {
let _guard = SCHEDULE_FILE_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
let now = unix_seconds_now();
let selected_id = request.id.as_deref();
let mut file = read_schedule_file(state_dir)?;
let group = request
.group
.as_deref()
.map(normalize_schedule_group)
.transpose()?;
if let (Some(id), Some(group)) = (selected_id, group.as_deref()) {
if let Some(entry) = file.schedules.iter().find(|entry| entry.id == id) {
if entry.group != group {
return Err(anyhow::anyhow!(
"schedule {} 属于 {},不能从 {} 二级命令执行",
id,
entry.group,
group
));
}
}
}
if request.max_runs == Some(0) {
return Err(anyhow::anyhow!("max_runs 必须大于 0"));
}
let mut results = Vec::new();
for index in 0..file.schedules.len() {
if request.max_runs.is_some_and(|max| results.len() >= max) {
break;
}
let due = {
let entry = &file.schedules[index];
entry.enabled
&& (request.force || entry.next_run_unix_seconds <= now)
&& selected_id.is_none_or(|id| id == entry.id)
&& group.as_deref().is_none_or(|group| group == entry.group)
};
if !due {
continue;
}
let entry = &mut file.schedules[index];
let id = entry.id.clone();
let command = schedule_child_command(entry, state_dir);
let started = unix_seconds_now();
if let Some(remaining) = entry.remaining_runs.as_mut() {
*remaining = remaining.saturating_sub(1);
}
entry.last_run_unix_seconds = Some(started);
entry.updated_unix_seconds = started;
entry.enabled = entry.remaining_runs != Some(0);
entry.next_run_unix_seconds = entry
.interval_seconds
.map(|seconds| started.saturating_add(seconds))
.unwrap_or(started);
write_schedule_file(state_dir, &file)?;
let status = Command::new(&command[0]).args(&command[1..]).status();
let (status_label, error) = match status {
Ok(status) if status.success() => ("completed".to_string(), None),
Ok(status) => (
"failed".to_string(),
Some(format!("子命令退出码:{}", status.code().unwrap_or(-1))),
),
Err(error) => ("failed".to_string(), Some(error.to_string())),
};
let (next_run_unix_seconds, enabled) = {
let entry = &mut file.schedules[index];
entry.last_status = Some(status_label.clone());
entry.last_error = error.clone();
entry.updated_unix_seconds = unix_seconds_now();
(entry.next_run_unix_seconds, entry.enabled)
};
write_schedule_file(state_dir, &file)?;
results.push(serde_json::json!({
"id": id,
"command": command,
"status": status_label,
"error": error,
"next_run_unix_seconds": next_run_unix_seconds,
"enabled": enabled,
}));
}
if selected_id.is_some() && results.is_empty() {
let status = match file
.schedules
.iter()
.find(|entry| Some(entry.id.as_str()) == selected_id)
{
None => "not_found",
Some(entry) if !entry.enabled => "disabled",
Some(_) => "not_due",
};
return Ok(serde_json::json!({
"command": "schedule-run",
"status": status,
"now_unix_seconds": now,
"executed": [],
}));
}
Ok(serde_json::json!({
"command": "schedule-run",
"status": "completed",
"now_unix_seconds": now,
"executed": results,
}))
}
fn build_schedule_entry(
request: &ScheduleMutationRequest,
now: u64,
) -> anyhow::Result<ScheduleEntry> {
validate_schedule_mutation(request, false)?;
let group = request
.group
.as_deref()
.map(normalize_schedule_group)
.transpose()?
.ok_or_else(|| anyhow::anyhow!("schedule 命令缺少所属一级命令"))?;
let action = request
.action
.as_deref()
.map(str::to_string)
.unwrap_or_else(|| default_schedule_action(&group).to_string());
validate_schedule_action(&group, &action)?;
validate_schedule_args(&request.args)?;
let next_run = schedule_next_run(request, now)?;
let interval_seconds = request
.every_seconds
.map(|value| nonzero_seconds(Duration::from_secs(value), "--schedule-every"))
.transpose()?;
let remaining_runs = request
.count
.or_else(|| interval_seconds.is_none().then_some(1));
if interval_seconds.is_none() && remaining_runs.is_some_and(|count| count > 1) {
return Err(anyhow::anyhow!(
"--schedule-count 大于 1 时必须指定 --schedule-every"
));
}
Ok(ScheduleEntry {
id: request
.id
.clone()
.ok_or_else(|| anyhow::anyhow!("schedule add 必须指定 --schedule-id"))?,
group,
action,
args: request.args.clone(),
next_run_unix_seconds: next_run,
interval_seconds,
remaining_runs,
enabled: request.enabled.unwrap_or(true),
created_unix_seconds: now,
updated_unix_seconds: now,
last_run_unix_seconds: None,
last_status: None,
last_error: None,
})
}
fn validate_schedule_mutation(
request: &ScheduleMutationRequest,
update: bool,
) -> anyhow::Result<()> {
if request.next_run_unix_seconds.is_some() && request.delay_seconds.is_some() {
return Err(anyhow::anyhow!(
"next_run_unix_seconds 与 delay_seconds 只能指定一个"
));
}
if request.every_seconds.is_some() && request.clear_every {
return Err(anyhow::anyhow!("every_seconds 与 clear_every 只能指定一个"));
}
if request.count == Some(0) {
return Err(anyhow::anyhow!("count 必须大于 0"));
}
if request.clear_args && !update {
return Err(anyhow::anyhow!("clear_args 只适用于 schedule update"));
}
if request.clear_every && !update {
return Err(anyhow::anyhow!("clear_every 只适用于 schedule update"));
}
Ok(())
}
fn schedule_next_run(request: &ScheduleMutationRequest, now: u64) -> anyhow::Result<u64> {
match (request.next_run_unix_seconds, request.delay_seconds) {
(Some(_), Some(_)) => Err(anyhow::anyhow!(
"--schedule-at-unix 与 --schedule-delay 只能指定一个"
)),
(Some(at), None) => Ok(at),
(None, Some(delay)) => Ok(now.saturating_add(delay)),
(None, None) => Ok(now),
}
}
fn schedule_child_command(entry: &ScheduleEntry, state_dir: &Path) -> Vec<String> {
let executable = env::current_exe().unwrap_or_else(|_| PathBuf::from("bat"));
let mut command = vec![
executable.to_string_lossy().into_owned(),
entry.group.clone(),
entry.action.clone(),
];
command.extend(entry.args.iter().cloned());
if !entry.args.iter().any(|arg| arg == "--state-dir") {
command.push("--state-dir".to_string());
command.push(state_dir.to_string_lossy().into_owned());
}
command.push("--no-banner".to_string());
command.push("--no-progress".to_string());
command
}
fn validate_schedule_command_options(
options: &CliOptions,
allow_empty: bool,
) -> anyhow::Result<()> {
if !allow_empty && options.schedule_group.is_none() {
return Err(anyhow::anyhow!("schedule 命令缺少所属一级命令"));
}
if options.watch && !matches!(options.command, CliCommand::ScheduleRun) {
return Err(anyhow::anyhow!("只有 schedule run 支持 --watch"));
}
if options.interval.is_zero() {
return Err(anyhow::anyhow!("schedule 轮询间隔必须大于 0"));
}
if options.schedule_every.is_some_and(|value| value.is_zero()) {
return Err(anyhow::anyhow!("--schedule-every 必须大于 0"));
}
if options.schedule_delay.is_some_and(|value| value.is_zero()) {
return Err(anyhow::anyhow!("--schedule-delay 必须大于 0"));
}
if options.schedule_count == Some(0) {
return Err(anyhow::anyhow!("--schedule-count 必须大于 0"));
}
if options.schedule_max_runs == Some(0) {
return Err(anyhow::anyhow!("--schedule-max-runs 必须大于 0"));
}
if options.schedule_max_runs.is_some() && !matches!(options.command, CliCommand::ScheduleRun) {
return Err(anyhow::anyhow!("--schedule-max-runs 只适用于 schedule run"));
}
if options.schedule_clear_args && !matches!(options.command, CliCommand::ScheduleUpdate) {
return Err(anyhow::anyhow!(
"--schedule-clear-args 只适用于 schedule update"
));
}
if options.schedule_clear_every && !matches!(options.command, CliCommand::ScheduleUpdate) {
return Err(anyhow::anyhow!(
"--schedule-clear-every 只适用于 schedule update"
));
}
Ok(())
}
fn validate_schedule_action(group: &str, action: &str) -> anyhow::Result<()> {
let valid = match group {
"res" => matches!(action, "pull" | "refresh" | "verify" | "repair"),
"parse" => matches!(action, "run" | "repack" | "clear-cache"),
"i18n" => matches!(action, "run" | "export" | "validate" | "publish"),
_ => false,
};
if valid {
Ok(())
} else {
Err(anyhow::anyhow!(
"不支持的 schedule actiongroup={group}, action={action}"
))
}
}
fn validate_schedule_entry_shape(entry: &ScheduleEntry) -> anyhow::Result<()> {
if entry.interval_seconds.is_none() && entry.remaining_runs.is_some_and(|count| count > 1) {
return Err(anyhow::anyhow!(
"非周期 schedule 不能保留多次执行次数;请设置 --schedule-every"
));
}
Ok(())
}
fn default_schedule_action(group: &str) -> &'static str {
match group {
"res" => "pull",
"parse" => "run",
"i18n" => "run",
_ => "",
}
}
fn validate_schedule_args(args: &[String]) -> anyhow::Result<()> {
if let Some(arg) = args
.iter()
.find(|arg| arg.starts_with("--schedule-") || matches!(arg.as_str(), "--id" | "--action"))
{
return Err(anyhow::anyhow!(
"schedule 子命令参数不能嵌套调度控制选项:{arg}"
));
}
Ok(())
}
fn nonzero_seconds(value: Duration, flag: &str) -> anyhow::Result<u64> {
let seconds = value.as_secs();
if seconds == 0 {
return Err(anyhow::anyhow!("{flag} 必须至少为 1s"));
}
Ok(seconds)
}
fn schedule_request_from_options(options: &CliOptions) -> ScheduleMutationRequest {
ScheduleMutationRequest {
id: options.schedule_id.clone(),
group: options.schedule_group.clone(),
action: options.schedule_action.clone(),
args: options.schedule_args.clone(),
next_run_unix_seconds: options.schedule_at_unix,
delay_seconds: options.schedule_delay.map(|value| value.as_secs()),
every_seconds: options.schedule_every.map(|value| value.as_secs()),
count: options.schedule_count,
clear_args: options.schedule_clear_args,
clear_every: options.schedule_clear_every,
enabled: options.schedule_enabled,
}
}
fn normalize_schedule_group(group: &str) -> anyhow::Result<String> {
let normalized = match group {
"res" | "resource" | "resources" => "res",
"parse" => "parse",
"i18n" | "tr" | "translation" | "translate" => "i18n",
other => {
return Err(anyhow::anyhow!(
"schedule 不支持的一级命令:{other}(支持 res、parse、i18n"
))
}
};
Ok(normalized.to_string())
}
fn schedule_result_value(
state_dir: &Path,
command: &'static str,
status: &'static str,
entry: &ScheduleEntry,
) -> serde_json::Value {
serde_json::json!({
"command": command,
"status": status,
"schedule": entry,
"state_file": schedule_file_path(state_dir),
})
}
fn schedule_file_path(state_dir: &Path) -> PathBuf {
state_dir.join(SCHEDULES_FILE_NAME)
}
pub(super) fn read_schedule_file(state_dir: &Path) -> anyhow::Result<ScheduleFile> {
let path = schedule_file_path(state_dir);
let Some(bytes) = read_file_no_symlink(&path, "调度计划文件").map_err(anyhow::Error::msg)?
else {
return Ok(ScheduleFile {
schema_version: SCHEDULES_SCHEMA_VERSION,
schedules: Vec::new(),
});
};
let file: ScheduleFile = serde_json::from_slice(&bytes)?;
if file.schema_version != SCHEDULES_SCHEMA_VERSION {
return Err(anyhow::anyhow!(
"不支持的调度计划 schema:{},当前版本={}",
file.schema_version,
SCHEDULES_SCHEMA_VERSION
));
}
Ok(file)
}
fn write_schedule_file(state_dir: &Path, file: &ScheduleFile) -> anyhow::Result<()> {
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
let path = schedule_file_path(state_dir);
let bytes = serde_json::to_vec_pretty(file)?;
write_file_atomic(
&path,
&bytes,
bat_infrastructure::STATE_FILE_MODE,
"调度计划文件",
)
.map_err(anyhow::Error::msg)
}
+635
View File
@@ -0,0 +1,635 @@
use super::*;
pub(super) const MAX_RETAINED_TASKS: usize = 64;
/// 每个任务保留的进度日志行数上限。
pub(super) const MAX_TASK_LOG_LINES: usize = 200;
/// 任务类型:覆盖资源同步、校验、修复、翻译 worker 与 catalog 更新检查。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum TaskKind {
Sync,
Verify,
Repair,
/// 翻译 provider worker 轮次。
TranslationWorker,
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
Refresh,
}
impl TaskKind {
pub(super) fn method(self) -> &'static str {
match self {
Self::Sync => RPC_METHOD_RESOURCE_SYNC,
Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
Self::Repair => RPC_METHOD_RESOURCE_REPAIR,
Self::TranslationWorker => RPC_METHOD_TRANSLATION_WORKER_RUN,
Self::Refresh => RPC_METHOD_CATALOG_REFRESH,
}
}
/// 由 daemon 基准配置派生该任务的实际同步配置。
pub(super) fn build_config(
self,
base: &OfficialUpdateConfig,
force: bool,
) -> OfficialUpdateConfig {
let mut config = base.clone();
match self {
Self::Sync => {
config.dry_run = false;
config.force = config.force || force;
}
Self::Verify => {
config.dry_run = true;
config.plan = true;
config.audit_local = true;
config.repair = false;
config.force = false;
}
Self::Repair => {
config.dry_run = false;
config.audit_local = true;
config.repair = true;
config.force = false;
}
Self::Refresh => {
config.dry_run = true;
config.plan = true;
config.audit_local = false;
config.repair = false;
config.force = force;
}
Self::TranslationWorker => {
config.dry_run = false;
config.force = false;
}
}
config
}
}
/// 请求取消任务的结果。
pub(super) enum CancelOutcome {
Requested,
AlreadyFinished,
NotFound,
}
/// 单个任务的可轮询记录。
#[derive(Debug, Clone, Serialize)]
pub(super) struct TaskRecord {
pub(super) id: String,
pub(super) kind: &'static str,
/// `queued` | `running` | `succeeded` | `failed` | `cancelled`。
pub(super) status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) stage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) message: Option<String>,
pub(super) created_at: u64,
pub(super) updated_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) started_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) finished_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) error: Option<ApiError>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) result: Option<serde_json::Value>,
/// 取消标志,worker 的 should_cancel 检查它;不参与序列化。
#[serde(skip)]
pub(super) cancel: Arc<AtomicBool>,
/// 进度日志(有界),经 task.logs 返回;不参与 task.status 序列化。
#[serde(skip)]
pub(super) log: Vec<String>,
}
impl TaskRecord {
pub(super) fn is_finished(&self) -> bool {
matches!(self.status, "succeeded" | "failed" | "cancelled")
}
}
struct TaskStore {
tasks: HashMap<String, TaskRecord>,
order: Vec<String>,
seq: u64,
/// 任务历史持久化文件路径;`None` 表示纯内存(测试等非 daemon 场景)。
persist_path: Option<PathBuf>,
}
/// daemon 任务历史持久化文件名(位于 state dir 内,`0600` 原子写)。
pub(super) const TASKS_FILE_NAME: &str = "bat-tasks.json";
/// 任务历史文件结构版本。
pub(super) const TASKS_FILE_VERSION: u32 = 1;
/// 任务历史文件的持久化形态(版本化;daemon 重启后恢复任务历史用)。
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct PersistedTaskFile {
pub(super) version: u32,
/// 任务 ID 序号计数器;恢复它避免 pid 复用时新任务与历史任务撞 ID。
pub(super) seq: u64,
pub(super) tasks: Vec<PersistedTaskRecord>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct PersistedTaskRecord {
id: String,
kind: String,
pub(super) status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
stage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
message: Option<String>,
created_at: u64,
updated_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
started_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
finished_at: Option<u64>,
/// `ApiError` 的序列化形态(code/kind/domain/location/message/retryable)。
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
log: Vec<String>,
}
/// 把持久化的任务类型映射回静态字符串;未识别(如未来版本新增)返回 `None`。
fn task_kind_static(kind: &str) -> Option<&'static str> {
match kind {
RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC),
RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY),
RPC_METHOD_RESOURCE_REPAIR => Some(RPC_METHOD_RESOURCE_REPAIR),
RPC_METHOD_TRANSLATION_WORKER_RUN => Some(RPC_METHOD_TRANSLATION_WORKER_RUN),
RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH),
_ => None,
}
}
/// 把持久化的任务状态映射回静态字符串;未识别返回 `None`。
fn task_status_static(status: &str) -> Option<&'static str> {
match status {
"queued" => Some("queued"),
"running" => Some("running"),
"succeeded" => Some("succeeded"),
"failed" => Some("failed"),
"cancelled" => Some("cancelled"),
_ => None,
}
}
impl PersistedTaskRecord {
fn from_record(record: &TaskRecord) -> Self {
Self {
id: record.id.clone(),
kind: record.kind.to_string(),
status: record.status.to_string(),
stage: record.stage.clone(),
message: record.message.clone(),
created_at: record.created_at,
updated_at: record.updated_at,
started_at: record.started_at,
finished_at: record.finished_at,
error: record
.error
.as_ref()
.and_then(|error| serde_json::to_value(error).ok()),
result: record.result.clone(),
log: record.log.clone(),
}
}
/// 还原为内存任务记录;kind/status 未识别时返回 `None`(调用方计数跳过)。
fn into_record(self) -> Option<TaskRecord> {
let kind = task_kind_static(&self.kind)?;
let status = task_status_static(&self.status)?;
// 错误从序列化形态还原:code 经码表反查(未登记回退 internal),
// location 固定为任务执行器(当前全部任务错误的唯一来源)。
let error = self.error.as_ref().map(|value| {
let code = value
.get("code")
.and_then(serde_json::Value::as_str)
.and_then(ErrorCode::from_id)
.unwrap_or(ErrorCode::INTERNAL);
let message = value
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("<持久化错误信息缺失>")
.to_string();
ApiError::new(code, "task.executor", message)
});
Some(TaskRecord {
id: self.id,
kind,
status,
stage: self.stage,
message: self.message,
created_at: self.created_at,
updated_at: self.updated_at,
started_at: self.started_at,
finished_at: self.finished_at,
error,
result: self.result,
cancel: Arc::new(AtomicBool::new(false)),
log: self.log,
})
}
}
/// 读取任务历史文件。文件缺失返回 `Ok(None)`;symlink、解析失败或版本不支持返回 `Err`。
fn load_persisted_tasks(path: &Path) -> Result<Option<PersistedTaskFile>, String> {
let Some(bytes) = read_file_no_symlink(path, "任务历史")? else {
return Ok(None);
};
let file: PersistedTaskFile = serde_json::from_slice(&bytes)
.map_err(|error| format!("解析任务历史失败 {}{error}", path.display()))?;
if file.version != TASKS_FILE_VERSION {
return Err(format!(
"不支持的任务历史版本 {},文件 {}",
file.version,
path.display()
));
}
Ok(Some(file))
}
/// 任务注册表句柄:包住内存存储,供 RPC handler 与 worker 共享。
///
/// 通过方法访问(而非直接摸内部 map),便于将来换成 Redis 等持久化后端。
#[derive(Clone)]
pub(super) struct TaskRegistry {
inner: Arc<Mutex<TaskStore>>,
}
impl TaskRegistry {
/// 纯内存注册表(无持久化);生产 daemon 走 [`Self::with_persistence`]。
#[cfg(test)]
pub(super) fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(TaskStore {
tasks: HashMap::new(),
order: Vec::new(),
seq: 0,
persist_path: None,
})),
}
}
/// 从 state dir 恢复任务历史并启用持久化。
///
/// 中断时仍处于 queued/running 的任务标记为 `failed``TASK_INTERRUPTED`);
/// 文件缺失按空历史处理;文件损坏或版本不支持时改名 `.corrupt` 留证并从
/// 空历史开始。返回注册表与恢复摘要(供 daemon 日志记录)。
pub(super) fn with_persistence(state_dir: &Path) -> (Self, String) {
let path = state_dir.join(TASKS_FILE_NAME);
let now = unix_seconds_now();
let mut seq = 0;
let mut tasks = HashMap::new();
let mut order = Vec::new();
let summary = match load_persisted_tasks(&path) {
Ok(None) => "无历史任务文件,从空任务历史开始".to_string(),
Ok(Some(file)) => {
seq = file.seq;
let total = file.tasks.len();
let mut interrupted = 0usize;
let mut skipped = 0usize;
for persisted in file.tasks {
let Some(mut record) = persisted.into_record() else {
skipped += 1;
continue;
};
if !record.is_finished() {
interrupted += 1;
record.status = "failed";
record.finished_at = Some(now);
record.updated_at = now;
record.error = Some(ApiError::new(
ErrorCode::TASK_INTERRUPTED,
"task.executor",
"daemon 停止/重启导致任务中断",
));
record
.log
.push("[daemon] 任务因 daemon 停止/重启而中断".to_string());
}
if tasks.insert(record.id.clone(), record.clone()).is_none() {
order.push(record.id);
} else {
skipped += 1;
}
}
format!("恢复任务历史 {total} 条(标记中断 {interrupted} 条,跳过无法识别 {skipped} 条)")
}
Err(error) => {
// 保留损坏文件供诊断(改名而非覆盖),从空历史开始。
let corrupt = path.with_extension("json.corrupt");
if fs::rename(&path, &corrupt).is_ok() {
format!(
"任务历史不可用({error});原文件已改名保留为 {}",
corrupt.display()
)
} else {
format!("任务历史不可用({error});且无法改名保留原文件")
}
}
};
let registry = Self {
inner: Arc::new(Mutex::new(TaskStore {
tasks,
order,
seq,
persist_path: Some(path),
})),
};
// 把中断标记(或空历史)立即写回,保证文件与内存视图一致。
registry.lock().persist();
(registry, summary)
}
fn lock(&self) -> std::sync::MutexGuard<'_, TaskStore> {
self.inner
.lock()
.unwrap_or_else(|poison| poison.into_inner())
}
/// 创建 queued 任务并返回 task_id。
pub(super) fn create(&self, kind: TaskKind) -> String {
let now = unix_seconds_now();
let mut store = self.lock();
store.seq += 1;
let id = format!("task-{}-{}", std::process::id(), store.seq);
let record = TaskRecord {
id: id.clone(),
kind: kind.method(),
status: "queued",
stage: None,
message: None,
created_at: now,
updated_at: now,
started_at: None,
finished_at: None,
error: None,
result: None,
cancel: Arc::new(AtomicBool::new(false)),
log: Vec::new(),
};
store.tasks.insert(id.clone(), record);
store.order.push(id.clone());
store.prune();
store.persist();
id
}
pub(super) fn update<F: FnOnce(&mut TaskRecord)>(&self, id: &str, update: F) {
let mut store = self.lock();
let mut status_changed = false;
if let Some(record) = store.tasks.get_mut(id) {
let previous_status = record.status;
update(record);
record.updated_at = unix_seconds_now();
status_changed = record.status != previous_status;
}
// 只在生命周期转换时落盘;stage/message/log 的高频进度更新以内存为准,
// 随下一次转换一起写入(避免每个进度事件一次磁盘写)。
if status_changed {
store.persist();
}
}
pub(super) fn get(&self, id: &str) -> Option<TaskRecord> {
self.lock().tasks.get(id).cloned()
}
/// 返回任务的取消标志(与 worker 共享同一 Arc)。
pub(super) fn cancel_flag(&self, id: &str) -> Option<Arc<AtomicBool>> {
self.lock()
.tasks
.get(id)
.map(|record| Arc::clone(&record.cancel))
}
/// 追加一行进度日志,超出上限时丢弃最旧的。
pub(super) fn append_log(&self, id: &str, line: String) {
let mut store = self.lock();
if let Some(record) = store.tasks.get_mut(id) {
record.log.push(line);
if record.log.len() > MAX_TASK_LOG_LINES {
let overflow = record.log.len() - MAX_TASK_LOG_LINES;
record.log.drain(0..overflow);
}
}
}
/// 返回任务的进度日志。
pub(super) fn logs(&self, id: &str) -> Option<Vec<String>> {
self.lock().tasks.get(id).map(|record| record.log.clone())
}
/// 请求取消任务:未结束的置取消标志,已结束的原样返回,不存在返回 NotFound。
pub(super) fn request_cancel(&self, id: &str) -> CancelOutcome {
let store = self.lock();
match store.tasks.get(id) {
None => CancelOutcome::NotFound,
Some(record) if record.is_finished() => CancelOutcome::AlreadyFinished,
Some(record) => {
record.cancel.store(true, Ordering::Relaxed);
CancelOutcome::Requested
}
}
}
/// 返回全部任务,最新创建的在前。
pub(super) fn list(&self) -> Vec<TaskRecord> {
let store = self.lock();
store
.order
.iter()
.rev()
.filter_map(|id| store.tasks.get(id).cloned())
.collect()
}
}
impl TaskStore {
/// 把当前任务历史落盘(`0600` 原子写、不跟随 symlink)。
///
/// 持久化未启用时为 no-op;写失败只记 stderr(进 daemon 日志),
/// 不让持久化故障拖垮任务执行本身。
fn persist(&self) {
let Some(path) = &self.persist_path else {
return;
};
let file = PersistedTaskFile {
version: TASKS_FILE_VERSION,
seq: self.seq,
tasks: self
.order
.iter()
.filter_map(|id| self.tasks.get(id))
.map(PersistedTaskRecord::from_record)
.collect(),
};
match serde_json::to_vec_pretty(&file) {
Ok(bytes) => {
if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史")
{
super::terminal_output::print_daemon_error(format!(
"任务历史落盘失败:{error}"
));
}
}
Err(error) => {
super::terminal_output::print_daemon_error(format!("任务历史序列化失败:{error}"))
}
}
}
/// 裁剪最旧的已结束任务,把内存占用控制在上限内;运行中/排队中的任务不裁剪。
fn prune(&mut self) {
while self.order.len() > MAX_RETAINED_TASKS {
let Some(position) = self.order.iter().position(|id| {
self.tasks
.get(id)
.map(TaskRecord::is_finished)
.unwrap_or(true)
}) else {
break;
};
let id = self.order.remove(position);
self.tasks.remove(&id);
}
}
}
/// 提交给任务 worker 的作业(配置已按任务类型派生完毕)。
pub(super) struct TaskJob {
pub(super) id: String,
pub(super) kind: TaskKind,
pub(super) config: OfficialUpdateConfig,
pub(super) translation_worker_config: Option<TranslationWorkerConfig>,
/// 与任务记录共享的取消标志。
pub(super) cancel: Arc<AtomicBool>,
}
/// daemon 任务上下文:RPC handler 借它创建任务、入队和读取。
#[derive(Clone)]
pub(super) struct DaemonTaskContext {
pub(super) registry: TaskRegistry,
pub(super) queue: mpsc::Sender<TaskJob>,
pub(super) base_config: OfficialUpdateConfig,
/// 串行化会读取或修改已发布资源状态的 daemon 操作。
pub(super) sync_lock: Arc<Mutex<()>>,
pub(super) restart_controller: DaemonRestartController,
}
pub(super) type DaemonRestartController = fn(&Path) -> anyhow::Result<u32>;
/// 任务 worker:单线程 FIFO 消费任务队列,串行执行官方同步/校验。
///
/// 每个任务执行前获取进程内 `sync_lock`,与 watch 循环互斥(等待而非撞文件锁失败);
/// 进度写入任务记录;`should_cancel` 接 daemon 停止标志,停机时中止在途任务。
pub(super) fn run_task_worker(
receiver: mpsc::Receiver<TaskJob>,
registry: TaskRegistry,
sync_lock: Arc<Mutex<()>>,
control: DaemonControl,
) {
let service = OfficialUpdateService::new();
for job in receiver {
registry.update(&job.id, |record| {
record.status = "running";
record.started_at = Some(unix_seconds_now());
});
let cancel = Arc::clone(&job.cancel);
let run_result = if job.kind == TaskKind::TranslationWorker {
let worker_config = job
.translation_worker_config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("translation worker 任务缺少运行配置"));
worker_config.and_then(|worker_config| {
let _sync_guard = sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
registry.append_log(&job.id, "[translation-worker] 开始执行".to_string());
registry.update(&job.id, |record| {
record.stage = Some("translation-worker".to_string());
record.message = Some("翻译 provider worker 正在执行".to_string());
});
let resource_root = active_official_resource_root(&job.config.output_root)?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime
.block_on(bat_infrastructure::run_translation_worker_at(
&resource_root,
worker_config,
))
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from))
})
} else {
let run_result = {
let _sync_guard = sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let progress_registry = registry.clone();
let progress_id = job.id.clone();
let cancel_check = Arc::clone(&cancel);
let stop_control = Arc::clone(&control);
service.run_with_progress_and_cancellation(
&job.config,
|event| {
progress_registry.append_log(
&progress_id,
format!("[{}] {}", event.stage, event.message),
);
progress_registry.update(&progress_id, |record| {
record.stage = Some(event.stage.to_string());
record.message = Some(event.message.clone());
});
},
|| {
cancel_check.load(Ordering::Relaxed)
|| daemon_control_stop_requested(Some(&stop_control))
},
)
};
run_result
.map(|report| serde_json::to_value(&report).map_err(anyhow::Error::from))
.and_then(|result| result)
};
match run_result {
Ok(report) => registry.update(&job.id, |record| {
record.status = "succeeded";
record.finished_at = Some(unix_seconds_now());
record.result = Some(report);
}),
Err(error) => {
let cancelled = cancel.load(Ordering::Relaxed);
// 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。
let code = error
.downcast_ref::<bat_infrastructure::DownloadError>()
.map(bat_infrastructure::DownloadError::code)
.unwrap_or(ErrorCode::INTERNAL);
registry.update(&job.id, |record| {
record.finished_at = Some(unix_seconds_now());
if cancelled {
record.status = "cancelled";
record.error = Some(ApiError::new(
ErrorCode::INTERNAL,
"task.executor",
"任务已取消",
));
} else {
record.status = "failed";
record.error =
Some(ApiError::new(code, "task.executor", error.to_string()));
}
});
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More