Compare commits

...
60 Commits
Author SHA1 Message Date
nyaKazuha 8fc93b8f39 docs: document Translation Memory and config contracts
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-06 22:52:10 +08:00
nyaKazuha e373e3fd32 feat(api): proxy Translation Memory over bat.sock 2026-09-06 22:50:59 +08:00
nyaKazuha 7d6389806b feat(translation): add Rust Translation Memory and config migration 2026-09-06 22:48:51 +08:00
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
177 changed files with 66595 additions and 10194 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 相关改动还应运行:
+99 -78
View File
@@ -1,35 +1,43 @@
# BlueArchiveToolkit 当前工作区状态
- **更新时间**2026-07-15
- **更新时间**2026-09-06
- **状态来源**:本地工作区盘点、代码验证和最新提交
- **状态分支**`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` 首次启动会在二进制所在目录释放 `config.toml.example` 配置模板(`0600`),`config.toml` 存在且 Unix 权限为 `0600` 或更严格时读取并使用它;`config.toml` 不存在时仅保留模板,不自动读取 example,运行时继续使用环境变量和内置默认值。优先级为命令行参数 > 进程环境变量 > `config.toml` > 内置默认值,`BAT_SKIP_ENV_FILE` 已废弃且不再影响启动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`;跨 release 的 Translation Memory V1 位于 `<output>/translation-memory.sqlite`,不放在 `versions/<id>` 或 release task 库中;
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
job/unit/provider run 状态;`translation.memory.summary/query/confirm` 提供
Rust-owned TM 的摘要、source/context 查询和显式 trusted 确认,`bat-api` 仅作
typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取
任务,最终资源报告按 pull plan 顺序输出。
---
@@ -40,7 +48,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 +97,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 +145,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` 继续推进 Glossary、完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit/TM 查询和通用 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 +205,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 +216,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 +272,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` 再次漂移;TM 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text 和 provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM 管理接口(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``translation.memory.summary/query/confirm``localized.publish``localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
生产要求:
1. 使用独立输出目录,例如 `/var/lib/bluearchive-toolkit/official`
@@ -261,34 +286,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. 继续资源查询和翻译基础设施:更丰富的查询、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 协作后台、Glossary、模糊 TM 匹配和通用 manifest 发布未完成
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、Glossary、通用 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)"
+94 -74
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``translation.*``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、翻译、TM 和 localized 发布/回滚;schedule CRUD、`translation.tasks` / `translation.handoff``translation.task.update``translation.worker.run``translation.proofread``translation.memory.*` 已经通过 `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`translation.memory.*` 已提供 Rust-owned TM 摘要、raw source/context 查询、provenance 和显式 confirm contractGo 侧仅代理
6. 受支持汉化 Patch 发布已具备 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 manifest/apply/rollback/完整性校验和 `localized.status` 严格校验;真实 provider worker 与项目级 Translation Memory V1 已接入,翻译记忆到完整汉化文件集合的构建仍未完成
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 复用和 Translation Memory V1 已可用,但完整解析覆盖、丰富查询扩展和真实线上 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``translation.memory.*` 可查询离线 TextUnit 与项目级 TM。剩余工作是更丰富的 TextUnit/TM 查询和通用 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`
---
@@ -242,11 +260,10 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
交付物:
1. PostgreSQL schemasource_text、translation、translation_memory、glossary、review、history
2. 实现精确匹配、模糊匹配、上下文匹配
3. 实现术语优先级、别名、分类、冲突检测和审核状态
4. 实现导入导出和版本历史
5. 实现 `translate memory``glossary` CLI 子命令。
1. Translation Memory V1 已使用项目级 SQLite schemasource raw/hash、translation、完整 context、candidate/trusted 和 provenance
2. 实现 raw source + 完整 context exact match;模糊匹配、Glossary 联动和完整导入导出仍待实现
3. 实现显式 per-record confirm术语优先级、别名、分类、冲突检测和审核队列仍待实现
4. 实现 `bat i18n memory summary|query|confirm` 与对应 Rust RPC
验收标准:
@@ -284,11 +301,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 +314,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
2. 任一步失败都能回滚到补丁前状态。
3. 不直接覆盖未经备份的客户端文件。
4. Patch 生成与应用有端到端测试。
5. 汉化产物写入 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立目录,保留官方相对目录结构;只有完整 Patch 发布并通过校验后才切换为 `localized`
---
@@ -305,12 +324,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 +343,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
### Milestone 10Web 管理后台
**目标**:为翻译协作和资源管理提供可用后台。
**目标**在已落地的 `bat-api` 内嵌 dashboard MVP 之上,为翻译协作和资源管理提供完整后台。
交付物:
1. 登录、权限、用户角色。
2. Dashboard:同步状态、翻译进度、质量问题、队列状态。
2. Dashboard:同步状态、翻译进度、质量问题、队列状态;当前 MVP 已覆盖资源、调度、任务、日志、parse、翻译和 localized 控制
3. 翻译审核:列表、详情、Diff、批量操作。
4. 术语管理:搜索、冲突提示、审核。
5. 资源浏览:版本、资源、Bundle、文本定位。
@@ -348,7 +369,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 +387,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 5. 推荐执行顺序
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是 Go CLI 入口、资源解析、同步结果进入 CAS/ResourceRepository,以及真实端到端验证。
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈是 Glossary、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证。
建议顺序:
@@ -374,20 +395,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` 继续推进 Glossary 和 Patch 构建
3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图
4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告
---
@@ -395,7 +415,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
### SQLite 权限问题
旧 Week 3 报告提到 SQLite 文件权限导致测试失败。处理策略:
本地 SQLite 元数据后端的权限和恢复风险需要通过显式测试覆盖。处理策略:
1. 本地元数据后端必须使用临时目录和明确权限测试。
2. SQLite 只作为 adapter,不进入领域层。
@@ -414,8 +434,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 +448,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、Translation Memory V1、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM 扩展、Glossary、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告
---
- **下一份应更新文档**:真实官方网络 smoke 记录
- **下一项工程任务**Go CLI 最小可用入口和官方同步端到端 smoke。
- **下一份应补充的验证材料**:真实官方网络 smoke 运行记录
- **下一项工程任务**推进 TM 扩展、Glossary、通用 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/worker.run``translation.memory.*``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/TM 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 V1TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。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 扩展体系:其中 Translation Memory V1 已由 Rust `bat` 持有;仍未实现的是 Glossary、模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台
- 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` 显式传入同步、输出、worker/TM 等 daemon 启动参数。后台 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 <资源目录>``config.toml``[resource].output_root` / 环境变量 `BAT_OUTPUT`;需要覆盖汉化产物位置时,用 `--localized-output <目录>``config.toml``[localized].output_root` / 环境变量 `BAT_LOCALIZED_OUTPUT`;需要启用官方 release 导入 CAS/索引时,用 `--import-repository`,并可用 `config.toml``[repository].import_cas_root``[repository].import_resource_repository_path` 或环境变量 `BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖默认路径;需要覆盖后台状态目录时,用 `--state-dir <状态目录>``config.toml``[runtime].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 扩展 Glossary、完整 Patch 构建和发布/回滚闭环
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
不建议在 Go CLI、资源解析和文本提取基础能力完成前优先开发 Web UI
当前已提供直接调用 bat-api 鉴权接口的内嵌 dashboard;完整 Web 协作后台仍应在 TM 扩展、权限模型和持久化 API 明确后推进
---
+221 -18
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` | 重新下载本地校验失败的资源 |
@@ -56,6 +73,145 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
| `clean-stable` | 清理 `.part`/`.tmp`/失效锁、PID、socketdaemon 运行中会拒绝执行) |
`status`/`stop`/`logs`/`reload` 和默认形态的 `refresh` 优先走 `bat.sock` JSON-RPCsocket 不可用时 `status`/`stop` 回退到 PID/状态文件兼容路径。
需要替换 daemon 启动参数时使用 `restart`,或给 `reload` 显式传入同步、输出、worker/TM 等启动参数;未显式传参的 `restart` 复用上次保存的启动命令。
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/memory/summary` | 读取 Rust TM schema、记录总数及 candidate/trusted 等状态计数;需要管理 token |
| `GET /admin/translation/memory/query?source_text=...&source_context=...&limit=100` | 按 raw source/context 查询 Rust TM 记录、复用判定和 provenance;需要管理 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` + 汉化状态 |
| `translation-memory-confirm` | `translation.memory.confirm` | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | `202` + 已确认的 TM 记录 |
| `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
```
---
@@ -115,21 +271,45 @@ 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。
### 配置文件(`.env`,无参启动)
### 配置文件(config.toml,无参启动)
`bat` 首次启动时会在**二进制所在目录**释放一个 `.env` 配置模板(`0600` 权限,已存在则不动)。之后每次启动自动加载该文件,把其中的键作为进程环境变量(不覆盖已存在的环境变量),因此编辑 `.env` 后直接运行 `bat`(无参数)即可按配置启动。
`bat` 首次启动时会在**二进制所在目录**释放一个 `config.toml.example` 配置模板(`0600` 权限,已存在则不动)。程序只读取同目录下的 `config.toml``config.toml.example` 只是模板,不会被自动读取,也不会自动复制或重命名为 `config.toml`。没有 `config.toml` 时,程序继续使用进程环境变量和内置默认值启动。
- 优先级:**命令行参数 > 进程环境变量 > `.env` > 内置默认值**
- 语法:每行 `KEY=VALUE``#` 开头为注释;值两侧成对引号会剥除;空值视为未设置。
- 支持的键:`BAT_OUTPUT``BAT_STATE_DIR``BAT_AUTO_DISCOVER``BAT_WATCH``BAT_DAEMON``BAT_PROXY``BAT_NO_PROXY``BAT_INTERVAL_SECONDS``BAT_ERROR_RETRY_SECONDS``BAT_APP_VERSION``BAT_CONNECTION_GROUP``BAT_LAUNCHER_VERSION``BAT_PLATFORMS``BAT_CURL``BAT_UNZIP``BAT_JSON``BAT_QUIET_UP_TO_DATE`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`
- `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run``.env` 的模式开关让位。`status` / `verify` 等子命令不受它们影响
由于 `config.toml` 可能包含代理凭据,Unix 下实际 `config.toml` 必须保持 `0600` 或更严格;权限过宽时程序会拒绝读取
- 优先级:**命令行参数 > 进程环境变量 > `config.toml` > 内置默认值**
- `config.toml` 的字段按职责分组:`[runtime]``[resource]``[localized]``[repository]``[network]``[translation.worker]`
- 现有 `BAT_*` 环境变量仍然有效,可继续覆盖 `config.toml` 中的同名配置。
- `BAT_SKIP_ENV_FILE` 已废弃且不再影响启动。
- 支持的环境变量:`BAT_OUTPUT``BAT_LOCALIZED_OUTPUT``BAT_STATE_DIR``BAT_AUTO_DISCOVER``BAT_WATCH``BAT_DAEMON``BAT_IMPORT_REPOSITORY``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB``BAT_PROXY``BAT_NO_PROXY``BAT_INTERVAL_SECONDS``BAT_ERROR_RETRY_SECONDS``BAT_APP_VERSION``BAT_CONNECTION_GROUP``BAT_LAUNCHER_VERSION``BAT_PLATFORMS``BAT_CURL``BAT_DOWNLOAD_CONCURRENCY``BAT_UNZIP``BAT_JSON``BAT_QUIET_UP_TO_DATE``BAT_TRANSLATION_PROVIDER``BAT_TRANSLATION_FIXTURE``BAT_TRANSLATION_MEMORY_PATH``BAT_TRANSLATION_CONCURRENCY``BAT_TRANSLATION_MAX_ATTEMPTS``BAT_TRANSLATION_LEASE_SECONDS``BAT_TRANSLATION_RETRY_BACKOFF_SECONDS``BAT_TRANSLATION_MAX_TASKS``BAT_TRANSLATION_WORKER_ID`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`
- `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时运行模式设置让位。`status` / `verify` 等子命令不受它们影响。
- 已运行的 daemon 不会热读 `config.toml`;默认 `reload` 只唤醒后台重新发现和刷新。需要应用配置文件变更时,使用带显式启动参数的 `restart`/`reload`,或先 `stop` 再重新启动 daemon。
- `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json`
-`BAT_SKIP_ENV_FILE=1` 可让 `bat` 完全跳过 `.env` 的生成与加载。
### Translation Memory V1
Translation Memory 由 Rust `bat` 独立持有,默认路径为
`<output>/translation-memory.sqlite`,不在 `versions/<id>` 内,也不使用当前 release
`translation-tasks.sqlite`。可通过 `[translation.worker].translation_memory_path`
`BAT_TRANSLATION_MEMORY_PATH``--translation-memory-path` 覆盖。
```bash
bat i18n memory summary
bat i18n memory query --tm-source-text '原始文本' --tm-context-json '{"destination":"Table.bytes","archive_entry":"","field_path":"Text"}'
bat i18n memory confirm --tm-record-id 'tm-...' --tm-reviewer 'operator' --tm-reason '人工校对通过'
```
只有 raw source 完全相同、完整 context 完全相同且状态为 `trusted` 的记录会被 worker
自动复用。provider 输出写入先是 `candidate`manual task result 即使 completed 也不会自动
建立 TM 或 trusted。查询、诊断和显式 confirm 对应 Rust
RPC `translation.memory.summary``translation.memory.query``translation.memory.confirm`
context 不完整或不一致、normalized source 辅助命中和 workflow `proofread` 都不会自动
建立 trusted 记录。
---
@@ -166,7 +346,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 +419,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 +453,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 +477,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 +523,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());
}
}
+478 -95
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,79 +479,116 @@ 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 primary_key = keys
.get(record.primary_key_index as usize)
.and_then(|key| key.as_ref())
.and_then(AddressablesObject::key_string)
.unwrap_or_else(|| format!("addressable_{}", index));
let path = Self::normalize_internal_id(&internal_id_prefixes, internal_id);
let extra = Self::extra_data_at(&extra_data, record.data_index);
let resource_type_name = Self::resource_type_name(json, record.resource_type_index);
let dependencies =
Self::compact_dependencies(record, &entry_records, &buckets, &keys);
let hash = extra
.hash
.filter(|value| !value.is_empty())
.or_else(|| extra.bundle_name.filter(|value| !value.is_empty()))
.unwrap_or_else(|| format!("addressable_{}", index));
let mut resources = Vec::with_capacity(entry_records.len());
for (index, record) in entry_records.iter().enumerate() {
if record.internal_id < 0 {
return Err(format!("compact entry {index} has negative internal_id"));
}
if record.provider_index < 0 {
return Err(format!("compact entry {index} has negative provider_index"));
}
if record.primary_key_index < 0 {
return Err(format!(
"compact entry {index} has negative primary_key_index"
));
}
Some(ResourceEntry {
path: path.clone(),
hash,
size: extra.bundle_size.unwrap_or_default(),
resource_type: Self::resource_type_for_compact_entry(
&path,
resource_type_name.as_deref(),
),
address: if primary_key.is_empty() {
None
} else {
Some(primary_key)
},
dependencies,
})
})
.collect()
let internal_id = internal_ids
.get(record.internal_id as usize)
.ok_or_else(|| {
format!(
"compact entry {index} internal_id index {} out of range {}",
record.internal_id,
internal_ids.len()
)
})?;
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)
.ok_or_else(|| {
format!(
"compact entry {index} primary_key_index {} has no decodable key",
record.primary_key_index
)
})?;
let path = Self::normalize_internal_id(&internal_id_prefixes, internal_id);
let extra = Self::extra_data_at(&extra_data, record.data_index)?;
let resource_type_name = Self::resource_type_name(json, record.resource_type_index)?;
let dependencies = Self::compact_dependencies(record, &entry_records, &buckets, &keys);
let hash = extra
.hash
.filter(|value| !value.is_empty())
.unwrap_or_else(|| format!("addressable_{}", index));
resources.push(ResourceEntry {
path: path.clone(),
hash,
size: extra.bundle_size.unwrap_or_default(),
resource_type: Self::resource_type_for_compact_entry(
&path,
resource_type_name.as_deref(),
),
address: if primary_key.is_empty() {
None
} else {
Some(primary_key)
},
dependencies,
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> {
fn has_compact_catalog_fields(json: &Value) -> bool {
[
"m_ProviderIds",
"m_KeyDataString",
"m_BucketDataString",
"m_EntryDataString",
"m_ExtraDataString",
"m_resourceTypes",
]
.iter()
.any(|field| json.get(field).is_some())
}
fn resources(json: &Value) -> Result<Vec<ResourceEntry>, String> {
let entry_resources = Self::entry_resources(json);
if !entry_resources.is_empty() {
return entry_resources;
return Ok(entry_resources);
}
let compact_resources = Self::compact_entry_resources(json);
if !compact_resources.is_empty() {
return compact_resources;
if Self::has_compact_catalog_fields(json) {
let compact_resources = Self::compact_entry_resources(json)?;
if !compact_resources.is_empty() {
return Ok(compact_resources);
}
}
let key_resources = Self::key_data_resources(json);
@@ -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>;
}
+7 -519
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)
};
let _ = metadata_size;
let _ = file_size;
let endian = if endian_flag == 0 {
Endian::Little
} else {
Endian::Big
};
reader.set_endian(endian);
let unity_version = reader.read_c_string("unity_version")?;
let platform = reader.read_i32("platform")?;
let enable_type_tree = reader.read_u8("enable_type_tree")?;
let type_count = reader.read_i32("type_count")?;
if type_count < 0 {
return Err(format!("Invalid Unity type count: {}", type_count));
}
let mut class_ids = Vec::with_capacity(type_count as usize);
for _ in 0..type_count {
class_ids.push(read_serialized_type(
&mut reader,
version,
enable_type_tree,
)?);
}
let big_id_enabled = if (11..14).contains(&version) {
reader.read_i32("big_id_enabled")?
} else {
0
};
let object_count = reader.read_i32("object_count")?;
if object_count < 0 {
return Err(format!("Invalid Unity object count: {}", object_count));
}
let mut text_assets = Vec::new();
for _ in 0..object_count {
if version >= 14 {
reader.align(4)?;
}
let path_id = if big_id_enabled != 0 {
reader.read_i64("path_id")?
} else if version < 14 {
reader.read_i32("path_id")? as i64
} else {
reader.read_i64("path_id")?
};
let byte_start = if version >= 22 {
reader.read_u64("byte_start")? as usize
} else {
reader.read_u32("byte_start")? as usize
};
let byte_size = reader.read_u32("byte_size")? as usize;
let type_id = reader.read_i32("type_id")?;
if version < 16 {
reader.read_u16("class_id")?;
}
if version < 11 {
reader.read_u16("is_destroyed")?;
}
if (11..17).contains(&version) {
reader.read_i16("script_type_index")?;
}
if version == 15 || version == 16 {
reader.read_u8("stripped")?;
}
let class_id = class_ids
.get(type_id as usize)
.copied()
.ok_or_else(|| format!("Invalid Unity type index: {}", type_id))?;
if class_id == 49 {
let object_start = data_offset
.checked_add(byte_start)
.ok_or_else(|| "Unity object offset overflow".to_string())?;
let object_end = object_start
.checked_add(byte_size)
.ok_or_else(|| "Unity object size overflow".to_string())?;
if object_end > data.len() {
return Err(format!(
"Unity object exceeds file size: start={}, size={}, file_size={}",
object_start,
byte_size,
data.len()
));
}
let asset = parse_text_asset(path_id, &data[object_start..object_end], endian)?;
text_assets.push(asset);
}
}
Ok(Self {
version,
unity_version,
platform,
text_assets,
})
}
/// Parses a serialized file from disk.
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, String> {
let path = path.as_ref();
let bytes = fs::read(path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
Self::from_slice(&bytes)
}
/// Returns all extracted text assets.
pub fn text_assets(&self) -> &[UnitySerializedTextAsset] {
&self.text_assets
}
/// Returns one extracted text asset by name.
pub fn text_asset(&self, name: &str) -> Option<&UnitySerializedTextAsset> {
self.text_assets.iter().find(|asset| asset.name == name)
}
}
fn parse_text_asset(
path_id: i64,
data: &[u8],
endian: Endian,
) -> Result<UnitySerializedTextAsset, String> {
let mut reader = Reader::new(data);
reader.set_endian(endian);
let name = reader.read_len_prefixed_string("text_asset_name")?;
reader.align(4)?;
let bytes_len = reader.read_u32("text_asset_bytes_len")? as usize;
let bytes = reader.read_bytes(bytes_len, "text_asset_bytes")?.to_vec();
Ok(UnitySerializedTextAsset {
path_id,
name,
bytes,
})
}
fn read_serialized_type(
reader: &mut Reader<'_>,
version: u32,
enable_type_tree: u8,
) -> Result<i32, String> {
let class_id = reader.read_i32("type_class_id")?;
if version >= 16 {
reader.read_u8("type_is_stripped")?;
}
if version >= 17 {
reader.read_i16("type_script_index")?;
}
if version >= 13 {
if (version < 16 && class_id < 0) || (version >= 16 && class_id == 114) {
reader.read_bytes(16, "type_script_id")?;
}
reader.read_bytes(16, "type_hash")?;
}
if enable_type_tree != 0 {
if version >= 12 || version == 10 {
let node_count = reader.read_i32("type_tree_node_count")?;
if node_count < 0 {
return Err(format!(
"Invalid Unity type tree node count: {}",
node_count
));
}
let string_buffer_size = reader.read_i32("type_tree_string_buffer_size")?;
if string_buffer_size < 0 {
return Err(format!(
"Invalid Unity type tree string buffer size: {}",
string_buffer_size
));
}
let node_size = 2 + 1 + 1 + 4 + 4 + 4 + 4 + 4 + if version >= 19 { 8 } else { 0 };
reader.read_bytes(node_count as usize * node_size, "type_tree_nodes")?;
reader.read_bytes(string_buffer_size as usize, "type_tree_strings")?;
}
if version >= 21 {
let dependency_count = reader.read_i32("type_tree_dependency_count")?;
if dependency_count < 0 {
return Err(format!(
"Invalid Unity type tree dependency count: {}",
dependency_count
));
}
reader.read_bytes(dependency_count as usize * 4, "type_tree_dependencies")?;
}
}
Ok(class_id)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Endian {
Little,
Big,
}
struct Reader<'a> {
data: &'a [u8],
offset: usize,
endian: Endian,
}
impl<'a> Reader<'a> {
fn new(data: &'a [u8]) -> Self {
Self {
data,
offset: 0,
endian: Endian::Big,
}
}
fn set_endian(&mut self, endian: Endian) {
self.endian = endian;
}
fn read_bytes(&mut self, len: usize, field: &str) -> Result<&'a [u8], String> {
let end = self
.offset
.checked_add(len)
.ok_or_else(|| format!("{field} length overflow at offset {}", self.offset))?;
if end > self.data.len() {
return Err(format!(
"Unexpected end while reading {field} at offset {}: need {}, have {}",
self.offset,
len,
self.data.len().saturating_sub(self.offset)
));
}
let bytes = &self.data[self.offset..end];
self.offset = end;
Ok(bytes)
}
fn align(&mut self, alignment: usize) -> Result<(), String> {
if alignment == 0 {
return Ok(());
}
let remainder = self.offset % alignment;
if remainder == 0 {
return Ok(());
}
let padding = alignment - remainder;
self.read_bytes(padding, "alignment padding").map(|_| ())
}
fn read_u8(&mut self, field: &str) -> Result<u8, String> {
Ok(self.read_bytes(1, field)?[0])
}
fn read_u16(&mut self, field: &str) -> Result<u16, String> {
let bytes = self.read_bytes(2, field)?;
Ok(match self.endian {
Endian::Little => u16::from_le_bytes([bytes[0], bytes[1]]),
Endian::Big => u16::from_be_bytes([bytes[0], bytes[1]]),
})
}
fn read_i16(&mut self, field: &str) -> Result<i16, String> {
let bytes = self.read_bytes(2, field)?;
Ok(match self.endian {
Endian::Little => i16::from_le_bytes([bytes[0], bytes[1]]),
Endian::Big => i16::from_be_bytes([bytes[0], bytes[1]]),
})
}
fn read_u32(&mut self, field: &str) -> Result<u32, String> {
let bytes = self.read_bytes(4, field)?;
Ok(match self.endian {
Endian::Little => u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
Endian::Big => u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
})
}
fn read_u32_be(&mut self, field: &str) -> Result<u32, String> {
let bytes = self.read_bytes(4, field)?;
Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}
fn read_i32(&mut self, field: &str) -> Result<i32, String> {
let bytes = self.read_bytes(4, field)?;
Ok(match self.endian {
Endian::Little => i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
Endian::Big => i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
})
}
fn read_u64(&mut self, field: &str) -> Result<u64, String> {
let bytes = self.read_bytes(8, field)?;
Ok(match self.endian {
Endian::Little => u64::from_le_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
]),
Endian::Big => u64::from_be_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
]),
})
}
fn read_u64_be(&mut self, field: &str) -> Result<u64, String> {
let bytes = self.read_bytes(8, field)?;
Ok(u64::from_be_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
]))
}
fn read_i64(&mut self, field: &str) -> Result<i64, String> {
let bytes = self.read_bytes(8, field)?;
Ok(match self.endian {
Endian::Little => i64::from_le_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
]),
Endian::Big => i64::from_be_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
]),
})
}
fn read_c_string(&mut self, field: &str) -> Result<String, String> {
let remaining = &self.data[self.offset..];
let Some(length) = remaining.iter().position(|&byte| byte == 0) else {
return Err(format!(
"Missing null terminator while reading {field} at offset {}",
self.offset
));
};
let bytes = self.read_bytes(length, field)?;
self.offset += 1;
std::str::from_utf8(bytes)
.map(ToOwned::to_owned)
.map_err(|error| format!("Invalid UTF-8 in {field}: {error}"))
}
fn read_len_prefixed_string(&mut self, field: &str) -> Result<String, String> {
let len = self.read_u32(field)? as usize;
let bytes = self.read_bytes(len, field)?;
std::str::from_utf8(bytes)
.map(ToOwned::to_owned)
.map_err(|error| format!("Invalid UTF-8 in {field}: {error}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i64_le(data: &mut Vec<u8>, value: i64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u32_be(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64_be(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn synthetic_serialized_file() -> Vec<u8> {
let mut object_data = Vec::new();
push_u32_le(&mut object_data, 14);
object_data.extend_from_slice(b"GameMainConfig");
align(&mut object_data, 4);
push_u32_le(&mut object_data, 5);
object_data.extend_from_slice(b"hello");
let mut metadata = Vec::new();
metadata.extend_from_slice(b"2021.3.56f2\0");
push_i32_le(&mut metadata, 19);
metadata.push(0);
push_i32_le(&mut metadata, 1);
push_i32_le(&mut metadata, 49);
metadata.push(0);
push_i16_le(&mut metadata, 0);
metadata.extend_from_slice(&[0; 16]);
push_i32_le(&mut metadata, 1);
align(&mut metadata, 4);
push_i64_le(&mut metadata, 1);
push_u64_le(&mut metadata, 0);
push_u32_le(&mut metadata, object_data.len() as u32);
push_i32_le(&mut metadata, 0);
let header_len = 48usize;
let data_offset = header_len + metadata.len();
let file_size = data_offset + object_data.len();
let mut file = Vec::new();
push_u32_be(&mut file, metadata.len() as u32);
push_u32_be(&mut file, file_size as u32);
push_u32_be(&mut file, 22);
push_u32_be(&mut file, 0);
file.push(0);
file.extend_from_slice(&[0, 0, 0]);
push_u32_be(&mut file, metadata.len() as u32);
push_u64_be(&mut file, file_size as u64);
push_u64_be(&mut file, data_offset as u64);
push_u64_be(&mut file, 0);
file.extend_from_slice(&metadata);
file.extend_from_slice(&object_data);
file
}
#[test]
fn parses_synthetic_text_asset() {
let file = synthetic_serialized_file();
let parsed = UnitySerializedFile::from_slice(&file).unwrap();
assert_eq!(parsed.version, 22);
assert_eq!(parsed.unity_version, "2021.3.56f2");
assert_eq!(parsed.platform, 19);
assert_eq!(parsed.text_assets.len(), 1);
let asset = parsed.text_asset("GameMainConfig").unwrap();
assert_eq!(asset.name, "GameMainConfig");
assert_eq!(asset.bytes, b"hello");
}
#[test]
#[ignore = "requires BAT_REAL_RESOURCES_ASSETS pointing at a local resources.assets"]
fn reads_text_asset_from_real_resource_file() {
let path = std::env::var("BAT_REAL_RESOURCES_ASSETS")
.expect("BAT_REAL_RESOURCES_ASSETS must be set");
let parsed =
UnitySerializedFile::from_path(Path::new(&path)).expect("parse local resources.assets");
let asset = parsed
.text_asset("GameMainConfig")
.expect("GameMainConfig TextAsset present");
assert_eq!(asset.name, "GameMainConfig");
assert!(!asset.bytes.is_empty());
}
}
pub use bat_assetbundle::{
UnitySerializedField, UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset,
UnitySerializedType, UnitySerializedValue, UnityTypeTreeNode,
};
+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`.
+10
View File
@@ -0,0 +1,10 @@
# 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 admin
panel's Rust-forwarded translation/TM management routes. It does not describe
a full game business API.
+621
View File
@@ -0,0 +1,621 @@
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/memory/summary:
get:
summary: Read Rust-owned Translation Memory summary
parameters:
- name: translation_memory_path
in: query
schema:
type: string
responses:
"200":
description: Translation Memory availability and candidate/trusted counts.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Translation Memory backend is unavailable.
/admin/translation/memory/query:
get:
summary: Query Rust-owned Translation Memory records
parameters:
- name: source_text
in: query
required: true
schema:
type: string
- name: source_context
in: query
description: JSON object whose values are strings.
schema:
type: string
- name: limit
in: query
schema:
type: integer
format: int64
minimum: 1
maximum: 1000
default: 100
- name: translation_memory_path
in: query
schema:
type: string
responses:
"200":
description: Translation Memory matches with reuse decision and provenance.
"400":
description: Missing source text or invalid context/limit.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Translation Memory 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, translation-memory-confirm, 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_memory_path:
type: string
record_id:
type: string
reviewer:
type: string
reason:
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());
}
}
+9 -1
View File
@@ -4,11 +4,19 @@ pub mod game_client;
pub mod game_version;
pub mod resource;
pub mod translation;
pub mod translation_memory;
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,
};
pub use translation_memory::{
TranslationMemoryContext, TranslationMemoryDraft, TranslationMemoryEntry,
TranslationMemoryMatch, TranslationMemoryMatchKind, TranslationMemorySourceKind,
TranslationMemorySourceTrace, TranslationMemorySummary, TranslationMemoryTrustStatus,
};
+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
})
);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! Translation Memory 领域对象。
use std::collections::BTreeMap;
/// TM 记录的来源类型。
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TranslationMemorySourceKind {
/// 来自 provider 输出。
Provider,
/// 来自人工确认。
Manual,
/// 来自外部导入。
Imported,
}
impl TranslationMemorySourceKind {
/// 返回稳定的持久化标签。
pub const fn as_str(&self) -> &'static str {
match self {
Self::Provider => "provider",
Self::Manual => "manual",
Self::Imported => "imported",
}
}
}
/// TM 记录的可信状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TranslationMemoryTrustStatus {
/// 候选记录,不能自动复用。
Candidate,
/// 已确认可信,可在强匹配时自动复用。
Trusted,
/// 已被后续记录取代。
Superseded,
/// 已明确拒绝。
Rejected,
}
impl TranslationMemoryTrustStatus {
/// 返回稳定的持久化标签。
pub const fn as_str(&self) -> &'static str {
match self {
Self::Candidate => "candidate",
Self::Trusted => "trusted",
Self::Superseded => "superseded",
Self::Rejected => "rejected",
}
}
}
/// TM 查询结果的匹配类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TranslationMemoryMatchKind {
/// 原始 source 和上下文都完全匹配,且记录可信,可自动复用。
StrongExact,
/// 原始 source 完全匹配,但上下文不同或不足,不能自动复用。
CandidateExact,
/// 原始 source 匹配,但上下文不兼容,不能自动复用。
SourceOnly,
}
impl TranslationMemoryMatchKind {
/// 返回稳定的查询结果标签。
pub const fn as_str(&self) -> &'static str {
match self {
Self::StrongExact => "strong_exact",
Self::CandidateExact => "candidate_exact",
Self::SourceOnly => "source_only",
}
}
}
/// 稳定的上下文键值。
pub type TranslationMemoryContext = BTreeMap<String, String>;
/// TM 记录的 TextUnit / provider 溯源信息。
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TranslationMemorySourceTrace {
/// 源官方 release ID。
pub official_release_id: String,
/// 来源 TextUnit ID。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit_id: Option<String>,
/// 来源任务 ID。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
/// 源资源 destination。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub destination: Option<String>,
/// 源 ZIP/archive entry。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive_entry: Option<String>,
/// Unity serialized file。
#[serde(default, skip_serializing_if = "Option::is_none")]
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。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class_id: Option<i32>,
/// TypeTree 字段路径。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field_path: Option<String>,
/// TextUnit format。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
/// TextAsset 名称。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_name: Option<String>,
/// TextUnit 来源类型。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_source_kind: Option<String>,
/// 源 URL。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_url: Option<String>,
}
/// TM 记录的候选输入。
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TranslationMemoryDraft {
/// 原始 source text。
pub source_text: String,
/// 稳定上下文。
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub source_context: TranslationMemoryContext,
/// 译文。
pub translated_text: String,
/// 译文来源类型。
pub translation_source_kind: TranslationMemorySourceKind,
/// 源官方 release。
pub official_release_id: String,
/// 原始 TextUnit / provider 溯源。
pub source_trace: TranslationMemorySourceTrace,
/// provider。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// provider run。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_run_id: Option<String>,
/// 创建时间。
pub observed_unix_seconds: u64,
}
/// 持久化 TM 记录。
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TranslationMemoryEntry {
/// 稳定记录 ID。
pub record_id: String,
/// 原始 source text。
pub source_text: String,
/// source text hash。
pub source_hash: String,
/// 保守归一化后的 source text,仅用于辅助查询。
pub normalized_source_text: String,
/// 稳定上下文。
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub source_context: TranslationMemoryContext,
/// 上下文 hash。
pub source_context_hash: String,
/// 译文。
pub translated_text: String,
/// 译文来源类型。
pub translation_source_kind: TranslationMemorySourceKind,
/// 当前可信状态。
pub trust_status: TranslationMemoryTrustStatus,
/// 源官方 release。
pub official_release_id: String,
/// 原始 TextUnit / provider 溯源。
pub source_trace: TranslationMemorySourceTrace,
/// provider。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// provider run。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_run_id: Option<String>,
/// 创建时间。
pub created_unix_seconds: u64,
/// 更新时间。
pub updated_unix_seconds: u64,
/// 可信确认时间。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_unix_seconds: Option<u64>,
/// 可信确认人。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_by: Option<String>,
/// 可信确认说明。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_reason: Option<String>,
/// 该记录替代了哪条记录。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes_record_id: Option<String>,
/// 该记录被哪条记录替代。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by_record_id: Option<String>,
}
/// TM 查询结果。
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TranslationMemoryMatch {
/// 记录本体。
pub entry: TranslationMemoryEntry,
/// 匹配类型。
pub match_kind: TranslationMemoryMatchKind,
/// 是否允许自动复用。
pub can_auto_reuse: bool,
}
/// TM 仓储摘要。
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TranslationMemorySummary {
/// schema 版本。
pub schema_version: u32,
/// 记录总数。
pub record_count: u64,
/// 可信记录数。
pub trusted_count: u64,
/// 候选记录数。
pub candidate_count: u64,
/// 已替代记录数。
pub superseded_count: u64,
/// 已拒绝记录数。
pub rejected_count: u64,
}
+2
View File
@@ -4,8 +4,10 @@
pub mod cas_repository;
pub mod resource_repository;
pub mod translation_memory_repository;
pub mod translation_repository;
pub use cas_repository::CasRepository;
pub use resource_repository::ResourceRepository;
pub use translation_memory_repository::TranslationMemoryRepository;
pub use translation_repository::TranslationRepository;
+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 可以被克隆
@@ -0,0 +1,47 @@
//! Translation Memory 仓储契约。
use crate::domain::{
TranslationMemoryContext, TranslationMemoryDraft, TranslationMemoryEntry,
TranslationMemoryMatch, TranslationMemorySummary,
};
use async_trait::async_trait;
/// 跨 official release 持久化的 Translation Memory 仓储。
///
/// 该契约只描述 V1 的精确查询和明确人工确认。仓储实现不得把
/// `TranslationTaskStatus::Completed` 或 provider 成功隐式解释为 trusted。
#[async_trait]
pub trait TranslationMemoryRepository: Send + Sync {
/// 保存一条 provider/manual/imported 译文候选。
///
/// 相同 source、上下文、译文、来源 release 和来源类型的重复写入必须幂等;
/// 已 trusted 的记录不得被普通候选静默覆盖。
async fn upsert_candidate(
&self,
draft: TranslationMemoryDraft,
) -> crate::Result<TranslationMemoryEntry>;
/// 按原始 source text 和上下文查询精确匹配。
///
/// 实现可以返回 source 归一化后但原文不同的辅助候选,但这类结果不能自动复用。
async fn find_matches(
&self,
source_text: &str,
source_context: &TranslationMemoryContext,
limit: usize,
) -> crate::Result<Vec<TranslationMemoryMatch>>;
/// 显式确认一条记录为 trusted。
async fn confirm(
&self,
record_id: &str,
reviewer: &str,
reason: Option<String>,
) -> crate::Result<TranslationMemoryEntry>;
/// 按稳定记录 ID 读取一条 TM 记录。
async fn find(&self, record_id: &str) -> crate::Result<TranslationMemoryEntry>;
/// 读取数据库和记录统计。
async fn summary(&self) -> crate::Result<TranslationMemorySummary>;
}
+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)
+158 -10
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
+24 -39
View File
@@ -1,48 +1,33 @@
# 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}`;其中翻译管理面包含
`/admin/translation/tasks``/admin/translation/handoff`
`/admin/translation/memory/summary``/admin/translation/memory/query`
`translation-memory-confirm` 转发
- `/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 安装包更新链
当前不属于已实现接口。
+85 -38
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,30 @@ 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 和白名单管理转发;
翻译任务与 TM 管理接口只通过 Rust RPC 代理,不在 Go 侧持有状态
- 不运行另一套同步器,不直接管理官方下载、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,11 +193,16 @@ pub struct ParserRegistry {
---
### 4. 翻译系统 (Go)
### 4. 翻译系统(目标扩展,Go;当前 worker 由 Rust `bat` 承担)
当前已实现的是 Rust `bat` 的离线 TextUnit 队列、mock/Crowdin provider worker、
lease/retry、结果落库和项目级 Translation Memory V1。TM 位于独立 SQLite,按 raw
source + 完整 context 做 trusted exact reusecandidate 必须显式 confirmGlossary、
模糊匹配和完整 Provider 体系仍属后续缺口。
**架构**
```
Text Extractor → Translation Memory (查询) → AI Provider → Glossary (术语替换) → Output
Text Extractor → TM exact query → AI Provider → Glossary (后续) → Output
↓ ↓
PostgreSQL 审核队列
```
@@ -182,7 +216,11 @@ type TranslationProvider interface {
}
```
**实现**
**当前实现**
- Rust `bat` 的 mock provider worker
- Rust `bat` 的 Crowdin provider worker
**目标 Provider**
- DeepL Provider
- OpenAI Provider
- Anthropic Provider
@@ -190,18 +228,19 @@ type TranslationProvider interface {
- Azure Translator Provider
**翻译记忆库**
- 精确匹配:100% 匹配直接使用
- 模糊匹配:使用相似度算法(Levenshtein Distance
- 上下文匹配:根据前后文提高匹配准确度
- 当前 V1raw source 完全相同、完整 context 完全相同且记录为 trusted 时自动复用。
- provider 输出写入先是 candidatemanual task result 不会自动建立 TM 或 trusted。`bat i18n memory confirm` 显式确认单条记录后才可自动复用。
- source、context、release、TextUnit、provider 和 run provenance 保存在 Rust TM SQLite 中。
- 模糊匹配、术语优先级和 PostgreSQL 服务化仍不是当前实现。
---
### 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 +263,10 @@ patch/
---
### 6. API Server (Go)
### 6. API Server (Go,目标设计)
当前可用的 Go HTTP 服务是 `cmd/bat-api` 的资源 bootstrap、只读分发和 Rust 管理
入口,不是下列完整游戏业务 API。
**框架**Gin 或 Echo
@@ -251,7 +293,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 +314,10 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
---
## 数据库设计
## 数据库设计(目标设计)
当前 Rust 资源链路使用 SQLite 维护本地 CAS、ResourceRepository 和翻译任务状态;
PostgreSQL/Redis 业务服务端方案尚未完整落地。
### PostgreSQL Schema
@@ -314,7 +361,11 @@ CREATE TABLE resource_versions (
---
## 部署架构
## 部署架构(目标设计)
当前可部署形态是 Rust `bat` 官方资源同步任务和同机/共享文件系统的 Go
`bat-api` 资源 bootstrap/分发服务。以下多实例 API、PostgreSQL 主从和 Redis
集群属于目标部署形态。
### 本地开发模式
@@ -342,7 +393,7 @@ API Server (多实例)
---
## 安全设计
## 安全设计(目标设计)
1. **认证**JWT Token
2. **授权**RBAC (Role-Based Access Control)
@@ -353,7 +404,7 @@ API Server (多实例)
---
## 性能优化
## 性能优化(目标设计)
1. **缓存策略**
- Redis 缓存热点数据
@@ -372,7 +423,7 @@ API Server (多实例)
---
## 监控与日志
## 监控与日志(目标设计)
- **日志**:结构化日志(JSON 格式)
- **指标**Prometheus + Grafana
@@ -393,10 +444,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
以及鉴权后的白名单管理转发;翻译任务和 TM 管理面只转发 Rust RPC
- 不下载官方资源、不写 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 协作后台、Glossary 和 Provider 扩展体系仍是后续目标**
Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。
---
## 后果
- 资源同步只有一个长期状态拥有者,`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` 扩展 TM/Glossary 和通用 manifest Patch 构建。
4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引。
5. 在通用 Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包和通用
Patch 发布流程统一,保留当前受支持 localized patch 发布/rollback 链路。
+111 -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,33 @@
### 3.5 导入到 CAS 和资源仓储
资源下载后,导入层会:
官方同步下载、校验并发布 release 后,可以通过 `--import-repository`
`config.toml` / 环境变量 `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 级译文结果。跨 release 的 Translation Memory V1 独立存储在
`<output>/translation-memory.sqlite`,记录 raw source/hash、完整 context、candidate/
trusted 和 release/TextUnit/provider/run provenance`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/TM 查询和通用 Patch 发布所需资源视图。
对应实现主要在:
@@ -189,31 +221,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 +291,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 +326,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 +336,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` 扩展 TM/Glossary 和通用 manifest Patch 构建
3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和通用 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、回归测试和文档同步验收,不要只靠合成样本宣称能力。
+228 -125
View File
@@ -4,103 +4,85 @@
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 服务层、Glossary 和完整
Provider 扩展,不是当前 `bat` / `bat-api` 的生产运行依赖;当前 Translation Memory V1
使用 `<output>/translation-memory.sqlite`
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` 的生产运行依赖。本模式只用于未来
服务层、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 +180,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 +209,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 +223,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 +234,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 +248,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 +267,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 +335,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
```
对应 `config.toml` / 环境变量键为 `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``config.toml` / 环境变量 `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`
+520
View File
@@ -0,0 +1,520 @@
# 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` 为准。
- `translation-memory.sqlite`:跨 release 的项目级 Translation Memory,不位于
`versions/<id>`,也不与 `translation-tasks.sqlite` 共用;记录 raw source/hash、完整
TextUnit context、candidate/trusted、translation 和 release/TextUnit/provider/run
provenance。默认路径为 `<output>/translation-memory.sqlite`,可由
`BAT_TRANSLATION_MEMORY_PATH``[translation.worker].translation_memory_path` 或 CLI
覆盖。
删除资源只进入 `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 标记为人工校对中,返回工作流状态报告。 |
| `translation.memory.summary` | 已实现 | 可选 `{ "translation_memory_path": "..." }` | 返回 TM schema 版本、总记录数及 candidate/trusted/rejected/superseded 状态计数。 |
| `translation.memory.query` | 已实现 | `{ "source_text": "...", "source_context": {...}, "limit": 100 }` | 按 raw source 查询记录,返回 match kind、trust、translation 和 provenance。 |
| `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | 显式确认一条 candidate 为 trustedworker 之后才可自动复用。 |
TM 的自动复用规则是 raw source 完全相同、完整 context 完全相同且状态为 `trusted`
context 缺失/不一致、normalized source 仅辅助查询、candidate 或 provider 成功都不会
自动复用或自动变成 trusted。TM 查询、confirm 和诊断由 Rust `bat` 持有,Go
`bat-api` 不维护第二份 TM 状态。
`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 前缀。 |
| `translation_memory_path` | string/null | 按配置推导 | 覆盖 Rust worker 使用的项目级 TM 数据库路径;未指定时使用 worker 配置或 `<output>/translation-memory.sqlite`。 |
数字字段必须是 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 / `config.toml` 或环境变量中的 `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 i18n memory summary` / `bat i18n memory query` | `translation.memory.summary` / `translation.memory.query` |
| `bat i18n memory confirm` | `translation.memory.confirm` |
| `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``translation.memory.summary`
`translation.memory.query``translation.memory.confirm`
`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 触发与人工校对标记 |
| `TranslationMemoryBackend` | `translation.memory.summary``translation.memory.query``translation.memory.confirm` | 鉴权后的 TM 摘要、source/context 查询和显式 candidate 确认;Go 只转发,不持有 TM 状态 |
| `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` 以及 Translation Memory query/缺库 mirror。
这些 fixture/mirror 由 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` 独立负责。
+97 -418
View File
@@ -1,472 +1,151 @@
# 当前实现缺口清单
- **更新时间**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-owned TM 的 summary/query/confirm 也通过 typed RPC
转发。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 不匹配会返回明确错误。
仍需完成通用 patch 发布、复杂重打包、双 release 查询/分发视图和清理策略
### G-005AssetBundle 解析器仍是占位
### G-012Translation Memory V1 已实现,扩展能力仍缺失
现象:
- `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`
### G-012Translation Memory 未实现
影响:
- 无法复用人工翻译和 AI 翻译历史。
验收:
- 精确匹配、模糊匹配、上下文匹配可用。
- 记录 Provider、模型、审核状态和历史版本。
Rust `bat` 已提供独立项目级 SQLite TM,记录 raw source/hash、完整 context、release/TextUnit/provider/run provenance,区分 candidate/trusted,只有显式 confirm 才能建立 trusted 记录;worker 只自动复用 trusted 的 raw source + 完整 context exact match。Go `bat-api` 已提供鉴权的 summary/query 只读接口和 confirm 转发,但 Go 不持有 TM 状态。仍缺少模糊匹配、Glossary 联动和更丰富的导入导出历史能力。
### 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:资源查询、TM 扩展、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/TM/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``translation.memory.summary/query/confirm` 和文件级 UnityFS patch 调用;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM admin forwarding + 内嵌 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.*`、翻译任务/TM 状态查询与显式确认代理、内嵌 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
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::*;

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