mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-21 22:11:17 +08:00
Compare commits
9
Commits
v0.2.0
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40bd82e227 | ||
|
|
84047fbacb | ||
|
|
43e1a33b88
|
||
|
|
924cff5f51
|
||
|
|
d76f6f1c88
|
||
|
|
0ab3f3b953
|
||
|
|
8efd8f36b4
|
||
|
|
a150407a14
|
||
|
|
d9332299ef
|
@@ -0,0 +1,81 @@
|
||||
# Gitea Actions workflow for the Rust workspace.
|
||||
# This workflow is linux-runner friendly and does not touch real resource
|
||||
# directories. It intentionally avoids external GitHub Actions so self-hosted
|
||||
# runners do not need to clone action repositories through a network proxy.
|
||||
|
||||
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: Load Runner Environment
|
||||
shell: bash
|
||||
run: |
|
||||
source /var/lib/act_runner/env.sh
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
- 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}" 2>/dev/null || git remote add 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: |
|
||||
source /var/lib/act_runner/env.sh
|
||||
set -euo pipefail
|
||||
command -v git
|
||||
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 --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
|
||||
+10
-4
@@ -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 的用户级工作流
|
||||
- [ ] 实现 AssetBundle 解析器(UnityFS header/block/directory 起步)
|
||||
- [ ] 继续逆向 Addressables catalog 可校验字段
|
||||
- [ ] 官方下载/导入路径接入 CRC/size 校验(复用 `verify_downloaded_bytes`)
|
||||
- [ ] 实现翻译系统
|
||||
- [ ] 实现 Patch 引擎
|
||||
- [ ] 实现 API Server
|
||||
- [ ] 实现 Web 管理后台
|
||||
|
||||
## [0.2.0] - 2026-07-17
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace -- -D warnings
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
如果改动只影响部分 crate,可以先跑更窄的测试,但合并前必须确保影响面被覆盖。官方资源同步、下载、daemon、status、verify 或 repair 相关改动还应运行:
|
||||
|
||||
+35
-34
@@ -1,6 +1,6 @@
|
||||
# BlueArchiveToolkit 当前工作区状态
|
||||
|
||||
- **更新时间**:2026-07-15
|
||||
- **更新时间**:2026-07-20
|
||||
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
||||
- **状态分支**:`experiment`
|
||||
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## 1. 总体判断
|
||||
|
||||
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备最小生产运行形态、Go CLI/API/Web 仍未落地** 阶段。
|
||||
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go CLI/API/Web 仍未形成产品入口** 阶段。
|
||||
|
||||
Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
@@ -19,17 +19,17 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
3. 默认平台为 `Windows + Android`。
|
||||
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`。
|
||||
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。
|
||||
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。
|
||||
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。下载支持多线程:并行仅作用于实际网络下载(默认并发 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` 会清除。
|
||||
8. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`。
|
||||
9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
|
||||
10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/<id>` 和 `.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||
12. `<output>/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。
|
||||
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、403/404、hash mismatch 均有离线回归 fixture。
|
||||
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`)。
|
||||
|
||||
仍需明确:这不是完整产品完成。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、注册表。
|
||||
- Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。
|
||||
- 真实形态 Addressables fixture/golden 测试。
|
||||
@@ -96,8 +96,8 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
待完成:
|
||||
|
||||
- Unity bundle serialize 仍是后续阶段能力。
|
||||
- Addressables parser 仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
||||
- `crates/bat-assetbundle` 仍是占位 crate,完整 UnityFS/对象表/TypeTree 引擎未实现。
|
||||
- Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
||||
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
|
||||
|
||||
### `bat-cas-engine`
|
||||
@@ -148,9 +148,9 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
当前只有:
|
||||
|
||||
- Parser trait 占位。
|
||||
- AssetType 占位。
|
||||
- 错误类型骨架。
|
||||
- Parser trait 仍是占位。
|
||||
- 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。
|
||||
- 它不持有 downloader、daemon、CAS handle、资源目录锁或长生命周期状态。
|
||||
- Go CLI 和生产运维默认应调用 `bat --json` 进程边界;未来稳定 SDK 也优先于 FFI。
|
||||
- 未来 Go 产品入口和生产运维默认应调用 `bat --json` 进程边界;未来稳定 SDK 也优先于 FFI。
|
||||
- FFI 仅用于需要嵌入 C ABI 的兼容场景,不能作为官方同步控制面或主集成边界。
|
||||
|
||||
待完成:
|
||||
@@ -198,34 +198,35 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
### Go / API / Web
|
||||
|
||||
状态:**CLI/API/Web 仍未实现,仅有可选 CGO 兼容包装**
|
||||
状态:**Go 产品入口仍未完成,仅有可选 CGO 兼容包装和试验性 `cmd/bat` 骨架**
|
||||
|
||||
当前情况:
|
||||
|
||||
- `internal/ffi/ffi.go` 已存在。
|
||||
- Go CLI 默认集成方向是调用 Rust `bat --json` 并转发结构化 report,而不是依赖 FFI。
|
||||
- `cmd/`、`pkg/`、`api/`、`web/` 仍无可用产品入口。
|
||||
- `go test ./...` 在没有 Go package 时可能无测试可运行;Makefile 会清晰跳过空 Go 阶段。
|
||||
- Go CLI 的稳定集成方向仍应优先通过 Rust `bat --json` 进程边界;`cmd/bat` 目前只是试验性骨架,不代表产品级 CLI 已完成。
|
||||
- `cmd/`、`pkg/`、`api/`、`web/` 仍无可用产品入口,`cmd/bat` 目前只覆盖 `doctor`、`manifest inspect`、`sync plan` 这类最小演示能力。
|
||||
- `go test ./...` 目前只有空测试包结果,`go vet ./...` 可作为基础门禁。
|
||||
|
||||
---
|
||||
|
||||
## 4. 已验证结果
|
||||
|
||||
最新功能提交前已运行并通过:
|
||||
本轮复核已运行并通过:
|
||||
|
||||
```bash
|
||||
cargo test -p bat-adapters -- --nocapture
|
||||
cargo test -p bat-ffi -- --nocapture
|
||||
cargo test -p bat-infrastructure -- --nocapture
|
||||
cargo test -p bat-infrastructure --bin bat -- --nocapture
|
||||
cargo run -p bat-infrastructure --bin bat -- --help
|
||||
git diff --cached --check
|
||||
cargo test --workspace --quiet
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build -o /tmp/bat-go-cli ./cmd/bat
|
||||
target/debug/bat --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
提交后确认:
|
||||
同步确认:
|
||||
|
||||
```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),当前处于长期运行测试阶段,运行报告将在后续提供。
|
||||
- Go CLI 端到端测试,因为 Go CLI 尚未实现。
|
||||
- Go CLI 端到端测试,因为 Go 产品入口尚未完成。
|
||||
- Web/API 测试,因为 Web/API 尚未实现。
|
||||
|
||||
---
|
||||
|
||||
## 5. 当前生产运行边界
|
||||
|
||||
当前唯一可作为 Linux 生产资源同步任务运行的入口是 Rust binary:
|
||||
当前唯一可作为 Linux 生产资源同步任务运行的入口仍是 Rust binary:
|
||||
|
||||
```bash
|
||||
cargo run -p bat-infrastructure --bin bat -- \
|
||||
@@ -263,12 +264,12 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
|
||||
## 6. 当前阻塞项
|
||||
|
||||
GitHub issue 状态:#4–#16 已全部关闭(#16 为 daemon status 版本失败输出与重复堆积 bug,已由失败版本去重和状态输出优化修复),当前 open 的是 #1(P1)、#2(P2)、#3(P2)。
|
||||
GitHub issue 状态:当前 open 的是 #1(P1)、#2(P2)、#3(P2)、#17(P2)、#19(P2)。其中 #17 的实现已合入 HEAD,但 issue 本身尚未关闭,需在验收后再同步关闭。
|
||||
|
||||
下一阶段必须优先完成:
|
||||
|
||||
1. Issue #1(P1,主体已实现):`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,不走 FFI(FFI 降级说明见 `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 查询)。
|
||||
4. Issue #3(P2):AssetBundle UnityFS 基础解析校验。
|
||||
5. Issue #2(P2):继续逆向 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.*`(待引擎)与任务持久化按后续里程碑推进。
|
||||
2. 实现 Go CLI 最小框架和 `doctor`,通过 RPC 或 `bat --json` 边界对接 Rust backend。
|
||||
2. 明确 Go 产品入口的边界:是继续推进独立 `bat` CLI,还是保留当前 Rust `bat` 为用户 CLI、Go 只做服务层与 `bat-api`。
|
||||
3. 跟进官方同步长期运行测试,收集并归档运行报告。
|
||||
4. 开始 AssetBundle parser 的 UnityFS header/block/directory(issue #3),并继续扩展 Addressables catalog 可校验字段(issue #2)。
|
||||
|
||||
---
|
||||
|
||||
- **当前总体完成度**:约 22%
|
||||
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;产品级 CLI/API/Web 仍未完成。
|
||||
- **下一工程里程碑**:Rust Resource Backend RPC API 最小方法集(issue #1)+ Go CLI 最小可用 + 官方同步结果接入 CAS/ResourceRepository + AssetBundle 解析起步。
|
||||
- **当前总体完成度**:不再固定写单一百分比,以各模块状态和 issue 为准。
|
||||
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;Go 产品入口、`bat-api`、CAS 用户级导入和完整 AssetBundle 引擎仍未完成。
|
||||
- **下一工程里程碑**:Rust Resource Backend RPC API 收尾、Go 产品入口收敛、官方同步结果接入 CAS/ResourceRepository、AssetBundle 解析起步。
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# BlueArchiveToolkit 文档索引
|
||||
|
||||
- **更新时间**:2026-07-15
|
||||
- **更新时间**:2026-07-20
|
||||
- **说明**:本索引用于快速定位当前权威文档和历史资料。
|
||||
|
||||
---
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
## 6. 状态摘要
|
||||
|
||||
当前总体完成度约 **22%**。
|
||||
当前总体完成度不再固定写单一百分比,以 `CURRENT_STATUS.md` 和 `CURRENT_GAPS.md` 的模块状态为准。
|
||||
|
||||
已完成:
|
||||
|
||||
@@ -105,6 +105,6 @@
|
||||
|
||||
优先待办:
|
||||
|
||||
- 落地 Go CLI 最小可用入口。
|
||||
- 收敛 Go 产品入口的最终形态,避免把试验性 `cmd/bat` 误当作完成品。
|
||||
- 将官方同步结果接入 CAS + ResourceRepository 的用户级流程。
|
||||
- 开始 AssetBundle UnityFS 解析。
|
||||
- 推进 AssetBundle UnityFS 引擎级解析。
|
||||
|
||||
+33
-32
@@ -1,7 +1,7 @@
|
||||
# BlueArchiveToolkit 完整开发计划
|
||||
|
||||
- **项目名称**:BlueArchiveToolkit
|
||||
- **文档版本**:2026-07-06 状态收口版
|
||||
- **文档版本**:2026-07-20 状态收口版
|
||||
- **权威状态**:以本文档和 `CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。
|
||||
- **最终目标**:构建一个可长期维护、可扩展、可审计的 Blue Archive 资源管理、文本提取、翻译和补丁平台。
|
||||
|
||||
@@ -22,7 +22,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 2. 当前真实状态
|
||||
|
||||
本节来自 2026-07-06 的工作区盘点、本地验证和最新功能提交。
|
||||
本节来自 2026-07-20 的工作区盘点、本地验证和最新功能提交。
|
||||
|
||||
### 已具备
|
||||
|
||||
@@ -38,9 +38,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
### 仍是骨架或占位
|
||||
|
||||
1. AssetBundle 解析器仍是占位 trait,未解析 UnityFS、压缩块、TypeTree 或对象表。
|
||||
2. Patch 的 Binary/JSON 模块仍返回空结果,不具备真实补丁能力。
|
||||
3. Go CLI/API/SDK 仍没有产品级入口;只有 `internal/ffi` 的可选兼容包装骨架。
|
||||
1. `bat-assetbundle` 仍是占位 crate;完整 UnityFS、压缩块、TypeTree 或对象表解析未完成。
|
||||
2. `bat-patch` 的 Binary/JSON 模块仍返回明确的未实现错误,不具备真实补丁能力。
|
||||
3. Go CLI/API/SDK 仍没有产品级入口;当前只有 `cmd/bat` 与 `internal/ffi` 的试验/兼容骨架。
|
||||
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
|
||||
5. 官方同步结果尚未作为用户级流程自动导入 CAS + ResourceRepository。
|
||||
6. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
|
||||
@@ -49,12 +49,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
### 已验证
|
||||
|
||||
1. `cargo test -p bat-adapters -- --nocapture` 通过。
|
||||
2. `cargo test -p bat-ffi -- --nocapture` 通过。
|
||||
3. `cargo test -p bat-infrastructure -- --nocapture` 通过。
|
||||
4. `cargo test -p bat-infrastructure --bin bat -- --nocapture` 通过。
|
||||
5. `cargo run -p bat-infrastructure --bin bat -- --help` 可用。
|
||||
6. `go test ./...` 当前无 Go 产品 package;`Makefile` 已调整为在 Go 未实现阶段明确跳过。
|
||||
1. `cargo test --workspace --quiet` 通过。
|
||||
2. `cargo clippy --workspace --all-targets -- -D warnings` 通过。
|
||||
3. `go test ./...` 通过,但目前没有 Go 产品级测试覆盖。
|
||||
4. `go vet ./...` 通过。
|
||||
5. `go build -o /tmp/bat-go-cli ./cmd/bat` 通过。
|
||||
6. `target/debug/bat --help` 可用。
|
||||
|
||||
---
|
||||
|
||||
@@ -84,7 +84,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
1. 无占位返回、无静默吞错、无未说明的 `TODO`。
|
||||
2. 公共接口具备文档、错误语义和兼容性说明。
|
||||
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 ./...` 通过。
|
||||
6. 用户可见命令必须有 `doctor` 检查和失败恢复建议。
|
||||
|
||||
@@ -141,7 +141,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
**目标**:完成可长期使用的 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 资源清单。
|
||||
|
||||
**当前状态**:部分完成。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 可见模型。
|
||||
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验和 repair。
|
||||
4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch`、`--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。
|
||||
5. Go CLI:**未完成**。需要实现 `bat doctor`、`bat sync --help`、Rust 官方同步命令包装和 JSON/human 输出。
|
||||
6. 用户级 `sync`、`manifest inspect`、`cache status`:**未完成**。Rust `bat --json` 是 Go CLI 默认进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
|
||||
7. 下载结果写入 CAS + ResourceRepository:**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果作为用户级流程自动导入。
|
||||
5. Go 产品入口:**未完成**。当前 `cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,尚不构成产品级 CLI;若要继续由 Go 承担用户入口,需要单独收敛命令集和调用边界。
|
||||
6. 用户级 `sync`、`manifest inspect`、`cache status`:**未完成**。Rust `bat --json` 是当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
|
||||
7. 下载结果写入 CAS + ResourceRepository:**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果自动作为用户级流程导入。
|
||||
8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`。
|
||||
9. 真实官方网络全量下载 smoke test:**命令已固化(G-018 已关闭)**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
|
||||
|
||||
@@ -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:本地开发、服务端部署。
|
||||
3. 数据备份与恢复文档。
|
||||
4. 用户文档、开发文档、故障排查文档。
|
||||
@@ -366,7 +366,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 5. 推荐执行顺序
|
||||
|
||||
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是 Go CLI 入口、资源解析、同步结果进入 CAS/ResourceRepository,以及真实端到端验证。
|
||||
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是 Go 产品入口边界、资源解析、同步结果进入 CAS/ResourceRepository,以及真实端到端验证。
|
||||
|
||||
建议顺序:
|
||||
|
||||
@@ -380,14 +380,15 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 6. 近期具体任务
|
||||
|
||||
1. 落地 Go CLI 的最小生产入口:`bat doctor`、`bat sync --help`、`bat official sync --help`。
|
||||
2. 让 Go CLI 默认调用 Rust `bat --json` 官方同步入口,并稳定转发结构化 report;除非有明确兼容需求,不走 FFI。
|
||||
3. 记录一次真实官方网络 smoke:dry-run、首次下载、二次 up-to-date、本地损坏 repair。
|
||||
4. 将官方同步下载结果接入 CAS + `SqliteResourceRepository` 的用户级流程。
|
||||
5. 继续扩展 Addressables parser 的真实 catalog 变体覆盖和错误诊断。
|
||||
6. 开始 AssetBundle UnityFS header/block/directory 解析。
|
||||
7. 为 CLI 和 CAS 增加 `doctor cas` 诊断入口。
|
||||
8. 为 `bat --watch` / `bat --daemon` 持续补充发布型构建、systemd service 示例和运维检查清单;后台 live control plane 已改为 Unix socket JSON-RPC;基础生产部署模板、日志路径、权限用户、升级/回滚流程已补齐。
|
||||
优先完善 Rust `bat` 后端,并同步收敛 Go 产品入口边界。当前事实是 Rust `bat` 已承担可用的资源同步/运维入口,Go `cmd/bat` 仍只是试验骨架,`bat-api` 是独立的 Go HTTP 服务目标(issue #19 / G-009):
|
||||
|
||||
1. 对 issue #17 做验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open。
|
||||
2. 继续逆向 Addressables catalog,扩大 bundle hash/size/CRC 等可校验字段覆盖(issue #2)。
|
||||
3. 对 AssetBundle/UnityFS 做引擎级解析:header/block/directory/metadata/object table(issue #3 / G-005)。
|
||||
4. 将官方同步下载结果接入 CAS + `SqliteResourceRepository` 的用户级流程(G-011)。
|
||||
5. 收敛 Go 产品入口:明确继续推进最小 Go CLI,或把用户 CLI 固化为 Rust `bat` 并把 Go 侧集中到 `bat-api`。
|
||||
6. 实现 `bat-api`(仿官方 API 的 Go HTTP 服务,含鉴权/签名验签,issue #19 / G-009)。
|
||||
7. 为 CAS 增加 `doctor cas` 诊断入口。
|
||||
|
||||
---
|
||||
|
||||
@@ -414,8 +415,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
处理策略:
|
||||
|
||||
1. Rust 提供稳定引擎能力,不承担 CLI 编排,但负责完整资源拉取和更新检查的核心逻辑。
|
||||
2. Go 负责用户命令、最小稳定 CLI、服务编排、网络和 Provider。
|
||||
1. Rust 提供稳定引擎能力,并在当前阶段承担可生产运行的官方资源同步 CLI、watch 和 daemon。
|
||||
2. Go 的长期职责包括用户命令、最小稳定 CLI、服务编排、网络和 Provider;当前 Go 产品入口尚未完成,不能把 `cmd/bat` 试验骨架视为完成。
|
||||
3. 跨边界优先进程或 SDK,FFI 只作为可选的粗粒度、无状态、安全、可测试兼容 API。
|
||||
4. Rust 不需要被强制写成 Go 调用库;当前 `bat --watch` / `bat --daemon` 是允许长期运行的 Rust 生产任务。
|
||||
|
||||
@@ -439,11 +440,11 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 8. 当前完成度评估
|
||||
|
||||
按最终目标计算,当前总体完成度约为 **22%**。
|
||||
按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准。
|
||||
|
||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1 和 Rust 官方资源同步闭环,不是完整产品能力。下一阶段的关键不是继续堆目录,而是把 Go CLI 最小入口、官方同步端到端验证、CAS/ResourceRepository 编排和 AssetBundle 解析链路做实。
|
||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1 和 Rust 官方资源同步闭环,不是完整产品能力。下一阶段的关键不是继续堆目录,而是把 Go 产品入口边界、官方同步端到端验证、CAS/ResourceRepository 编排和 AssetBundle 解析链路做实。
|
||||
|
||||
---
|
||||
|
||||
- **下一份应更新文档**:真实官方网络 smoke 记录
|
||||
- **下一项工程任务**:Go CLI 最小可用入口和官方同步端到端 smoke。
|
||||
- **下一项工程任务**:收敛 Go 产品入口边界、执行官方同步端到端 smoke,并推进 CAS/ResourceRepository 与 AssetBundle 解析。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
|
||||
|
||||
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用 `--watch` 常驻定期检查更新。Go CLI、API Server、Web、完整 AssetBundle 解析、翻译系统和 Patch 系统仍在后续阶段。
|
||||
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用 `--watch` 常驻定期检查更新。Go 侧目前只有试验性的 `cmd/bat` 骨架和 `internal/ffi` 兼容包装,产品级 CLI、API Server、Web、完整 AssetBundle 解析、翻译系统和 Patch 系统仍在后续阶段。
|
||||
|
||||
---
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
|
||||
- Rust workspace 和 monorepo 结构。
|
||||
- `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-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`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||
- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。
|
||||
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
|
||||
@@ -21,8 +21,8 @@
|
||||
|
||||
仍未完成:
|
||||
|
||||
- Go CLI 最小可用入口。
|
||||
- 完整 UnityFS / AssetBundle 解析。
|
||||
- Go CLI 产品入口(当前仅有试验性 `cmd/bat` 骨架)。
|
||||
- 完整 UnityFS / AssetBundle 引擎解析。
|
||||
- 真实 Patch apply/diff。
|
||||
- Translation Memory、Glossary、AI Provider。
|
||||
- API Server、SDK、Web 管理后台。
|
||||
@@ -48,13 +48,13 @@
|
||||
- `curl`
|
||||
- `unzip`,仅旧版 launcher manifest 指向整包 ZIP 且 `--auto-discover` 需要从 ZIP 解析 `GameMainConfig` 时使用;当前目录型 manifest 会直接下载 `resources.assets`
|
||||
|
||||
运行当前主要测试:
|
||||
运行当前通用验证:
|
||||
|
||||
```bash
|
||||
cargo test -p bat-adapters -- --nocapture
|
||||
cargo test -p bat-ffi -- --nocapture
|
||||
cargo test -p bat-infrastructure -- --nocapture
|
||||
cargo test -p bat-infrastructure --bin bat -- --nocapture
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
go test ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
查看官方同步命令:
|
||||
@@ -157,7 +157,7 @@ BlueArchiveToolkit/
|
||||
│ ├── bat-patch/
|
||||
│ └── bat-ffi/ # 可选无状态 C ABI 兼容层
|
||||
├── internal/ffi/ # 可选 CGO 兼容包装,不是 Go CLI 主路径
|
||||
├── cmd/ # Go CLI 入口,尚未实现
|
||||
├── cmd/ # Go CLI 试验骨架与后续产品入口
|
||||
├── pkg/ # Go SDK 包,尚未实现
|
||||
├── api/ # API 定义,尚未实现
|
||||
├── web/ # Web 管理后台,尚未实现
|
||||
@@ -174,13 +174,13 @@ BlueArchiveToolkit/
|
||||
|
||||
近期优先级:
|
||||
|
||||
1. 落地 Go CLI 最小可用入口:`bat doctor`、`bat sync --help`、通过 `bat --json` 包装 Rust 同步命令。
|
||||
2. 补齐 AssetBundle UnityFS header/block/directory 解析。
|
||||
1. 收敛 Go CLI 产品入口的最终形态:当前 `cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,不应误写成完整 CLI。
|
||||
2. 补齐 AssetBundle UnityFS 引擎级解析。
|
||||
3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。
|
||||
4. 将官方同步结果接入 CAS + ResourceRepository 的用户级工作流。
|
||||
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
|
||||
|
||||
不建议在 Go CLI、资源解析和文本提取基础能力完成前优先开发 Web UI。
|
||||
不建议在 Go 产品入口、资源解析和文本提取基础能力完成前优先开发 Web UI。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-2
@@ -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 |
|
||||
| `--no-proxy` | 强制直连 |
|
||||
| `--unzip <PATH>` | unzip 可执行文件(默认 `unzip`) |
|
||||
| `--download-concurrency <N>` | 并行下载数,`1..=256`(默认 `4`)。并行仅作用于实际网络下载;manifest/quarantine 簿记与 seed `.hash` 校验仍串行,`fail-fast` 与「不发布不完整资源」不变量保留。可下载失败重试带指数退避以对官方 CDN 礼貌 |
|
||||
| `--dry-run` | 不写同步状态 |
|
||||
| `--plan` | dry-run 时输出计划中的 URL |
|
||||
| `--force` | 强制下载/刷新 |
|
||||
@@ -126,7 +127,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
||||
|
||||
- 优先级:**命令行参数 > 进程环境变量 > `.env` > 内置默认值**。
|
||||
- 语法:每行 `KEY=VALUE`;`#` 开头为注释;值两侧成对引号会剥除;空值视为未设置。
|
||||
- 支持的键:`BAT_OUTPUT`、`BAT_STATE_DIR`、`BAT_AUTO_DISCOVER`、`BAT_WATCH`、`BAT_DAEMON`、`BAT_PROXY`、`BAT_NO_PROXY`、`BAT_INTERVAL_SECONDS`、`BAT_ERROR_RETRY_SECONDS`、`BAT_APP_VERSION`、`BAT_CONNECTION_GROUP`、`BAT_LAUNCHER_VERSION`、`BAT_PLATFORMS`、`BAT_CURL`、`BAT_UNZIP`、`BAT_JSON`、`BAT_QUIET_UP_TO_DATE`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`。
|
||||
- 支持的键:`BAT_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_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json`。
|
||||
- 设 `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` 是该类错误的默认可重试性。
|
||||
|
||||
> 说明:错误码模型(`core/src/error_code.rs`)已建立并作为公共契约;将各链路的报错逐步接入到该码表的工作在 issue #1 下推进。下表随码表更新。
|
||||
> 说明:错误码模型(`core/src/error_code.rs`)已建立并作为公共契约;下载、launcher/metadata、server-info/marker、配置校验、任务/RPC 等主要链路已接入该码表。剩余未实现命名空间和后续引擎能力继续按本表扩展。
|
||||
|
||||
### 域一览
|
||||
|
||||
|
||||
@@ -272,6 +272,12 @@ impl AddressablesCatalogDriver {
|
||||
.unwrap_or_default();
|
||||
|
||||
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 {
|
||||
path: path.to_string(),
|
||||
@@ -280,6 +286,7 @@ impl AddressablesCatalogDriver {
|
||||
resource_type: Self::resource_type_for_path(path),
|
||||
address,
|
||||
dependencies,
|
||||
crc,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -376,6 +383,7 @@ impl AddressablesCatalogDriver {
|
||||
resource_type: Self::resource_type_for_path(path),
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -413,6 +421,7 @@ impl AddressablesCatalogDriver {
|
||||
resource_type,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -483,6 +492,7 @@ impl AddressablesCatalogDriver {
|
||||
Some(primary_key)
|
||||
},
|
||||
dependencies,
|
||||
crc: extra.crc,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -619,6 +629,11 @@ impl AddressablesCatalogDriver {
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToOwned::to_owned),
|
||||
bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()),
|
||||
// m_Crc 是 bundle 的 IEEE CRC-32;0 表示不做 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>,
|
||||
bundle_name: Option<String>,
|
||||
bundle_size: Option<u64>,
|
||||
crc: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for AddressablesCatalogDriver {
|
||||
@@ -954,6 +970,7 @@ mod tests {
|
||||
"internal_id": "synthetic/minimal.bundle",
|
||||
"hash": "synthetic-entry-hash",
|
||||
"size": 119,
|
||||
"crc": 3735928559,
|
||||
"address": "Character_001",
|
||||
"dependencies": ["synthetic/shared.bundle"]
|
||||
},
|
||||
@@ -971,6 +988,9 @@ mod tests {
|
||||
assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle");
|
||||
assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash");
|
||||
assert_eq!(manifest.resources[0].size, 119);
|
||||
// m_Crc(此处 0xDEADBEEF)应被提取;缺该字段的条目为 None。
|
||||
assert_eq!(manifest.resources[0].crc, Some(0xDEAD_BEEF));
|
||||
assert_eq!(manifest.resources[1].crc, None);
|
||||
assert_eq!(
|
||||
manifest.resources[0].address.as_deref(),
|
||||
Some("Character_001")
|
||||
|
||||
@@ -86,6 +86,7 @@ impl Unity2021_3Adapter {
|
||||
flags,
|
||||
)?;
|
||||
let (blocks, directories) = Self::parse_blocks_info(&block_info)?;
|
||||
validate_directory_bounds(&blocks, &directories)?;
|
||||
|
||||
Ok(ParsedAssetBundle {
|
||||
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} 个 block(uncompressed_size={})时超过 u64",
|
||||
block.uncompressed_size
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
for (index, directory) in directories.iter().enumerate() {
|
||||
let end = directory
|
||||
.offset
|
||||
.checked_add(directory.size)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"UnityFS 目录条目 {} 的 offset({}) + size({}) 溢出 u64",
|
||||
directory.path, directory.offset, directory.size
|
||||
)
|
||||
})?;
|
||||
if end > data_region_size {
|
||||
return Err(format!(
|
||||
"UnityFS 目录条目 {}(第 {index} 项)越界:offset({}) + size({}) = {} 超过解压数据区大小 {}",
|
||||
directory.path, directory.offset, directory.size, end, data_region_size
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_blocks_info_bytes<'a>(
|
||||
data: &'a [u8],
|
||||
reader: &mut UnityFsReader<'a>,
|
||||
@@ -497,4 +542,81 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("signature"));
|
||||
}
|
||||
|
||||
fn block(uncompressed_size: u32) -> UnityFsBlockInfo {
|
||||
UnityFsBlockInfo {
|
||||
uncompressed_size,
|
||||
compressed_size: uncompressed_size,
|
||||
flags: 0,
|
||||
compression: UnityFsCompression::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn directory(offset: u64, size: u64) -> UnityFsDirectoryInfo {
|
||||
UnityFsDirectoryInfo {
|
||||
offset,
|
||||
size,
|
||||
flags: 0,
|
||||
path: "CAB-test".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_within_data_region_is_accepted() {
|
||||
// 两个 block 共 12 字节解压数据区;目录条目正好覆盖尾部,合法。
|
||||
let result =
|
||||
validate_directory_bounds(&[block(8), block(4)], &[directory(0, 8), directory(8, 4)]);
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_past_data_region_is_rejected() {
|
||||
// 解压数据区仅 4 字节,目录声称 [0, 8) 越界,应被拒绝并带上下文。
|
||||
let error = validate_directory_bounds(&[block(4)], &[directory(0, 8)]).unwrap_err();
|
||||
assert!(error.contains("越界"), "{error}");
|
||||
assert!(error.contains("解压数据区大小 4"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_offset_size_overflow_is_rejected() {
|
||||
let error = validate_directory_bounds(&[block(4)], &[directory(u64::MAX, 1)]).unwrap_err();
|
||||
assert!(error.contains("溢出"), "{error}");
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"address": "academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle",
|
||||
"dependencies": [
|
||||
"shared_assets_all_123.bundle"
|
||||
]
|
||||
],
|
||||
"crc": 0
|
||||
},
|
||||
{
|
||||
"path": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
|
||||
@@ -20,7 +21,8 @@
|
||||
"size": 162134,
|
||||
"resource_type": "AssetBundle",
|
||||
"address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
|
||||
"dependencies": []
|
||||
"dependencies": [],
|
||||
"crc": 0
|
||||
},
|
||||
{
|
||||
"path": "shared_assets_all_123.bundle",
|
||||
@@ -28,7 +30,8 @@
|
||||
"size": 153480,
|
||||
"resource_type": "AssetBundle",
|
||||
"address": "shared_assets_all_123.bundle",
|
||||
"dependencies": []
|
||||
"dependencies": [],
|
||||
"crc": 0
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
|
||||
@@ -22,6 +22,7 @@ async fn parses_real_shape_addressables_catalog_against_golden() {
|
||||
"resource_type": format!("{:?}", resource.resource_type),
|
||||
"address": resource.address,
|
||||
"dependencies": resource.dependencies,
|
||||
"crc": resource.crc,
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
"metadata": manifest.metadata.extra,
|
||||
|
||||
@@ -7,7 +7,7 @@ pub mod translation;
|
||||
|
||||
pub use game_client::{ClientStatus, GameClient, GameRegion};
|
||||
pub use game_version::{GameVersion, UnityVersion};
|
||||
pub use resource::{Resource, ResourceEntry, ResourceType};
|
||||
pub use resource::{crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceType};
|
||||
pub use translation::{
|
||||
ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText,
|
||||
TranslationStatus,
|
||||
|
||||
+145
-5
@@ -34,6 +34,94 @@ pub struct ResourceEntry {
|
||||
pub address: Option<String>,
|
||||
/// 该资源依赖的其他资源标识
|
||||
pub dependencies: Vec<String>,
|
||||
/// Addressables bundle 的 CRC32(catalog 中的 `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 {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resource_entry() {
|
||||
let entry = ResourceEntry {
|
||||
fn entry_with(size: u64, crc: Option<u32>) -> ResourceEntry {
|
||||
ResourceEntry {
|
||||
path: "test.bundle".to_string(),
|
||||
hash: "abc123".to_string(),
|
||||
size: 1024,
|
||||
size,
|
||||
resource_type: ResourceType::AssetBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
};
|
||||
crc,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resource_entry() {
|
||||
let entry = entry_with(1024, None);
|
||||
assert_eq!(entry.path, "test.bundle");
|
||||
assert_eq!(entry.size, 1024);
|
||||
assert_eq!(entry.crc, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc32_matches_known_vector() {
|
||||
// 标准 IEEE CRC-32 测试向量:crc32("123456789") == 0xCBF43926。
|
||||
assert_eq!(crc32_ieee(b"123456789"), 0xCBF4_3926);
|
||||
assert_eq!(crc32_ieee(b""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_crc_treats_zero_as_absent() {
|
||||
assert_eq!(entry_with(0, None).declared_crc(), None);
|
||||
assert_eq!(entry_with(0, Some(0)).declared_crc(), None);
|
||||
assert_eq!(entry_with(0, Some(42)).declared_crc(), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_downloaded_bytes_checks_size_and_crc() {
|
||||
let data = b"123456789";
|
||||
let crc = crc32_ieee(data);
|
||||
|
||||
// size + crc 均匹配。
|
||||
assert!(entry_with(data.len() as u64, Some(crc))
|
||||
.verify_downloaded_bytes(data)
|
||||
.is_ok());
|
||||
|
||||
// size=0 与 crc=0/None 视为未声明,跳过校验。
|
||||
assert!(entry_with(0, None).verify_downloaded_bytes(data).is_ok());
|
||||
assert!(entry_with(0, Some(0)).verify_downloaded_bytes(data).is_ok());
|
||||
|
||||
// size 不符。
|
||||
assert_eq!(
|
||||
entry_with(3, None).verify_downloaded_bytes(data),
|
||||
Err(IntegrityMismatch::Size {
|
||||
expected: 3,
|
||||
actual: 9
|
||||
})
|
||||
);
|
||||
|
||||
// size 通过、crc 不符。
|
||||
assert_eq!(
|
||||
entry_with(data.len() as u64, Some(0xDEAD_BEEF)).verify_downloaded_bytes(data),
|
||||
Err(IntegrityMismatch::Crc {
|
||||
expected: 0xDEAD_BEEF,
|
||||
actual: crc
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
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. 并发写入相同内容测试通过。
|
||||
5. 损坏对象读取返回明确错误。
|
||||
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 和资源仓储
|
||||
|
||||
资源下载后,导入层会:
|
||||
当前实现已经提供资源导入能力,但官方同步下载完成后尚未自动作为用户级流程触发导入。手动或上层流程调用导入层时,它会:
|
||||
|
||||
1. 把 bundle 原始字节写入 CAS。
|
||||
2. 解析 UnityFS 基础摘要。
|
||||
3. 把资源条目写入 `ResourceRepository`。
|
||||
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`,不直接接管下载器内部状态。
|
||||
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、鉴权、内容分发,
|
||||
以及作为 RPC client 调用本机 daemon(连接 `bat.sock`,每行一个
|
||||
JSON-RPC 请求/响应)。
|
||||
JSON-RPC 请求/响应)。当前 Go 产品入口尚未完成,`cmd/bat` 仍是试验骨架。
|
||||
- Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查、
|
||||
版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。
|
||||
- Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源
|
||||
|
||||
+10
-8
@@ -1,6 +1,6 @@
|
||||
# 稳定工程基线指南
|
||||
|
||||
- **更新时间**:2026-07-06
|
||||
- **更新时间**:2026-07-20
|
||||
- **目标**:让工作区处于可继续开发核心功能的可信状态。
|
||||
|
||||
---
|
||||
@@ -13,7 +13,7 @@
|
||||
2. 根目录只保留入口文档和工程配置。
|
||||
3. 旧报告归档,且不再和当前状态混淆。
|
||||
4. Rust workspace 成员显式列出。
|
||||
5. Go 尚未实现时,Makefile 不误报失败。
|
||||
5. Go 产品入口尚未完成时,Makefile 不把骨架包误报为完整产品。
|
||||
6. 当前缺口有集中清单和关闭顺序。
|
||||
7. 架构边界有 ADR 记录。
|
||||
8. 基础验证命令通过。
|
||||
@@ -36,14 +36,16 @@ make lint
|
||||
```bash
|
||||
cargo test --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 阶段明确跳过。
|
||||
2. 如果后续新增 Go package,必须让 `go test ./...` 和 `go vet ./...` 纳入硬性验证。
|
||||
3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入 CI。
|
||||
1. 当前已有 `cmd/bat` 和 `internal/ffi` 骨架,但没有 Go 产品级测试覆盖;`go test ./...` 出现 `[no test files]` 不代表 CLI/API 已完成。
|
||||
2. 如果后续新增 Go 产品 package,必须让 `go test ./...` 和 `go vet ./...` 纳入硬性验证。
|
||||
3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入本地门禁。
|
||||
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 官方同步闭环完成后,下一阶段优先推进:
|
||||
|
||||
1. Go CLI 的 `doctor` 和基础命令框架。
|
||||
1. 收敛 Go 产品入口:当前 `cmd/bat` 仅是试验骨架,不能视为完成。
|
||||
2. 按 `docs/guides/official-full-pull-smoke.md` 执行真实官方网络全量下载 smoke,并保留隔离目录报告。
|
||||
3. 官方同步结果接入 CAS + ResourceRepository。
|
||||
4. AssetBundle UnityFS 解析。
|
||||
4. AssetBundle UnityFS 引擎级解析。
|
||||
|
||||
优先阅读:
|
||||
|
||||
|
||||
@@ -23,6 +23,20 @@ rustc --version # 验证安装
|
||||
cargo --version
|
||||
```
|
||||
|
||||
#### 自托管 Gitea runner
|
||||
|
||||
`.gitea/workflows/bat.yml` 使用 `runs-on: linux`,并且不依赖 `actions/checkout`、`dtolnay/rust-toolchain` 等外部 GitHub Action。runner 需要在执行环境中预装以下命令:
|
||||
|
||||
```bash
|
||||
git --version
|
||||
rustc --version
|
||||
cargo --version
|
||||
rustfmt --version
|
||||
cargo clippy --version
|
||||
```
|
||||
|
||||
该 workflow 会用 `GITHUB_SERVER_URL`、`GITHUB_REPOSITORY`、`GITHUB_REF` 和 `GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的格式化、检查、构建、clippy 和测试。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。
|
||||
|
||||
#### Docker
|
||||
```bash
|
||||
# 安装 Docker 和 Docker Compose
|
||||
@@ -127,9 +141,11 @@ git push origin feature/your-feature-name
|
||||
cargo fmt --check
|
||||
cargo test --workspace
|
||||
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` 二进制测试。
|
||||
|
||||
`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 兼容层问题
|
||||
|
||||
`bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。默认 Go CLI 集成优先运行 Rust `bat --json`。
|
||||
`bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。未来 Go 产品入口集成优先运行 Rust `bat --json` 或调用 daemon RPC。
|
||||
|
||||
重新构建兼容库:
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 当前实现缺口清单
|
||||
|
||||
- **更新时间**:2026-07-17
|
||||
- **更新时间**:2026-07-20
|
||||
- **用途**:集中跟踪当前代码中的占位实现、设计缺口和下一步验收项。
|
||||
- **权威计划**:`../../PROJECT_PLAN.md`
|
||||
|
||||
@@ -107,30 +107,32 @@
|
||||
- 并发写入相同内容只产生一个对象。
|
||||
- 读取时 Hash 不匹配会返回明确错误。
|
||||
|
||||
### G-005:AssetBundle 解析器仍是占位
|
||||
### G-005:AssetBundle 引擎解析器仍未完成
|
||||
|
||||
现象:
|
||||
|
||||
- `crates/bat-assetbundle/src/parser.rs` 只有 `Parser::name`。
|
||||
- `types.rs` 只有 `AssetType::TextAsset`。
|
||||
- `adapters/src/unity/unity_2021_3.rs` 已能解析 UnityFS header、block info、directory,并校验 directory `offset+size` 不越界;这属于 adapter 层基础摘要能力,不等于 `bat-assetbundle` 引擎已完成。
|
||||
- 仍没有对象表、TypeTree、TextAsset、MonoBehaviour、ScriptableObject 或可扩展提取入口。
|
||||
|
||||
影响:
|
||||
|
||||
- 无法解析真实 UnityFS。
|
||||
- 可以对部分 UnityFS 样本做基础结构校验,但无法完成真实资源对象解析和文本提取。
|
||||
- 无法提取 TextAsset 或配置文本。
|
||||
|
||||
验收:
|
||||
|
||||
- 能解析结构化测试样本。
|
||||
- 支持 UnityFS header、blocks、directory、metadata。
|
||||
- `crates/bat-assetbundle` 能解析结构化测试样本和隔离真实样本。
|
||||
- 支持 UnityFS header、blocks、directory、metadata、object table。
|
||||
- 错误包含偏移和字段上下文。
|
||||
|
||||
### G-006:Patch 引擎仍是占位
|
||||
|
||||
现象:
|
||||
|
||||
- `binary::apply_patch` 返回空 `Vec`。
|
||||
- `json::apply_json_patch` 返回空字符串。
|
||||
- `binary::apply_patch` 明确返回 `PatchError::ApplyFailed`,提示 Binary patch 尚未实现。
|
||||
- `json::apply_json_patch` 明确返回 `PatchError::ApplyFailed`,提示 JSON patch 尚未实现。
|
||||
|
||||
影响:
|
||||
|
||||
@@ -150,7 +152,8 @@
|
||||
现象:
|
||||
|
||||
- `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 结构变体、二进制/压缩字段组合和更明确的失败诊断。
|
||||
|
||||
影响:
|
||||
@@ -160,53 +163,56 @@
|
||||
验收:
|
||||
|
||||
- 能解析项目目标版本的真实 Catalog 样本集合。
|
||||
- 解析结果包含资源 key、provider、dependency、hash、size、path。
|
||||
- 解析结果包含资源 key、provider、dependency、hash、size、path、CRC。
|
||||
- 对不支持的 catalog 结构返回明确错误,而不是静默丢字段。
|
||||
|
||||
---
|
||||
|
||||
## 3. 应用层缺口
|
||||
|
||||
### G-008:Go CLI 尚未实现
|
||||
### G-008:Go CLI 产品入口尚未完成
|
||||
|
||||
状态:**未完成(此前“并入 G-009”只是短期跟踪调整,不代表能力完成)**
|
||||
|
||||
现象:
|
||||
|
||||
- `cmd/bat` 已有 `main.go`,但只是通过 cgo 调用 `bat-ffi` 的最小骨架(doctor/manifest inspect/sync plan),不是产品级用户入口;且默认 Go/Rust 集成边界应是 `bat --json` 进程边界,而非 FFI。
|
||||
- `internal/ffi/ffi.go` 已存在,但只是可选 CGO 兼容包装,不是用户可运行的产品 CLI,也不是默认集成边界。
|
||||
- `go test ./...` 当前没有产品级 Go package 覆盖。
|
||||
|
||||
当前进展:
|
||||
|
||||
- 对接边界已就绪:Rust daemon 的 `bat.sock` Unix socket JSON-RPC Backend API(issue #1 主体已完成:统一 envelope、`BAT-ERR` 错误码模型、`daemon.*`/`resource.*`/`catalog.*`/`task.*` 方法集)与 `bat --json` 进程边界均可用。Go CLI 缺的是产品级入口本身,实现时应重写 `cmd/bat` 现有 cgo 骨架为 RPC/进程边界对接。
|
||||
|
||||
影响:
|
||||
|
||||
- 用户没有统一入口。
|
||||
- 同步、提取、补丁流程无法从命令行串联。
|
||||
- 当前可用的用户同步/运维入口是 Rust `bat` binary。
|
||||
- `cmd/bat` 已存在,但仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力;`doctor` 只输出固定 `ok`,`manifest`/`sync` 依赖可选 CGO/FFI helper。
|
||||
- Go 侧尚未实现通过 Rust `bat --json` 或 daemon RPC 包装官方同步命令、稳定 human/json 输出、真实 doctor 检查和端到端测试。
|
||||
- 如果项目决策改为“用户 CLI 永久由 Rust `bat` 承担,Go 只做 `bat-api`/服务层”,必须同步更新 `AGENTS.md`、`PROJECT_PLAN.md` 和 issue 跟踪;在完成该决策前,不能把 Go CLI 写成已完成。
|
||||
|
||||
验收:
|
||||
|
||||
- `bat doctor` 可运行。
|
||||
- `bat --help` 命令结构稳定。
|
||||
- 命令支持默认人类可读输出和 `--json` 机器输出。
|
||||
- Go CLI 默认通过 Rust `bat --json` 进程边界获取同步 report;除非明确兼容需求,不依赖 FFI。
|
||||
- `cmd/bat doctor` 做真实环境诊断,而不是固定字符串。
|
||||
- `cmd/bat sync` 能通过 Rust `bat --json` 或 RPC 触发/查询官方同步,不走 FFI 控制下载器或 daemon。
|
||||
- human/json 输出、退出码和错误码与 Rust `bat` 契约一致。
|
||||
- `go test ./...`、`go vet ./...` 覆盖命令解析、错误输出和至少一个 mocked Rust 边界。
|
||||
|
||||
### G-009:API Server 和 OpenAPI 尚未实现
|
||||
### G-009:API Server(`bat-api`,仿官方 API)尚未实现
|
||||
|
||||
现象:
|
||||
|
||||
- `api/` 只有目录结构。
|
||||
- 无 handler、service、OpenAPI schema。
|
||||
- `api/` 只有目录结构,无 handler、service、路由。
|
||||
- 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` 可用。
|
||||
- 统一错误结构落地。
|
||||
- OpenAPI 与实际路由同步。
|
||||
- Go 单测覆盖路由、签名验签、错误响应形态。
|
||||
- 真机 e2e:daemon 发布 fixture release → 启动 `bat-api` → 按官方 URL 与鉴权头请求 server-info / catalog / bundle / launcher 链,断言字节与形态正确、验签生效(缺签/错签被拒)。
|
||||
- 统一错误结构与官方响应 envelope 对齐;Makefile 增加 Go 构建/测试目标。
|
||||
|
||||
排期:P2,排在 issue #17 验收收口以及 issue #2 / #3 的解析能力继续推进之后启动;可与 G-008 的 Go 产品入口边界收敛并行。
|
||||
|
||||
### G-010:Web 管理后台尚未实现
|
||||
|
||||
@@ -396,7 +402,9 @@
|
||||
处理结果:
|
||||
|
||||
- 明确决策:本项目不加入 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`)。
|
||||
|
||||
限制:
|
||||
@@ -462,11 +470,11 @@
|
||||
|
||||
## 6. 当前关闭顺序建议
|
||||
|
||||
1. G-008
|
||||
2. G-011
|
||||
3. G-005
|
||||
4. G-007
|
||||
5. G-012
|
||||
6. G-006
|
||||
1. issue #17 验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open。
|
||||
2. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。
|
||||
3. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。
|
||||
4. G-011:官方同步结果接入 CAS + ResourceRepository 用户级工作流。
|
||||
5. G-008 / G-009:收敛 Go 产品入口边界,并实现 `bat-api`(issue #19)。
|
||||
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_ERROR_RETRY_SECONDS=60
|
||||
# 下载并发度(并行网络下载数,1~256;默认 4,对官方 CDN 礼貌)
|
||||
#BAT_DOWNLOAD_CONCURRENCY=4
|
||||
|
||||
# ---- 网络 ----
|
||||
# 显式代理 URL(支持 http/https/socks4/socks4a/socks5/socks5h)。
|
||||
@@ -5141,6 +5143,9 @@ fn apply_bat_env_overrides(
|
||||
if let Some(v) = value("BAT_UNZIP") {
|
||||
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") {
|
||||
options.config.curl_proxy = parse_proxy_config(&v)?;
|
||||
}
|
||||
@@ -5318,6 +5323,11 @@ fn parse_args_with_env(
|
||||
"--unzip" => {
|
||||
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" => {
|
||||
options.config.dry_run = 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!(" --no-proxy Force direct curl connections");
|
||||
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!(" --plan Include planned URLs in dry-run");
|
||||
eprintln!(" --force Force download/refresh");
|
||||
@@ -5730,6 +5741,17 @@ fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
|
||||
.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> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"windows" | "win" => Ok(PatchPlatform::Windows),
|
||||
@@ -6012,6 +6034,51 @@ mod tests {
|
||||
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]
|
||||
fn parses_auto_discover_sync_args() {
|
||||
let options = parse(&[
|
||||
|
||||
@@ -427,6 +427,34 @@ impl std::fmt::Display 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(
|
||||
curl_command: &Path,
|
||||
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),
|
||||
};
|
||||
let retryable = failure.retryable();
|
||||
// ETXTBSY 是 fork/exec 竞态窗口造成的瞬时忙,立刻重试往往仍落在同一窗口内。
|
||||
// 让出 CPU 并做一次极短退避,使持有可写句柄的兄弟进程完成其 execve。
|
||||
let busy = failure.kind == CurlFailureKind::ProcessBusy;
|
||||
failures.push(CurlAttemptFailure {
|
||||
attempt,
|
||||
@@ -459,8 +485,8 @@ pub(crate) fn run_curl_with_retry_with_proxy(
|
||||
if !retryable {
|
||||
break;
|
||||
}
|
||||
if busy && attempt < attempts {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5 * attempt as u64));
|
||||
if attempt < attempts {
|
||||
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, "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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +425,7 @@ mod tests {
|
||||
resource_type: ResourceType::AssetBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
ResourceEntry {
|
||||
path: "synthetic/catalog.json".to_string(),
|
||||
@@ -433,6 +434,7 @@ mod tests {
|
||||
resource_type: ResourceType::Manifest,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
ResourceEntry {
|
||||
path: "TextAssets/dialogue.csv".to_string(),
|
||||
@@ -441,6 +443,7 @@ mod tests {
|
||||
resource_type: ResourceType::TextAsset,
|
||||
address: Some("dialogue".to_string()),
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
ResourceEntry {
|
||||
path: "TableBundles/ExcelDB.db".to_string(),
|
||||
@@ -449,6 +452,7 @@ mod tests {
|
||||
resource_type: ResourceType::TableBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
ResourceEntry {
|
||||
path: "MediaResources-Windows/voice/title.acb".to_string(),
|
||||
@@ -457,6 +461,7 @@ mod tests {
|
||||
resource_type: ResourceType::Media,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
],
|
||||
metadata: ManifestMetadata {
|
||||
@@ -487,6 +492,7 @@ mod tests {
|
||||
resource_type: ResourceType::TextAsset,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +513,7 @@ mod tests {
|
||||
resource_type: ResourceType::AssetBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。
|
||||
@@ -61,6 +64,10 @@ const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
|
||||
const DOWNLOAD_MANIFEST_VERSION: u32 = 1;
|
||||
const DOWNLOAD_QUARANTINE_VERSION: u32 = 1;
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -481,6 +488,7 @@ pub struct OfficialResourcePullService {
|
||||
curl_command: PathBuf,
|
||||
curl_proxy: CurlProxyConfig,
|
||||
retry_attempts: usize,
|
||||
download_concurrency: usize,
|
||||
}
|
||||
|
||||
impl OfficialResourcePullService {
|
||||
@@ -499,6 +507,7 @@ impl OfficialResourcePullService {
|
||||
curl_command: curl_command.into(),
|
||||
curl_proxy: CurlProxyConfig::default(),
|
||||
retry_attempts: DEFAULT_RETRY_ATTEMPTS,
|
||||
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,6 +523,17 @@ impl OfficialResourcePullService {
|
||||
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.
|
||||
pub fn output_root(&self) -> &Path {
|
||||
&self.output_root
|
||||
@@ -562,29 +582,21 @@ impl OfficialResourcePullService {
|
||||
let mut manifest = self.read_download_manifest()?;
|
||||
let urls = plan.all_urls()?;
|
||||
let total = urls.len();
|
||||
let mut items = Vec::new();
|
||||
let mut verified_hashes = Vec::new();
|
||||
let mut verified_hash_urls = HashSet::<String>::new();
|
||||
let mut processed_urls = HashSet::<String>::new();
|
||||
for (offset, url) in urls.into_iter().enumerate() {
|
||||
if should_cancel() {
|
||||
return Err("官方资源拉取已被停止请求中断".to_string().into());
|
||||
}
|
||||
|
||||
let index = offset + 1;
|
||||
progress(OfficialResourcePullProgress::started(
|
||||
index,
|
||||
total,
|
||||
url.clone(),
|
||||
));
|
||||
|
||||
// Phase A:无网络的前置校验(fail-fast)。逐个校验官方性、算目标路径、
|
||||
// 建目录,并判定该 URL 是「已验证可跳过」还是「需下载」。任何非官方
|
||||
// URL 在发起任何下载前即拒绝。
|
||||
if should_cancel() {
|
||||
return Err("官方资源拉取已被停止请求中断".to_string().into());
|
||||
}
|
||||
let mut planned: Vec<PlannedDownload> = Vec::with_capacity(total);
|
||||
for url in urls {
|
||||
if !is_official_yostar_jp_url(&url) {
|
||||
return Err(DownloadError::new(
|
||||
bat_core::ErrorCode::NON_OFFICIAL_URL,
|
||||
format!("拒绝下载非官方 URL:{url}"),
|
||||
));
|
||||
}
|
||||
|
||||
let destination = self.destination_for_url(&url)?;
|
||||
if let Some(parent) = destination.parent() {
|
||||
ensure_safe_directory_path(parent, "下载目标目录")?;
|
||||
@@ -596,50 +608,193 @@ impl OfficialResourcePullService {
|
||||
ensure_safe_file_target(&self.output_root, &destination, "下载目标文件")?;
|
||||
|
||||
let force_refresh = force_refresh_urls.contains(&url);
|
||||
let result = if force_refresh {
|
||||
let existing = if force_refresh {
|
||||
None
|
||||
} else {
|
||||
self.validated_existing_file(&url, &destination, &manifest)?
|
||||
};
|
||||
let result = if let Some(result) = result {
|
||||
self.clear_quarantine_entry(&url)?;
|
||||
result
|
||||
} else {
|
||||
let result = match self.pull_one(&url, &destination) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
self.record_quarantine_entry(&url, &destination, &error)?;
|
||||
progress(OfficialResourcePullProgress::failed(
|
||||
index,
|
||||
total,
|
||||
url.clone(),
|
||||
&error,
|
||||
));
|
||||
return Err(DownloadError::new(
|
||||
error.error_code(),
|
||||
format!(
|
||||
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={url} quarantine={};{}",
|
||||
self.download_quarantine_path().display(),
|
||||
error.message
|
||||
),
|
||||
));
|
||||
planned.push(PlannedDownload {
|
||||
url,
|
||||
destination,
|
||||
existing,
|
||||
});
|
||||
}
|
||||
|
||||
// Phase B:并发下载 need-download 项(各 URL 目标/`.part` 相互独立,
|
||||
// 天然可并行)。worker 只做只读 `&self` 的网络下载,经 mpsc 把结果送回
|
||||
// 主线程;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,
|
||||
item.url.clone(),
|
||||
));
|
||||
// 停止请求:置 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
self.clear_quarantine_entry(&url)?;
|
||||
self.record_download_manifest_entry(&mut manifest, &url, &destination)?;
|
||||
self.write_download_manifest(&manifest)?;
|
||||
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(
|
||||
error.error_code(),
|
||||
format!(
|
||||
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={} quarantine={};{}",
|
||||
item.url,
|
||||
self.download_quarantine_path().display(),
|
||||
error.message
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_cancel() {
|
||||
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(
|
||||
index,
|
||||
total,
|
||||
url.clone(),
|
||||
result.status,
|
||||
result.bytes,
|
||||
result.transferred_bytes,
|
||||
));
|
||||
processed_urls.insert(url.clone());
|
||||
processed_urls.insert(item.url.clone());
|
||||
self.verify_ready_official_hashes(
|
||||
&official_hash_pairs,
|
||||
&processed_urls,
|
||||
@@ -648,17 +803,14 @@ impl OfficialResourcePullService {
|
||||
&mut manifest,
|
||||
)?;
|
||||
items.push(OfficialResourcePullItem {
|
||||
url,
|
||||
destination,
|
||||
url: item.url.clone(),
|
||||
destination: item.destination.clone(),
|
||||
bytes: result.bytes,
|
||||
transferred_bytes: result.transferred_bytes,
|
||||
status: result.status,
|
||||
});
|
||||
}
|
||||
|
||||
if should_cancel() {
|
||||
return Err("官方资源拉取已被停止请求中断".to_string().into());
|
||||
}
|
||||
self.verify_all_official_hashes_are_complete(&official_hash_pairs, &verified_hash_urls)?;
|
||||
|
||||
Ok(OfficialResourcePullReport {
|
||||
@@ -1603,6 +1755,25 @@ fn default_download_quarantine_version() -> u32 {
|
||||
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)]
|
||||
struct PullOneResult {
|
||||
bytes: u64,
|
||||
@@ -2853,6 +3024,8 @@ exit 22
|
||||
let curl_path = bin_dir.path().join("curl");
|
||||
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 plan = build_official_pull_plan_for_platforms(
|
||||
discovery_plan(),
|
||||
@@ -2868,19 +3041,98 @@ exit 22
|
||||
|
||||
assert_eq!(report.items.len(), expected_urls);
|
||||
assert_eq!(events.len(), expected_urls * 2);
|
||||
assert_eq!(events[0].kind, OfficialResourcePullProgressKind::Started);
|
||||
assert_eq!(events[0].index, 1);
|
||||
assert_eq!(events[0].total, expected_urls);
|
||||
assert_eq!(events[1].kind, OfficialResourcePullProgressKind::Finished);
|
||||
assert_eq!(
|
||||
events[1].status,
|
||||
Some(OfficialResourcePullStatus::Downloaded)
|
||||
);
|
||||
|
||||
let started: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| event.kind == OfficialResourcePullProgressKind::Started)
|
||||
.collect();
|
||||
let finished: Vec<_> = events
|
||||
.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 覆盖同一组 URL;finished 均为已下载。
|
||||
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
|
||||
.iter()
|
||||
.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]
|
||||
fn retries_transient_download_failures() {
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -96,6 +96,8 @@ pub struct OfficialUpdateConfig {
|
||||
pub audit_local: bool,
|
||||
/// Repair local files when the local manifest audit fails.
|
||||
pub repair: bool,
|
||||
/// 下载并发度(并行执行的网络下载数)。默认 4,钳制到 `1..=256`。
|
||||
pub download_concurrency: usize,
|
||||
}
|
||||
|
||||
impl Default for OfficialUpdateConfig {
|
||||
@@ -117,6 +119,7 @@ impl Default for OfficialUpdateConfig {
|
||||
force: false,
|
||||
audit_local: true,
|
||||
repair: true,
|
||||
download_concurrency: crate::official_download::DEFAULT_DOWNLOAD_CONCURRENCY,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -898,7 +901,8 @@ impl OfficialUpdateService {
|
||||
&active_resource_root,
|
||||
&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 bootstrap_cache_path = config.bootstrap_cache_path();
|
||||
|
||||
@@ -1337,7 +1341,8 @@ impl OfficialUpdateService {
|
||||
&publish_plan.staging_path,
|
||||
&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.snapshot_path = staging_snapshot_path.clone();
|
||||
report.download_manifest = staging_fetcher.download_manifest_path();
|
||||
|
||||
@@ -129,13 +129,18 @@ impl SqliteResourceRepository {
|
||||
resource_type TEXT NOT NULL,
|
||||
local_path TEXT NOT NULL,
|
||||
address TEXT,
|
||||
dependencies_json TEXT NOT NULL DEFAULT '[]'
|
||||
dependencies_json TEXT NOT NULL DEFAULT '[]',
|
||||
crc INTEGER
|
||||
)
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 向后兼容:早于 crc 列的旧库缺少该列,按需补加(新建库已含该列,
|
||||
// pragma 检查后不会重复 ALTER)。
|
||||
Self::ensure_column(&self.pool, "resources", "crc", "INTEGER").await?;
|
||||
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
@@ -182,6 +187,33 @@ impl SqliteResourceRepository {
|
||||
.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 {
|
||||
match resource_type {
|
||||
ResourceType::AssetBundle => "AssetBundle",
|
||||
@@ -219,7 +251,8 @@ impl SqliteResourceRepository {
|
||||
}
|
||||
|
||||
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 {
|
||||
id,
|
||||
local_path: PathBuf::from(local_path),
|
||||
@@ -230,6 +263,7 @@ impl SqliteResourceRepository {
|
||||
resource_type: Self::resource_type_from_str(&resource_type)?,
|
||||
address,
|
||||
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>,
|
||||
) -> bat_core::Result<Vec<Resource>> {
|
||||
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)?;
|
||||
builder.push(" ORDER BY id");
|
||||
@@ -308,9 +342,9 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
sqlx::query(
|
||||
r#"
|
||||
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
|
||||
path = excluded.path,
|
||||
hash = excluded.hash,
|
||||
@@ -318,7 +352,8 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
resource_type = excluded.resource_type,
|
||||
local_path = excluded.local_path,
|
||||
address = excluded.address,
|
||||
dependencies_json = excluded.dependencies_json
|
||||
dependencies_json = excluded.dependencies_json,
|
||||
crc = excluded.crc
|
||||
"#,
|
||||
)
|
||||
.bind(resource.id.clone())
|
||||
@@ -328,7 +363,8 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
.bind(Self::resource_type_to_str(resource.entry.resource_type))
|
||||
.bind(resource.local_path.to_string_lossy().to_string())
|
||||
.bind(resource.entry.address.clone())
|
||||
.bind(dependencies),
|
||||
.bind(dependencies)
|
||||
.bind(resource.entry.crc.map(i64::from)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -338,7 +374,7 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
|
||||
let row: Option<ResourceRow> = sqlx::query_as(
|
||||
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
|
||||
WHERE id = ?1
|
||||
"#,
|
||||
@@ -356,7 +392,7 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
|
||||
let row: Option<ResourceRow> = sqlx::query_as(
|
||||
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
|
||||
WHERE hash = ?1
|
||||
ORDER BY id
|
||||
@@ -418,6 +454,7 @@ type ResourceRow = (
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<i64>,
|
||||
);
|
||||
|
||||
fn glob_to_like(pattern: &str) -> String {
|
||||
@@ -505,6 +542,7 @@ mod tests {
|
||||
resource_type,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user