11 Commits
Author SHA1 Message Date
nyaKazuhaandGitHub d694100d6c fix(ci)
bat-rust / Build and test Rust (push) Successful in 3m44s
2026-07-21 22:52:39 +08:00
nyaKazuhaandGitHub 3add5f7327 fix(ci): 产物构建与上传 2026-07-21 22:24:17 +08:00
nyaKazuhaandGitHub 40bd82e227 fix(ci):
bat-rust / Build and test Rust (push) Successful in 1m45s
2026-07-21 22:05:25 +08:00
nyaKazuhaandGitHub 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
26 changed files with 1132 additions and 239 deletions
+163
View File
@@ -0,0 +1,163 @@
# 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
+10 -4
View File
@@ -6,14 +6,20 @@
## [未发布] ## [未发布]
### 新增
- 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
- 官方资源下载支持多线程:并行仅作用于实际网络下载(默认并发 4,可经 `--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`),manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17
### 修复
- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s),并发下载时对官方 CDN 更礼貌
### 计划 ### 计划
- [ ] 实现 Go CLI 最小可用入口(默认经 daemon RPC 或 `bat --json` 进程边界 - [ ] 实现 `bat-api`:仿 BlueArchive 官方 API 的 Go HTTP 服务(含鉴权/签名验签,issue #19
- [ ] 官方同步结果接入 CAS + ResourceRepository 的用户级工作流 - [ ] 官方同步结果接入 CAS + ResourceRepository 的用户级工作流
- [ ] 实现 AssetBundle 解析器(UnityFS header/block/directory 起步 - [ ] 官方下载/导入路径接入 CRC/size 校验(复用 `verify_downloaded_bytes`
- [ ] 继续逆向 Addressables catalog 可校验字段
- [ ] 实现翻译系统 - [ ] 实现翻译系统
- [ ] 实现 Patch 引擎 - [ ] 实现 Patch 引擎
- [ ] 实现 API Server
- [ ] 实现 Web 管理后台 - [ ] 实现 Web 管理后台
## [0.2.0] - 2026-07-17 ## [0.2.0] - 2026-07-17
+1 -1
View File
@@ -39,7 +39,7 @@
```bash ```bash
cargo fmt --check cargo fmt --check
cargo test --workspace cargo test --workspace
cargo clippy --workspace -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
``` ```
如果改动只影响部分 crate,可以先跑更窄的测试,但合并前必须确保影响面被覆盖。官方资源同步、下载、daemon、status、verify 或 repair 相关改动还应运行: 如果改动只影响部分 crate,可以先跑更窄的测试,但合并前必须确保影响面被覆盖。官方资源同步、下载、daemon、status、verify 或 repair 相关改动还应运行:
+35 -34
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 当前工作区状态 # BlueArchiveToolkit 当前工作区状态
- **更新时间**2026-07-15 - **更新时间**2026-07-20
- **状态来源**:本地工作区盘点、代码验证和最新提交 - **状态来源**:本地工作区盘点、代码验证和最新提交
- **状态分支**`experiment` - **状态分支**`experiment`
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准 - **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
@@ -10,7 +10,7 @@
## 1. 总体判断 ## 1. 总体判断
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备最小生产运行形态、Go CLI/API/Web 仍未落地** 阶段。 当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go CLI/API/Web 仍未形成产品入口** 阶段。
Rust 侧官方日服资源链路已经从实验验证推进到正式入口: Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
@@ -19,17 +19,17 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
3. 默认平台为 `Windows + Android` 3. 默认平台为 `Windows + Android`
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json` 4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。 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 切换。下载支持多线程:并行仅作用于实际网络下载(默认并发 4,可经 `--download-concurrency`/`BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`),manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变。
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` 会清除。 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 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`
9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。 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`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/<id>``.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock``bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 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 内联打印。 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。 13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、403/404、hash mismatch、CRC 与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖
14. `bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。 14. `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`)。
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。 仍需明确:这不是完整产品完成。Go 产品入口、完整 AssetBundle 引擎解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。
--- ---
@@ -87,7 +87,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
已包含: 已包含:
- Unity adapter trait、注册表、Unity 2021.3 adapter 骨架 - Unity adapter trait、注册表、Unity 2021.3 adapter 基础解析与校验
- Manifest driver trait、Addressables driver、注册表。 - Manifest driver trait、Addressables driver、注册表。
- Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。 - Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。
- 真实形态 Addressables fixture/golden 测试。 - 真实形态 Addressables fixture/golden 测试。
@@ -96,8 +96,8 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
待完成: 待完成:
- Unity bundle serialize 仍是后续阶段能力 - `crates/bat-assetbundle` 仍是占位 crate,完整 UnityFS/对象表/TypeTree 引擎未实现
- Addressables parser 仍需继续覆盖二进制/压缩字段组合和更细失败诊断。 - Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
- 客户端发现、备份、应用补丁流程尚未连接真实实现。 - 客户端发现、备份、应用补丁流程尚未连接真实实现。
### `bat-cas-engine` ### `bat-cas-engine`
@@ -148,9 +148,9 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
当前只有: 当前只有:
- Parser trait 占位。 - Parser trait 仍是占位。
- AssetType 占位。 - AssetType 仍是占位。
- 错误类型骨架。 - 错误类型骨架可用,但没有完整解析引擎
待完成: 待完成:
@@ -163,7 +163,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
状态:**占位** 状态:**占位**
当前 Binary Patch 和 JSON Patch 函数返回空结果,不具备真实补丁能力。 当前 Binary Patch 和 JSON Patch 函数会明确返回未实现错误,不具备真实补丁能力。
待完成: 待完成:
@@ -188,7 +188,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
- `bat-ffi` 只暴露粗粒度、无状态、一次调用一次 JSON 输入输出的 C ABI helper。 - `bat-ffi` 只暴露粗粒度、无状态、一次调用一次 JSON 输入输出的 C ABI helper。
- 它不持有 downloader、daemon、CAS handle、资源目录锁或长生命周期状态。 - 它不持有 downloader、daemon、CAS handle、资源目录锁或长生命周期状态。
- Go CLI 和生产运维默认应调用 `bat --json` 进程边界;未来稳定 SDK 也优先于 FFI。 - 未来 Go 产品入口和生产运维默认应调用 `bat --json` 进程边界;未来稳定 SDK 也优先于 FFI。
- FFI 仅用于需要嵌入 C ABI 的兼容场景,不能作为官方同步控制面或主集成边界。 - FFI 仅用于需要嵌入 C ABI 的兼容场景,不能作为官方同步控制面或主集成边界。
待完成: 待完成:
@@ -198,34 +198,35 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
### Go / API / Web ### Go / API / Web
状态:**CLI/API/Web 仍未实现,仅有可选 CGO 兼容包装** 状态:**Go 产品入口仍未完成,仅有可选 CGO 兼容包装和试验性 `cmd/bat` 骨架**
当前情况: 当前情况:
- `internal/ffi/ffi.go` 已存在。 - `internal/ffi/ffi.go` 已存在。
- Go CLI 默认集成方向是调用 Rust `bat --json` 并转发结构化 report,而不是依赖 FFI - Go CLI 的稳定集成方向仍应优先通过 Rust `bat --json` 进程边界;`cmd/bat` 目前只是试验性骨架,不代表产品级 CLI 已完成
- `cmd/``pkg/``api/``web/` 仍无可用产品入口。 - `cmd/``pkg/``api/``web/` 仍无可用产品入口`cmd/bat` 目前只覆盖 `doctor``manifest inspect``sync plan` 这类最小演示能力
- `go test ./...` 在没有 Go package 时可能无测试可运行;Makefile 会清晰跳过空 Go 阶段 - `go test ./...` 目前只有空测试包结果,`go vet ./...` 可作为基础门禁
--- ---
## 4. 已验证结果 ## 4. 已验证结果
最新功能提交前已运行并通过: 本轮复核已运行并通过:
```bash ```bash
cargo test -p bat-adapters -- --nocapture cargo test --workspace --quiet
cargo test -p bat-ffi -- --nocapture cargo clippy --workspace --all-targets -- -D warnings
cargo test -p bat-infrastructure -- --nocapture go test ./...
cargo test -p bat-infrastructure --bin bat -- --nocapture go vet ./...
cargo run -p bat-infrastructure --bin bat -- --help go build -o /tmp/bat-go-cli ./cmd/bat
git diff --cached --check target/debug/bat --help
git diff --check
``` ```
提交后确认: 同步确认:
```bash ```bash
git status --short git status --short --branch
``` ```
结果:工作区干净。 结果:工作区干净。
@@ -233,14 +234,14 @@ git status --short
未执行: 未执行:
- 本次状态更新未执行一次性真实官方网络全量下载 smoke;该流程已由 `docs/guides/official-full-pull-smoke.md``scripts/official-full-pull-smoke.sh` 固化并关闭(G-018),当前处于长期运行测试阶段,运行报告将在后续提供。 - 本次状态更新未执行一次性真实官方网络全量下载 smoke;该流程已由 `docs/guides/official-full-pull-smoke.md``scripts/official-full-pull-smoke.sh` 固化并关闭(G-018),当前处于长期运行测试阶段,运行报告将在后续提供。
- Go CLI 端到端测试,因为 Go CLI 尚未实现 - Go CLI 端到端测试,因为 Go 产品入口尚未完成
- Web/API 测试,因为 Web/API 尚未实现。 - Web/API 测试,因为 Web/API 尚未实现。
--- ---
## 5. 当前生产运行边界 ## 5. 当前生产运行边界
当前唯一可作为 Linux 生产资源同步任务运行的入口是 Rust binary 当前唯一可作为 Linux 生产资源同步任务运行的入口是 Rust binary
```bash ```bash
cargo run -p bat-infrastructure --bin bat -- \ cargo run -p bat-infrastructure --bin bat -- \
@@ -263,12 +264,12 @@ cargo run -p bat-infrastructure --bin bat -- \
## 6. 当前阻塞项 ## 6. 当前阻塞项
GitHub issue 状态:#4#16 已全部关闭(#16 为 daemon status 版本失败输出与重复堆积 bug,已由失败版本去重和状态输出优化修复),当前 open 的是 #1P1)、#2P2)、#3P2)。 GitHub issue 状态:当前 open 的是 #1P1)、#2P2)、#3P2#17P2)、#19P2)。其中 #17 的实现已合入 HEAD,但 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)。 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 2. `cmd/bat` Go CLI 骨架:当前只实现 `doctor``manifest inspect``sync plan` 这类试验性入口,不能视作产品级 CLI;是否继续作为长期产品入口需要单独收敛
3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。 3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。
4. Issue #3P2):AssetBundle UnityFS 基础解析校验。 4. Issue #3P2):AssetBundle UnityFS 基础解析校验。
5. Issue #2P2):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。 5. Issue #2P2):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
@@ -283,12 +284,12 @@ GitHub issue 状态:#4#16 已全部关闭(#16 为 daemon status 版本失
立即任务: 立即任务:
1. Issue #1 收尾:协议基础设施、最小方法集及 `catalog.*`/`task.*` 全量、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)与任务持久化按后续里程碑推进。 1. Issue #1 收尾:协议基础设施、最小方法集及 `catalog.*`/`task.*` 全量、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)与任务持久化按后续里程碑推进。
2. 实现 Go CLI 最小框架和 `doctor`,通过 RPC 或 `bat --json` 边界对接 Rust backend 2. 明确 Go 产品入口的边界:是继续推进独立 `bat` CLI,还是保留当前 Rust `bat` 为用户 CLI、Go 只做服务层与 `bat-api`
3. 跟进官方同步长期运行测试,收集并归档运行报告。 3. 跟进官方同步长期运行测试,收集并归档运行报告。
4. 开始 AssetBundle parser 的 UnityFS header/block/directoryissue #3),并继续扩展 Addressables catalog 可校验字段(issue #2)。 4. 开始 AssetBundle parser 的 UnityFS header/block/directoryissue #3),并继续扩展 Addressables catalog 可校验字段(issue #2)。
--- ---
- **当前总体完成度**约 22% - **当前总体完成度**不再固定写单一百分比,以各模块状态和 issue 为准。
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;产品级 CLI/API/Web 仍未完成。 - **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;Go 产品入口、`bat-api`、CAS 用户级导入和完整 AssetBundle 引擎仍未完成。
- **下一工程里程碑**Rust Resource Backend RPC API 最小方法集(issue #1+ Go CLI 最小可用 + 官方同步结果接入 CAS/ResourceRepository + AssetBundle 解析起步。 - **下一工程里程碑**Rust Resource Backend RPC API 收尾、Go 产品入口收敛、官方同步结果接入 CAS/ResourceRepositoryAssetBundle 解析起步。
+4 -4
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 文档索引 # BlueArchiveToolkit 文档索引
- **更新时间**2026-07-15 - **更新时间**2026-07-20
- **说明**:本索引用于快速定位当前权威文档和历史资料。 - **说明**:本索引用于快速定位当前权威文档和历史资料。
--- ---
@@ -89,7 +89,7 @@
## 6. 状态摘要 ## 6. 状态摘要
当前总体完成度**22%** 当前总体完成度不再固定写单一百分比,以 `CURRENT_STATUS.md``CURRENT_GAPS.md` 的模块状态为准
已完成: 已完成:
@@ -105,6 +105,6 @@
优先待办: 优先待办:
- 落地 Go CLI 最小可用入口 - 收敛 Go 产品入口的最终形态,避免把试验性 `cmd/bat` 误当作完成品
- 将官方同步结果接入 CAS + ResourceRepository 的用户级流程。 - 将官方同步结果接入 CAS + ResourceRepository 的用户级流程。
- 开始 AssetBundle UnityFS 解析。 - 推进 AssetBundle UnityFS 引擎级解析。
+33 -32
View File
@@ -1,7 +1,7 @@
# BlueArchiveToolkit 完整开发计划 # BlueArchiveToolkit 完整开发计划
- **项目名称**BlueArchiveToolkit - **项目名称**BlueArchiveToolkit
- **文档版本**2026-07-06 状态收口版 - **文档版本**2026-07-20 状态收口版
- **权威状态**:以本文档和 `CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。 - **权威状态**:以本文档和 `CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。
- **最终目标**:构建一个可长期维护、可扩展、可审计的 Blue Archive 资源管理、文本提取、翻译和补丁平台。 - **最终目标**:构建一个可长期维护、可扩展、可审计的 Blue Archive 资源管理、文本提取、翻译和补丁平台。
@@ -22,7 +22,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 2. 当前真实状态 ## 2. 当前真实状态
本节来自 2026-07-06 的工作区盘点、本地验证和最新功能提交。 本节来自 2026-07-20 的工作区盘点、本地验证和最新功能提交。
### 已具备 ### 已具备
@@ -38,9 +38,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
### 仍是骨架或占位 ### 仍是骨架或占位
1. AssetBundle 解析器仍是占位 trait,未解析 UnityFS、压缩块、TypeTree 或对象表。 1. `bat-assetbundle` 仍是占位 crate;完整 UnityFS、压缩块、TypeTree 或对象表解析未完成
2. Patch 的 Binary/JSON 模块仍返回空结果,不具备真实补丁能力。 2. `bat-patch` 的 Binary/JSON 模块仍返回明确的未实现错误,不具备真实补丁能力。
3. Go CLI/API/SDK 仍没有产品级入口;只有 `internal/ffi`可选兼容包装骨架。 3. Go CLI/API/SDK 仍没有产品级入口;当前只有 `cmd/bat``internal/ffi`试验/兼容骨架。
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。 4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
5. 官方同步结果尚未作为用户级流程自动导入 CAS + ResourceRepository。 5. 官方同步结果尚未作为用户级流程自动导入 CAS + ResourceRepository。
6. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。 6. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
@@ -49,12 +49,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
### 已验证 ### 已验证
1. `cargo test -p bat-adapters -- --nocapture` 通过。 1. `cargo test --workspace --quiet` 通过。
2. `cargo test -p bat-ffi -- --nocapture` 通过。 2. `cargo clippy --workspace --all-targets -- -D warnings` 通过。
3. `cargo test -p bat-infrastructure -- --nocapture` 通过 3. `go test ./...` 通过,但目前没有 Go 产品级测试覆盖
4. `cargo test -p bat-infrastructure --bin bat -- --nocapture` 通过。 4. `go vet ./...` 通过。
5. `cargo run -p bat-infrastructure --bin bat -- --help` 可用 5. `go build -o /tmp/bat-go-cli ./cmd/bat` 通过
6. `go test ./...` 当前无 Go 产品 package`Makefile` 已调整为在 Go 未实现阶段明确跳过 6. `target/debug/bat --help` 可用
--- ---
@@ -84,7 +84,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
1. 无占位返回、无静默吞错、无未说明的 `TODO` 1. 无占位返回、无静默吞错、无未说明的 `TODO`
2. 公共接口具备文档、错误语义和兼容性说明。 2. 公共接口具备文档、错误语义和兼容性说明。
3. 单元测试覆盖核心分支;跨模块能力补集成测试。 3. 单元测试覆盖核心分支;跨模块能力补集成测试。
4. `cargo fmt``cargo clippy --workspace -- -D warnings``cargo test --workspace` 通过。 4. `cargo fmt``cargo clippy --workspace --all-targets -- -D warnings``cargo test --workspace` 通过。
5. Go 模块落地后,`go test ./...``go vet ./...` 通过。 5. Go 模块落地后,`go test ./...``go vet ./...` 通过。
6. 用户可见命令必须有 `doctor` 检查和失败恢复建议。 6. 用户可见命令必须有 `doctor` 检查和失败恢复建议。
@@ -141,7 +141,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
**目标**:完成可长期使用的 Content Addressable Storage。 **目标**:完成可长期使用的 Content Addressable Storage。
**当前状态**:已完成 CAS V1。Go CLI 以最小稳定入口优先,Rust 继续承载完整资源拉取与更新检查核心逻辑;`bat-ffi` 仅保留为可选兼容层。 **当前状态**:已完成 CAS V1。Go CLI 产品入口尚未完成,当前仅存在 `cmd/bat` 的试验骨架;Rust 继续承载完整资源拉取与更新检查核心逻辑;`bat-ffi` 仅保留为可选兼容层。
交付物: 交付物:
@@ -166,7 +166,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
**目标**:能够获取、解析和同步 Blue Archive 资源清单。 **目标**:能够获取、解析和同步 Blue Archive 资源清单。
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;Go CLI、完整解析覆盖、CAS 导入编排和真实线上 smoke 仍待完成。 **当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;Go 产品入口、完整解析覆盖、CAS 导入编排和真实线上 smoke 仍待完成。
交付物: 交付物:
@@ -174,9 +174,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;仍需冻结 Go CLI/API 可见模型。 2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;仍需冻结 Go CLI/API 可见模型。
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验和 repair。 3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验和 repair。
4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch``--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。 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 输出 5. Go 产品入口**未完成**。当前 `cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,尚不构成产品级 CLI;若要继续由 Go 承担用户入口,需要单独收敛命令集和调用边界
6. 用户级 `sync``manifest inspect``cache status`**未完成**。Rust `bat --json` Go CLI 默认进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。 6. 用户级 `sync``manifest inspect``cache status`**未完成**。Rust `bat --json`当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
7. 下载结果写入 CAS + ResourceRepository**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果作为用户级流程自动导入。 7. 下载结果写入 CAS + ResourceRepository**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果自动作为用户级流程导入。
8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig` 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:**命令已固化(G-018 已关闭)**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
@@ -348,7 +348,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 与脚本承担(决策:不引入 GitHub Workflows 等托管 CI,见 `docs/reports/CURRENT_GAPS.md` G-017)。
2. Docker Compose:本地开发、服务端部署。 2. Docker Compose:本地开发、服务端部署。
3. 数据备份与恢复文档。 3. 数据备份与恢复文档。
4. 用户文档、开发文档、故障排查文档。 4. 用户文档、开发文档、故障排查文档。
@@ -366,7 +366,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 5. 推荐执行顺序 ## 5. 推荐执行顺序
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是 Go CLI 入口、资源解析、同步结果进入 CAS/ResourceRepository,以及真实端到端验证。 近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是 Go 产品入口边界、资源解析、同步结果进入 CAS/ResourceRepository,以及真实端到端验证。
建议顺序: 建议顺序:
@@ -380,14 +380,15 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 6. 近期具体任务 ## 6. 近期具体任务
1. 落地 Go CLI 的最小生产入口:`bat doctor``bat sync --help``bat official sync --help` 优先完善 Rust `bat` 后端,并同步收敛 Go 产品入口边界。当前事实是 Rust `bat` 已承担可用的资源同步/运维入口,Go `cmd/bat` 仍只是试验骨架,`bat-api` 是独立的 Go HTTP 服务目标(issue #19 / G-009):
2. 让 Go CLI 默认调用 Rust `bat --json` 官方同步入口,并稳定转发结构化 report;除非有明确兼容需求,不走 FFI。
3. 记录一次真实官方网络 smoke:dry-run、首次下载、二次 up-to-date、本地损坏 repair 1. 对 issue #17 做验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open
4. 将官方同步下载结果接入 CAS + `SqliteResourceRepository` 的用户级流程 2. 继续逆向 Addressables catalog,扩大 bundle hash/size/CRC 等可校验字段覆盖(issue #2
5. 继续扩展 Addressables parser 的真实 catalog 变体覆盖和错误诊断 3. 对 AssetBundle/UnityFS 做引擎级解析:header/block/directory/metadata/object tableissue #3 / G-005
6. 开始 AssetBundle UnityFS header/block/directory 解析 4. 将官方同步下载结果接入 CAS + `SqliteResourceRepository` 的用户级流程(G-011
7. 为 CLI 和 CAS 增加 `doctor cas` 诊断入口 5. 收敛 Go 产品入口:明确继续推进最小 Go CLI,或把用户 CLI 固化为 Rust `bat` 并把 Go 侧集中到 `bat-api`
8. `bat --watch` / `bat --daemon` 持续补充发布型构建、systemd service 示例和运维检查清单;后台 live control plane 已改为 Unix socket JSON-RPC;基础生产部署模板、日志路径、权限用户、升级/回滚流程已补齐 6. 实现 `bat-api`(仿官方 API 的 Go HTTP 服务,含鉴权/签名验签,issue #19 / G-009
7. 为 CAS 增加 `doctor cas` 诊断入口。
--- ---
@@ -414,8 +415,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
处理策略: 处理策略:
1. Rust 提供稳定引擎能力,不承担 CLI 编排,但负责完整资源拉取和更新检查的核心逻辑 1. Rust 提供稳定引擎能力,并在当前阶段承担可生产运行的官方资源同步 CLI、watch 和 daemon
2. Go 负责用户命令、最小稳定 CLI、服务编排、网络和 Provider。 2. Go 的长期职责包括用户命令、最小稳定 CLI、服务编排、网络和 Provider;当前 Go 产品入口尚未完成,不能把 `cmd/bat` 试验骨架视为完成
3. 跨边界优先进程或 SDK,FFI 只作为可选的粗粒度、无状态、安全、可测试兼容 API。 3. 跨边界优先进程或 SDK,FFI 只作为可选的粗粒度、无状态、安全、可测试兼容 API。
4. Rust 不需要被强制写成 Go 调用库;当前 `bat --watch` / `bat --daemon` 是允许长期运行的 Rust 生产任务。 4. Rust 不需要被强制写成 Go 调用库;当前 `bat --watch` / `bat --daemon` 是允许长期运行的 Rust 生产任务。
@@ -439,11 +440,11 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 8. 当前完成度评估 ## 8. 当前完成度评估
按最终目标计算,当前总体完成度约为 **22%** 按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准
已完成的是稳定基线、架构骨架、部分接口、CAS V1 和 Rust 官方资源同步闭环,不是完整产品能力。下一阶段的关键不是继续堆目录,而是把 Go CLI 最小入口、官方同步端到端验证、CAS/ResourceRepository 编排和 AssetBundle 解析链路做实。 已完成的是稳定基线、架构骨架、部分接口、CAS V1 和 Rust 官方资源同步闭环,不是完整产品能力。下一阶段的关键不是继续堆目录,而是把 Go 产品入口边界、官方同步端到端验证、CAS/ResourceRepository 编排和 AssetBundle 解析链路做实。
--- ---
- **下一份应更新文档**:真实官方网络 smoke 记录 - **下一份应更新文档**:真实官方网络 smoke 记录
- **下一项工程任务**Go CLI 最小可用入口和官方同步端到端 smoke - **下一项工程任务**收敛 Go 产品入口边界、执行官方同步端到端 smoke,并推进 CAS/ResourceRepository 与 AssetBundle 解析
+14 -14
View File
@@ -2,7 +2,7 @@
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。 **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` 常驻定期检查更新。Go 侧目前只有试验性的 `cmd/bat` 骨架和 `internal/ffi` 兼容包装,产品级 CLI、API Server、Web、完整 AssetBundle 解析、翻译系统和 Patch 系统仍在后续阶段。
--- ---
@@ -10,10 +10,10 @@
- Rust workspace 和 monorepo 结构。 - Rust workspace 和 monorepo 结构。
- `bat-core` 领域对象和仓储接口骨架。 - `bat-core` 领域对象和仓储接口骨架。
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖。 - `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 基础校验
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。 - `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 - `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 分类重试、指数退避、多线程下载、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC 后台控制命令 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable`
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。 - 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。 - `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
@@ -21,8 +21,8 @@
仍未完成: 仍未完成:
- Go CLI 最小可用入口 - Go CLI 产品入口(当前仅有试验性 `cmd/bat` 骨架)
- 完整 UnityFS / AssetBundle 解析。 - 完整 UnityFS / AssetBundle 引擎解析。
- 真实 Patch apply/diff。 - 真实 Patch apply/diff。
- Translation Memory、Glossary、AI Provider。 - Translation Memory、Glossary、AI Provider。
- API Server、SDK、Web 管理后台。 - API Server、SDK、Web 管理后台。
@@ -48,13 +48,13 @@
- `curl` - `curl`
- `unzip`,仅旧版 launcher manifest 指向整包 ZIP 且 `--auto-discover` 需要从 ZIP 解析 `GameMainConfig` 时使用;当前目录型 manifest 会直接下载 `resources.assets` - `unzip`,仅旧版 launcher manifest 指向整包 ZIP 且 `--auto-discover` 需要从 ZIP 解析 `GameMainConfig` 时使用;当前目录型 manifest 会直接下载 `resources.assets`
运行当前主要测试 运行当前通用验证
```bash ```bash
cargo test -p bat-adapters -- --nocapture cargo test --workspace
cargo test -p bat-ffi -- --nocapture cargo clippy --workspace --all-targets -- -D warnings
cargo test -p bat-infrastructure -- --nocapture go test ./...
cargo test -p bat-infrastructure --bin bat -- --nocapture go vet ./...
``` ```
查看官方同步命令: 查看官方同步命令:
@@ -157,7 +157,7 @@ BlueArchiveToolkit/
│ ├── bat-patch/ │ ├── bat-patch/
│ └── bat-ffi/ # 可选无状态 C ABI 兼容层 │ └── bat-ffi/ # 可选无状态 C ABI 兼容层
├── internal/ffi/ # 可选 CGO 兼容包装,不是 Go CLI 主路径 ├── internal/ffi/ # 可选 CGO 兼容包装,不是 Go CLI 主路径
├── cmd/ # Go CLI 入口,尚未实现 ├── cmd/ # Go CLI 试验骨架与后续产品入口
├── pkg/ # Go SDK 包,尚未实现 ├── pkg/ # Go SDK 包,尚未实现
├── api/ # API 定义,尚未实现 ├── api/ # API 定义,尚未实现
├── web/ # Web 管理后台,尚未实现 ├── web/ # Web 管理后台,尚未实现
@@ -174,13 +174,13 @@ BlueArchiveToolkit/
近期优先级: 近期优先级:
1. 落地 Go CLI 最小可用入口:`bat doctor``bat sync --help`、通过 `bat --json` 包装 Rust 同步命令 1. 收敛 Go CLI 产品入口的最终形态:当前 `cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,不应误写成完整 CLI
2. 补齐 AssetBundle UnityFS header/block/directory 解析。 2. 补齐 AssetBundle UnityFS 引擎级解析。
3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。 3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。
4. 将官方同步结果接入 CAS + ResourceRepository 的用户级工作流。 4. 将官方同步结果接入 CAS + ResourceRepository 的用户级工作流。
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。 5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
不建议在 Go CLI、资源解析和文本提取基础能力完成前优先开发 Web UI。 不建议在 Go 产品入口、资源解析和文本提取基础能力完成前优先开发 Web UI。
--- ---
+3 -2
View File
@@ -84,6 +84,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
| `--proxy <URL\|auto\|none>` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h | | `--proxy <URL\|auto\|none>` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h |
| `--no-proxy` | 强制直连 | | `--no-proxy` | 强制直连 |
| `--unzip <PATH>` | unzip 可执行文件(默认 `unzip` | | `--unzip <PATH>` | unzip 可执行文件(默认 `unzip` |
| `--download-concurrency <N>` | 并行下载数,`1..=256`(默认 `4`)。并行仅作用于实际网络下载;manifest/quarantine 簿记与 seed `.hash` 校验仍串行,`fail-fast` 与「不发布不完整资源」不变量保留。可下载失败重试带指数退避以对官方 CDN 礼貌 |
| `--dry-run` | 不写同步状态 | | `--dry-run` | 不写同步状态 |
| `--plan` | dry-run 时输出计划中的 URL | | `--plan` | dry-run 时输出计划中的 URL |
| `--force` | 强制下载/刷新 | | `--force` | 强制下载/刷新 |
@@ -126,7 +127,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
- 优先级:**命令行参数 > 进程环境变量 > `.env` > 内置默认值**。 - 优先级:**命令行参数 > 进程环境变量 > `.env` > 内置默认值**。
- 语法:每行 `KEY=VALUE``#` 开头为注释;值两侧成对引号会剥除;空值视为未设置。 - 语法:每行 `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_OUTPUT``BAT_STATE_DIR``BAT_AUTO_DISCOVER``BAT_WATCH``BAT_DAEMON``BAT_PROXY``BAT_NO_PROXY``BAT_INTERVAL_SECONDS``BAT_ERROR_RETRY_SECONDS``BAT_DOWNLOAD_CONCURRENCY``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` 等子命令不受它们影响。 - `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run``.env` 的模式开关让位。`status` / `verify` 等子命令不受它们影响。
- `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json` - `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json`
-`BAT_SKIP_ENV_FILE=1` 可让 `bat` 完全跳过 `.env` 的生成与加载。 -`BAT_SKIP_ENV_FILE=1` 可让 `bat` 完全跳过 `.env` 的生成与加载。
@@ -166,7 +167,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
`location` 是稳定的「组件·操作」标签(跟随语义、不随行号漂移)。`retryable` 是该类错误的默认可重试性。 `location` 是稳定的「组件·操作」标签(跟随语义、不随行号漂移)。`retryable` 是该类错误的默认可重试性。
> 说明:错误码模型(`core/src/error_code.rs`)已建立并作为公共契约;将各链路的报错逐步接入该码表的工作在 issue #1 下推进。下表随码表更新 > 说明:错误码模型(`core/src/error_code.rs`)已建立并作为公共契约;下载、launcher/metadata、server-info/marker、配置校验、任务/RPC 等主要链路已接入该码表。剩余未实现命名空间和后续引擎能力继续按本表扩展
### 域一览 ### 域一览
+20
View File
@@ -272,6 +272,12 @@ impl AddressablesCatalogDriver {
.unwrap_or_default(); .unwrap_or_default();
let dependencies = Self::dependencies_from_entry(value); let dependencies = Self::dependencies_from_entry(value);
let crc = value
.get("crc")
.or_else(|| value.get("Crc"))
.or_else(|| value.get("m_Crc"))
.and_then(|value| value.as_u64())
.and_then(|value| u32::try_from(value).ok());
Some(ResourceEntry { Some(ResourceEntry {
path: path.to_string(), path: path.to_string(),
@@ -280,6 +286,7 @@ impl AddressablesCatalogDriver {
resource_type: Self::resource_type_for_path(path), resource_type: Self::resource_type_for_path(path),
address, address,
dependencies, dependencies,
crc,
}) })
} }
@@ -376,6 +383,7 @@ impl AddressablesCatalogDriver {
resource_type: Self::resource_type_for_path(path), resource_type: Self::resource_type_for_path(path),
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}) })
}) })
.collect() .collect()
@@ -413,6 +421,7 @@ impl AddressablesCatalogDriver {
resource_type, resource_type,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}); });
} }
@@ -483,6 +492,7 @@ impl AddressablesCatalogDriver {
Some(primary_key) Some(primary_key)
}, },
dependencies, dependencies,
crc: extra.crc,
}) })
}) })
.collect() .collect()
@@ -619,6 +629,11 @@ impl AddressablesCatalogDriver {
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.map(ToOwned::to_owned), .map(ToOwned::to_owned),
bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()), bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()),
// m_Crc 是 bundle 的 IEEE CRC-320 表示不做 CRC 校验,忠实保留原值。
crc: json
.get("m_Crc")
.and_then(|value| value.as_u64())
.and_then(|value| u32::try_from(value).ok()),
} }
} }
@@ -821,6 +836,7 @@ struct AddressablesExtraData {
hash: Option<String>, hash: Option<String>,
bundle_name: Option<String>, bundle_name: Option<String>,
bundle_size: Option<u64>, bundle_size: Option<u64>,
crc: Option<u32>,
} }
impl Default for AddressablesCatalogDriver { impl Default for AddressablesCatalogDriver {
@@ -954,6 +970,7 @@ mod tests {
"internal_id": "synthetic/minimal.bundle", "internal_id": "synthetic/minimal.bundle",
"hash": "synthetic-entry-hash", "hash": "synthetic-entry-hash",
"size": 119, "size": 119,
"crc": 3735928559,
"address": "Character_001", "address": "Character_001",
"dependencies": ["synthetic/shared.bundle"] "dependencies": ["synthetic/shared.bundle"]
}, },
@@ -971,6 +988,9 @@ mod tests {
assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle"); assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle");
assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash"); assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash");
assert_eq!(manifest.resources[0].size, 119); assert_eq!(manifest.resources[0].size, 119);
// m_Crc(此处 0xDEADBEEF)应被提取;缺该字段的条目为 None。
assert_eq!(manifest.resources[0].crc, Some(0xDEAD_BEEF));
assert_eq!(manifest.resources[1].crc, None);
assert_eq!( assert_eq!(
manifest.resources[0].address.as_deref(), manifest.resources[0].address.as_deref(),
Some("Character_001") Some("Character_001")
+122
View File
@@ -86,6 +86,7 @@ impl Unity2021_3Adapter {
flags, flags,
)?; )?;
let (blocks, directories) = Self::parse_blocks_info(&block_info)?; let (blocks, directories) = Self::parse_blocks_info(&block_info)?;
validate_directory_bounds(&blocks, &directories)?;
Ok(ParsedAssetBundle { Ok(ParsedAssetBundle {
unity_version: header.unity_version.clone(), unity_version: header.unity_version.clone(),
@@ -145,6 +146,50 @@ impl Unity2021_3Adapter {
} }
} }
/// 校验目录条目落在解压数据区内。
///
/// UnityFS 的 directory 是解压后(所有 block 的 uncompressed 数据依次拼接而成的)
/// 连续数据区上的 `[offset, offset + size)` 切片。解析阶段只按结构读取这些数值,
/// 并不保证它们不越界;截断或损坏的 bundle 会给出指向数据区之外的目录条目,
/// 若不校验就静默接受,后续按 offset/size 取数据时才会出错或读到错误内容。
/// 这里把每个目录条目与「各 block 解压大小之和」比对,越界即报错并带上下文。
fn validate_directory_bounds(
blocks: &[UnityFsBlockInfo],
directories: &[UnityFsDirectoryInfo],
) -> Result<(), String> {
let mut data_region_size: u64 = 0;
for (index, block) in blocks.iter().enumerate() {
data_region_size = data_region_size
.checked_add(u64::from(block.uncompressed_size))
.ok_or_else(|| {
format!(
"UnityFS 解压数据区大小溢出:累加到第 {index} 个 blockuncompressed_size={})时超过 u64",
block.uncompressed_size
)
})?;
}
for (index, directory) in directories.iter().enumerate() {
let end = directory
.offset
.checked_add(directory.size)
.ok_or_else(|| {
format!(
"UnityFS 目录条目 {} 的 offset({}) + size({}) 溢出 u64",
directory.path, directory.offset, directory.size
)
})?;
if end > data_region_size {
return Err(format!(
"UnityFS 目录条目 {}(第 {index} 项)越界:offset({}) + size({}) = {} 超过解压数据区大小 {}",
directory.path, directory.offset, directory.size, end, data_region_size
));
}
}
Ok(())
}
fn read_blocks_info_bytes<'a>( fn read_blocks_info_bytes<'a>(
data: &'a [u8], data: &'a [u8],
reader: &mut UnityFsReader<'a>, reader: &mut UnityFsReader<'a>,
@@ -497,4 +542,81 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
assert!(result.unwrap_err().contains("signature")); assert!(result.unwrap_err().contains("signature"));
} }
fn block(uncompressed_size: u32) -> UnityFsBlockInfo {
UnityFsBlockInfo {
uncompressed_size,
compressed_size: uncompressed_size,
flags: 0,
compression: UnityFsCompression::None,
}
}
fn directory(offset: u64, size: u64) -> UnityFsDirectoryInfo {
UnityFsDirectoryInfo {
offset,
size,
flags: 0,
path: "CAB-test".to_string(),
}
}
#[test]
fn directory_within_data_region_is_accepted() {
// 两个 block 共 12 字节解压数据区;目录条目正好覆盖尾部,合法。
let result =
validate_directory_bounds(&[block(8), block(4)], &[directory(0, 8), directory(8, 4)]);
assert!(result.is_ok(), "{result:?}");
}
#[test]
fn directory_past_data_region_is_rejected() {
// 解压数据区仅 4 字节,目录声称 [0, 8) 越界,应被拒绝并带上下文。
let error = validate_directory_bounds(&[block(4)], &[directory(0, 8)]).unwrap_err();
assert!(error.contains("越界"), "{error}");
assert!(error.contains("解压数据区大小 4"), "{error}");
}
#[test]
fn directory_offset_size_overflow_is_rejected() {
let error = validate_directory_bounds(&[block(4)], &[directory(u64::MAX, 1)]).unwrap_err();
assert!(error.contains("溢出"), "{error}");
}
#[tokio::test]
async fn test_parse_rejects_out_of_bounds_directory() {
// 构造一个 directory.size 超过 block 解压大小的 bundle,端到端验证被拒。
let mut blocks_info = Vec::new();
blocks_info.extend_from_slice(&[0; 16]);
push_i32(&mut blocks_info, 1);
push_u32(&mut blocks_info, 4); // block uncompressed_size = 4
push_u32(&mut blocks_info, 4);
push_u16(&mut blocks_info, 0);
push_i32(&mut blocks_info, 1);
push_u64(&mut blocks_info, 0);
push_u64(&mut blocks_info, 99); // directory size 99 远超数据区
push_u32(&mut blocks_info, 0);
push_c_string(&mut blocks_info, "CAB-test");
let mut data = Vec::new();
push_c_string(&mut data, "UnityFS");
push_u32(&mut data, 8);
push_c_string(&mut data, "5.x.x");
push_c_string(&mut data, "2021.3.56f2");
push_u64(&mut data, 0);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, 0);
align(&mut data, UNITYFS_ALIGNMENT);
data.extend_from_slice(&blocks_info);
data.extend_from_slice(b"data");
let adapter = Unity2021_3Adapter::new();
let bundle = RawAssetBundle {
data,
path: Some("out-of-bounds.bundle".to_string()),
};
let error = adapter.parse(&bundle).await.unwrap_err();
assert!(error.contains("越界"), "{error}");
}
} }
+6 -3
View File
@@ -12,7 +12,8 @@
"address": "academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle", "address": "academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle",
"dependencies": [ "dependencies": [
"shared_assets_all_123.bundle" "shared_assets_all_123.bundle"
] ],
"crc": 0
}, },
{ {
"path": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle", "path": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
@@ -20,7 +21,8 @@
"size": 162134, "size": 162134,
"resource_type": "AssetBundle", "resource_type": "AssetBundle",
"address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle", "address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
"dependencies": [] "dependencies": [],
"crc": 0
}, },
{ {
"path": "shared_assets_all_123.bundle", "path": "shared_assets_all_123.bundle",
@@ -28,7 +30,8 @@
"size": 153480, "size": 153480,
"resource_type": "AssetBundle", "resource_type": "AssetBundle",
"address": "shared_assets_all_123.bundle", "address": "shared_assets_all_123.bundle",
"dependencies": [] "dependencies": [],
"crc": 0
} }
], ],
"metadata": { "metadata": {
@@ -22,6 +22,7 @@ async fn parses_real_shape_addressables_catalog_against_golden() {
"resource_type": format!("{:?}", resource.resource_type), "resource_type": format!("{:?}", resource.resource_type),
"address": resource.address, "address": resource.address,
"dependencies": resource.dependencies, "dependencies": resource.dependencies,
"crc": resource.crc,
}) })
}).collect::<Vec<_>>(), }).collect::<Vec<_>>(),
"metadata": manifest.metadata.extra, "metadata": manifest.metadata.extra,
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod translation;
pub use game_client::{ClientStatus, GameClient, GameRegion}; pub use game_client::{ClientStatus, GameClient, GameRegion};
pub use game_version::{GameVersion, UnityVersion}; pub use game_version::{GameVersion, UnityVersion};
pub use resource::{Resource, ResourceEntry, ResourceType}; pub use resource::{crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceType};
pub use translation::{ pub use translation::{
ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText, ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText,
TranslationStatus, TranslationStatus,
+145 -5
View File
@@ -34,6 +34,94 @@ pub struct ResourceEntry {
pub address: Option<String>, pub address: Option<String>,
/// 该资源依赖的其他资源标识 /// 该资源依赖的其他资源标识
pub dependencies: Vec<String>, pub dependencies: Vec<String>,
/// Addressables bundle 的 CRC32catalog 中的 `m_Crc`)。
///
/// `None` 表示 catalog 未提供该字段;Unity 用 `0` 表示「不做 CRC 校验」,
/// 因此 `Some(0)` 与 `None` 在校验时同样视为「无 CRC」。为向后兼容旧的
/// 持久化数据,反序列化时缺省为 `None`。
#[serde(default)]
pub crc: Option<u32>,
}
/// 已下载字节与 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
} }
/// 资源 /// 资源
@@ -51,18 +139,70 @@ pub struct Resource {
mod tests { mod tests {
use super::*; use super::*;
#[test] fn entry_with(size: u64, crc: Option<u32>) -> ResourceEntry {
fn test_resource_entry() { ResourceEntry {
let entry = ResourceEntry {
path: "test.bundle".to_string(), path: "test.bundle".to_string(),
hash: "abc123".to_string(), hash: "abc123".to_string(),
size: 1024, size,
resource_type: ResourceType::AssetBundle, resource_type: ResourceType::AssetBundle,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
}; crc,
}
}
#[test]
fn test_resource_entry() {
let entry = entry_with(1024, None);
assert_eq!(entry.path, "test.bundle"); assert_eq!(entry.path, "test.bundle");
assert_eq!(entry.size, 1024); 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
})
);
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@
BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建一个可持续维护十年以上的工业级开源项目。 BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建一个可持续维护十年以上的工业级开源项目。
当前文档描述目标架构和已经落地的关键边界。它不是部署手册;当前可部署能力只有 Rust 官方资源同步任务。API Server、Web、Provider 编排和完整 Go CLI 仍未实现,实际实现状态以根目录 `CURRENT_STATUS.md``PROJECT_PLAN.md` 为准。 当前文档描述目标架构和已经落地的关键边界。它不是部署手册;当前可部署能力只有 Rust 官方资源同步任务。API Server、Web、Provider 编排和 Go 产品入口仍未完成,实际实现状态以根目录 `CURRENT_STATUS.md``PROJECT_PLAN.md` 为准。
当前已经可用的官方资源入口包括: 当前已经可用的官方资源入口包括:
@@ -65,7 +65,7 @@ CAS V1 不以“能通过简单 put/get 测试”为完成标准。必须满足
4. 并发写入相同内容测试通过。 4. 并发写入相同内容测试通过。
5. 损坏对象读取返回明确错误。 5. 损坏对象读取返回明确错误。
6. 权限或路径错误有清晰错误类型。 6. 权限或路径错误有清晰错误类型。
7. `cargo test --workspace``cargo clippy --workspace -- -D warnings` 通过。 7. `cargo test --workspace``cargo clippy --workspace --all-targets -- -D warnings` 通过。
--- ---
@@ -148,14 +148,14 @@
### 3.5 导入到 CAS 和资源仓储 ### 3.5 导入到 CAS 和资源仓储
资源下载后,导入层会: 当前实现已经提供资源导入能力,但官方同步下载完成后尚未自动作为用户级流程触发导入。手动或上层流程调用导入层时,它会:
1. 把 bundle 原始字节写入 CAS。 1. 把 bundle 原始字节写入 CAS。
2. 解析 UnityFS 基础摘要。 2. 解析 UnityFS 基础摘要。
3. 把资源条目写入 `ResourceRepository` 3. 把资源条目写入 `ResourceRepository`
4. 记录资源路径、hash、大小和解析摘要。 4. 记录资源路径、hash、大小和解析摘要。
这层的意义是把“下载到磁盘的文件”变成“可查询、可复用、可去重”的资源对象。 这层的意义是把“下载到磁盘的文件”变成“可查询、可复用、可去重”的资源对象。把官方同步结果自动接入 CAS + `ResourceRepository` 仍属于 G-011 剩余工作。
对应实现主要在: 对应实现主要在:
@@ -189,7 +189,7 @@
集成边界: 集成边界:
1. 当前生产和 Go CLI 默认集成路径是运行 `bat --json` 并消费结构化 report。 1. 当前生产集成路径是运行 `bat --json` 并消费结构化 report;未来 Go CLI 若继续作为产品入口,也应优先使用该进程边界或 daemon RPC
2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。 2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。
3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。 3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。
@@ -304,7 +304,7 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
- Go 层负责:BlueArchive 客户端请求处理、HTTP API、鉴权、内容分发, - Go 层负责:BlueArchive 客户端请求处理、HTTP API、鉴权、内容分发,
以及作为 RPC client 调用本机 daemon(连接 `bat.sock`,每行一个 以及作为 RPC client 调用本机 daemon(连接 `bat.sock`,每行一个
JSON-RPC 请求/响应)。 JSON-RPC 请求/响应)。当前 Go 产品入口尚未完成,`cmd/bat` 仍是试验骨架。
- Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查、 - Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查、
版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。 版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。
- Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源 - Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源
+10 -8
View File
@@ -1,6 +1,6 @@
# 稳定工程基线指南 # 稳定工程基线指南
- **更新时间**2026-07-06 - **更新时间**2026-07-20
- **目标**:让工作区处于可继续开发核心功能的可信状态。 - **目标**:让工作区处于可继续开发核心功能的可信状态。
--- ---
@@ -13,7 +13,7 @@
2. 根目录只保留入口文档和工程配置。 2. 根目录只保留入口文档和工程配置。
3. 旧报告归档,且不再和当前状态混淆。 3. 旧报告归档,且不再和当前状态混淆。
4. Rust workspace 成员显式列出。 4. Rust workspace 成员显式列出。
5. Go 尚未实现时,Makefile 不误报失败 5. Go 产品入口尚未完成时,Makefile 不把骨架包误报为完整产品
6. 当前缺口有集中清单和关闭顺序。 6. 当前缺口有集中清单和关闭顺序。
7. 架构边界有 ADR 记录。 7. 架构边界有 ADR 记录。
8. 基础验证命令通过。 8. 基础验证命令通过。
@@ -36,14 +36,16 @@ make lint
```bash ```bash
cargo test --workspace cargo test --workspace
cargo check --workspace cargo check --workspace
cargo clippy --workspace -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
go test ./...
go vet ./...
``` ```
说明: 说明:
1. 当前没有 Go 产品入口,因此 Go build/test/check/fmt/lint 会在空 Go 阶段明确跳过 1. 当前已有 `cmd/bat``internal/ffi` 骨架,但没有 Go 产品级测试覆盖;`go test ./...` 出现 `[no test files]` 不代表 CLI/API 已完成
2. 如果后续新增 Go package,必须让 `go test ./...``go vet ./...` 纳入硬性验证。 2. 如果后续新增 Go 产品 package,必须让 `go test ./...``go vet ./...` 纳入硬性验证。
3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入 CI 3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入本地门禁
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture` 4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`
--- ---
@@ -88,10 +90,10 @@ git check-ignore -v Cargo.lock CLAUDE.md AGENTS.md CONTRIBUTING.md
CAS V1 和 Rust 官方同步闭环完成后,下一阶段优先推进: CAS V1 和 Rust 官方同步闭环完成后,下一阶段优先推进:
1. Go CLI 的 `doctor` 和基础命令框架 1. 收敛 Go 产品入口:当前 `cmd/bat` 仅是试验骨架,不能视为完成
2.`docs/guides/official-full-pull-smoke.md` 执行真实官方网络全量下载 smoke,并保留隔离目录报告。 2.`docs/guides/official-full-pull-smoke.md` 执行真实官方网络全量下载 smoke,并保留隔离目录报告。
3. 官方同步结果接入 CAS + ResourceRepository。 3. 官方同步结果接入 CAS + ResourceRepository。
4. AssetBundle UnityFS 解析。 4. AssetBundle UnityFS 引擎级解析。
优先阅读: 优先阅读:
+19 -3
View File
@@ -23,6 +23,20 @@ rustc --version # 验证安装
cargo --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
```
该 workflow 会用 `GITHUB_SERVER_URL``GITHUB_REPOSITORY``GITHUB_REF``GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的格式化、检查、构建、clippy 和测试。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。
#### Docker #### Docker
```bash ```bash
# 安装 Docker 和 Docker Compose # 安装 Docker 和 Docker Compose
@@ -127,9 +141,11 @@ git push origin feature/your-feature-name
cargo fmt --check cargo fmt --check
cargo test --workspace cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
go test ./...
go vet ./...
``` ```
Go CLI 尚未实现时,`go test ./...` 可能没有产品级 package 可运行;Makefile 会在空 Go 阶段清晰跳过 Go 产品入口尚未完成,但仓库已有 `cmd/bat``internal/ffi` 骨架。提交前应运行 `go test ./...``go vet ./...`;目前输出可能只有 `[no test files]`,这代表缺少 Go 产品测试覆盖,不代表 Go CLI 已完成
### 常用聚焦命令 ### 常用聚焦命令
@@ -144,7 +160,7 @@ cargo clippy -p bat-core -p bat-adapters -p bat-infrastructure --all-targets --
官方资源同步、下载、daemon、status、verify 或 repair 相关改动必须至少覆盖 `bat-infrastructure``bat` 二进制测试。 官方资源同步、下载、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 导出、JSON schema、错误返回或 `internal/ffi` CGO 包装时必须运行 `cargo test -p bat-ffi -- --nocapture`未来 Go 产品入口和生产同步默认应通过 Rust `bat --json` 或 daemon RPC 进程边界集成。
### 集成测试 ### 集成测试
@@ -215,7 +231,7 @@ cargo fetch
### 3. FFI 兼容层问题 ### 3. FFI 兼容层问题
`bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。默认 Go CLI 集成优先运行 Rust `bat --json` `bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。未来 Go 产品入口集成优先运行 Rust `bat --json` 或调用 daemon RPC
重新构建兼容库: 重新构建兼容库:
```bash ```bash
+49 -41
View File
@@ -1,6 +1,6 @@
# 当前实现缺口清单 # 当前实现缺口清单
- **更新时间**2026-07-17 - **更新时间**2026-07-20
- **用途**:集中跟踪当前代码中的占位实现、设计缺口和下一步验收项。 - **用途**:集中跟踪当前代码中的占位实现、设计缺口和下一步验收项。
- **权威计划**`../../PROJECT_PLAN.md` - **权威计划**`../../PROJECT_PLAN.md`
@@ -107,30 +107,32 @@
- 并发写入相同内容只产生一个对象。 - 并发写入相同内容只产生一个对象。
- 读取时 Hash 不匹配会返回明确错误。 - 读取时 Hash 不匹配会返回明确错误。
### G-005AssetBundle 解析器仍是占位 ### G-005AssetBundle 引擎解析器仍未完成
现象: 现象:
- `crates/bat-assetbundle/src/parser.rs` 只有 `Parser::name` - `crates/bat-assetbundle/src/parser.rs` 只有 `Parser::name`
- `types.rs` 只有 `AssetType::TextAsset` - `types.rs` 只有 `AssetType::TextAsset`
- `adapters/src/unity/unity_2021_3.rs` 已能解析 UnityFS header、block info、directory,并校验 directory `offset+size` 不越界;这属于 adapter 层基础摘要能力,不等于 `bat-assetbundle` 引擎已完成。
- 仍没有对象表、TypeTree、TextAsset、MonoBehaviour、ScriptableObject 或可扩展提取入口。
影响: 影响:
- 无法解析真实 UnityFS。 - 可以对部分 UnityFS 样本做基础结构校验,但无法完成真实资源对象解析和文本提取
- 无法提取 TextAsset 或配置文本。 - 无法提取 TextAsset 或配置文本。
验收: 验收:
- 能解析结构化测试样本。 - `crates/bat-assetbundle` 能解析结构化测试样本和隔离真实样本
- 支持 UnityFS header、blocks、directory、metadata。 - 支持 UnityFS header、blocks、directory、metadata、object table
- 错误包含偏移和字段上下文。 - 错误包含偏移和字段上下文。
### G-006Patch 引擎仍是占位 ### G-006Patch 引擎仍是占位
现象: 现象:
- `binary::apply_patch` 返回 `Vec` - `binary::apply_patch` 明确返回 `PatchError::ApplyFailed`,提示 Binary patch 尚未实现
- `json::apply_json_patch` 返回空字符串 - `json::apply_json_patch` 明确返回 `PatchError::ApplyFailed`,提示 JSON patch 尚未实现
影响: 影响:
@@ -150,7 +152,8 @@
现象: 现象:
- `AddressablesCatalogDriver` 已能解析当前真实形态 JSON catalog fixture/golden。 - `AddressablesCatalogDriver` 已能解析当前真实形态 JSON catalog fixture/golden。
- 已输出 path、hash、size、resource_type、address、dependencies、metadata。 - 已输出 path、hash、size、resource_type、address、dependencies、metadata,并已提取 `m_Crc``crc` 字段
- `bat-core` 已提供 `crc32_ieee``ResourceEntry::verify_downloaded_bytes`SQLite `ResourceRepository` 已有 `crc` 列迁移。
- 仍需覆盖更多官方 catalog 结构变体、二进制/压缩字段组合和更明确的失败诊断。 - 仍需覆盖更多官方 catalog 结构变体、二进制/压缩字段组合和更明确的失败诊断。
影响: 影响:
@@ -160,53 +163,56 @@
验收: 验收:
- 能解析项目目标版本的真实 Catalog 样本集合。 - 能解析项目目标版本的真实 Catalog 样本集合。
- 解析结果包含资源 key、provider、dependency、hash、size、path。 - 解析结果包含资源 key、provider、dependency、hash、size、path、CRC
- 对不支持的 catalog 结构返回明确错误,而不是静默丢字段。 - 对不支持的 catalog 结构返回明确错误,而不是静默丢字段。
--- ---
## 3. 应用层缺口 ## 3. 应用层缺口
### G-008Go CLI 尚未实现 ### G-008Go CLI 产品入口尚未完成
状态:**未完成(此前“并入 G-009”只是短期跟踪调整,不代表能力完成)**
现象: 现象:
- `cmd/bat` 已有 `main.go`,但只是通过 cgo 调用 `bat-ffi` 的最小骨架(doctor/manifest inspect/sync plan),不是产品级用户入口;且默认 Go/Rust 集成边界应是 `bat --json` 进程边界,而非 FFI - 当前可用的用户同步/运维入口是 Rust `bat` binary
- `internal/ffi/ffi.go` 已存在,但只是可选 CGO 兼容包装,不是用户可运行的产品 CLI,也不是默认集成边界 - `cmd/bat` 已存在,但仅有 `doctor``manifest inspect``sync plan` 试验能力;`doctor` 只输出固定 `ok``manifest`/`sync` 依赖可选 CGO/FFI helper
- `go test ./...` 当前没有产品级 Go package 覆盖 - Go 侧尚未实现通过 Rust `bat --json` 或 daemon RPC 包装官方同步命令、稳定 human/json 输出、真实 doctor 检查和端到端测试
- 如果项目决策改为“用户 CLI 永久由 Rust `bat` 承担,Go 只做 `bat-api`/服务层”,必须同步更新 `AGENTS.md``PROJECT_PLAN.md` 和 issue 跟踪;在完成该决策前,不能把 Go CLI 写成已完成。
当前进展:
- 对接边界已就绪: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` 可运行 - `cmd/bat doctor` 做真实环境诊断,而不是固定字符串
- `bat --help` 命令结构稳定 - `cmd/bat sync` 能通过 Rust `bat --json` 或 RPC 触发/查询官方同步,不走 FFI 控制下载器或 daemon
- 命令支持默认人类可读输出和 `--json` 机器输出 - human/json 输出、退出码和错误码与 Rust `bat` 契约一致
- Go CLI 默认通过 Rust `bat --json` 进程边界获取同步 report;除非明确兼容需求,不依赖 FFI - `go test ./...``go vet ./...` 覆盖命令解析、错误输出和至少一个 mocked Rust 边界
### G-009API Server 和 OpenAPI 尚未实现 ### G-009API Server`bat-api`,仿官方 API尚未实现
现象: 现象:
- `api/` 只有目录结构。 - `api/` 只有目录结构,无 handler、service、路由
- 无 handler、service、OpenAPI schema - Go 侧尚无对接 daemon RPC / `current/` 发布布局的服务端入口
目标(对应 issue #19):
- 新建 `cmd/bat-api`**完全仿照 BlueArchive 官方 API** 的 Go HTTP 服务,把 Rust `bat` 后端发布的 `current/` release 按官方接口形态对外提供,使真实客户端/工具可将其当作官方服务端。
- 仿真面:资源 CDN 面(TableCatalog/MediaCatalog/BundlePackingInfo、bundle、seed `.hash`)、server-info 面、launcher API 面。
- 鉴权/签名**完全仿照**官方实现,服务端做验签(对齐 `adapters/src/official/launcher.rs` 的签名逻辑)。
- 版本/状态经 daemon RPC`catalog.*` / `resource.manifest`)发现,资源字节从 `current/` 读取;不读写 daemon 状态文件内部。
影响: 影响:
- Web 和第三方集成无服务端入口。 - 客户端、工具和第三方集成无服务端入口。
验收: 验收:
- `/api/v1/health` 可用 - Go 单测覆盖路由、签名验签、错误响应形态
- 统一错误结构落地 - 真机 e2edaemon 发布 fixture release → 启动 `bat-api` → 按官方 URL 与鉴权头请求 server-info / catalog / bundle / launcher 链,断言字节与形态正确、验签生效(缺签/错签被拒)
- OpenAPI 与实际路由同步 - 统一错误结构与官方响应 envelope 对齐;Makefile 增加 Go 构建/测试目标
排期:P2,排在 issue #17 验收收口以及 issue #2 / #3 的解析能力继续推进之后启动;可与 G-008 的 Go 产品入口边界收敛并行。
### G-010Web 管理后台尚未实现 ### G-010Web 管理后台尚未实现
@@ -396,7 +402,9 @@
处理结果: 处理结果:
- 明确决策:本项目不加入 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` - 目前补充了自托管 Gitea linux-runner workflow`.gitea/workflows/bat.yml`),仅用于 Rust workspace 的构建和测试,不改变“不引入托管 CI”的决策
- workflow 不使用外部 GitHub Action;它通过 runner 环境变量手动 `git fetch` 当前提交,并要求 runner 预装 `git`、Rust stable、rustfmt 和 clippy,避免准备阶段因第三方 action 仓库代理或网络限制失败。
- 质量门禁由本地默认验证命令和自托管 workflow 共同承担:提交前执行 `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`)。 - 发布类检查(build、smoke)由 `Makefile``scripts/` 下的可重复脚本承担(如 `make official-smoke`)。
限制: 限制:
@@ -462,11 +470,11 @@
## 6. 当前关闭顺序建议 ## 6. 当前关闭顺序建议
1. G-008 1. issue #17 验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open。
2. G-011 2. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。
3. G-005 3. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。
4. G-007 4. G-011:官方同步结果接入 CAS + ResourceRepository 用户级工作流。
5. G-012 5. G-008 / G-009:收敛 Go 产品入口边界,并实现 `bat-api`issue #19)。
6. G-006 6. G-012 / G-006:翻译系统、Patch 引擎。
这个顺序优先补齐用户入口和官方同步结果的资源索引编排,再推进解析、翻译和补丁。G-018 已固化为可重复 smoke 命令并关闭;G-017 已按"不引入托管 CI"决策关闭。 这个顺序优先把 Rust `bat` 后端做扎实(解析能力 + 下载性能),再把官方同步结果进入可查询资源库,之后收敛 Go 入口和仿官方 API 服务端,最后推进翻译和补丁。G-018 已固化为可重复 smoke 命令并关闭;G-017 已按不引入托管 CI决策关闭。
@@ -4957,6 +4957,8 @@ BAT_AUTO_DISCOVER=1
# #
#BAT_INTERVAL_SECONDS=3600 #BAT_INTERVAL_SECONDS=3600
#BAT_ERROR_RETRY_SECONDS=60 #BAT_ERROR_RETRY_SECONDS=60
# 1~256 4 CDN
#BAT_DOWNLOAD_CONCURRENCY=4
# ---- ---- # ---- ----
# URL http/https/socks4/socks4a/socks5/socks5h # URL http/https/socks4/socks4a/socks5/socks5h
@@ -5141,6 +5143,9 @@ fn apply_bat_env_overrides(
if let Some(v) = value("BAT_UNZIP") { if let Some(v) = value("BAT_UNZIP") {
options.config.unzip_command = PathBuf::from(v); options.config.unzip_command = PathBuf::from(v);
} }
if let Some(v) = value("BAT_DOWNLOAD_CONCURRENCY") {
options.config.download_concurrency = parse_download_concurrency(&v)?;
}
if let Some(v) = value("BAT_PROXY") { if let Some(v) = value("BAT_PROXY") {
options.config.curl_proxy = parse_proxy_config(&v)?; options.config.curl_proxy = parse_proxy_config(&v)?;
} }
@@ -5318,6 +5323,11 @@ fn parse_args_with_env(
"--unzip" => { "--unzip" => {
options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?); options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?);
} }
"--download-concurrency" => {
let value = next_option_value(&mut args, &flag)?;
options.config.download_concurrency = parse_download_concurrency(&value)?;
options.sync_option_explicit = true;
}
"--dry-run" => { "--dry-run" => {
options.config.dry_run = true; options.config.dry_run = true;
options.sync_option_explicit = true; options.sync_option_explicit = true;
@@ -5656,6 +5666,7 @@ fn print_usage(binary: &str) {
eprintln!(" --proxy <URL|auto|none> curl proxy override (default: auto from env)"); eprintln!(" --proxy <URL|auto|none> curl proxy override (default: auto from env)");
eprintln!(" --no-proxy Force direct curl connections"); eprintln!(" --no-proxy Force direct curl connections");
eprintln!(" --unzip <PATH> unzip executable (default: unzip)"); eprintln!(" --unzip <PATH> unzip executable (default: unzip)");
eprintln!(" --download-concurrency <N> Parallel downloads, 1..=256 (default: 4)");
eprintln!(" --dry-run Do not write sync state"); eprintln!(" --dry-run Do not write sync state");
eprintln!(" --plan Include planned URLs in dry-run"); eprintln!(" --plan Include planned URLs in dry-run");
eprintln!(" --force Force download/refresh"); eprintln!(" --force Force download/refresh");
@@ -5730,6 +5741,17 @@ fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
.collect() .collect()
} }
/// 解析下载并发度:正整数,钳制到 `1..=256`。
fn parse_download_concurrency(value: &str) -> anyhow::Result<usize> {
let parsed = value
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("下载并发度无效:{error}"))?;
if parsed == 0 {
return Err(anyhow::anyhow!("下载并发度必须大于 0"));
}
Ok(parsed.min(256))
}
fn parse_platform(value: &str) -> Result<PatchPlatform, String> { fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
match value.to_ascii_lowercase().as_str() { match value.to_ascii_lowercase().as_str() {
"windows" | "win" => Ok(PatchPlatform::Windows), "windows" | "win" => Ok(PatchPlatform::Windows),
@@ -6012,6 +6034,51 @@ mod tests {
assert_eq!(registry.get("task-1-2").unwrap().status, "succeeded"); assert_eq!(registry.get("task-1-2").unwrap().status, "succeeded");
} }
#[test]
fn parses_download_concurrency_flag_and_env() {
// 默认值(与 OfficialUpdateConfig 默认一致)。
assert_eq!(
parse(&["bat"]).unwrap().config.download_concurrency,
OfficialUpdateConfig::default().download_concurrency
);
// CLI 显式设置 + 上限钳制。
assert_eq!(
parse(&["bat", "--download-concurrency", "16"])
.unwrap()
.config
.download_concurrency,
16
);
assert_eq!(
parse(&["bat", "--download-concurrency", "9999"])
.unwrap()
.config
.download_concurrency,
256
);
// 0 与非数字报错。
assert!(parse(&["bat", "--download-concurrency", "0"]).is_err());
assert!(parse(&["bat", "--download-concurrency", "abc"]).is_err());
// 环境变量(含 .env)设置默认值;CLI 覆盖之。
assert_eq!(
parse_with_env(&["bat"], &[("BAT_DOWNLOAD_CONCURRENCY", "12")])
.unwrap()
.config
.download_concurrency,
12
);
assert_eq!(
parse_with_env(
&["bat", "--download-concurrency", "3"],
&[("BAT_DOWNLOAD_CONCURRENCY", "12")]
)
.unwrap()
.config
.download_concurrency,
3
);
}
#[test] #[test]
fn parses_auto_discover_sync_args() { fn parses_auto_discover_sync_args() {
let options = parse(&[ let options = parse(&[
+44 -4
View File
@@ -427,6 +427,34 @@ impl std::fmt::Display for CurlRetryError {
impl std::error::Error for CurlRetryError {} impl std::error::Error for CurlRetryError {}
/// 网络类可重试失败的退避基值(毫秒)。生产 200ms、指数增长;测试下为 0
/// 以免拖慢单测(单测仍验证退避时长的计算,只是不真正 sleep)。
#[cfg(not(test))]
const RETRY_BACKOFF_BASE_MS: u64 = 200;
#[cfg(test)]
const RETRY_BACKOFF_BASE_MS: u64 = 0;
/// 退避上限(毫秒)。
const RETRY_BACKOFF_MAX_MS: u64 = 5_000;
/// 计算第 `attempt` 次失败后、下次重试前的退避时长。
fn retry_backoff(busy: bool, attempt: usize) -> std::time::Duration {
std::time::Duration::from_millis(backoff_delay_ms(RETRY_BACKOFF_BASE_MS, busy, attempt))
}
/// 退避时长(毫秒)的纯计算,便于独立于 cfg 门控的基值做单测。
///
/// - `ETXTBSY`fork/exec 竞态):极短固定退避,只为让兄弟进程完成 execve。
/// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),并发下载
/// 时对官方 CDN 更礼貌,避免 N 个连接失败后同时立即重发。
fn backoff_delay_ms(base: u64, busy: bool, attempt: usize) -> u64 {
if busy {
return 5 * attempt as u64;
}
let shift = attempt.saturating_sub(1).min(5) as u32;
base.saturating_mul(1u64 << shift).min(RETRY_BACKOFF_MAX_MS)
}
pub(crate) fn run_curl_with_retry_with_proxy( pub(crate) fn run_curl_with_retry_with_proxy(
curl_command: &Path, curl_command: &Path,
url: &str, url: &str,
@@ -448,8 +476,6 @@ pub(crate) fn run_curl_with_retry_with_proxy(
Err(error) => CurlFailure::process_failed(url, destination, curl_command, error), Err(error) => CurlFailure::process_failed(url, destination, curl_command, error),
}; };
let retryable = failure.retryable(); let retryable = failure.retryable();
// ETXTBSY 是 fork/exec 竞态窗口造成的瞬时忙,立刻重试往往仍落在同一窗口内。
// 让出 CPU 并做一次极短退避,使持有可写句柄的兄弟进程完成其 execve。
let busy = failure.kind == CurlFailureKind::ProcessBusy; let busy = failure.kind == CurlFailureKind::ProcessBusy;
failures.push(CurlAttemptFailure { failures.push(CurlAttemptFailure {
attempt, attempt,
@@ -459,8 +485,8 @@ pub(crate) fn run_curl_with_retry_with_proxy(
if !retryable { if !retryable {
break; break;
} }
if busy && attempt < attempts { if attempt < attempts {
std::thread::sleep(std::time::Duration::from_millis(5 * attempt as u64)); std::thread::sleep(retry_backoff(busy, attempt));
} }
} }
@@ -838,4 +864,18 @@ mod tests {
assert_eq!(command_env(&command, "ALL_PROXY"), Some(None)); assert_eq!(command_env(&command, "ALL_PROXY"), Some(None));
assert_eq!(command_env(&command, "HTTPS_PROXY"), Some(None)); assert_eq!(command_env(&command, "HTTPS_PROXY"), Some(None));
} }
#[test]
fn backoff_is_exponential_capped_and_busy_is_short() {
// 网络类失败:base·2^(attempt-1),上限 5s。
assert_eq!(backoff_delay_ms(200, false, 1), 200);
assert_eq!(backoff_delay_ms(200, false, 2), 400);
assert_eq!(backoff_delay_ms(200, false, 3), 800);
assert_eq!(backoff_delay_ms(200, false, 4), 1600);
// 高次方触顶 5s 上限。
assert_eq!(backoff_delay_ms(200, false, 10), 5000);
// ETXTBSY 走极短固定退避,与指数无关。
assert_eq!(backoff_delay_ms(200, true, 1), 5);
assert_eq!(backoff_delay_ms(200, true, 3), 15);
}
} }
+7
View File
@@ -425,6 +425,7 @@ mod tests {
resource_type: ResourceType::AssetBundle, resource_type: ResourceType::AssetBundle,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
ResourceEntry { ResourceEntry {
path: "synthetic/catalog.json".to_string(), path: "synthetic/catalog.json".to_string(),
@@ -433,6 +434,7 @@ mod tests {
resource_type: ResourceType::Manifest, resource_type: ResourceType::Manifest,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
ResourceEntry { ResourceEntry {
path: "TextAssets/dialogue.csv".to_string(), path: "TextAssets/dialogue.csv".to_string(),
@@ -441,6 +443,7 @@ mod tests {
resource_type: ResourceType::TextAsset, resource_type: ResourceType::TextAsset,
address: Some("dialogue".to_string()), address: Some("dialogue".to_string()),
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
ResourceEntry { ResourceEntry {
path: "TableBundles/ExcelDB.db".to_string(), path: "TableBundles/ExcelDB.db".to_string(),
@@ -449,6 +452,7 @@ mod tests {
resource_type: ResourceType::TableBundle, resource_type: ResourceType::TableBundle,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
ResourceEntry { ResourceEntry {
path: "MediaResources-Windows/voice/title.acb".to_string(), path: "MediaResources-Windows/voice/title.acb".to_string(),
@@ -457,6 +461,7 @@ mod tests {
resource_type: ResourceType::Media, resource_type: ResourceType::Media,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
], ],
metadata: ManifestMetadata { metadata: ManifestMetadata {
@@ -487,6 +492,7 @@ mod tests {
resource_type: ResourceType::TextAsset, resource_type: ResourceType::TextAsset,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
} }
} }
@@ -507,6 +513,7 @@ mod tests {
resource_type: ResourceType::AssetBundle, resource_type: ResourceType::AssetBundle,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
]); ]);
+307 -55
View File
@@ -16,6 +16,9 @@ use std::fs::{self, File};
use std::io::Read; use std::io::Read;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
/// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。 /// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。
@@ -61,6 +64,10 @@ const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
const DOWNLOAD_MANIFEST_VERSION: u32 = 1; const DOWNLOAD_MANIFEST_VERSION: u32 = 1;
const DOWNLOAD_QUARANTINE_VERSION: u32 = 1; const DOWNLOAD_QUARANTINE_VERSION: u32 = 1;
const DEFAULT_RETRY_ATTEMPTS: usize = 3; const DEFAULT_RETRY_ATTEMPTS: usize = 3;
/// 默认下载并发度。保守取值,对官方 CDN 礼貌;可经配置调到 1..=256。
pub(crate) const DEFAULT_DOWNLOAD_CONCURRENCY: usize = 4;
/// 下载并发度上限。
const MAX_DOWNLOAD_CONCURRENCY: usize = 256;
/// Outcome for one official resource pull item. /// Outcome for one official resource pull item.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -481,6 +488,7 @@ pub struct OfficialResourcePullService {
curl_command: PathBuf, curl_command: PathBuf,
curl_proxy: CurlProxyConfig, curl_proxy: CurlProxyConfig,
retry_attempts: usize, retry_attempts: usize,
download_concurrency: usize,
} }
impl OfficialResourcePullService { impl OfficialResourcePullService {
@@ -499,6 +507,7 @@ impl OfficialResourcePullService {
curl_command: curl_command.into(), curl_command: curl_command.into(),
curl_proxy: CurlProxyConfig::default(), curl_proxy: CurlProxyConfig::default(),
retry_attempts: DEFAULT_RETRY_ATTEMPTS, retry_attempts: DEFAULT_RETRY_ATTEMPTS,
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
} }
} }
@@ -514,6 +523,17 @@ impl OfficialResourcePullService {
self self
} }
/// 设置下载并发度(并行执行的网络下载数),钳制到 `1..=256`。
pub fn with_download_concurrency(mut self, concurrency: usize) -> Self {
self.download_concurrency = concurrency.clamp(1, MAX_DOWNLOAD_CONCURRENCY);
self
}
/// 返回当前下载并发度。
pub fn download_concurrency(&self) -> usize {
self.download_concurrency
}
/// Returns the output root used for downloaded files. /// Returns the output root used for downloaded files.
pub fn output_root(&self) -> &Path { pub fn output_root(&self) -> &Path {
&self.output_root &self.output_root
@@ -562,29 +582,21 @@ impl OfficialResourcePullService {
let mut manifest = self.read_download_manifest()?; let mut manifest = self.read_download_manifest()?;
let urls = plan.all_urls()?; let urls = plan.all_urls()?;
let total = urls.len(); let total = urls.len();
let mut items = Vec::new();
let mut verified_hashes = Vec::new(); // Phase A:无网络的前置校验(fail-fast)。逐个校验官方性、算目标路径、
let mut verified_hash_urls = HashSet::<String>::new(); // 建目录,并判定该 URL 是「已验证可跳过」还是「需下载」。任何非官方
let mut processed_urls = HashSet::<String>::new(); // URL 在发起任何下载前即拒绝。
for (offset, url) in urls.into_iter().enumerate() {
if should_cancel() { if should_cancel() {
return Err("官方资源拉取已被停止请求中断".to_string().into()); return Err("官方资源拉取已被停止请求中断".to_string().into());
} }
let mut planned: Vec<PlannedDownload> = Vec::with_capacity(total);
let index = offset + 1; for url in urls {
progress(OfficialResourcePullProgress::started(
index,
total,
url.clone(),
));
if !is_official_yostar_jp_url(&url) { if !is_official_yostar_jp_url(&url) {
return Err(DownloadError::new( return Err(DownloadError::new(
bat_core::ErrorCode::NON_OFFICIAL_URL, bat_core::ErrorCode::NON_OFFICIAL_URL,
format!("拒绝下载非官方 URL{url}"), format!("拒绝下载非官方 URL{url}"),
)); ));
} }
let destination = self.destination_for_url(&url)?; let destination = self.destination_for_url(&url)?;
if let Some(parent) = destination.parent() { if let Some(parent) = destination.parent() {
ensure_safe_directory_path(parent, "下载目标目录")?; ensure_safe_directory_path(parent, "下载目标目录")?;
@@ -596,50 +608,193 @@ impl OfficialResourcePullService {
ensure_safe_file_target(&self.output_root, &destination, "下载目标文件")?; ensure_safe_file_target(&self.output_root, &destination, "下载目标文件")?;
let force_refresh = force_refresh_urls.contains(&url); let force_refresh = force_refresh_urls.contains(&url);
let result = if force_refresh { let existing = if force_refresh {
None None
} else { } else {
self.validated_existing_file(&url, &destination, &manifest)? self.validated_existing_file(&url, &destination, &manifest)?
}; };
let result = if let Some(result) = result { planned.push(PlannedDownload {
self.clear_quarantine_entry(&url)?; url,
result destination,
} else { existing,
let result = match self.pull_one(&url, &destination) { });
Ok(result) => result, }
Err(error) => {
self.record_quarantine_entry(&url, &destination, &error)?; // Phase B:并发下载 need-download 项(各 URL 目标/`.part` 相互独立,
progress(OfficialResourcePullProgress::failed( // 天然可并行)。worker 只做只读 `&self` 的网络下载,经 mpsc 把结果送回
index, // 主线程;manifest/quarantine 簿记与进度回调全部在主线程串行完成,无需
// 加锁。首个失败或 `should_cancel` 会置 cancel 标志,其余 worker 在下一
// 个任务边界停止,保持 fail-fast 与「不发布不完整资源」不变量。
let download_indices: Vec<usize> = planned
.iter()
.enumerate()
.filter(|(_, item)| item.existing.is_none())
.map(|(index, _)| index)
.collect();
let mut download_results: Vec<Option<Result<PullOneResult, PullOneError>>> =
(0..planned.len()).map(|_| None).collect();
if !download_indices.is_empty() {
let concurrency = self.download_concurrency.clamp(1, MAX_DOWNLOAD_CONCURRENCY);
let cursor = AtomicUsize::new(0);
let cancel = AtomicBool::new(false);
let (sender, receiver) = mpsc::channel::<WorkerMessage>();
thread::scope(|scope| {
for _ in 0..concurrency.min(download_indices.len()) {
let sender = sender.clone();
let cursor = &cursor;
let cancel = &cancel;
let download_indices = &download_indices;
let planned = &planned;
let service = &*self;
scope.spawn(move || loop {
if cancel.load(Ordering::Relaxed) {
break;
}
let slot = cursor.fetch_add(1, Ordering::Relaxed);
let Some(&plan_index) = download_indices.get(slot) else {
break;
};
let item = &planned[plan_index];
if sender.send(WorkerMessage::Started { plan_index }).is_err() {
break;
}
let result = service.pull_one(&item.url, &item.destination);
if result.is_err() {
cancel.store(true, Ordering::Relaxed);
}
if sender
.send(WorkerMessage::Done { plan_index, result })
.is_err()
{
break;
}
});
}
// 主线程持有的 sender 副本必须丢弃,否则 receiver 永不结束。
drop(sender);
for message in receiver {
match message {
WorkerMessage::Started { plan_index } => {
let item = &planned[plan_index];
progress(OfficialResourcePullProgress::started(
plan_index + 1,
total, total,
url.clone(), item.url.clone(),
&error, ));
// 停止请求:置 cancel,让 worker 在下个任务边界退出。
if should_cancel() {
cancel.store(true, Ordering::Relaxed);
}
}
WorkerMessage::Done { plan_index, result } => {
if let Ok(pull_result) = &result {
let item = &planned[plan_index];
// manifest/quarantine 簿记在主线程串行执行。
if let Err(error) = self
.clear_quarantine_entry(&item.url)
.and_then(|_| {
self.record_download_manifest_entry(
&mut manifest,
&item.url,
&item.destination,
)
})
.and_then(|_| self.write_download_manifest(&manifest))
{
// 簿记失败:记为该项错误并触发 fail-fast。
cancel.store(true, Ordering::Relaxed);
download_results[plan_index] = Some(Err(PullOneError::plain(
format!("记录下载 manifest 失败:{error}"),
)));
continue;
}
progress(OfficialResourcePullProgress::finished(
plan_index + 1,
total,
item.url.clone(),
pull_result.status,
pull_result.bytes,
pull_result.transferred_bytes,
));
}
download_results[plan_index] = Some(result);
}
}
}
});
if should_cancel() && download_results.iter().any(Option::is_none) {
return Err("官方资源拉取已被停止请求中断".to_string().into());
}
// fail-fast:按 plan 顺序取首个失败项,记 quarantine、发 failed 进度并中止。
for &plan_index in &download_indices {
if let Some(Some(Err(error))) = download_results.get(plan_index) {
let item = &planned[plan_index];
self.record_quarantine_entry(&item.url, &item.destination, error)?;
progress(OfficialResourcePullProgress::failed(
plan_index + 1,
total,
item.url.clone(),
error,
)); ));
return Err(DownloadError::new( return Err(DownloadError::new(
error.error_code(), error.error_code(),
format!( format!(
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={url} quarantine={}{}", "官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={} quarantine={}{}",
item.url,
self.download_quarantine_path().display(), self.download_quarantine_path().display(),
error.message error.message
), ),
)); ));
} }
}; }
self.clear_quarantine_entry(&url)?; }
self.record_download_manifest_entry(&mut manifest, &url, &destination)?;
self.write_download_manifest(&manifest)?; if should_cancel() {
result return Err("官方资源拉取已被停止请求中断".to_string().into());
}
// Phase C:按 plan 顺序串行收尾——跳过项清 quarantine 并补发进度,
// 逐项做官方 seed `.hash` 校验(顺序相关、可 fail-fast),构建有序结果。
let mut items = Vec::with_capacity(planned.len());
let mut verified_hashes = Vec::new();
let mut verified_hash_urls = HashSet::<String>::new();
let mut processed_urls = HashSet::<String>::new();
for (plan_index, item) in planned.iter().enumerate() {
let result = if let Some(existing) = &item.existing {
self.clear_quarantine_entry(&item.url)?;
progress(OfficialResourcePullProgress::started(
plan_index + 1,
total,
item.url.clone(),
));
progress(OfficialResourcePullProgress::finished(
plan_index + 1,
total,
item.url.clone(),
existing.status,
existing.bytes,
existing.transferred_bytes,
));
*existing
} else {
// Phase B 已保证需下载项此时均为 Ok(失败会在上面 fail-fast 返回)。
match download_results[plan_index].take() {
Some(Ok(result)) => result,
_ => {
return Err(DownloadError::new(
bat_core::ErrorCode::INTERNAL,
format!("内部错误:下载结果缺失 url={}", item.url),
))
}
}
}; };
progress(OfficialResourcePullProgress::finished( processed_urls.insert(item.url.clone());
index,
total,
url.clone(),
result.status,
result.bytes,
result.transferred_bytes,
));
processed_urls.insert(url.clone());
self.verify_ready_official_hashes( self.verify_ready_official_hashes(
&official_hash_pairs, &official_hash_pairs,
&processed_urls, &processed_urls,
@@ -648,17 +803,14 @@ impl OfficialResourcePullService {
&mut manifest, &mut manifest,
)?; )?;
items.push(OfficialResourcePullItem { items.push(OfficialResourcePullItem {
url, url: item.url.clone(),
destination, destination: item.destination.clone(),
bytes: result.bytes, bytes: result.bytes,
transferred_bytes: result.transferred_bytes, transferred_bytes: result.transferred_bytes,
status: result.status, status: result.status,
}); });
} }
if should_cancel() {
return Err("官方资源拉取已被停止请求中断".to_string().into());
}
self.verify_all_official_hashes_are_complete(&official_hash_pairs, &verified_hash_urls)?; self.verify_all_official_hashes_are_complete(&official_hash_pairs, &verified_hash_urls)?;
Ok(OfficialResourcePullReport { Ok(OfficialResourcePullReport {
@@ -1603,6 +1755,25 @@ fn default_download_quarantine_version() -> u32 {
DOWNLOAD_QUARANTINE_VERSION DOWNLOAD_QUARANTINE_VERSION
} }
/// Phase A 产出的单个下载计划项:URL、目标路径,以及若命中本地 manifest
/// 校验则带上「已验证可跳过」的结果(`existing`)。
struct PlannedDownload {
url: String,
destination: PathBuf,
existing: Option<PullOneResult>,
}
/// worker 线程经 mpsc 送回主线程的消息。
enum WorkerMessage {
/// worker 已领取某计划项、即将下载(主线程据此发 started 进度)。
Started { plan_index: usize },
/// 某计划项下载结束(成功或失败)。
Done {
plan_index: usize,
result: Result<PullOneResult, PullOneError>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PullOneResult { struct PullOneResult {
bytes: u64, bytes: u64,
@@ -2853,6 +3024,8 @@ exit 22
let curl_path = bin_dir.path().join("curl"); let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path); write_fake_curl(&curl_path);
// 并发下事件顺序不固定,此处只断言与顺序无关的不变量;并发正确性另有
// downloads_run_concurrently_and_each_url_reports_once 专测。
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path); let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
let plan = build_official_pull_plan_for_platforms( let plan = build_official_pull_plan_for_platforms(
discovery_plan(), discovery_plan(),
@@ -2868,19 +3041,98 @@ exit 22
assert_eq!(report.items.len(), expected_urls); assert_eq!(report.items.len(), expected_urls);
assert_eq!(events.len(), expected_urls * 2); assert_eq!(events.len(), expected_urls * 2);
assert_eq!(events[0].kind, OfficialResourcePullProgressKind::Started);
assert_eq!(events[0].index, 1); let started: Vec<_> = events
assert_eq!(events[0].total, expected_urls); .iter()
assert_eq!(events[1].kind, OfficialResourcePullProgressKind::Finished); .filter(|event| event.kind == OfficialResourcePullProgressKind::Started)
assert_eq!( .collect();
events[1].status, let finished: Vec<_> = events
Some(OfficialResourcePullStatus::Downloaded) .iter()
); .filter(|event| event.kind == OfficialResourcePullProgressKind::Finished)
.collect();
assert_eq!(started.len(), expected_urls);
assert_eq!(finished.len(), expected_urls);
// 每个 started 的 total 一致;index 覆盖 1..=N 且不重复。
let mut indices: Vec<usize> = started.iter().map(|event| event.index).collect();
indices.sort_unstable();
assert_eq!(indices, (1..=expected_urls).collect::<Vec<_>>());
assert!(started.iter().all(|event| event.total == expected_urls));
// started 与 finished 覆盖同一组 URLfinished 均为已下载。
let started_urls: HashSet<_> = started.iter().map(|event| event.url.clone()).collect();
let finished_urls: HashSet<_> = finished.iter().map(|event| event.url.clone()).collect();
assert_eq!(started_urls, finished_urls);
assert!(finished
.iter()
.all(|event| event.status == Some(OfficialResourcePullStatus::Downloaded)));
assert!(events assert!(events
.iter() .iter()
.any(|event| event.url.ends_with("/TableBundles/ExcelDB.db"))); .any(|event| event.url.ends_with("/TableBundles/ExcelDB.db")));
} }
#[test]
fn downloads_run_concurrently_and_each_url_reports_once() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path);
// 并发度 8:每个 URL 恰好一次 started + 一次 finished,全部文件落盘。
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path)
.with_download_concurrency(8);
assert_eq!(service.download_concurrency(), 8);
let plan = build_official_pull_plan_for_platforms(
discovery_plan(),
inventory(),
&[PatchPlatform::Windows],
);
let all_urls = plan.all_urls().unwrap();
let mut events = Vec::new();
let report = service
.pull_with_progress(&plan, |event| events.push(event))
.unwrap();
assert_eq!(report.items.len(), all_urls.len());
for url in &all_urls {
let started = events
.iter()
.filter(|event| {
&event.url == url && event.kind == OfficialResourcePullProgressKind::Started
})
.count();
let finished = events
.iter()
.filter(|event| {
&event.url == url && event.kind == OfficialResourcePullProgressKind::Finished
})
.count();
assert_eq!(started, 1, "url {url} 的 started 次数");
assert_eq!(finished, 1, "url {url} 的 finished 次数");
}
// 所有下载都记入 manifest。
let manifest = service.read_download_manifest().unwrap();
assert_eq!(manifest.entries.len(), all_urls.len());
}
#[test]
fn download_concurrency_is_clamped() {
let service = OfficialResourcePullService::with_curl_command("/tmp/unused", "curl");
assert_eq!(service.download_concurrency(), DEFAULT_DOWNLOAD_CONCURRENCY);
assert_eq!(
OfficialResourcePullService::with_curl_command("/tmp/unused", "curl")
.with_download_concurrency(0)
.download_concurrency(),
1
);
assert_eq!(
OfficialResourcePullService::with_curl_command("/tmp/unused", "curl")
.with_download_concurrency(9999)
.download_concurrency(),
MAX_DOWNLOAD_CONCURRENCY
);
}
#[test] #[test]
fn retries_transient_download_failures() { fn retries_transient_download_failures() {
let out_dir = TempDir::new().unwrap(); let out_dir = TempDir::new().unwrap();
+7 -2
View File
@@ -96,6 +96,8 @@ pub struct OfficialUpdateConfig {
pub audit_local: bool, pub audit_local: bool,
/// Repair local files when the local manifest audit fails. /// Repair local files when the local manifest audit fails.
pub repair: bool, pub repair: bool,
/// 下载并发度(并行执行的网络下载数)。默认 4,钳制到 `1..=256`。
pub download_concurrency: usize,
} }
impl Default for OfficialUpdateConfig { impl Default for OfficialUpdateConfig {
@@ -117,6 +119,7 @@ impl Default for OfficialUpdateConfig {
force: false, force: false,
audit_local: true, audit_local: true,
repair: true, repair: true,
download_concurrency: crate::official_download::DEFAULT_DOWNLOAD_CONCURRENCY,
} }
} }
} }
@@ -898,7 +901,8 @@ impl OfficialUpdateService {
&active_resource_root, &active_resource_root,
&config.curl_command, &config.curl_command,
) )
.with_proxy_config(config.curl_proxy.clone()); .with_proxy_config(config.curl_proxy.clone())
.with_download_concurrency(config.download_concurrency);
let snapshot_path = snapshot_path_for(config, &active_resource_root); let snapshot_path = snapshot_path_for(config, &active_resource_root);
let bootstrap_cache_path = config.bootstrap_cache_path(); let bootstrap_cache_path = config.bootstrap_cache_path();
@@ -1337,7 +1341,8 @@ impl OfficialUpdateService {
&publish_plan.staging_path, &publish_plan.staging_path,
&config.curl_command, &config.curl_command,
) )
.with_proxy_config(config.curl_proxy.clone()); .with_proxy_config(config.curl_proxy.clone())
.with_download_concurrency(config.download_concurrency);
report.staging_path = Some(publish_plan.staging_path.clone()); report.staging_path = Some(publish_plan.staging_path.clone());
report.snapshot_path = staging_snapshot_path.clone(); report.snapshot_path = staging_snapshot_path.clone();
report.download_manifest = staging_fetcher.download_manifest_path(); report.download_manifest = staging_fetcher.download_manifest_path();
+47 -9
View File
@@ -129,13 +129,18 @@ impl SqliteResourceRepository {
resource_type TEXT NOT NULL, resource_type TEXT NOT NULL,
local_path TEXT NOT NULL, local_path TEXT NOT NULL,
address TEXT, address TEXT,
dependencies_json TEXT NOT NULL DEFAULT '[]' dependencies_json TEXT NOT NULL DEFAULT '[]',
crc INTEGER
) )
"#, "#,
), ),
) )
.await?; .await?;
// 向后兼容:早于 crc 列的旧库缺少该列,按需补加(新建库已含该列,
// pragma 检查后不会重复 ALTER)。
Self::ensure_column(&self.pool, "resources", "crc", "INTEGER").await?;
Self::execute_query( Self::execute_query(
&self.pool, &self.pool,
sqlx::query( sqlx::query(
@@ -182,6 +187,33 @@ impl SqliteResourceRepository {
.map_err(|error| bat_core::Error::Other(error.into())) .map_err(|error| bat_core::Error::Other(error.into()))
} }
/// 幂等地为 `table` 补加 `column`(若尚不存在)。用于向后兼容的 schema 迁移。
async fn ensure_column(
pool: &SqlitePool,
table: &str,
column: &str,
column_type: &str,
) -> bat_core::Result<()> {
let exists: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM pragma_table_info(?1) WHERE name = ?2")
.bind(table)
.bind(column)
.fetch_one(pool)
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
if exists == 0 {
// 表名/列名/类型均为内部常量,非用户输入,可安全内插。
Self::execute_query(
pool,
sqlx::query(&format!(
"ALTER TABLE {table} ADD COLUMN {column} {column_type}"
)),
)
.await?;
}
Ok(())
}
fn resource_type_to_str(resource_type: ResourceType) -> &'static str { fn resource_type_to_str(resource_type: ResourceType) -> &'static str {
match resource_type { match resource_type {
ResourceType::AssetBundle => "AssetBundle", ResourceType::AssetBundle => "AssetBundle",
@@ -219,7 +251,8 @@ impl SqliteResourceRepository {
} }
fn resource_from_row(row: ResourceRow) -> bat_core::Result<Resource> { fn resource_from_row(row: ResourceRow) -> bat_core::Result<Resource> {
let (id, path, hash, size, resource_type, local_path, address, dependencies_json) = row; let (id, path, hash, size, resource_type, local_path, address, dependencies_json, crc) =
row;
Ok(Resource { Ok(Resource {
id, id,
local_path: PathBuf::from(local_path), local_path: PathBuf::from(local_path),
@@ -230,6 +263,7 @@ impl SqliteResourceRepository {
resource_type: Self::resource_type_from_str(&resource_type)?, resource_type: Self::resource_type_from_str(&resource_type)?,
address, address,
dependencies: Self::dependencies_from_json(&dependencies_json)?, dependencies: Self::dependencies_from_json(&dependencies_json)?,
crc: crc.and_then(|value| u32::try_from(value).ok()),
}, },
}) })
} }
@@ -269,7 +303,7 @@ impl SqliteResourceRepository {
limit: Option<usize>, limit: Option<usize>,
) -> bat_core::Result<Vec<Resource>> { ) -> bat_core::Result<Vec<Resource>> {
let mut builder = QueryBuilder::<Sqlite>::new( let mut builder = QueryBuilder::<Sqlite>::new(
"SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json FROM resources", "SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc FROM resources",
); );
Self::apply_filters(&mut builder, query)?; Self::apply_filters(&mut builder, query)?;
builder.push(" ORDER BY id"); builder.push(" ORDER BY id");
@@ -308,9 +342,9 @@ impl ResourceRepository for SqliteResourceRepository {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO resources ( INSERT INTO resources (
id, path, hash, size, resource_type, local_path, address, dependencies_json id, path, hash, size, resource_type, local_path, address, dependencies_json, crc
) )
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
path = excluded.path, path = excluded.path,
hash = excluded.hash, hash = excluded.hash,
@@ -318,7 +352,8 @@ impl ResourceRepository for SqliteResourceRepository {
resource_type = excluded.resource_type, resource_type = excluded.resource_type,
local_path = excluded.local_path, local_path = excluded.local_path,
address = excluded.address, address = excluded.address,
dependencies_json = excluded.dependencies_json dependencies_json = excluded.dependencies_json,
crc = excluded.crc
"#, "#,
) )
.bind(resource.id.clone()) .bind(resource.id.clone())
@@ -328,7 +363,8 @@ impl ResourceRepository for SqliteResourceRepository {
.bind(Self::resource_type_to_str(resource.entry.resource_type)) .bind(Self::resource_type_to_str(resource.entry.resource_type))
.bind(resource.local_path.to_string_lossy().to_string()) .bind(resource.local_path.to_string_lossy().to_string())
.bind(resource.entry.address.clone()) .bind(resource.entry.address.clone())
.bind(dependencies), .bind(dependencies)
.bind(resource.entry.crc.map(i64::from)),
) )
.await?; .await?;
@@ -338,7 +374,7 @@ impl ResourceRepository for SqliteResourceRepository {
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> { async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
let row: Option<ResourceRow> = sqlx::query_as( let row: Option<ResourceRow> = sqlx::query_as(
r#" r#"
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc
FROM resources FROM resources
WHERE id = ?1 WHERE id = ?1
"#, "#,
@@ -356,7 +392,7 @@ impl ResourceRepository for SqliteResourceRepository {
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> { async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
let row: Option<ResourceRow> = sqlx::query_as( let row: Option<ResourceRow> = sqlx::query_as(
r#" r#"
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc
FROM resources FROM resources
WHERE hash = ?1 WHERE hash = ?1
ORDER BY id ORDER BY id
@@ -418,6 +454,7 @@ type ResourceRow = (
String, String,
Option<String>, Option<String>,
String, String,
Option<i64>,
); );
fn glob_to_like(pattern: &str) -> String { fn glob_to_like(pattern: &str) -> String {
@@ -505,6 +542,7 @@ mod tests {
resource_type, resource_type,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
} }
} }