feat(patch):统一清单驱动汉化发布
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-12 01:35:29 +08:00
parent 69b6e36bf0
commit 8a77502272
26 changed files with 2250 additions and 158 deletions
+9 -10
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 当前工作区状态 # BlueArchiveToolkit 当前工作区状态
- **更新时间**2026-09-08 - **更新时间**2026-09-12
- **状态来源**:本地工作区盘点、代码验证和最新提交 - **状态来源**:本地工作区盘点、代码验证和最新提交
- **状态分支**`experiment` - **状态分支**`experiment`
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准 - **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
@@ -30,7 +30,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root``--import-resource-db``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,release、平台、bundle path 和常用数组 metadata 过滤已下推到 SQLite,数据库不存在时返回 `available=false` 且不会创建空库;`bat doctor cas` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计,不会因诊断创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。 13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root``--import-resource-db``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,release、平台、bundle path 和常用数组 metadata 过滤已下推到 SQLite,数据库不存在时返回 `available=false` 且不会创建空库;`bat doctor cas` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计,不会因诊断创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units``parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixtureup-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published``status_code=localized.published``localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`bat` 首次启动会在二进制所在目录释放 `config.toml.example` 配置模板(`0600`),`config.toml` 存在且 Unix 权限为 `0600` 或更严格时读取并使用它;`config.toml` 不存在时仅保留模板,不自动读取 example,运行时继续使用环境变量和内置默认值。优先级为命令行参数 > 进程环境变量 > `config.toml` > 内置默认值,`BAT_SKIP_ENV_FILE` 已废弃且不再影响启动;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。官方同步报告还分别统计当前 manifest 复用、历史 release 复用、CAS 复用、网络传输字节和复用回退诊断,下载事件状态使用 `release_reused``cas_reused``downloaded` 等稳定值。 14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units``parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixtureup-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published``status_code=localized.published``localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`bat` 首次启动会在二进制所在目录释放 `config.toml.example` 配置模板(`0600`),`config.toml` 存在且 Unix 权限为 `0600` 或更严格时读取并使用它;`config.toml` 不存在时仅保留模板,不自动读取 example,运行时继续使用环境变量和内置默认值。优先级为命令行参数 > 进程环境变量 > `config.toml` > 内置默认值,`BAT_SKIP_ENV_FILE` 已废弃且不再影响启动;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。官方同步报告还分别统计当前 manifest 复用、历史 release 复用、CAS 复用、网络传输字节和复用回退诊断,下载事件状态使用 `release_reused``cas_reused``downloaded` 等稳定值。
15. Rust `bat` 已提供 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。`translation.worker.run` 已接入 provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。`bat-api` 已提供内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该工作流只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作。真实官方网络全量拉取 smoke 已固化,真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md` 15. Rust `bat` 已提供 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、generic manifest 驱动的 Binary/JSON/Text/受支持 UnityFS 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。`translation.worker.run` 已接入 provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。`bat-api` 已提供内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该工作流只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作。真实官方网络全量拉取 smoke 已固化,真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`
当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`;跨 release 的 Translation Memory V1 位于 `<output>/translation-memory.sqlite`,不放在 `versions/<id>` 或 release task 库中; 当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`;跨 release 的 Translation Memory V1 位于 `<output>/translation-memory.sqlite`,不放在 `versions/<id>` 或 release task 库中;
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整 `translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
@@ -156,7 +156,7 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
待完成: 待完成:
- 基于已接入的 `translation.worker.run` 继续推进完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit/TM/Glossary 查询和通用 Patch 发布资源视图。 - 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary、复杂 AssetBundle fixture 和 generic manifest 发布资源视图。
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`;实际运行报告由脚本写入隔离输出目录。 - 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`;实际运行报告由脚本写入隔离输出目录。
- 增加更多权限和极端文件系统场景测试。 - 增加更多权限和极端文件系统场景测试。
@@ -187,14 +187,14 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
### `bat-patch` ### `bat-patch`
状态:**通用 Binary/JSON/Text Patch 基础可用;受支持 localized patch 发布/rollback 已完成,通用 Patch 发布仍未完成** 状态:**通用 manifest 驱动的受支持 Patch 发布/rollback 已完成;复杂 AssetBundle 兼容仍未完成**
当前已有确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据。`patch.apply` RPC 与 `patch-apply` CLI 可对显式 source/patch/target 文件执行 Binary/JSON/Text patch,并返回 size/BLAKE3 报告;`unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` RPC 和 `unityfs-patch-text-asset` / `unityfs-patch-string-field` / `unityfs-patch-field` CLI 可对显式 UnityFS bundle 输出目标文件。`unityfs.patch_field` 支持 bool、signed/unsigned integer、float raw bits、string、bytes、enum、bit_field、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 同长度替换、PPtr、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换语义 JSON 值;array/vector/List/HashSet/map 扩容会复用当前首个元素或 TypeTree data node 的编码 schema,空容器扩容已用合成 fixture 覆盖,嵌套 vector `Array``List<T>``HashSet<T>` 形态、enum、bit_field、unknown fixed-size raw bytes、managed-reference registry `data``managedReferenceData` payload 字符串已有重建后重解析 fixture。`bat-assetbundle` + `LocalizedPatchService` 已能对 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 执行替换,写入带 TextUnit/provider/review/rollback trace 的 localized patch manifest,在独立 staging 校验后发布汉化 release,并通过 `localized.publish` / `localized.rollback` RPC、`i18n publish` / `i18n rollback` CLI 和 bat-api 控制面暴露`LocalizedPatchManifest` 可转换为通用 `bat_patch::PatchManifest`,通用 manifest 驱动发布仍未迁移 当前已有确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、manifest builder、BLAKE3/size 完整性校验和 rollback 元数据。`patch.apply` RPC 与 `patch-apply` CLI 可对显式 source/patch/target 文件执行 Binary/JSON/Text patch,并返回 size/BLAKE3 报告;`unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` RPC 和对应 CLI 可对显式 UnityFS bundle 输出目标文件。`bat-assetbundle` + `LocalizedPatchService` 已把 Binary/JSON/Text 与当前支持的 UnityFS TextAsset、TypeTree string/semantic field 操作统一到有序 generic manifest,在独立 staging 中逐操作校验 source precondition、最终 hash/size 和 ZIP 内层重解析结果,发布后保留 TextUnit/provider/TM/Glossary/review/rollback provenance,并通过 `localized.publish` / `localized.rollback` RPC、`i18n publish` / `i18n rollback` CLI 和 bat-api 控制面暴露。
待完成: 待完成:
- 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包。 - 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包。
- 通用 manifest 驱动的跨类型 patch build/apply/diff 发布;当前 localized 发布仍仅接受已验证 TextUnit 对应的受支持 UnityFS 文本字段,不等价于任意整体 AssetBundle 重打包。 - 未见样本驱动的复杂 AssetBundle 重打包、更多双 release 查询/分发视图和清理策略;当前 generic manifest 只承诺已验证的 Binary/JSON/Text 与 UnityFS 结构,不等价于任意整体 AssetBundle 重打包。
- `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。 - `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。
### `bat-ffi` ### `bat-ffi`
@@ -316,7 +316,6 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
- **当前基线状态**Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP、 - **当前基线状态**Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP、
HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard
`backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、 `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、
通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用; 通用 Binary/JSON/Text Patch 基础、generic manifest 和受支持 localized patch 发布/rollback 可用;
完整 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和通用 manifest 发布未完成。 复杂 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和更多双 release 运维视图未完成。
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、通用 manifest Patch - **下一工程里程碑**:复杂 AssetBundle 解析和重打包、双 release 运维视图,以及真实官方资源长期运行验证。
构建,以及真实官方资源长期运行验证。
+6 -6
View File
@@ -42,7 +42,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
### 仍是骨架或占位 ### 仍是骨架或占位
1. `bat-assetbundle` 已具备 UnityFS 解包、TextAsset/TypeTree 字段读取和 TextUnit 提取;对当前真实/合成回归覆盖的结构,UnityFS TextAsset、TypeTree string、managed-reference string 和语义字段已形成 parse→modify→rebuild→reparse 闭环,保留已识别压缩/对齐/目录形态并校验未修改对象/字段;所有真实版本差异、未知字段语义和任意复杂 AssetBundle 兼容仍未完成。 1. `bat-assetbundle` 已具备 UnityFS 解包、TextAsset/TypeTree 字段读取和 TextUnit 提取;对当前真实/合成回归覆盖的结构,UnityFS TextAsset、TypeTree string、managed-reference string 和语义字段已形成 parse→modify→rebuild→reparse 闭环,保留已识别压缩/对齐/目录形态并校验未修改对象/字段;所有真实版本差异、未知字段语义和任意复杂 AssetBundle 兼容仍未完成。
2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService`完成受支持 TextUnit 到 localized patch manifest、独立 staging、发布和 rollback 闭环;ZIP 内 bundle 在 `archive_entry` 可验证时会重写外层 ZIP通用 manifest 发布与任意 AssetBundle 重打包仍后置。 2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest/builder、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService`用同一 generic manifest 完成受支持 Binary/JSON/Text/UnityFS 操作的独立 staging、发布和 rollback 闭环;ZIP 内 bundle 在 `archive_entry` 可验证时会重写外层 ZIP并在最终发布校验中重新解析和核对实际字段值,任意 AssetBundle 重打包仍后置。
3. Go 侧边界已确定(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发和内嵌 dashboard = `cmd/bat-api` MVP`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / 完整 Web 协作后台 / SDK 仍未完成。 3. Go 侧边界已确定(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发和内嵌 dashboard = `cmd/bat-api` MVP`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / 完整 Web 协作后台 / SDK 仍未完成。
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。 4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract`translation.worker.run` 已提供真实 provider worker 触发、lease/retry 和结果落库 contract`translation.proofread` 已提供汉化 workflow 人工校对标记 contract`translation.memory.*` 已提供 Rust-owned TM 摘要、raw source/context 查询、provenance 和显式 confirm contractGo 侧仅代理。 5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract`translation.worker.run` 已提供真实 provider worker 触发、lease/retry 和结果落库 contract`translation.proofread` 已提供汉化 workflow 人工校对标记 contract`translation.memory.*` 已提供 Rust-owned TM 摘要、raw source/context 查询、provenance 和显式 confirm contractGo 侧仅代理。
@@ -306,7 +306,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
3. 实现客户端发现、路径校验、备份、应用、回滚。 3. 实现客户端发现、路径校验、备份、应用、回滚。
4. 实现 `patch build``patch apply``patch rollback``verify` 4. 实现 `patch build``patch apply``patch rollback``verify`
5. 实现 dry-run 和安全检查。 5. 实现 dry-run 和安全检查。
6. 将通用 Patch manifest 与汉化发布流程进一步统一 6. 在 generic Patch manifest 已统一当前支持类型后,继续补复杂 AssetBundle 兼容和双 release 运维视图
验收标准: 验收标准:
@@ -387,7 +387,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 5. 推荐执行顺序 ## 5. 推荐执行顺序
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证。 近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、复杂 AssetBundle 重打包、双 release 运维视图和真实官方资源长期运行验证。
建议顺序: 建议顺序:
@@ -405,7 +405,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
`docs/reports/GO_STATUS.md` `docs/reports/GO_STATUS.md`
1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力。 1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力。
2. 基于 `translation.worker.run` 继续推进 Patch 构建。 2. 基于 `translation.worker.run` 继续补充复杂 AssetBundle 的 Patch 构建与发布验证
3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。 3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告。 4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告。
@@ -461,9 +461,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。 按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、通用 manifest 发布、真实版本与复杂 AssetBundle 兼容和官方资源长期运行报告。 已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、generic manifest、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、真实版本与复杂 AssetBundle 兼容、双 release 运维视图和官方资源长期运行报告。
--- ---
- **下一份应补充的验证材料**:真实官方网络 smoke 运行记录 - **下一份应补充的验证材料**:真实官方网络 smoke 运行记录
- **下一项工程任务**:推进 TM/Glossary 扩展、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。 - **下一项工程任务**:推进 TM/Glossary 扩展、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
+2 -2
View File
@@ -2,7 +2,7 @@
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。 **BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、CDN path 只读分发和内嵌管理 dashboard);`internal/backendrpc` 为 RPC client`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、完整 Web 协作后台复杂 AssetBundle 重打包和通用 Patch 发布仍在后续阶段。 当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、CDN path 只读分发和内嵌管理 dashboard);`internal/backendrpc` 为 RPC client`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、完整 Web 协作后台复杂 AssetBundle 重打包仍在后续阶段。
--- ---
@@ -30,7 +30,7 @@
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。 - `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。 - 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段 - 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前 generic manifest 已驱动已验证的 Binary/JSON/Text 与 UnityFS localized 操作,未知结构仍明确拒绝
- Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory V1 与 Glossary V1 已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。 - Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory V1 与 Glossary V1 已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。
- SDK、完整 Web 协作后台。 - SDK、完整 Web 协作后台。
+2 -2
View File
@@ -59,7 +59,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
| `i18n status` | 显示当前汉化 release 状态 | | `i18n status` | 显示当前汉化 release 状态 |
| `i18n task update` | 回写 provider worker 任务状态 | | `i18n task update` | 回写 provider worker 任务状态 |
| `i18n worker run` | 运行真实 provider worker;支持单次、限定次数和周期执行 | | `i18n worker run` | 运行真实 provider worker;支持单次、限定次数和周期执行 |
| `i18n publish` | 校验工作台发布独立汉化 release`--force` 使用新的手动 release ID | | `i18n publish` | 工作台`--patch-manifest` 发布独立汉化 release`--force` 使用新的手动 release ID |
| `i18n schedule` | 管理翻译和汉化发布计划 | | `i18n schedule` | 管理翻译和汉化发布计划 |
| `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 | | `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 |
| `verify` | 校验远端计划、本地 manifest 和官方 seed hashdry-run + 审计当前 release | | `verify` | 校验远端计划、本地 manifest 和官方 seed hashdry-run + 审计当前 release |
@@ -195,7 +195,7 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
| `translation-glossary-approve` | `translation.glossary.approve` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + approved term | | `translation-glossary-approve` | `translation.glossary.approve` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + approved term |
| `translation-glossary-deprecate` | `translation.glossary.deprecate` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deprecated term | | `translation-glossary-deprecate` | `translation.glossary.deprecate` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deprecated term |
| `translation-glossary-delete` | `translation.glossary.delete` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deleted term snapshot | | `translation-glossary-delete` | `translation.glossary.delete` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deleted term snapshot |
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }``{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest | | `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }``{ "from_worker": true, "localized_release_id": "..." }``{ "patch_manifest": "...", "localized_release_id": "..." }` | `202` + localized release manifest |
| `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report | | `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report |
`stop``clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。 `stop``clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。
+2
View File
@@ -723,6 +723,8 @@ paths:
type: string type: string
from_worker: from_worker:
type: boolean type: boolean
patch_manifest:
type: string
localized_release_id: localized_release_id:
type: string type: string
responses: responses:
+56 -2
View File
@@ -149,8 +149,18 @@ pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<V
patch.path_id, patch.path_id,
None, None,
)?; )?;
let asset = verified let verified_serialized = verified
.text_assets .serialized_files
.iter()
.find(|file| file.source_path.as_deref() == Some(patch.serialized_file_path.as_str()))
.ok_or_else(|| {
AssetBundleError::Parse(format!(
"patched serialized file {} was not found after rebuild",
patch.serialized_file_path
))
})?;
let asset = verified_serialized
.text_assets()
.iter() .iter()
.find(|asset| asset.path_id == patch.path_id) .find(|asset| asset.path_id == patch.path_id)
.ok_or_else(|| { .ok_or_else(|| {
@@ -2119,6 +2129,50 @@ mod tests {
); );
} }
#[test]
fn post_rebuild_text_asset_verification_scopes_duplicate_path_id_to_serialized_file() {
let first = synthetic_serialized_text_asset(b"first");
let second = synthetic_serialized_text_asset(b"second");
let source = synthetic_bundle_with_compressed_blocks(
&[("CAB-first", &first), ("CAB-second", &second)],
0,
0,
);
let patched = patch_unityfs_text_asset(
&source,
&TextAssetPatch {
serialized_file_path: "CAB-second".to_string(),
path_id: 1,
expected_name: Some("Scenario".to_string()),
replacement: b"localized-second".to_vec(),
},
)
.unwrap();
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
assert_eq!(
reparsed
.serialized_files
.iter()
.find(|file| file.source_path.as_deref() == Some("CAB-first"))
.unwrap()
.text_assets()[0]
.bytes,
b"first"
);
assert_eq!(
reparsed
.serialized_files
.iter()
.find(|file| file.source_path.as_deref() == Some("CAB-second"))
.unwrap()
.text_assets()[0]
.bytes,
b"localized-second"
);
}
#[test] #[test]
fn rebuild_preserves_block_info_at_end_and_lzma_compression() { fn rebuild_preserves_block_info_at_end_and_lzma_compression() {
let serialized = synthetic_serialized_text_asset(b"old"); let serialized = synthetic_serialized_text_asset(b"old");
+3 -2
View File
@@ -19,8 +19,9 @@ pub mod text;
pub use error::{PatchError, Result}; pub use error::{PatchError, Result};
pub use manifest::{ pub use manifest::{
PatchIntegrity, PatchKind, PatchManifest, PatchManifestFile, PatchRollback, build_patch_manifest, validate_patch_manifest, verify_patch_file_bytes, PatchIntegrity,
PATCH_MANIFEST_VERSION, PatchKind, PatchManifest, PatchManifestBuildFile, PatchManifestFile, PatchManifestOperation,
PatchManifestOperationPayload, PatchManifestProvenance, PatchRollback, PATCH_MANIFEST_VERSION,
}; };
/// Patch 引擎版本号 /// Patch 引擎版本号
+663 -9
View File
@@ -1,7 +1,8 @@
//! Patch manifest, integrity and rollback primitives. //! Patch manifest, integrity and rollback primitives.
use crate::PatchError; use crate::{binary::BinaryPatch, json::JsonPatchOperation, text::TextPatch, PatchError};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs; use std::fs;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
@@ -52,10 +53,30 @@ pub struct PatchManifestFile {
pub source_size: u64, pub source_size: u64,
/// Expected target byte length. /// Expected target byte length.
pub target_size: u64, pub target_size: u64,
/// Ordered operations that produce the target bytes.
#[serde(default)]
pub operations: Vec<PatchManifestOperation>,
}
/// Input specification for building one manifest file from verified source and
/// target release roots.
///
/// Operation payloads and provenance are deliberately independent from
/// filesystem metadata. This keeps the manifest builder usable for UnityFS operations,
/// whose bytes are produced by `bat-assetbundle`, while still requiring the
/// resulting source and target files to exist and match the recorded manifest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchManifestBuildFile {
/// Release-relative path.
pub path: PathBuf,
/// Patch kind declared for this file.
pub patch_kind: PatchKind,
/// Ordered operations, including archive and provenance metadata.
pub operations: Vec<PatchManifestOperation>,
} }
/// Patch algorithm family used by one manifest file. /// Patch algorithm family used by one manifest file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum PatchKind { pub enum PatchKind {
/// Deterministic binary hunk patch. /// Deterministic binary hunk patch.
@@ -65,7 +86,185 @@ pub enum PatchKind {
/// UTF-8 text patch. /// UTF-8 text patch.
Text, Text,
/// UnityFS TextAsset replacement patch. /// UnityFS TextAsset replacement patch.
#[serde(rename = "unityfs_text_asset")]
UnityFsTextAsset, UnityFsTextAsset,
/// UnityFS TypeTree string-field replacement patch.
#[serde(rename = "unityfs_string_field")]
UnityFsStringField,
/// UnityFS semantic TypeTree field replacement patch.
#[serde(rename = "unityfs_field")]
UnityFsField,
/// A file containing more than one supported operation kind.
Mixed,
}
/// One ordered, auditable operation in a manifest file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchManifestOperation {
/// Stable zero-based order within the file.
pub sequence: u32,
/// Optional BLAKE3 hash of the bytes immediately before this operation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_blake3: Option<String>,
/// Optional size of the bytes immediately before this operation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_size: Option<u64>,
/// Optional archive entry for a UnityFS bundle nested in a ZIP.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive_entry: Option<String>,
/// Algorithm payload and UnityFS target location.
#[serde(flatten)]
pub payload: PatchManifestOperationPayload,
/// Translation and review provenance, when this operation came from a
/// localized workflow.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<PatchManifestProvenance>,
}
/// Supported operation payloads in the generic manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PatchManifestOperationPayload {
/// Deterministic binary hunk patch.
Binary {
/// Complete binary patch document.
patch: BinaryPatch,
},
/// RFC 6902 JSON Patch document.
Json {
/// JSON Patch array. It is retained as JSON to preserve the wire
/// contract while the algorithm crate validates each operation.
patch: Value,
},
/// UTF-8 text patch.
Text {
/// Complete text patch document.
patch: TextPatch,
},
/// UnityFS TextAsset replacement.
#[serde(rename = "unityfs_text_asset")]
UnityFsTextAsset {
/// Serialized file path in the UnityFS directory table.
serialized_file_path: String,
/// Unity object path ID.
path_id: i64,
/// Optional expected TextAsset name.
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_name: Option<String>,
/// Replacement bytes.
replacement: Vec<u8>,
},
/// UnityFS TypeTree string field replacement.
#[serde(rename = "unityfs_string_field")]
UnityFsStringField {
/// Serialized file path in the UnityFS directory table.
serialized_file_path: String,
/// Unity object path ID.
path_id: i64,
/// TypeTree field path.
field_path: String,
/// Optional expected source string.
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_value: Option<String>,
/// Replacement string.
replacement: String,
},
/// UnityFS semantic TypeTree field replacement.
#[serde(rename = "unityfs_field")]
UnityFsField {
/// Serialized file path in the UnityFS directory table.
serialized_file_path: String,
/// Unity object path ID.
path_id: i64,
/// TypeTree field path.
field_path: String,
/// Optional expected semantic source value.
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_value: Option<Value>,
/// Replacement semantic value using the Unity serialized value schema.
replacement: Value,
},
}
impl PatchManifestOperationPayload {
/// Returns the file-level patch kind represented by this payload.
pub fn patch_kind(&self) -> PatchKind {
match self {
Self::Binary { .. } => PatchKind::Binary,
Self::Json { .. } => PatchKind::Json,
Self::Text { .. } => PatchKind::Text,
Self::UnityFsTextAsset { .. } => PatchKind::UnityFsTextAsset,
Self::UnityFsStringField { .. } => PatchKind::UnityFsStringField,
Self::UnityFsField { .. } => PatchKind::UnityFsField,
}
}
fn validate(&self) -> crate::Result<()> {
match self {
Self::Binary { patch } if patch.version != crate::binary::BINARY_PATCH_VERSION => {
return Err(PatchError::ApplyFailed(format!(
"unsupported binary patch version {}",
patch.version
)))
}
Self::Text { patch } if patch.version != crate::text::TEXT_PATCH_VERSION => {
return Err(PatchError::ApplyFailed(format!(
"unsupported text patch version {}",
patch.version
)))
}
Self::Json { patch } => {
serde_json::from_value::<Vec<JsonPatchOperation>>(patch.clone()).map_err(
|error| {
PatchError::ApplyFailed(format!(
"invalid JSON patch operation list: {error}"
))
},
)?;
}
_ => {}
}
Ok(())
}
}
/// Provenance retained for a localized operation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchManifestProvenance {
/// Stable TextUnit identifier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_unit_id: Option<String>,
/// BLAKE3 of the validated source text.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_text_blake3: Option<String>,
/// Translation provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_provider: Option<String>,
/// Provider run identifier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_run_id: Option<String>,
/// Translation source kind.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_source_kind: Option<String>,
/// Trusted Translation Memory record identifier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_memory_record_id: Option<String>,
/// Review status.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_status: Option<String>,
/// Deterministic Glossary QA report.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<Value>,
/// Explicit Glossary QA override.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<Value>,
}
impl PatchManifestOperation {
/// Returns the operation kind.
pub fn patch_kind(&self) -> PatchKind {
self.payload.patch_kind()
}
} }
/// Rollback metadata owned by higher-level publication code. /// Rollback metadata owned by higher-level publication code.
@@ -94,12 +293,7 @@ pub fn verify_patch_manifest_files(
target_root: &Path, target_root: &Path,
manifest: &PatchManifest, manifest: &PatchManifest,
) -> crate::Result<PatchIntegrity> { ) -> crate::Result<PatchIntegrity> {
if manifest.version != PATCH_MANIFEST_VERSION { validate_patch_manifest(manifest)?;
return Err(PatchError::ApplyFailed(format!(
"unsupported patch manifest version {}",
manifest.version
)));
}
let mut integrity = PatchIntegrity { let mut integrity = PatchIntegrity {
file_count: 0, file_count: 0,
@@ -119,6 +313,275 @@ pub fn verify_patch_manifest_files(
Ok(integrity) Ok(integrity)
} }
/// Validates manifest schema, paths, operation order and kind compatibility.
pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
if manifest.version != PATCH_MANIFEST_VERSION {
return Err(PatchError::ApplyFailed(format!(
"unsupported patch manifest version {}",
manifest.version
)));
}
for (label, value) in [
("patch_id", manifest.patch_id.as_str()),
("source_version", manifest.source_version.as_str()),
("target_version", manifest.target_version.as_str()),
] {
if value.is_empty()
|| value.contains('\0')
|| value.contains('/')
|| value.contains('\\')
|| value == "."
|| value == ".."
{
return Err(PatchError::ApplyFailed(format!(
"invalid patch manifest {label}: {value}"
)));
}
}
let mut paths = std::collections::BTreeSet::new();
for file in &manifest.files {
resolve_manifest_path(Path::new("."), &file.path)?;
if !paths.insert(file.path.clone()) {
return Err(PatchError::ApplyFailed(format!(
"patch manifest contains duplicate file path: {}",
file.path.display()
)));
}
let mut targets = std::collections::BTreeSet::new();
for (expected_sequence, operation) in file.operations.iter().enumerate() {
operation.payload.validate()?;
if operation.sequence != expected_sequence as u32 {
return Err(PatchError::ApplyFailed(format!(
"patch manifest operation order is not contiguous for {}: expected {}, got {}",
file.path.display(),
expected_sequence,
operation.sequence
)));
}
if let Some(archive_entry) = operation.archive_entry.as_deref() {
validate_archive_entry(archive_entry)?;
}
if operation.source_blake3.is_some() != operation.source_size.is_some() {
return Err(PatchError::ApplyFailed(format!(
"patch manifest operation source precondition must include hash and size: {} operation {}",
file.path.display(),
operation.sequence
)));
}
if file.operations.len() > 1
&& (operation.source_blake3.is_none() || operation.source_size.is_none())
{
return Err(PatchError::ApplyFailed(format!(
"multiple operations require source preconditions: {} operation {}",
file.path.display(),
operation.sequence
)));
}
if operation.archive_entry.is_some()
&& !matches!(
operation.payload,
PatchManifestOperationPayload::UnityFsTextAsset { .. }
| PatchManifestOperationPayload::UnityFsStringField { .. }
| PatchManifestOperationPayload::UnityFsField { .. }
)
{
return Err(PatchError::ApplyFailed(format!(
"archive_entry is only supported for UnityFS operations: {} operation {}",
file.path.display(),
operation.sequence
)));
}
if let Some(target) = unity_operation_target(operation) {
if !targets.insert(target) {
return Err(PatchError::ApplyFailed(format!(
"patch manifest repeats an incompatible UnityFS target: {} operation {}",
file.path.display(),
operation.sequence
)));
}
}
if file.patch_kind != PatchKind::Mixed && file.patch_kind != operation.patch_kind() {
return Err(PatchError::ApplyFailed(format!(
"patch manifest kind mismatch for {} operation {}",
file.path.display(),
operation.sequence
)));
}
}
}
Ok(())
}
fn unity_operation_target(operation: &PatchManifestOperation) -> Option<(Option<&str>, &str, i64)> {
let (serialized_file_path, path_id) = match &operation.payload {
PatchManifestOperationPayload::UnityFsTextAsset {
serialized_file_path,
path_id,
..
} => (serialized_file_path, *path_id),
PatchManifestOperationPayload::UnityFsStringField {
serialized_file_path,
path_id,
..
}
| PatchManifestOperationPayload::UnityFsField {
serialized_file_path,
path_id,
..
} => (serialized_file_path, *path_id),
_ => return None,
};
Some((
operation.archive_entry.as_deref(),
serialized_file_path.as_str(),
path_id,
))
}
/// Builds a manifest from release-root bytes and ordered operation payloads.
///
/// This function does not infer or apply operations. Callers construct target
/// bytes with the owning algorithm/adapter first, then this builder binds the
/// actual source and target hash/size to the auditable manifest. Operation
/// sequence numbers are assigned from the supplied vector order.
pub fn build_patch_manifest(
source_root: &Path,
target_root: &Path,
patch_id: impl Into<String>,
source_version: impl Into<String>,
target_version: impl Into<String>,
files: Vec<PatchManifestBuildFile>,
rollback: PatchRollback,
) -> crate::Result<PatchManifest> {
let mut manifest_files = Vec::with_capacity(files.len());
for file in files {
let source_path = resolve_manifest_path(source_root, &file.path)?;
let target_path = resolve_manifest_path(target_root, &file.path)?;
let source = read_manifest_file(&source_path, "source")?;
let target = read_manifest_file(&target_path, "target")?;
let has_unity_operation = file.operations.iter().any(|operation| {
matches!(
&operation.payload,
PatchManifestOperationPayload::UnityFsTextAsset { .. }
| PatchManifestOperationPayload::UnityFsStringField { .. }
| PatchManifestOperationPayload::UnityFsField { .. }
)
});
let has_archive_operation = file
.operations
.iter()
.any(|operation| operation.archive_entry.is_some());
if has_archive_operation && !has_unity_operation {
return Err(PatchError::ApplyFailed(format!(
"manifest builder archive operations must be UnityFS operations: {}",
file.path.display()
)));
}
if file.operations.len() > 1 && has_unity_operation {
return Err(PatchError::ApplyFailed(format!(
"manifest builder requires one UnityFS operation per file; use adapter-supplied manifest operations for multiple UnityFS targets: {}",
file.path.display()
)));
}
let operation_count = file.operations.len();
let mut current = source.clone();
let operations = file
.operations
.into_iter()
.enumerate()
.map(|(sequence, mut operation)| {
operation.payload.validate()?;
let source_blake3 = (operation_count > 1).then(|| blake3_hex(&current));
let source_size = (operation_count > 1).then_some(current.len() as u64);
if operation.source_blake3.is_none() && operation_count > 1 {
if has_archive_operation || has_unity_operation {
return Err(PatchError::ApplyFailed(format!(
"manifest builder requires source preconditions for multiple non-direct operations: {}",
file.path.display()
)));
}
operation.source_blake3 = source_blake3;
operation.source_size = source_size;
}
if let (Some(expected_hash), Some(expected_size)) =
(operation.source_blake3.as_deref(), operation.source_size)
{
if expected_hash != blake3_hex(&current)
|| expected_size != current.len() as u64
{
return Err(PatchError::ApplyFailed(format!(
"manifest builder operation source precondition mismatch: {} operation {}",
file.path.display(),
sequence
)));
}
}
if !has_unity_operation && !has_archive_operation {
current = apply_direct_payload(&current, &operation.payload)?;
}
operation.sequence = sequence as u32;
Ok(operation)
})
.collect::<crate::Result<Vec<_>>>()?;
if !has_unity_operation && !has_archive_operation && current != target {
return Err(PatchError::ApplyFailed(format!(
"manifest builder operations do not produce target bytes: {}",
file.path.display()
)));
}
manifest_files.push(PatchManifestFile {
path: file.path,
patch_kind: file.patch_kind,
source_blake3: blake3_hex(&source),
target_blake3: blake3_hex(&target),
source_size: source.len() as u64,
target_size: target.len() as u64,
operations,
});
}
let manifest = PatchManifest {
version: PATCH_MANIFEST_VERSION,
patch_id: patch_id.into(),
source_version: source_version.into(),
target_version: target_version.into(),
files: manifest_files,
rollback,
};
validate_patch_manifest(&manifest)?;
Ok(manifest)
}
fn apply_direct_payload(
source: &[u8],
payload: &PatchManifestOperationPayload,
) -> crate::Result<Vec<u8>> {
match payload {
PatchManifestOperationPayload::Binary { patch } => {
crate::binary::apply_binary_patch(source, patch)
}
PatchManifestOperationPayload::Json { patch } => {
let source = std::str::from_utf8(source).map_err(|error| {
PatchError::ApplyFailed(format!("JSON patch source is not UTF-8: {error}"))
})?;
let patch = serde_json::to_string(patch).map_err(|error| {
PatchError::ApplyFailed(format!("failed to serialize JSON patch: {error}"))
})?;
crate::json::apply_json_patch(source, &patch).map(|value| value.into_bytes())
}
PatchManifestOperationPayload::Text { patch } => {
let source = std::str::from_utf8(source).map_err(|error| {
PatchError::ApplyFailed(format!("text patch source is not UTF-8: {error}"))
})?;
crate::text::apply_text_patch(source, patch).map(|value| value.into_bytes())
}
PatchManifestOperationPayload::UnityFsTextAsset { .. }
| PatchManifestOperationPayload::UnityFsStringField { .. }
| PatchManifestOperationPayload::UnityFsField { .. } => Err(PatchError::ApplyFailed(
"manifest builder cannot apply UnityFS payload without bat-assetbundle".to_string(),
)),
}
}
/// Verifies one manifest file entry against source and target bytes. /// Verifies one manifest file entry against source and target bytes.
pub fn verify_patch_file_bytes( pub fn verify_patch_file_bytes(
source: &[u8], source: &[u8],
@@ -151,7 +614,14 @@ pub fn verify_patch_file_bytes(
} }
fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf> { fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf> {
if relative.is_absolute() { if relative.is_absolute()
|| relative.as_os_str().is_empty()
|| relative.to_string_lossy().contains('\0')
|| relative.to_string_lossy().contains('\\')
|| relative == Path::new(".")
|| relative.components().count() == 0
|| relative.to_string_lossy().as_bytes().get(1) == Some(&b':')
{
return Err(PatchError::ApplyFailed(format!( return Err(PatchError::ApplyFailed(format!(
"patch manifest path must be relative: {}", "patch manifest path must be relative: {}",
relative.display() relative.display()
@@ -171,6 +641,26 @@ fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf>
Ok(root.join(relative)) Ok(root.join(relative))
} }
fn validate_archive_entry(entry: &str) -> crate::Result<()> {
if entry.is_empty()
|| entry.contains('\0')
|| entry.contains('\\')
|| entry.as_bytes().get(1) == Some(&b':')
{
return Err(PatchError::ApplyFailed(format!(
"patch manifest archive entry is unsafe: {entry}"
)));
}
for component in Path::new(entry).components() {
if !matches!(component, Component::Normal(_) | Component::CurDir) {
return Err(PatchError::ApplyFailed(format!(
"patch manifest archive entry escapes archive root: {entry}"
)));
}
}
Ok(())
}
fn read_manifest_file(path: &Path, label: &str) -> crate::Result<Vec<u8>> { fn read_manifest_file(path: &Path, label: &str) -> crate::Result<Vec<u8>> {
fs::read(path).map_err(|error| { fs::read(path).map_err(|error| {
PatchError::ApplyFailed(format!( PatchError::ApplyFailed(format!(
@@ -238,6 +728,169 @@ mod tests {
assert!(matches!(error, PatchError::ApplyFailed(_))); assert!(matches!(error, PatchError::ApplyFailed(_)));
} }
#[test]
fn build_patch_manifest_binds_release_metadata_and_operation_order() {
let temp = tempfile::tempdir().unwrap();
let source_root = temp.path().join("source");
let target_root = temp.path().join("target");
fs::create_dir_all(&source_root).unwrap();
fs::create_dir_all(&target_root).unwrap();
let source = b"before";
let target = b"after";
fs::write(source_root.join("file.bin"), source).unwrap();
fs::write(target_root.join("file.bin"), target).unwrap();
let manifest = build_patch_manifest(
&source_root,
&target_root,
"localized-v1",
"official-v1",
"localized-v1",
vec![PatchManifestBuildFile {
path: PathBuf::from("file.bin"),
patch_kind: PatchKind::Binary,
operations: vec![PatchManifestOperation {
sequence: 99,
source_blake3: None,
source_size: None,
archive_entry: None,
payload: PatchManifestOperationPayload::Binary {
patch: crate::binary::diff(source, target),
},
provenance: None,
}],
}],
PatchRollback {
previous_current_target: None,
remove_target_path: None,
},
)
.unwrap();
assert_eq!(manifest.source_version, "official-v1");
assert_eq!(manifest.files[0].source_blake3, blake3_hex(source));
assert_eq!(manifest.files[0].target_blake3, blake3_hex(target));
assert_eq!(manifest.files[0].operations[0].sequence, 0);
}
#[test]
fn build_patch_manifest_records_each_direct_operation_source_precondition() {
let temp = tempfile::tempdir().unwrap();
let source_root = temp.path().join("source");
let target_root = temp.path().join("target");
fs::create_dir_all(&source_root).unwrap();
fs::create_dir_all(&target_root).unwrap();
let source = b"before";
let intermediate = b"middle";
let target = b"after";
fs::write(source_root.join("file.bin"), source).unwrap();
fs::write(target_root.join("file.bin"), target).unwrap();
let first = crate::binary::diff(source, intermediate);
let second = crate::binary::diff(intermediate, target);
let manifest = build_patch_manifest(
&source_root,
&target_root,
"localized-v1",
"official-v1",
"localized-v1",
vec![PatchManifestBuildFile {
path: PathBuf::from("file.bin"),
patch_kind: PatchKind::Binary,
operations: vec![
PatchManifestOperation {
sequence: 20,
source_blake3: None,
source_size: None,
archive_entry: None,
payload: PatchManifestOperationPayload::Binary { patch: first },
provenance: None,
},
PatchManifestOperation {
sequence: 21,
source_blake3: None,
source_size: None,
archive_entry: None,
payload: PatchManifestOperationPayload::Binary { patch: second },
provenance: None,
},
],
}],
PatchRollback {
previous_current_target: None,
remove_target_path: None,
},
)
.unwrap();
assert_eq!(
manifest.files[0].operations[0].source_blake3,
Some(blake3_hex(source))
);
assert_eq!(
manifest.files[0].operations[1].source_blake3,
Some(blake3_hex(intermediate))
);
}
#[test]
fn validate_patch_manifest_rejects_invalid_json_operation_payload() {
let mut manifest = manifest_for("file.json", b"{}", b"{\"value\":1}");
manifest.files[0].patch_kind = PatchKind::Json;
manifest.files[0].operations = vec![PatchManifestOperation {
sequence: 0,
source_blake3: None,
source_size: None,
archive_entry: None,
payload: PatchManifestOperationPayload::Json {
patch: serde_json::json!({"op": "replace", "path": "/value", "value": 1}),
},
provenance: None,
}];
assert!(validate_patch_manifest(&manifest).is_err());
}
#[test]
fn validate_patch_manifest_rejects_repeated_unity_object_target() {
let source = b"source";
let target = b"target";
let mut manifest = manifest_for("bundle", source, target);
manifest.files[0].patch_kind = PatchKind::Mixed;
manifest.files[0].operations = vec![
PatchManifestOperation {
sequence: 0,
source_blake3: Some(blake3_hex(source)),
source_size: Some(source.len() as u64),
archive_entry: None,
payload: PatchManifestOperationPayload::UnityFsStringField {
serialized_file_path: "CAB-one".to_string(),
path_id: 7,
field_path: "first".to_string(),
expected_value: None,
replacement: "one".to_string(),
},
provenance: None,
},
PatchManifestOperation {
sequence: 1,
source_blake3: Some(blake3_hex(target)),
source_size: Some(target.len() as u64),
archive_entry: None,
payload: PatchManifestOperationPayload::UnityFsField {
serialized_file_path: "CAB-one".to_string(),
path_id: 7,
field_path: "second".to_string(),
expected_value: None,
replacement: serde_json::json!({"kind": "string", "value": "two"}),
},
provenance: None,
},
];
assert!(validate_patch_manifest(&manifest).is_err());
}
fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest { fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest {
PatchManifest { PatchManifest {
version: PATCH_MANIFEST_VERSION, version: PATCH_MANIFEST_VERSION,
@@ -251,6 +904,7 @@ mod tests {
target_blake3: blake3_hex(target), target_blake3: blake3_hex(target),
source_size: source.len() as u64, source_size: source.len() as u64,
target_size: target.len() as u64, target_size: target.len() as u64,
operations: Vec::new(),
}], }],
rollback: PatchRollback { rollback: PatchRollback {
previous_current_target: None, previous_current_target: None,
+8 -8
View File
@@ -1,6 +1,6 @@
# AssetBundle 与资源解析路线图 # AssetBundle 与资源解析路线图
- **更新时间**2026-09-04 - **更新时间**2026-09-12
- **适用范围**:Rust 解析引擎、官方同步后的解析缓存、CAS/ResourceRepository 接入、后续文本提取和 Patch 发布。 - **适用范围**:Rust 解析引擎、官方同步后的解析缓存、CAS/ResourceRepository 接入、后续文本提取和 Patch 发布。
- **权威关联**`PROJECT_PLAN.md` Milestone 3/4/5/8`docs/reports/CURRENT_GAPS.md` G-005/G-007/G-011/G-011D。 - **权威关联**`PROJECT_PLAN.md` Milestone 3/4/5/8`docs/reports/CURRENT_GAPS.md` G-005/G-007/G-011/G-011D。
- **开发状态**:解析扩展当前按路线图和真实回归继续推进。 - **开发状态**:解析扩展当前按路线图和真实回归继续推进。
@@ -32,7 +32,7 @@
| UnityFS container | `.bundle`、zip 内 bundle | header、block、directory、解压文件、基础摘要 | 已支持基础解包、LZ4/LZMA、alignment、大小/计数/路径/边界校验;受支持 patch 可保留容器形态重建 | | UnityFS container | `.bundle`、zip 内 bundle | header、block、directory、解压文件、基础摘要 | 已支持基础解包、LZ4/LZMA、alignment、大小/计数/路径/边界校验;受支持 patch 可保留容器形态重建 |
| Serialized file | UnityFS directory 文件 | header、type table、TypeTree node、object table、TextAsset bytes | 已支持基础表结构和 TextAsset bytes | | Serialized file | UnityFS directory 文件 | header、type table、TypeTree node、object table、TextAsset bytes | 已支持基础表结构和 TextAsset bytes |
| Unity 对象字段 | TextAsset、MonoBehaviour、ScriptableObject | 可翻译文本单元、上下文、资源定位 | TypeTree 基础字段读取、`SerializedReference` / prefixed managed-reference metadata alias、payload 提取和字符串提取已落地,真实结构覆盖继续扩大 | | Unity 对象字段 | TextAsset、MonoBehaviour、ScriptableObject | 可翻译文本单元、上下文、资源定位 | TypeTree 基础字段读取、`SerializedReference` / prefixed managed-reference metadata alias、payload 提取和字符串提取已落地,真实结构覆盖继续扩大 |
| Patch 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | 已支持 TextAsset、TypeTree string field、managed-reference string field 的 UnityFS parse→modify→rebuild→reparse;可验证 ZIP 内 bundle 时会重写外层 ZIP;通用 manifest 发布仍未完成 | | Patch 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | generic manifest 已驱动 Binary/JSON/Text 与当前支持的 UnityFS 操作;可验证 ZIP 内 bundle 时会在外层重写后重新读取、重解析并校验定位字段/替换值 |
--- ---
@@ -171,9 +171,9 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
交付: 交付:
1. 已定义 `localized-patch-manifest.json`:目标官方版本、localized release、输出文件、hash、size、byte delta、TextUnit/provider/review trace 和回滚信息。 1. 已定义并接入 generic `PatchManifest`:目标官方版本、localized release、输出文件、hash、size、按序 operation、算法载荷、UnityFS 定位、TextUnit/provider/review trace 和回滚信息。
2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作;普通 ZIP 条目在 `archive_entry` 可验证、内层可重解析时会解包、重建并重写外层 ZIP,路径穿越、symlink、混合直接/ZIP patch 和无效内层 bundle 明确失败。 2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作;普通 ZIP 条目在 `archive_entry` 可验证、内层可重解析时会解包、重建并重写外层 ZIP,路径穿越、symlink、混合直接/ZIP patch 和无效内层 bundle 明确失败。
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果。 3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果generic manifest 不把 UnityFS 定位信息扁平化
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`rollback 按 manifest 恢复上一 release。 4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`rollback 按 manifest 恢复上一 release。
5. 成功后发布状态从 `not_localized` 切到 `localized``localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 releaseZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验。 5. 成功后发布状态从 `not_localized` 切到 `localized``localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 releaseZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验。
@@ -181,7 +181,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
1. Patch 失败不影响 `bat-resources/current` 1. Patch 失败不影响 `bat-resources/current`
2. 汉化 release 保留官方相对目录结构。 2. 汉化 release 保留官方相对目录结构。
3. `localized` 状态能证明原版和汉化两套资源都已发布,且 patch manifest 可验证。 3. `localized` 状态能证明原版和汉化两套资源都已发布,且 generic/localized patch manifest 可验证。
--- ---
@@ -210,7 +210,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
1. 继续补充 Addressables Windows/Android 真实 catalog 样本和独立二进制格式诊断。 1. 继续补充 Addressables Windows/Android 真实 catalog 样本和独立二进制格式诊断。
2. 继续补充 TypeTree 字段 reader、MonoBehaviour/ScriptableObject 遍历和真实版本差异。 2. 继续补充 TypeTree 字段 reader、MonoBehaviour/ScriptableObject 遍历和真实版本差异。
3. 基于 `translation.worker.run` 扩展 TM/Glossary 和通用 manifest Patch 构建 3. 基于 `translation.worker.run` 继续扩展 TM/Glossary provenance 和真实资源发布样本
4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引。 4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引。
5.通用 Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包和通用 5. generic Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包,但只在
Patch 发布流程统一,保留当前受支持 localized patch 发布/rollback 链路 新结构有真实 fixture 和完整重建验证时接入;不扩大当前 UnityFS V1 的宣称范围
@@ -244,7 +244,7 @@ trusted 和 release/TextUnit/provider/run provenance`translation.tasks` 优
13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json``crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。 13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json``crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。
14. 随后刷新 active release 下的 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。 14. 随后刷新 active release 下的 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。
15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。历史 release 候选失效时,已有 CAS 对象会先经过完整性和元数据校验,再增加 release 引用并原子物化;当前 release 在 `official-cas-reuse-references.json` 中记录引用,staging/release 清理时递减,失败则回退网络并保留诊断。 15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。历史 release 候选失效时,已有 CAS 对象会先经过完整性和元数据校验,再增加 release 引用并原子物化;当前 release 在 `official-cas-reuse-references.json` 中记录引用,staging/release 清理时递减,失败则回退网络并保留诊断。
16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 及 ZIP 内层重解析校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。 16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;generic manifest 驱动的 Binary/JSON/Text 以及当前支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 及 ZIP 内层最终重解析校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。
维护期特殊分支:如果官方 launcher/server-info 已经指向新资源根,但 client-patch seed marker 或必需 seed catalog 仍返回 403/404 等未开放状态,`bat` 返回 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging;若本轮启用 `--auto-discover`,会在 `<output>/official-launcher-bootstrap.pending.json` 写入待处理 launcher bootstrap 证据,供后续排障和自研客户端开发使用。 维护期特殊分支:如果官方 launcher/server-info 已经指向新资源根,但 client-patch seed marker 或必需 seed catalog 仍返回 403/404 等未开放状态,`bat` 返回 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging;若本轮启用 `--auto-discover`,会在 `<output>/official-launcher-bootstrap.pending.json` 写入待处理 launcher bootstrap 证据,供后续排障和自研客户端开发使用。
+4 -2
View File
@@ -1,6 +1,6 @@
# 官方资源 Release 布局与资源侧契约 # 官方资源 Release 布局与资源侧契约
- **更新时间**2026-09-04 - **更新时间**2026-09-12
- **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `bat-api` 分发 path 的 1:1 对应关系。 - **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `bat-api` 分发 path 的 1:1 对应关系。
- **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。 - **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。
- **权威代码** - **权威代码**
@@ -75,7 +75,9 @@
<localized-output>/ # 汉化产物发布根(--localized-output / BAT_LOCALIZED_OUTPUT <localized-output>/ # 汉化产物发布根(--localized-output / BAT_LOCALIZED_OUTPUT
current -> versions/<id> # 已汉化后才切换;未汉化状态不发布 current -> versions/<id> # 已汉化后才切换;未汉化状态不发布
versions/<id>/ # 与官方相对路径一致的汉化资源 versions/<id>/ # 与官方相对路径一致的汉化资源
localized-version-state.json # 预留:后续 Patch 发布阶段维护,官方同步阶段不写入 localized-patch-manifest.json # localized wrapper + generic PatchManifest 审计输入/结果
.staging/<id>/ # generic/translation patch 未发布写侧
localized-version-state.json # localized current、官方 source release 和 workflow 状态
``` ```
官方资源发布和汉化发布是两个独立状态: 官方资源发布和汉化发布是两个独立状态:
+2 -2
View File
@@ -89,8 +89,8 @@ git check-ignore -v Cargo.lock CLAUDE.md AGENTS.md CONTRIBUTING.md
当前开发优先推进: 当前开发优先推进:
1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。 1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。
2. 基于 `translation.worker.run` 继续扩展 TM/Glossary,并推进通用 manifest Patch 构建 2. 基于 `translation.worker.run` 继续扩展 TM/Glossary,并补充复杂 AssetBundle 的真实 fixture 与发布验证
3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。 3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和 generic manifest 发布所需资源视图。
4. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。 4. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
5. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。 5. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
+18 -2
View File
@@ -336,7 +336,8 @@ source text、destination 和 archive entry 后再落库。blocking Glossary QA
## localized patch 发布与回滚 ## localized patch 发布与回滚
`i18n publish` 会在独立的 `.staging/<localized-release-id>` 中复制当前官方 `i18n publish` 会在独立的 `.staging/<localized-release-id>` 中复制当前官方
release,校验工作台与当前 TextUnit 索引的 source/location 一致后,写入已有支持 release,校验工作台或 generic patch manifest 与当前官方 release 的 source identity
一致后,按确定的 operation sequence 写入 Binary、JSON、UTF-8 Text,以及已有支持
范围内的 TextAsset、TypeTree string field 和 managed-reference string field 范围内的 TextAsset、TypeTree string field 和 managed-reference string field
patch。校验通过后才原子切换 `localized/current`,并在 release manifest 中记录 patch。校验通过后才原子切换 `localized/current`,并在 release manifest 中记录
源/目标 BLAKE3、字节数、patch kind、TextUnit、provider、review、发布时重新计算的 源/目标 BLAKE3、字节数、patch kind、TextUnit、provider、review、发布时重新计算的
@@ -359,6 +360,21 @@ bat i18n publish \
--localized-release-id release-worker-1 --localized-release-id release-worker-1
``` ```
已有 generic manifest 时直接发布:
```bash
bat i18n publish \
--patch-manifest /tmp/bat-patch-manifest.json \
--localized-release-id localized-v1
```
manifest 必须声明当前官方 `source_version` 和目标 localized `target_version`
文件 hash/size、操作顺序和算法载荷;直接文件支持 Binary/JSON/TextUnityFS 操作
必须保留 serialized file、path ID、field path 或 TextAsset 定位,ZIP 内 UnityFS
还必须保留 `archive_entry`。Rust 会在 staging 中逐操作验证 source precondition、
最终 hash/size,并在 ZIP 外层重写后重新读取条目、重解析 UnityFS 和校验实际替换值。
其他未支持的 UnityFS 结构仍明确拒绝。
发布失败会清理 staging,不切换 `current`。当前 release 的 rollback 目标由 发布失败会清理 staging,不切换 `current`。当前 release 的 rollback 目标由
manifest 记录,执行后删除本次版本目录并恢复上一版本;没有上一版本时移除 manifest 记录,执行后删除本次版本目录并恢复上一版本;没有上一版本时移除
`current` `current`
@@ -370,7 +386,7 @@ bat i18n rollback --localized-release-id release-worker-1
Rust RPC 方法为 `localized.publish``localized.rollback`bat-api 对应为 Rust RPC 方法为 `localized.publish``localized.rollback`bat-api 对应为
`POST /admin/control/localized-publish``POST /admin/control/localized-rollback` `POST /admin/control/localized-publish``POST /admin/control/localized-rollback`
以及鉴权的 `GET /admin/translation/status`。发布请求必须且只能包含 以及鉴权的 `GET /admin/translation/status`。发布请求必须且只能包含
`translation_file``from_worker=true`rollback 可省略 release ID 以操作当前 `translation_file``from_worker=true``patch_manifest`rollback 可省略 release ID 以操作当前
release。Go 只做鉴权、参数校验和转发,状态与产物仍由 Rust 持有。 release。Go 只做鉴权、参数校验和转发,状态与产物仍由 Rust 持有。
## 边界 ## 边界
+10 -2
View File
@@ -337,7 +337,7 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t
| 方法 | 状态 | params | data | | 方法 | 状态 | params | data |
|---|---|---|---| |---|---|---|---|
| `localized.status` | 已实现 | `null` | 汉化发布状态、当前官方 release 匹配关系和汉化输出目录。 | | `localized.status` | 已实现 | `null` | 汉化发布状态、当前官方 release 匹配关系和汉化输出目录。 |
| `localized.publish` | 已实现 | `{ "translation_file": "...", "localized_release_id": "...", "force": false }``{ "from_worker": true, "localized_release_id": "...", "force": false }` | 已校验并发布的汉化 release、manifest 和完整性报告。 | | `localized.publish` | 已实现 | `{ "translation_file": "...", "localized_release_id": "...", "force": false }``{ "from_worker": true, "localized_release_id": "...", "force": false }``{ "patch_manifest": "...", "localized_release_id": "...", "force": false }`;三者只能选一个 | 已校验并发布的汉化 release、generic/localized manifest 和完整性报告。 |
| `localized.rollback` | 已实现 | `{ "localized_release_id": "..." }`,可省略 | 删除当前 release、恢复 manifest 记录的上一 release 和新状态。 | | `localized.rollback` | 已实现 | `{ "localized_release_id": "..." }`,可省略 | 删除当前 release、恢复 manifest 记录的上一 release 和新状态。 |
`localized.status` 严格按 daemon / `config.toml` 或环境变量中的 `BAT_LOCALIZED_OUTPUT` `localized.status` 严格按 daemon / `config.toml` 或环境变量中的 `BAT_LOCALIZED_OUTPUT`
@@ -353,12 +353,20 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t
`current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的 `current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的
`localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回 `localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回
`patch_manifest_path``patch_manifest_available` `patch_manifest_path``patch_manifest_available`
`patch_manifest_matches_release``patch_file_count` `patch_manifest_matches_release``patch_manifest_integrity_status`
`patch_manifest_source_version``patch_manifest_target_version`
`patch_file_count``patch_operation_count``patch_kind_counts`
`patch_text_asset_operation_count``rollback_previous_current_target` `patch_text_asset_operation_count``rollback_previous_current_target`
每个 localized patch operation 的 manifest metadata 记录发布时重新计算的 每个 localized patch operation 的 manifest metadata 记录发布时重新计算的
`glossary_qa`(包括 `qa_identity`)及对应 `glossary_override`,不会复用 workbench `glossary_qa`(包括 `qa_identity`)及对应 `glossary_override`,不会复用 workbench
中已经过期的 QA 快照。 中已经过期的 QA 快照。
`localized.publish` 也可直接接收由 Rust `bat-patch` 构建的 generic manifest。
Rust 会把其 source version 绑定当前官方 release,在独立 staging 中按 manifest
顺序执行 Binary、JSON、UTF-8 Text 和当前支持的 UnityFS TextAsset/TypeTree 字段
操作,并保留实际操作载荷、hash/size、定位信息和 TextUnit/TM/Glossary/review
provenance。Go 只做鉴权、typed 参数校验和 RPC 转发。
### catalog ### catalog
| 方法 | 状态 | params | data | | 方法 | 状态 | params | data |
+14 -13
View File
@@ -1,6 +1,6 @@
# 当前实现缺口清单 # 当前实现缺口清单
- **更新时间**2026-09-10 - **更新时间**2026-09-12
- **文档角色**:只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态。 - **文档角色**:只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态。
- **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。 - **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。
- **Go 进度**`GO_STATUS.md` - **Go 进度**`GO_STATUS.md`
@@ -32,21 +32,22 @@ directory 形态和未修改对象/字段,并明确拒绝未知压缩或无法
回归和 `bat-infrastructure` 的解析缓存、ZIP 内 bundle 发布测试。新增格式覆盖必须 回归和 `bat-infrastructure` 的解析缓存、ZIP 内 bundle 发布测试。新增格式覆盖必须
同时补真实 fixture、回归测试和文档。 同时补真实 fixture、回归测试和文档。
### G-006:通用 Patch 发布仍未完成 ### G-006:通用 Patch 的复杂格式和运维视图仍未完成
状态:**基础完成,发布流程部分完成** 状态:**V1 已完成(当前支持类型),复杂格式和运维扩展继续推进**
`bat-patch` 已提供 Binary/JSON/Text Patch、manifest、BLAKE3/size 校验和 `bat-patch` 已提供 Binary/JSON/Text Patch、manifest builder、BLAKE3/size 校验和
rollback 元数据;文件级 `patch.apply` 与受支持的 UnityFS TextAsset、TypeTree rollback 元数据;`LocalizedPatchService` 已使用同一有序 generic manifest 驱动
string field、managed-reference string field 写入及 localized publish/rollback Binary/JSON/Text 与当前支持的 UnityFS TextAsset、TypeTree string/semantic field
已可用。 写入及 localized publish/rollback。发布会在独立 staging 中校验 source/target identity、
逐操作 precondition、ZIP 内层重解析和实际字段替换,并保留 TextUnit/TM/Glossary/review
provenance`localized.publish``i18n publish` 和 bat-api typed forwarding 均已接入。
仍需完成: 仍需完成:
- 通用 manifest 驱动的跨类型 patch build/apply/publish/rollback。
- 任意复杂 AssetBundle 重打包和完整翻译文件集合构建;当前 localized publish 已支持 - 任意复杂 AssetBundle 重打包和完整翻译文件集合构建;当前 localized publish 已支持
可验证 ZIP 内 bundle 的外层 ZIP 重写,但不扩大 UnityFS 结构支持范围。 可验证 ZIP 内 bundle 的外层 ZIP 重写,但不扩大 UnityFS 结构支持范围。
- 原版 release 与 localized release 双发布后的查询、分发和清理策略。 - 原版 release 与 localized release 双发布后的更丰富查询、分发和清理策略。
所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变 所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变
已发布的 `bat-resources/current``bat-localized/current` 已发布的 `bat-resources/current``bat-localized/current`
@@ -105,14 +106,14 @@ format 等资源级过滤,`parse.text_units` / `parse.errors` 和翻译任务
- 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。 - 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。
- 更多 schema 迁移、权限、并发和损坏恢复场景验证。 - 更多 schema 迁移、权限、并发和损坏恢复场景验证。
### G-011D:双 release 的完整查询与发布策略仍未完成 ### G-011D:双 release 的完整查询与运维策略仍未完成
状态:**受支持范围完成,通用范围部分完成** 状态:**发布闭环完成,查询/清理扩展继续推进**
官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和 官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和
rollback`localized.status` 能校验当前官方 release 与 patch manifest 的一致性。 rollback`localized.status` 能校验当前官方 release 与 patch manifest 的一致性。
仍需完成通用 patch 发布、复杂重打包、双 release 查询/分发视图和清理策略。 仍需完成复杂重打包、双 release 查询/分发视图和清理策略。
### G-012Translation Memory V1 已实现,扩展能力仍缺失 ### G-012Translation Memory V1 已实现,扩展能力仍缺失
@@ -147,7 +148,7 @@ Rust `bat` 已提供独立项目级 `glossary.sqlite`term/alias/recommended/a
## 3. 后续推进顺序 ## 3. 后续推进顺序
1. 继续 G-005:更多真实 AssetBundle 样本、复杂字段解析、版本差异和任意结构重打包。 1. 继续 G-005:更多真实 AssetBundle 样本、复杂字段解析、版本差异和任意结构重打包。
2. 继续 G-006/G-011D通用 manifest Patch 和双 release 查询/清理策略。 2. 继续 G-006/G-011D复杂 AssetBundle 兼容和双 release 查询/清理策略。
3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider 3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider
扩展体系。 扩展体系。
4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。 4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。
+2 -2
View File
@@ -1,6 +1,6 @@
# Go 侧进度与边界(权威) # Go 侧进度与边界(权威)
- **更新时间**2026-09-04 - **更新时间**2026-09-12
- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。 - **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。
- **关联缺口**G-009(资源 bootstrap/分发);相关契约见 `docs/architecture/official-resource-backend.md` §7 和 `docs/guides/bat-api-local-live-smoke.md` - **关联缺口**G-009(资源 bootstrap/分发);相关契约见 `docs/architecture/official-resource-backend.md` §7 和 `docs/guides/bat-api-local-live-smoke.md`
@@ -95,7 +95,7 @@
| 组件 | 路径 | 状态 | 说明 | | 组件 | 路径 | 状态 | 说明 |
|---|---|---|---| |---|---|---|---|
| Module | `go.mod``bat-api` | 已用 | 服务层模块名 | | Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``catalog.*``parse.*``localized.status/publish/rollback``task.*``translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 | | RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``catalog.*``parse.*``localized.status/publish/rollback``task.*``translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`localized.publish` 的 typed params 支持 `translation_file``from_worker``patch_manifest` 三选一;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` | | 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` |
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI |
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
+77 -6
View File
@@ -395,6 +395,7 @@ struct CliOptions {
output_explicit: bool, output_explicit: bool,
resource_root: Option<PathBuf>, resource_root: Option<PathBuf>,
translation_file: Option<PathBuf>, translation_file: Option<PathBuf>,
patch_manifest: Option<PathBuf>,
translation_id: Option<String>, translation_id: Option<String>,
translation_text: Option<String>, translation_text: Option<String>,
translation_text_file: Option<PathBuf>, translation_text_file: Option<PathBuf>,
@@ -521,6 +522,7 @@ impl Default for CliOptions {
output_explicit: false, output_explicit: false,
resource_root: None, resource_root: None,
translation_file: None, translation_file: None,
patch_manifest: None,
translation_id: None, translation_id: None,
translation_text: None, translation_text: None,
translation_text_file: None, translation_text_file: None,
@@ -1130,6 +1132,8 @@ struct LocalizedPublishRpcParams {
alias = "from_worker_results" alias = "from_worker_results"
)] )]
from_worker: bool, from_worker: bool,
#[serde(default, alias = "patch_manifest_path")]
patch_manifest: Option<PathBuf>,
#[serde(default, alias = "release_id")] #[serde(default, alias = "release_id")]
localized_release_id: Option<String>, localized_release_id: Option<String>,
#[serde(default)] #[serde(default)]
@@ -1226,8 +1230,8 @@ fn is_pending_rpc_method(method: &str) -> bool {
// 等语义方法创建,通用创建接口暂不开放。 // 等语义方法创建,通用创建接口暂不开放。
// daemon.clean-stableCLI 侧按进程生命周期处理; // daemon.clean-stableCLI 侧按进程生命周期处理;
// live RPC 内不做在线清理。 // live RPC 内不做在线清理。
// patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建、复杂 // patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建和 inspect
// UnityFS 语义编辑和 inspect 等子命令仍未开放。 // 等子命令仍未开放,发布级 generic manifest 通过 localized.publish 进入
matches!(method, "task.create" | RPC_METHOD_CLEAN_STABLE) matches!(method, "task.create" | RPC_METHOD_CLEAN_STABLE)
|| (method.starts_with("patch.") && method != RPC_METHOD_PATCH_APPLY) || (method.starts_with("patch.") && method != RPC_METHOD_PATCH_APPLY)
|| (method.starts_with("unityfs.") || (method.starts_with("unityfs.")
@@ -3277,8 +3281,13 @@ fn build_localized_status_report(
let mut patch_manifest_path = None; let mut patch_manifest_path = None;
let mut patch_manifest_available = false; let mut patch_manifest_available = false;
let mut patch_manifest_matches_release = false; let mut patch_manifest_matches_release = false;
let mut patch_manifest_integrity_status = None;
let mut patch_manifest_source_version = None;
let mut patch_manifest_target_version = None;
let mut patch_file_count = None; let mut patch_file_count = None;
let mut patch_text_asset_operation_count = None; let mut patch_text_asset_operation_count = None;
let mut patch_operation_count = None;
let mut patch_kind_counts = serde_json::Map::new();
let mut rollback_previous_current_target = None; let mut rollback_previous_current_target = None;
let mut flow_status_code = if official_version_id.is_some() { let mut flow_status_code = if official_version_id.is_some() {
ReleaseFlowStatusCode::LocalizedPending ReleaseFlowStatusCode::LocalizedPending
@@ -3326,6 +3335,47 @@ fn build_localized_status_report(
patch_file_count = Some(manifest.file_count); patch_file_count = Some(manifest.file_count);
patch_text_asset_operation_count = Some(manifest.text_asset_operation_count); patch_text_asset_operation_count = Some(manifest.text_asset_operation_count);
rollback_previous_current_target = manifest.rollback.previous_current_target; rollback_previous_current_target = manifest.rollback.previous_current_target;
if let Some(generic_manifest) = manifest.patch_manifest.as_ref() {
patch_manifest_source_version =
Some(generic_manifest.source_version.clone());
patch_manifest_target_version =
Some(generic_manifest.target_version.clone());
let mut operation_count = 0usize;
for file in &generic_manifest.files {
for operation in &file.operations {
operation_count += 1;
let key = match operation.patch_kind() {
bat_patch::PatchKind::Binary => "binary",
bat_patch::PatchKind::Json => "json",
bat_patch::PatchKind::Text => "text",
bat_patch::PatchKind::UnityFsTextAsset => "unityfs_text_asset",
bat_patch::PatchKind::UnityFsStringField => {
"unityfs_string_field"
}
bat_patch::PatchKind::UnityFsField => "unityfs_field",
bat_patch::PatchKind::Mixed => "mixed",
}
.to_string();
let count = patch_kind_counts
.entry(key)
.or_insert_with(|| serde_json::json!(0));
*count = serde_json::json!(count.as_u64().unwrap_or_default() + 1);
}
}
patch_operation_count = Some(operation_count);
patch_manifest_integrity_status = Some(
if bat_patch::validate_patch_manifest(generic_manifest).is_ok() {
"valid"
} else {
"invalid"
},
);
patch_manifest_matches_release &= generic_manifest.source_version
== manifest.official_release_id
&& generic_manifest.target_version == manifest.localized_release_id;
} else {
patch_manifest_integrity_status = Some("legacy");
}
} }
if candidate.is_dir() if candidate.is_dir()
&& current_points_to_published_version && current_points_to_published_version
@@ -3363,8 +3413,13 @@ fn build_localized_status_report(
"patch_manifest_path": patch_manifest_path, "patch_manifest_path": patch_manifest_path,
"patch_manifest_available": patch_manifest_available, "patch_manifest_available": patch_manifest_available,
"patch_manifest_matches_release": patch_manifest_matches_release, "patch_manifest_matches_release": patch_manifest_matches_release,
"patch_manifest_integrity_status": patch_manifest_integrity_status,
"patch_manifest_source_version": patch_manifest_source_version,
"patch_manifest_target_version": patch_manifest_target_version,
"patch_file_count": patch_file_count, "patch_file_count": patch_file_count,
"patch_text_asset_operation_count": patch_text_asset_operation_count, "patch_text_asset_operation_count": patch_text_asset_operation_count,
"patch_operation_count": patch_operation_count,
"patch_kind_counts": patch_kind_counts,
"rollback_previous_current_target": rollback_previous_current_target, "rollback_previous_current_target": rollback_previous_current_target,
"matches_current_official_release": matches_current_official_release, "matches_current_official_release": matches_current_official_release,
"current_points_to_published_version": current_points_to_published_version, "current_points_to_published_version": current_points_to_published_version,
@@ -3487,11 +3542,17 @@ fn localized_publish_rpc_report(
let translation_file = params let translation_file = params
.translation_file .translation_file
.filter(|path| !path.as_os_str().is_empty()); .filter(|path| !path.as_os_str().is_empty());
if translation_file.is_some() == params.from_worker { let patch_manifest = params
.patch_manifest
.filter(|path| !path.as_os_str().is_empty());
let input_count = usize::from(translation_file.is_some())
+ usize::from(params.from_worker)
+ usize::from(patch_manifest.is_some());
if input_count != 1 {
return Err(ApiError::new( return Err(ApiError::new(
ErrorCode::RPC_INVALID_PARAMS, ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_LOCALIZED_PUBLISH, RPC_METHOD_LOCALIZED_PUBLISH,
"localized.publish 必须且只能指定 translation_filefrom_worker", "localized.publish 必须且只能指定 translation_filefrom_worker 或 patch_manifest",
)); ));
} }
let mut config = base_config.clone(); let mut config = base_config.clone();
@@ -3501,6 +3562,7 @@ fn localized_publish_rpc_report(
config, config,
state_dir: state_dir.to_path_buf(), state_dir: state_dir.to_path_buf(),
translation_file, translation_file,
patch_manifest,
translation_from_worker: params.from_worker, translation_from_worker: params.from_worker,
localized_release_id: normalize_optional_rpc_string(params.localized_release_id), localized_release_id: normalize_optional_rpc_string(params.localized_release_id),
..CliOptions::default() ..CliOptions::default()
@@ -6844,6 +6906,9 @@ fn parse_args_with_env(
options.translation_file = options.translation_file =
Some(PathBuf::from(next_option_value(&mut args, &flag)?)); Some(PathBuf::from(next_option_value(&mut args, &flag)?));
} }
"--patch-manifest" | "--patch-manifest-file" => {
options.patch_manifest = Some(PathBuf::from(next_option_value(&mut args, &flag)?));
}
"--translation-id" => { "--translation-id" => {
options.translation_id = Some(next_option_value(&mut args, &flag)?); options.translation_id = Some(next_option_value(&mut args, &flag)?);
} }
@@ -7554,6 +7619,9 @@ fn parse_args_with_env(
if options.command != CliCommand::PublishLocalized && options.translation_from_worker { if options.command != CliCommand::PublishLocalized && options.translation_from_worker {
return Err(anyhow::anyhow!("--from-worker 只适用于 i18n publish")); return Err(anyhow::anyhow!("--from-worker 只适用于 i18n publish"));
} }
if options.command != CliCommand::PublishLocalized && options.patch_manifest.is_some() {
return Err(anyhow::anyhow!("--patch-manifest 只适用于 i18n publish"));
}
match options.command { match options.command {
CliCommand::Status | CliCommand::Stop | CliCommand::Logs => { CliCommand::Status | CliCommand::Stop | CliCommand::Logs => {
@@ -7805,10 +7873,13 @@ fn parse_args_with_env(
)); ));
} }
if matches!(options.command, CliCommand::PublishLocalized) if matches!(options.command, CliCommand::PublishLocalized)
&& options.translation_file.is_some() == options.translation_from_worker && (usize::from(options.translation_file.is_some())
+ usize::from(options.translation_from_worker)
+ usize::from(options.patch_manifest.is_some())
!= 1)
{ {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"i18n publish 必须且只能指定 --translation-file--from-worker" "i18n publish 必须且只能指定 --translation-file--from-worker 或 --patch-manifest"
)); ));
} }
} }
+17
View File
@@ -659,6 +659,19 @@ fn grouped_workflow_commands_use_short_top_level_aliases() {
assert_eq!(options.command, CliCommand::PublishLocalized); assert_eq!(options.command, CliCommand::PublishLocalized);
assert!(options.translation_from_worker); assert!(options.translation_from_worker);
assert_eq!(options.localized_release_id.as_deref(), Some("localized-1")); assert_eq!(options.localized_release_id.as_deref(), Some("localized-1"));
let options = parse(&[
"bat",
"i18n",
"publish",
"--patch-manifest",
"/tmp/patch-manifest.json",
])
.unwrap();
assert_eq!(options.command, CliCommand::PublishLocalized);
assert_eq!(
options.patch_manifest,
Some(PathBuf::from("/tmp/patch-manifest.json"))
);
assert!(parse(&[ assert!(parse(&[
"bat", "bat",
"i18n", "i18n",
@@ -5107,6 +5120,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
file_count: 0, file_count: 0,
text_asset_operation_count: 0, text_asset_operation_count: 0,
files: Vec::new(), files: Vec::new(),
patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo { rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: None, previous_current_target: None,
remove_version_path: localized_version.clone(), remove_version_path: localized_version.clone(),
@@ -5243,6 +5257,7 @@ fn dispatch_localized_rollback_restores_manifest_previous_release() {
file_count: 0, file_count: 0,
text_asset_operation_count: 0, text_asset_operation_count: 0,
files: Vec::new(), files: Vec::new(),
patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo { rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: None, previous_current_target: None,
remove_version_path: previous.clone(), remove_version_path: previous.clone(),
@@ -5261,6 +5276,7 @@ fn dispatch_localized_rollback_restores_manifest_previous_release() {
file_count: 0, file_count: 0,
text_asset_operation_count: 0, text_asset_operation_count: 0,
files: Vec::new(), files: Vec::new(),
patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo { rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: Some(PathBuf::from("versions/release-1")), previous_current_target: Some(PathBuf::from("versions/release-1")),
remove_version_path: current.clone(), remove_version_path: current.clone(),
@@ -5362,6 +5378,7 @@ fn localized_status_keeps_published_release_during_manual_proofreading() {
file_count: 0, file_count: 0,
text_asset_operation_count: 0, text_asset_operation_count: 0,
files: Vec::new(), files: Vec::new(),
patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo { rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: None, previous_current_target: None,
remove_version_path: localized_version.clone(), remove_version_path: localized_version.clone(),
@@ -172,6 +172,7 @@ fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Re
if options.patch_kind.is_some() if options.patch_kind.is_some()
|| options.patch_source_path.is_some() || options.patch_source_path.is_some()
|| options.patch_patch_path.is_some() || options.patch_patch_path.is_some()
|| options.patch_manifest.is_some()
{ {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"{command} 不接受 --patch-kind、--source-file 或 --patch-file" "{command} 不接受 --patch-kind、--source-file 或 --patch-file"
@@ -191,6 +192,7 @@ fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::
|| options.unityfs_expected_value.is_some() || options.unityfs_expected_value.is_some()
|| options.unityfs_replacement_value.is_some() || options.unityfs_replacement_value.is_some()
|| options.unityfs_expected_semantic_value.is_some() || options.unityfs_expected_semantic_value.is_some()
|| options.patch_manifest.is_some()
{ {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令" "{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令"
@@ -339,6 +339,49 @@ pub(super) fn publish_localized_report(
options: &CliOptions, options: &CliOptions,
) -> anyhow::Result<LocalizedPatchReport> { ) -> anyhow::Result<LocalizedPatchReport> {
let (resource_root, official_release_id) = current_official_release(options)?; let (resource_root, official_release_id) = current_official_release(options)?;
if let Some(manifest_path) = options.patch_manifest.as_ref() {
let manifest_bytes = read_file_no_symlink(manifest_path, "generic patch manifest")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| {
anyhow::anyhow!("generic patch manifest 不存在:{}", manifest_path.display())
})?;
let manifest: bat_patch::PatchManifest = serde_json::from_slice(&manifest_bytes)
.map_err(|error| anyhow::anyhow!("generic patch manifest 无效:{error}"))?;
bat_patch::validate_patch_manifest(&manifest)
.map_err(|error| anyhow::anyhow!("generic patch manifest 无效:{error}"))?;
if manifest.source_version != official_release_id {
return Err(anyhow::anyhow!(
"generic patch source version={} 与当前官方 release={} 不一致",
manifest.source_version,
official_release_id
));
}
let localized_release_id = options
.localized_release_id
.clone()
.unwrap_or_else(|| manifest.target_version.clone());
if localized_release_id != manifest.target_version {
return Err(anyhow::anyhow!(
"generic patch target version={} 必须与 localized release id={} 一致",
manifest.target_version,
localized_release_id
));
}
let config = LocalizedPatchConfig::new(
resource_root,
options.config.localized_output_root.clone(),
official_release_id,
Vec::new(),
)
.with_archive_commands(
options.config.unzip_command.clone(),
options.config.zip_command.clone(),
)
.with_manifest(manifest)
.with_localized_release_id(localized_release_id)
.with_force(options.config.force);
return LocalizedPatchService::new().publish(&config);
}
let workbench = if options.translation_from_worker { let workbench = if options.translation_from_worker {
let runtime = tokio::runtime::Builder::new_current_thread() let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -1306,8 +1306,19 @@ func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryC
func validateLocalizedPublishParams(params backendrpc.LocalizedPublishParams) error { func validateLocalizedPublishParams(params backendrpc.LocalizedPublishParams) error {
hasFile := strings.TrimSpace(params.TranslationFile) != "" hasFile := strings.TrimSpace(params.TranslationFile) != ""
if hasFile == params.FromWorker { hasManifest := strings.TrimSpace(params.PatchManifest) != ""
return errors.New("localized publish requires exactly one of translation_file or from_worker") inputs := 0
if hasFile {
inputs++
}
if params.FromWorker {
inputs++
}
if hasManifest {
inputs++
}
if inputs != 1 {
return errors.New("localized publish requires exactly one of translation_file, from_worker, or patch_manifest")
} }
return nil return nil
} }
+9
View File
@@ -1954,6 +1954,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{name: "translation glossary deprecate", action: "translation-glossary-deprecate", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"retired"}`, rpcMethod: "translation.glossary.deprecate", call: "translation.glossary.deprecate"}, {name: "translation glossary deprecate", action: "translation-glossary-deprecate", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"retired"}`, rpcMethod: "translation.glossary.deprecate", call: "translation.glossary.deprecate"},
{name: "translation glossary delete", action: "translation-glossary-delete", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"duplicate"}`, rpcMethod: "translation.glossary.delete", call: "translation.glossary.delete"}, {name: "translation glossary delete", action: "translation-glossary-delete", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"duplicate"}`, rpcMethod: "translation.glossary.delete", call: "translation.glossary.delete"},
{name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"}, {name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"},
{name: "localized patch manifest publish", action: "localized-publish", body: `{"patch_manifest":"/tmp/patch-manifest.json","localized_release_id":"localized-1"}`, rpcMethod: "localized.publish", call: "localized.publish"},
{name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"}, {name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"},
} }
for _, tc := range tests { for _, tc := range tests {
@@ -2041,6 +2042,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
if recorder.Code != http.StatusBadRequest { if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid localized publish status=%d body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("invalid localized publish status=%d body=%s", recorder.Code, recorder.Body.String())
} }
request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"patch_manifest":"/tmp/patch-manifest.json"}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid localized patch manifest status=%d body=%s", recorder.Code, recorder.Body.String())
}
} }
func TestAdminLocalizedStatusRequiresAuthAndForwards(t *testing.T) { func TestAdminLocalizedStatusRequiresAuthAndForwards(t *testing.T) {
+2
View File
@@ -730,6 +730,8 @@ paths:
type: string type: string
from_worker: from_worker:
type: boolean type: boolean
patch_manifest:
type: string
localized_release_id: localized_release_id:
type: string type: string
responses: responses:
+3 -1
View File
@@ -637,10 +637,12 @@ type GlossaryDiagnoseReport struct {
} }
// LocalizedPublishParams selects the source of one localized release // LocalizedPublishParams selects the source of one localized release
// publication. TranslationFile and FromWorker are mutually exclusive. // publication. Exactly one of TranslationFile, FromWorker, or PatchManifest
// must be set.
type LocalizedPublishParams struct { type LocalizedPublishParams struct {
TranslationFile string `json:"translation_file,omitempty"` TranslationFile string `json:"translation_file,omitempty"`
FromWorker bool `json:"from_worker,omitempty"` FromWorker bool `json:"from_worker,omitempty"`
PatchManifest string `json:"patch_manifest,omitempty"`
LocalizedReleaseID string `json:"localized_release_id,omitempty"` LocalizedReleaseID string `json:"localized_release_id,omitempty"`
Force bool `json:"force,omitempty"` Force bool `json:"force,omitempty"`
} }
+32
View File
@@ -797,6 +797,38 @@ func TestLocalizedPublishSendsWorkerSourceAndReleaseOptions(t *testing.T) {
} }
} }
func TestLocalizedPublishSendsPatchManifestSource(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
var params LocalizedPublishParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.PatchManifest != "/tmp/patch-manifest.json" ||
params.TranslationFile != "" || params.FromWorker {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-localized-patch-manifest",
Data: map[string]any{"status": "published"},
},
}
})
raw, err := client.LocalizedPublish(context.Background(), LocalizedPublishParams{
PatchManifest: "/tmp/patch-manifest.json",
LocalizedReleaseID: "localized-v1",
})
if err != nil {
t.Fatalf("LocalizedPublish error: %v", err)
}
if !json.Valid(raw) {
t.Fatalf("invalid raw JSON: %s", string(raw))
}
}
func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) { func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "localized.rollback" { if req.Method != "localized.rollback" {