feat: track official versions and import resources

Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures.

Fixes #13

Fixes #12

Fixes #8
This commit is contained in:
2026-07-15 19:53:10 +08:00
parent 4192d7ee4c
commit 90307a3243
30 changed files with 1269 additions and 157 deletions
+3
View File
@@ -26,6 +26,9 @@
- 新增真实官方网络全量拉取 smoke:`scripts/official-full-pull-smoke.sh``make official-smoke``docs/guides/official-full-pull-smoke.md`
- 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验
- 新增下载失败分类与 quarantine403/404/普通 4xx 不重试,5xx/429/网络类错误重试,最终失败写入 `official-download-quarantine.json` 并阻止发布不完整资源;旧 launcher 包支持官方 primary/backup CDN 切换
- 新增官方版本状态管理:`official-version-state.json` 持久化当前已完成版本、正在拉取版本、上一个可用版本和失败版本,并在 `bat status` 中暴露状态摘要
- 优化资源导入链路:CAS 导入会写入 `ResourceRepository`AssetBundle 记录 UnityFS 摘要,TextAsset/Table/Media 按资源类型分类并可被索引
- 新增离线回归 fixture:当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和官方 seed hash mismatch 样本
- 新增 Addressables 当前真实形态 fixture/golden 测试
- 新增 SQLite Resource Repository 和粗粒度 FFI JSON 接口
- 补齐官方资源运行、架构、状态、缺口和交接文档
+6 -4
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 当前工作区状态
- **更新时间**2026-07-14
- **更新时间**2026-07-15
- **状态来源**:本地工作区盘点、代码验证和最新提交
- **状态分支**`experiment`
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
@@ -24,7 +24,8 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
8. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
9. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/<id>``.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;后台状态目录包含 `bat.sock``bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
10. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
11. `bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、结构化日志路径和轮转日志路径。
11. `<output>/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、结构化日志路径和轮转日志路径。
12. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、403/404、hash mismatch 均有离线回归 fixture。
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook,真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。
@@ -88,12 +89,13 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
- Manifest driver trait、Addressables driver、注册表。
- Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。
- 真实形态 Addressables fixture/golden 测试。
- 当前 catalog、上一个版本 catalog、结构变化 catalog 的离线回归 fixture。
- 官方日服 `server-info`、URL 规则、平台 discovery 和 inventory 枚举。
待完成:
- Unity bundle parse/serialize 仍是后续阶段能力。
- Addressables parser 仍需继续覆盖更多官方 catalog 结构变体和失败诊断。
- Unity bundle serialize 仍是后续阶段能力。
- Addressables parser 仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
### `bat-cas-engine`
+4 -2
View File
@@ -12,8 +12,10 @@
- `bat-core` 领域对象和仓储接口骨架。
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖。
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、官方资源 pull/update 服务。
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC 后台控制命令 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable`
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。
- `bat-ffi` 粗粒度 JSON 接口:Manifest inspect 和官方 sync plan。
- 文档路线图、当前状态、缺口清单、官方资源运行指南。
@@ -96,7 +98,7 @@ cargo run -p bat-infrastructure --bin bat -- stop
`bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
非 dry-run 同步不会把新文件直接写进生产可读目录。资源会先下载到 `<output>/.staging/<id>`,完成 manifest、BLAKE3、ZIP 和官方 `.hash` 校验后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应只读取 `<output>/current`
非 dry-run 同步不会把新文件直接写进生产可读目录。资源会先下载到 `<output>/.staging/<id>`,完成 manifest、BLAKE3、ZIP 和官方 `.hash` 校验后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应只读取 `<output>/current`同步过程会更新 `<output>/official-version-state.json`:下载开始时写入 `in_progress_version`,发布成功后写入 `current_completed_version``previous_available_version`,失败或中断时写入 `failed_versions`
资源操作命令默认输出人类可读摘要,并在没有显式 metadata 参数时默认走官方自动发现。脚本或上层程序需要稳定结构化输出时加 `--json`
+55 -5
View File
@@ -187,12 +187,32 @@ impl AddressablesCatalogDriver {
}
fn resource_type_for_path(path: &str) -> ResourceType {
if path.contains("TableBundles/") || path.ends_with(".bytes") {
ResourceType::TableBundle
} else if path.ends_with(".bundle") {
ResourceType::AssetBundle
} else if path.contains("catalog") {
let normalized = path.replace('\\', "/").to_ascii_lowercase();
if normalized.contains("catalog") || normalized.ends_with(".hash") {
ResourceType::Manifest
} else if normalized.contains("tablebundles/") {
ResourceType::TableBundle
} else if normalized.contains("mediaresources/")
|| normalized.contains("mediaresources-")
|| matches!(
normalized.rsplit('.').next(),
Some(
"mp3" | "mp4" | "ogg" | "wav" | "png" | "jpg" | "jpeg" | "webp" | "acb" | "awb"
)
)
{
ResourceType::Media
} else if normalized.ends_with(".bundle") {
ResourceType::AssetBundle
} else if normalized.contains("textassets/")
|| matches!(
normalized.rsplit('.').next(),
Some("txt" | "csv" | "xml" | "yaml" | "yml")
)
{
ResourceType::TextAsset
} else if normalized.ends_with(".bytes") {
ResourceType::TableBundle
} else {
ResourceType::Other
}
@@ -210,6 +230,16 @@ impl AddressablesCatalogDriver {
return ResourceType::Manifest;
}
if resource_type_name.is_some_and(|name| name.contains("TextAsset")) {
return ResourceType::TextAsset;
}
if resource_type_name.is_some_and(|name| {
name.contains("AudioClip") || name.contains("VideoClip") || name.contains("Texture2D")
}) {
return ResourceType::Media;
}
Self::resource_type_for_path(path)
}
@@ -1017,4 +1047,24 @@ mod tests {
ResourceType::TableBundle
);
}
#[tokio::test]
async fn test_parse_text_and_media_resource_types() {
let driver = AddressablesCatalogDriver::new();
let catalog_json = r#"{
"m_LocatorId": "AddressablesMainContentCatalog",
"m_Entries": [
{"internal_id": "TextAssets/dialogue.csv"},
{"internal_id": "MediaResources-Windows/voice/academy.acb"},
{"internal_id": "MediaResources-Windows/movie/opening.mp4"}
]
}"#;
let manifest = driver.parse(catalog_json.as_bytes()).await.unwrap();
assert_eq!(manifest.resources[0].resource_type, ResourceType::TextAsset);
assert_eq!(manifest.resources[1].resource_type, ResourceType::Media);
assert_eq!(manifest.resources[2].resource_type, ResourceType::Media);
}
}
+3 -3
View File
@@ -114,7 +114,7 @@ fn parse_obfuscated_game_main_config(bytes: &[u8]) -> Result<YostarJpGameMainCon
}
fn decrypt_inferred_ascii_json(bytes: &[u8]) -> Result<String, String> {
if bytes.len() % 2 != 0 {
if !bytes.len().is_multiple_of(2) {
return Err("Obfuscated GameMainConfig byte length is not UTF-16LE aligned".to_string());
}
@@ -288,7 +288,7 @@ fn xor_bytes(bytes: &mut [u8], key: &[u8]) {
}
fn utf16le_to_string(bytes: &[u8]) -> Result<String, String> {
if bytes.len() % 2 != 0 {
if !bytes.len().is_multiple_of(2) {
return Err("GameMainConfig decrypted byte length is not UTF-16LE aligned".into());
}
@@ -454,7 +454,7 @@ mod tests {
fn encrypt_utf16le_xor(value: &str, key: &[u8]) -> Vec<u8> {
let mut utf16 = utf16le_bytes(value);
xor_bytes(&mut utf16, &key);
xor_bytes(&mut utf16, key);
utf16
}
+2 -2
View File
@@ -965,8 +965,8 @@ mod tests {
};
let delta = current.diff(Some(&previous));
assert!(delta.connection_group_changed == false);
assert!(delta.app_version_changed == false);
assert!(!delta.connection_group_changed);
assert!(!delta.app_version_changed);
assert!(delta.bundle_version_changed);
assert!(delta.addressables_root_changed);
assert!(delta.root_token_changed);
+1 -1
View File
@@ -78,7 +78,7 @@ impl UnitySerializedFile {
)?);
}
let big_id_enabled = if version >= 11 && version < 14 {
let big_id_enabled = if (11..14).contains(&version) {
reader.read_i32("big_id_enabled")?
} else {
0
+68
View File
@@ -0,0 +1,68 @@
use bat_adapters::manifest::ManifestDriverRegistry;
use bat_core::domain::ResourceType;
#[tokio::test]
async fn parses_current_catalog_fixture_with_resource_categories() {
let manifest = ManifestDriverRegistry::with_defaults()
.parse(include_bytes!(
"fixtures/addressables_regression/current_catalog.json"
))
.await
.unwrap();
assert_eq!(manifest.resources.len(), 4);
assert_eq!(
manifest.resources[0].resource_type,
ResourceType::TableBundle
);
assert_eq!(manifest.resources[1].resource_type, ResourceType::Media);
assert_eq!(manifest.resources[2].resource_type, ResourceType::TextAsset);
assert_eq!(
manifest.resources[3].dependencies,
vec!["shared_dependencies.bundle".to_string()]
);
assert_eq!(
manifest.metadata.cdn_prefixes,
vec!["https://fixture.invalid/current/".to_string()]
);
}
#[tokio::test]
async fn parses_previous_catalog_fixture_using_internal_ids() {
let manifest = ManifestDriverRegistry::with_defaults()
.parse(include_bytes!(
"fixtures/addressables_regression/previous_catalog.json"
))
.await
.unwrap();
assert_eq!(manifest.resources.len(), 3);
assert_eq!(
manifest.resources[0].resource_type,
ResourceType::AssetBundle
);
assert_eq!(manifest.resources[1].resource_type, ResourceType::Manifest);
assert_eq!(
manifest.resources[2].resource_type,
ResourceType::TableBundle
);
}
#[tokio::test]
async fn parses_catalog_structure_change_with_alias_fields() {
let manifest = ManifestDriverRegistry::with_defaults()
.parse(include_bytes!(
"fixtures/addressables_regression/structure_changed_catalog.json"
))
.await
.unwrap();
assert_eq!(manifest.resources.len(), 2);
assert_eq!(manifest.resources[0].resource_type, ResourceType::Media);
assert_eq!(
manifest.resources[0].dependencies,
vec!["shared_assets_current.bundle".to_string()]
);
assert_eq!(manifest.resources[1].resource_type, ResourceType::TextAsset);
assert_eq!(manifest.resources[1].address.as_deref(), Some("lesson"));
}
@@ -0,0 +1,38 @@
{
"m_LocatorId": "AddressablesMainContentCatalog",
"m_InternalIdPrefixes": [
"https://fixture.invalid/current/"
],
"m_Entries": [
{
"internal_id": "TableBundles/ExcelDB.db",
"hash": "current-table-hash",
"size": 4096,
"address": "ExcelDB",
"dependencies": []
},
{
"internal_id": "MediaResources-Windows/voice/title.acb",
"hash": "current-media-hash",
"size": 2048,
"address": "title",
"dependencies": []
},
{
"internal_id": "TextAssets/dialogue.csv",
"hash": "current-text-hash",
"size": 128,
"address": "dialogue",
"dependencies": []
},
{
"internal_id": "shared_assets_current.bundle",
"hash": "current-bundle-hash",
"size": 8192,
"address": "shared_assets_current",
"dependencies": [
"shared_dependencies.bundle"
]
}
]
}
@@ -0,0 +1,9 @@
{
"m_LocatorId": "AddressablesMainContentCatalog",
"m_InternalIdPrefixes": [],
"m_InternalIds": [
"shared_assets_previous.bundle",
"catalog_previous.json",
"TableBundles/ExcelDB.db"
]
}
@@ -0,0 +1,24 @@
{
"m_LocatorId": "AddressablesMainContentCatalog",
"m_InternalIdPrefixes": [],
"m_InternalIds": [
"fallback.bundle"
],
"m_Entries": [
{
"InternalId": "MediaResources-Android/voice/title.awb",
"Hash": "changed-media-hash",
"Size": 65536,
"Address": "title-android",
"m_Dependencies": [
"shared_assets_current.bundle"
]
},
{
"Path": "TextAssets/lesson.json",
"Hash": "changed-text-hash",
"Size": 512,
"Key": "lesson"
}
]
}
+4
View File
@@ -11,6 +11,10 @@ pub enum ResourceType {
Manifest,
/// TableBundle
TableBundle,
/// TextAsset
TextAsset,
/// Media resource
Media,
/// 其他
Other,
}
@@ -238,6 +238,9 @@ Linux 生产路径:
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress logstdout 默认输出人类可读摘要;progress log 覆盖总体下载进度、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status``stop``restart``reload``logs``refresh --force``verify``repair``doctor``clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging``versions``current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态
- curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN
- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本,`bat status` 会暴露版本状态摘要
- 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类
- 离线回归样本已覆盖当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和 seed hash mismatch
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
- 下载层能在本地文件 size/BLAKE3/path、ZIP 结构或 manifest 不匹配时重新下载
- 官方 seed `.hash` mismatch 会导致下载失败,而不是降级为本地 BLAKE3 猜测
@@ -161,6 +161,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
- 旧 launcher 包或 `resources.assets` 下载路径使用官方 launcher CDN 配置,primary CDN 失败后会切换官方 backup CDN;资源 patch host 当前只使用 server-info 返回的官方 client-patch host,不猜测非官方镜像。
- 远端和本地都一致:单次模式输出 `update_status=up_to_date`,watch 模式默认静默并等待下次检查。
- 有远端变化或本地 repair:生成 pull plan,下载完整官方资源到 staging,成功后更新 snapshot 并原子发布到 `current`
- 非 dry-run 会维护 `<output>/official-version-state.json`:开始下载后写入 `in_progress_version`,发布成功后写入 `current_completed_version``previous_available_version`,失败或中断后写入 `failed_versions`
- `--dry-run`:只报告本次是否会下载,不写 snapshot;如果 cache miss,也不会写入新的 bootstrap cache。
- `--dry-run --plan`:除更新判断外,还会解析 seed catalog 并打印完整下载 URL。
- 真实更新会输出 `downloaded_count``resumed_count``skipped_count``transferred_bytes``official_seed_hash_verified_count`
@@ -171,6 +172,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
- `<output>/current/official-sync-snapshot.json`:上一次成功同步的 v2 snapshot,包含 app version、connection group、bundle version、addressables root、endpoint URL、官方 seed `.hash` 内容、Addressables `catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。
- `<output>/official-bootstrap-cache.json``--auto-discover``GameMainConfig` 解析缓存。launcher metadata 未变时复用缓存;metadata 变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
- `<output>/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。
- `<output>/current/official-download-quarantine.json` 或当前 staging 下同名文件:下载最终失败的 URL 诊断记录,包含失败类型、HTTP 状态、是否可重试、尝试次数和最后错误。
+71 -1
View File
@@ -228,7 +228,8 @@
影响:
- `SqliteResourceRepository` 已存在,可按领域 repository 接口保存资源元数据。
- 官方同步下载结果尚未作为用户级流程自动写入 CAS + ResourceRepository。
- `ResourceImportService` 已能把 manifest 中有数据的资源写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会分类索引
- 官方同步下载结果尚未作为用户级流程自动触发导入 CAS + ResourceRepository。
- 迁移、版本化 schema 和 CLI 查询入口仍需补齐。
验收:
@@ -237,6 +238,75 @@
- 可按版本、类型、hash、路径查询资源。
- 官方同步后的资源可通过 CLI 查询并能追溯到 CAS 对象。
### G-011A:资源导入链路基础能力不足
状态:**已关闭**
历史现象:
- 资源导入链路只导入 AssetBundle。
- 非 AssetBundle manifest 条目只会被跳过。
- 导入报告只包含 UnityFS 基础摘要,不包含稳定分类统计。
处理结果:
- `ResourceImportService` 会把有数据的 manifest 条目导入 CAS 并写入 `ResourceRepository`
- AssetBundle 仍执行 UnityFS header/block/directory 摘要解析。
- TextAsset、TableBundle、Media 会按资源类型分类,缺少数据时记录为 skipped,便于渐进导入。
- `ResourceImportReport` 增加 `category_counts``ImportedResource` 增加 `resource_type``category` 和可选 UnityFS 摘要。
验收:
- `cargo test -p bat-infrastructure import::tests::`
- `cargo test -p bat-infrastructure --test synthetic_phase2_import`
### G-011B:官方版本状态管理不明确
状态:**已关闭**
历史现象:
- 当前可用版本主要靠 `current` symlink 和 release 内 snapshot 推断。
- 未显式保存“正在拉取版本”和“失败版本”。
- 上一个可用版本需要从目录状态间接判断。
处理结果:
- 新增 `<output>/official-version-state.json`
- 开始下载后写入 `in_progress_version`
- 发布成功后写入 `current_completed_version``previous_available_version`
- 失败或中断后写入 `failed_versions` 并清空 in-progress。
- `bat status` 会读取并展示版本状态摘要。
验收:
- `cargo test -p bat-infrastructure version_state`
- `cargo test -p bat-infrastructure --test official_game_main_config_bootstrap`
### G-011C:真实 fixture 与回归样本不足
状态:**已关闭当前阶段**
历史现象:
- 已有 Addressables real-shape fixture/golden,但缺少按问题类型命名的当前/上一版本/结构变化样本。
- 403/404 和 hash mismatch 主要依赖单测内联构造,不便于后续回归扩展。
处理结果:
- 新增 `adapters/tests/fixtures/addressables_regression/current_catalog.json`
- 新增 `adapters/tests/fixtures/addressables_regression/previous_catalog.json`
- 新增 `adapters/tests/fixtures/addressables_regression/structure_changed_catalog.json`
- 新增 `infrastructure/tests/fixtures/official_regression/http_403.json`
- 新增 `infrastructure/tests/fixtures/official_regression/http_404.json`
- 新增 `infrastructure/tests/fixtures/official_regression/hash_mismatch_catalog.json`
- 对应测试会解析这些 fixture,防止样本只存在但不参与验证。
验收:
- `cargo test -p bat-adapters --test addressables_regression`
- `cargo test -p bat-infrastructure regression_fixture`
### G-012Translation Memory 未实现
影响:
+5 -5
View File
@@ -37,8 +37,8 @@
仍未完成:
- Go CLI 最小可用入口。
- 官方同步下载结果自动导入 CAS + ResourceRepository 的用户级流程。
- 完整 AssetBundle 解析
- 官方同步下载结果自动触发 CAS + ResourceRepository 导入的用户级流程。
- AssetBundle 对象表、TypeTree 和 TextAsset 提取
- Patch、翻译系统、API Server、Web。
## 3. 当前核查结果
@@ -64,11 +64,11 @@ git status --short
## 4. 当前结论
当前 Rust 官方资源同步链路已经具备可运行闭环,并已固化真实网络 smoke 的可重复命令但仓库还不是完整产品。下一步不应继续只扩展 example,而应补齐 Go CLI 最小入口、CAS/ResourceRepository 编排和 AssetBundle 解析。
当前 Rust 官方资源同步链路已经具备可运行闭环,并已固化真实网络 smoke 的可重复命令;版本状态、资源导入基础链路和离线回归 fixture 已补齐。但仓库还不是完整产品。下一步不应继续只扩展 example,而应补齐 Go CLI 最小入口、官方同步后自动导入编排和 AssetBundle 对象级解析。
## 5. 下一步
1. 实现 Go CLI`bat doctor``bat sync --help``bat official sync --help`
2.`docs/guides/official-full-pull-smoke.md` 在具备网络和磁盘窗口的环境中执行真实官方网络 smoke,输出目录必须隔离。
3. 将官方同步下载结果接入 CAS + `SqliteResourceRepository`
4. 开始 AssetBundle UnityFS header/block/directory 解析
3. 将官方同步下载结果自动接入 CAS + `SqliteResourceRepository` 的用户级命令
4. 开始 AssetBundle 对象表、TypeTree 和 TextAsset 提取
+7 -4
View File
@@ -1,6 +1,6 @@
# 当前进度交接
- **更新时间**2026-07-14
- **更新时间**2026-07-15
- **状态分支**`experiment`
- **最新功能提交**:以当前 `git log --oneline -1` 为准
- **用途**:给下一次对话快速恢复上下文,优先以当前工作区和已验证结果为准。
@@ -23,6 +23,9 @@
- `--watch` 是 Rust 内部常驻检查模式,正常检查默认 1 小时,失败重试默认 60 秒;外部 systemd/container 只负责守护进程。
- `bat` progress log 会输出总体下载进度、单文件进度和校验结果摘要。
- 真实官方网络 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke``docs/guides/official-full-pull-smoke.md`,默认写入 `/tmp` 隔离目录。
- `official-version-state.json` 已持久化当前完成、正在拉取、上一个可用和失败版本,`bat status` 会显示版本状态摘要。
- 资源导入链路已支持 CAS + `ResourceRepository` 写入,AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类索引。
- 当前 catalog、上一个版本 catalog、结构变化 catalog、403、404、hash mismatch 已有离线回归 fixture。
## 2. 现在不要误解的点
@@ -39,9 +42,9 @@
1. Go CLI 最小入口:`bat doctor``bat sync --help``bat official sync --help`
2. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方网络 smoke,并保留隔离目录报告。
3. 官方同步下载结果进入 CAS + `SqliteResourceRepository` 的用户级流程。
4. Addressables parser 覆盖更多真实 catalog 结构
5. AssetBundle UnityFS header/block/directory 解析
3. 官方同步下载结果自动触发 CAS + `SqliteResourceRepository` 的用户级导入流程。
4. Addressables parser 覆盖二进制/压缩字段组合和更细失败诊断
5. AssetBundle 对象表、TypeTree 和 TextAsset 提取链路继续推进
6. Patch、翻译库、API Server、Web。
## 4. 下一次对话最合适的起点
@@ -379,7 +379,7 @@ fn main() -> anyhow::Result<()> {
let pull_plan = pull_plan
.as_ref()
.ok_or_else(|| anyhow::anyhow!("download requested but no pull plan exists"))?;
let report = fetcher.pull(&pull_plan).map_err(anyhow::Error::msg)?;
let report = fetcher.pull(pull_plan).map_err(anyhow::Error::msg)?;
let final_audit = fetcher
.audit_local_manifest(pull_plan)
.map_err(anyhow::Error::msg)?;
+84 -67
View File
@@ -1,9 +1,9 @@
use bat_adapters::official::yostar_jp::PatchPlatform;
use bat_infrastructure::{
lexical_absolute, open_append_file, read_file_no_symlink, validate_output_root,
validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource, OfficialUpdateConfig,
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus,
OfficialVerificationSummary, PRIVATE_FILE_MODE,
lexical_absolute, open_append_file, read_file_no_symlink, read_version_state,
validate_output_root, validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionState, PRIVATE_FILE_MODE,
};
use serde::{Deserialize, Serialize};
use std::env;
@@ -608,17 +608,7 @@ fn record_daemon_status(
return;
}
if let Err(error) = update_daemon_status(
state_dir,
update.state,
update.last_update_status,
update.last_error,
update.next_retry_seconds,
update.last_success_unix_seconds,
update.next_check_unix_seconds,
update.pending_scheduled_force,
update.next_forced_refresh_at,
) {
if let Err(error) = update_daemon_status(state_dir, update) {
logger.log_text("daemon", format!("更新后台状态失败:{error}"));
}
}
@@ -731,10 +721,7 @@ fn classify_pid_lock_file(path: &Path) -> anyhow::Result<PidLockState> {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Ok(PidLockState::Corrupt);
}
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) => return Err(error.into()),
};
let contents = fs::read_to_string(path)?;
let Some(pid) = parse_pid_value(&contents) else {
return Ok(PidLockState::Corrupt);
};
@@ -1322,6 +1309,8 @@ struct DaemonStatusReport {
current_stage: Option<String>,
current_message: Option<String>,
download_progress: Option<DaemonDownloadProgress>,
version_state_path: Option<PathBuf>,
version_state: Option<OfficialVersionState>,
pending_scheduled_force: Option<bool>,
next_forced_refresh_unix_seconds: Option<u64>,
command: Option<Vec<String>>,
@@ -1600,6 +1589,14 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
.unwrap_or_else(|| daemon_structured_log_path(state_dir));
let structured_log_exists = path_exists_no_follow(&structured_log_path)?;
let rotated_structured_log_paths = rotated_structured_log_paths(&structured_log_path);
let version_state_path = status_file.as_ref().map(|status| {
status
.resource_output_root
.join("official-version-state.json")
});
let version_state = version_state_path
.as_ref()
.and_then(|path| read_version_state(path).ok().flatten());
Ok(DaemonStatusReport {
status: if running { "running" } else { "stopped" },
@@ -1659,6 +1656,8 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
download_progress: status_file
.as_ref()
.and_then(|status| status.download_progress.clone()),
version_state_path,
version_state,
pending_scheduled_force: status_file
.as_ref()
.map(|status| status.pending_scheduled_force),
@@ -1896,6 +1895,8 @@ fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
print_json_field(value, "current_stage", "当前阶段");
print_json_field(value, "current_message", "当前消息");
print_json_field(value, "download_progress", "下载进度");
print_json_field(value, "version_state_path", "版本状态路径");
print_json_field(value, "version_state", "版本状态");
print_json_field(value, "resource_output_root", "资源目录");
print_json_field(value, "state_dir", "状态目录");
print_json_field(value, "socket_path", "socket");
@@ -2102,6 +2103,7 @@ impl HumanReport for OfficialUpdateReport {
print_path_field("输出根目录", &self.output_root);
print_path_field("active release", &self.active_resource_root);
print_path_field("current", &self.current_path);
print_path_field("version state", &self.version_state_path);
print_optional_path_field("staging", self.staging_path.as_ref());
print_optional_path_field("published", self.published_version_path.as_ref());
print_path_field("snapshot", &self.snapshot_path);
@@ -2174,7 +2176,32 @@ impl HumanReport for DaemonStatusReport {
if let Some(progress) = self.download_progress.as_ref() {
print_field("下载进度", format_daemon_download_progress(progress));
}
if let Some(version_state) = self.version_state.as_ref() {
print_optional_field(
"当前完成版本",
version_state
.current_completed_version
.as_ref()
.map(|version| version.id.as_str()),
);
print_optional_field(
"正在拉取版本",
version_state
.in_progress_version
.as_ref()
.map(|version| version.id.as_str()),
);
print_optional_field(
"上一个可用版本",
version_state
.previous_available_version
.as_ref()
.map(|version| version.id.as_str()),
);
print_field("失败版本数", version_state.failed_versions.len());
}
print_optional_path_field("资源目录", self.resource_output_root.as_ref());
print_optional_path_field("版本状态", self.version_state_path.as_ref());
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_optional_path_field("日志", self.log_path.as_ref());
@@ -2499,29 +2526,26 @@ struct DoctorReport {
}
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
let mut checks = Vec::new();
checks.push(path_check(
"state_dir",
&options.state_dir,
"后台状态目录可用",
));
checks.push(path_check(
let mut checks = vec![
path_check("state_dir", &options.state_dir, "后台状态目录可用"),
path_check(
"output_root",
&options.config.output_root,
"资源输出目录可用",
));
checks.push(safety_check(
),
safety_check(
"output_root_safety",
validate_output_root(&options.config.output_root),
"资源输出目录安全边界通过",
));
checks.push(safety_check(
),
safety_check(
"state_dir_safety",
validate_runtime_state_dir(&options.state_dir),
"后台状态目录安全边界通过",
));
checks.push(command_check("curl", &options.config.curl_command));
checks.push(command_check("unzip", &options.config.unzip_command));
),
command_check("curl", &options.config.curl_command),
command_check("unzip", &options.config.unzip_command),
];
let pid_path = daemon_pid_path(&options.state_dir);
let daemon_running = read_pid_file(&pid_path)
@@ -2864,17 +2888,7 @@ fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::R
Ok(())
}
fn update_daemon_status(
state_dir: &Path,
state: &str,
last_update_status: Option<String>,
last_error: Option<String>,
next_retry_seconds: Option<u64>,
last_success_unix_seconds: Option<u64>,
next_check_unix_seconds: Option<u64>,
pending_scheduled_force: bool,
next_forced_refresh_at: SystemTime,
) -> anyhow::Result<()> {
fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> anyhow::Result<()> {
let status_path = daemon_status_path(state_dir);
let mut status = read_daemon_status_file(&status_path)?.unwrap_or_else(|| DaemonStatusFile {
version: DAEMON_STATUS_VERSION,
@@ -2899,22 +2913,23 @@ fn update_daemon_status(
command: env::args().collect(),
});
status.pid = std::process::id();
status.state = state.to_string();
status.state = update.state.to_string();
status.updated_unix_seconds = unix_seconds_now();
status.last_update_status = last_update_status;
status.last_error = last_error;
status.next_retry_seconds = next_retry_seconds;
if last_success_unix_seconds.is_some() {
status.last_success_unix_seconds = last_success_unix_seconds;
status.last_update_status = update.last_update_status;
status.last_error = update.last_error;
status.next_retry_seconds = update.next_retry_seconds;
if update.last_success_unix_seconds.is_some() {
status.last_success_unix_seconds = update.last_success_unix_seconds;
}
status.next_check_unix_seconds = next_check_unix_seconds;
if state != "running" {
status.next_check_unix_seconds = update.next_check_unix_seconds;
if update.state != "running" {
status.current_stage = None;
status.current_message = None;
status.download_progress = None;
}
status.pending_scheduled_force = pending_scheduled_force;
status.next_forced_refresh_unix_seconds = system_time_to_unix_seconds(next_forced_refresh_at);
status.pending_scheduled_force = update.pending_scheduled_force;
status.next_forced_refresh_unix_seconds =
system_time_to_unix_seconds(update.next_forced_refresh_at);
write_daemon_status_file(&status_path, &status)
}
@@ -3130,11 +3145,11 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
fn format_duration_arg(duration: Duration) -> String {
let millis = duration.as_millis();
if millis % 3_600_000 == 0 {
if millis.is_multiple_of(3_600_000) {
format!("{}h", millis / 3_600_000)
} else if millis % 60_000 == 0 {
} else if millis.is_multiple_of(60_000) {
format!("{}m", millis / 60_000)
} else if millis % 1_000 == 0 {
} else if millis.is_multiple_of(1_000) {
format!("{}s", millis / 1_000)
} else {
format!("{millis}ms")
@@ -3825,7 +3840,7 @@ fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result
"verify 只验证本地资源,不能和 --force 同时使用"
));
}
if options.config.repair == false && options.config.audit_local == false {
if !options.config.repair && !options.config.audit_local {
return Err(anyhow::anyhow!(
"verify 必须启用本地审计;不要同时传 --no-repair --no-audit-local"
));
@@ -3854,7 +3869,7 @@ fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()>
}
fn tools_are_non_default(config: &OfficialUpdateConfig) -> bool {
config.curl_command != PathBuf::from("curl") || config.unzip_command != PathBuf::from("unzip")
config.curl_command != Path::new("curl") || config.unzip_command != Path::new("unzip")
}
fn next_option_value(
@@ -4503,14 +4518,16 @@ mod tests {
let next_check = unix_seconds_after(Duration::from_secs(30));
update_daemon_status(
&state_dir,
"sleeping",
Some("downloaded".to_string()),
None,
Some(30),
Some(123456),
Some(next_check),
false,
SystemTime::now() + Duration::from_secs(3600),
DaemonStatusUpdate {
state: "sleeping",
last_update_status: Some("downloaded".to_string()),
last_error: None,
next_retry_seconds: Some(30),
last_success_unix_seconds: Some(123456),
next_check_unix_seconds: Some(next_check),
pending_scheduled_force: false,
next_forced_refresh_at: SystemTime::now() + Duration::from_secs(3600),
},
)
.unwrap();
let report = build_daemon_status_report(&state_dir).unwrap();
+236 -29
View File
@@ -4,6 +4,7 @@ use bat_adapters::manifest::GenericManifest;
use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry};
use bat_core::domain::{Resource, ResourceType};
use bat_core::repositories::{CasRepository, ResourceRepository};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// 待导入的 AssetBundle 数据。
@@ -32,12 +33,47 @@ pub struct ImportedResource {
pub id: String,
/// Manifest 中的资源路径。
pub source_path: String,
/// Manifest 或路径推断出的资源类型。
pub resource_type: ResourceType,
/// 导入链路使用的稳定分类标签。
pub category: ResourceImportCategory,
/// CAS 返回的对象 ID。
pub object_id: String,
/// 写入 CAS 的字节数。
pub bytes: u64,
/// UnityFS 解析摘要。
pub unityfs: UnityFsImportSummary,
/// UnityFS 解析摘要,仅 AssetBundle 资源会填充
pub unityfs: Option<UnityFsImportSummary>,
}
/// Stable resource category produced by the import pipeline.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ResourceImportCategory {
/// UnityFS AssetBundle resource.
AssetBundle,
/// Unity TextAsset or text-like payload.
TextAsset,
/// Table bundle/database resource.
Table,
/// Media payload such as audio, video, texture, or media archive.
Media,
/// Manifest/catalog sidecar.
Manifest,
/// Resource that does not match a known category yet.
Other,
}
impl ResourceImportCategory {
/// Returns a stable lowercase label for reports and golden tests.
pub fn as_str(self) -> &'static str {
match self {
Self::AssetBundle => "asset_bundle",
Self::TextAsset => "text_asset",
Self::Table => "table",
Self::Media => "media",
Self::Manifest => "manifest",
Self::Other => "other",
}
}
}
/// 导入时解析到的 UnityFS 基础结构摘要。
@@ -58,8 +94,10 @@ pub struct UnityFsImportSummary {
pub struct ResourceImportReport {
/// 成功导入的资源。
pub imported: Vec<ImportedResource>,
/// 被跳过的非 AssetBundle 资源路径。
/// 被跳过的资源路径。
pub skipped: Vec<String>,
/// 按稳定分类标签统计的成功导入数量。
pub category_counts: BTreeMap<String, usize>,
}
/// 将解析后的资源清单写入 CAS 与资源仓储的应用服务。
@@ -88,10 +126,12 @@ impl<'a> ResourceImportService<'a> {
}
}
/// 导入 manifest 中的 AssetBundle 资源
/// 导入 manifest 中可用的资源数据
///
/// AssetBundle 条目会记录到 `skipped`。AssetBundle 条目必须能在
/// `bundles` 中按完整路径或文件名找到对应数据,否则返回错误。
/// AssetBundle 条目必须能在 `bundles` 中按完整路径或文件名找到对应
/// 数据并成功解析 UnityFS,否则返回错误。TextAsset/Table/Media 等
/// 非 AssetBundle 条目在有数据时写入 CAS 和 `ResourceRepository`;缺少
/// 数据时记录到 `skipped`,用于渐进式导入官方下载结果。
pub async fn import_manifest_bundles(
&self,
manifest: &GenericManifest,
@@ -100,18 +140,23 @@ impl<'a> ResourceImportService<'a> {
let mut report = ResourceImportReport::default();
for entry in &manifest.resources {
if entry.resource_type != ResourceType::AssetBundle {
report.skipped.push(entry.path.clone());
continue;
}
let bundle = find_bundle(bundles, &entry.path).ok_or_else(|| {
bat_core::Error::InvalidArgument(format!(
let category = classify_import_category(entry.resource_type, &entry.path);
let Some(bundle) = find_bundle(bundles, &entry.path) else {
if entry.resource_type == ResourceType::AssetBundle {
return Err(bat_core::Error::InvalidArgument(format!(
"Missing bundle data for manifest resource: {}",
entry.path
))
})?;
let unityfs = self.parse_unityfs_summary(&entry.path, bundle).await?;
)));
}
report.skipped.push(entry.path.clone());
continue;
};
let unityfs = if entry.resource_type == ResourceType::AssetBundle {
Some(self.parse_unityfs_summary(&entry.path, bundle).await?)
} else {
None
};
let object_id = self.cas.store(&bundle.data).await?;
let mut stored_entry = entry.clone();
@@ -128,10 +173,16 @@ impl<'a> ResourceImportService<'a> {
report.imported.push(ImportedResource {
id,
source_path: entry.path.clone(),
resource_type: entry.resource_type,
category,
object_id,
bytes: bundle.data.len() as u64,
unityfs,
});
*report
.category_counts
.entry(category.as_str().to_string())
.or_insert(0) += 1;
}
Ok(report)
@@ -196,6 +247,43 @@ fn file_name(path: &str) -> Option<&str> {
Path::new(path).file_name()?.to_str()
}
fn classify_import_category(resource_type: ResourceType, path: &str) -> ResourceImportCategory {
match resource_type {
ResourceType::AssetBundle => ResourceImportCategory::AssetBundle,
ResourceType::TextAsset => ResourceImportCategory::TextAsset,
ResourceType::TableBundle => ResourceImportCategory::Table,
ResourceType::Media => ResourceImportCategory::Media,
ResourceType::Manifest => ResourceImportCategory::Manifest,
ResourceType::Other => classify_import_category_from_path(path),
}
}
fn classify_import_category_from_path(path: &str) -> ResourceImportCategory {
let normalized = path.replace('\\', "/").to_ascii_lowercase();
if normalized.contains("tablebundles/") {
ResourceImportCategory::Table
} else if normalized.contains("mediaresources/")
|| normalized.contains("mediaresources-")
|| matches!(
normalized.rsplit('.').next(),
Some("mp3" | "mp4" | "ogg" | "wav" | "png" | "jpg" | "jpeg" | "webp" | "acb" | "awb")
)
{
ResourceImportCategory::Media
} else if normalized.contains("textassets/")
|| matches!(
normalized.rsplit('.').next(),
Some("txt" | "csv" | "xml" | "yaml" | "yml")
)
{
ResourceImportCategory::TextAsset
} else if normalized.contains("catalog") || normalized.ends_with(".hash") {
ResourceImportCategory::Manifest
} else {
ResourceImportCategory::Other
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -287,6 +375,30 @@ mod tests {
address: None,
dependencies: Vec::new(),
},
ResourceEntry {
path: "TextAssets/dialogue.csv".to_string(),
hash: "synthetic-text-hash".to_string(),
size: 1,
resource_type: ResourceType::TextAsset,
address: Some("dialogue".to_string()),
dependencies: Vec::new(),
},
ResourceEntry {
path: "TableBundles/ExcelDB.db".to_string(),
hash: "synthetic-table-hash".to_string(),
size: 1,
resource_type: ResourceType::TableBundle,
address: None,
dependencies: Vec::new(),
},
ResourceEntry {
path: "MediaResources-Windows/voice/title.acb".to_string(),
hash: "synthetic-media-hash".to_string(),
size: 1,
resource_type: ResourceType::Media,
address: None,
dependencies: Vec::new(),
},
],
metadata: ManifestMetadata {
locator_id: None,
@@ -306,32 +418,78 @@ mod tests {
let report = service
.import_manifest_bundles(
&synthetic_manifest(),
&[BundleSource::new(
"minimal.bundle",
synthetic_minimal_unityfs_bundle(),
)],
&[
BundleSource::new("minimal.bundle", synthetic_minimal_unityfs_bundle()),
BundleSource::new("dialogue.csv", b"id,text\n1,hello".to_vec()),
BundleSource::new("ExcelDB.db", b"table-fixture".to_vec()),
BundleSource::new("title.acb", b"media-fixture".to_vec()),
],
)
.await
.unwrap();
assert_eq!(report.imported.len(), 1);
assert_eq!(report.imported.len(), 4);
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
assert!(cas.exists(&report.imported[0].object_id).await);
assert_eq!(report.imported[0].unityfs.unity_version, "2021.3.56f2");
assert_eq!(report.imported[0].unityfs.block_count, 1);
assert_eq!(report.imported[0].unityfs.directory_count, 1);
assert_eq!(report.imported[0].resource_type, ResourceType::AssetBundle);
assert_eq!(
report.imported[0].unityfs.directories,
vec!["SYNTHETIC-CAB".to_string()]
report.imported[0].category,
ResourceImportCategory::AssetBundle
);
let unityfs = report.imported[0].unityfs.as_ref().unwrap();
assert_eq!(unityfs.unity_version, "2021.3.56f2");
assert_eq!(unityfs.block_count, 1);
assert_eq!(unityfs.directory_count, 1);
assert_eq!(unityfs.directories, vec!["SYNTHETIC-CAB".to_string()]);
assert_eq!(
report.imported[1].category,
ResourceImportCategory::TextAsset
);
assert_eq!(report.imported[2].category, ResourceImportCategory::Table);
assert_eq!(report.imported[3].category, ResourceImportCategory::Media);
assert_eq!(report.category_counts.get("asset_bundle"), Some(&1));
assert_eq!(report.category_counts.get("text_asset"), Some(&1));
assert_eq!(report.category_counts.get("table"), Some(&1));
assert_eq!(report.category_counts.get("media"), Some(&1));
let indexed = resources.list(ResourceQuery::all()).await.unwrap();
assert_eq!(indexed.len(), 1);
assert_eq!(indexed[0].entry.hash, report.imported[0].object_id);
assert_eq!(indexed.len(), 4);
let indexed_bundle = indexed
.iter()
.find(|resource| resource.entry.path == "synthetic/minimal.bundle")
.unwrap();
assert_eq!(indexed_bundle.entry.hash, report.imported[0].object_id);
assert_eq!(
indexed[0].entry.size,
indexed_bundle.entry.size,
synthetic_minimal_unityfs_bundle().len() as u64
);
assert_eq!(
indexed
.iter()
.find(|resource| resource.entry.path.ends_with("title.acb"))
.unwrap()
.entry
.resource_type,
ResourceType::Media
);
assert_eq!(
indexed
.iter()
.find(|resource| resource.entry.path.ends_with("ExcelDB.db"))
.unwrap()
.entry
.resource_type,
ResourceType::TableBundle
);
assert_eq!(
indexed
.iter()
.find(|resource| resource.entry.path.ends_with("dialogue.csv"))
.unwrap()
.entry
.resource_type,
ResourceType::TextAsset
);
}
#[tokio::test]
@@ -370,4 +528,53 @@ mod tests {
assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
assert_eq!(resources.count(ResourceQuery::all()).await.unwrap(), 0);
}
#[tokio::test]
async fn indexes_non_bundle_resources_without_unityfs_summary() {
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let mut manifest = synthetic_manifest();
manifest
.resources
.retain(|entry| entry.resource_type != ResourceType::AssetBundle);
let report = service
.import_manifest_bundles(
&manifest,
&[
BundleSource::new("dialogue.csv", b"id,text\n1,hello".to_vec()),
BundleSource::new("ExcelDB.db", b"table-fixture".to_vec()),
BundleSource::new("title.acb", b"media-fixture".to_vec()),
],
)
.await
.unwrap();
assert_eq!(report.imported.len(), 3);
assert!(report.imported.iter().all(|item| item.unityfs.is_none()));
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
assert_eq!(
resources
.count(ResourceQuery::by_type(ResourceType::TextAsset))
.await
.unwrap(),
1
);
assert_eq!(
resources
.count(ResourceQuery::by_type(ResourceType::TableBundle))
.await
.unwrap(),
1
);
assert_eq!(
resources
.count(ResourceQuery::by_type(ResourceType::Media))
.await
.unwrap(),
1
);
}
}
+11 -6
View File
@@ -24,7 +24,10 @@ pub mod resources;
mod zip_validation;
pub use cas::FileSystemCasRepository;
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
pub use import::{
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
ResourceImportService,
};
pub use official_download::{
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem,
@@ -48,11 +51,13 @@ pub use official_sync::{
};
pub use official_update::{
cached_game_main_config_for_metadata, diff_extended_snapshot, read_bootstrap_cache,
read_snapshot, write_bootstrap_cache, write_snapshot, ExtendedSnapshotDelta,
GameMainConfigSnapshot, LauncherMetadataSnapshot, OfficialBootstrapCache,
OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialServerInfoSource,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, ResolvedBootstrap,
read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot, write_version_state,
ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot,
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState,
ResolvedBootstrap,
};
pub use path_security::{
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
+53
View File
@@ -2787,6 +2787,59 @@ exit 22
assert!(service.read_download_manifest().unwrap().entries.is_empty());
}
#[test]
fn official_http_failure_regression_fixtures_are_enforced() {
for fixture in [
include_str!("../tests/fixtures/official_regression/http_403.json"),
include_str!("../tests/fixtures/official_regression/http_404.json"),
] {
let fixture: serde_json::Value = serde_json::from_str(fixture).unwrap();
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
let status = fixture["http_status"].as_u64().unwrap() as u16;
write_fake_http_error_curl(&curl_path, status);
let service =
OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path)
.with_retry_attempts(3);
let url = fixture["url"].as_str().unwrap();
let error = service.pull(&one_url_plan(url)).unwrap_err();
let expected_kind = fixture["failure_kind"].as_str().unwrap();
let expected_retryable = fixture["retryable"].as_bool().unwrap();
let expected_attempts = fixture["expected_attempts"].as_u64().unwrap();
assert!(error.contains(expected_kind));
assert!(error.contains(&format!("retryable={expected_retryable}")));
assert_eq!(
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
expected_attempts.to_string()
);
let quarantine = service.read_download_quarantine().unwrap();
let entry = quarantine.entries.get(url).unwrap();
assert_eq!(entry.failure_kind.as_deref(), Some(expected_kind));
assert_eq!(entry.retryable, Some(expected_retryable));
assert_eq!(entry.attempts, expected_attempts as usize);
}
}
#[test]
fn official_hash_mismatch_regression_fixture_is_enforced() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../tests/fixtures/official_regression/hash_mismatch_catalog.json"
))
.unwrap();
let error = verify_official_seed_catalog_hash(
fixture["data_url"].as_str().unwrap(),
fixture["data"].as_str().unwrap().as_bytes(),
fixture["hash_url"].as_str().unwrap(),
fixture["official_hash"].as_str().unwrap().as_bytes(),
)
.unwrap_err();
assert!(error.contains(fixture["expected_error_contains"].as_str().unwrap()));
}
#[test]
fn retries_http_5xx_before_quarantine() {
let out_dir = TempDir::new().unwrap();
+499 -3
View File
@@ -37,11 +37,14 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub const OFFICIAL_UPDATE_SNAPSHOT_VERSION: u32 = 2;
/// Current official bootstrap cache schema version.
pub const OFFICIAL_BOOTSTRAP_CACHE_VERSION: u32 = 1;
/// Current official version-state schema version.
pub const OFFICIAL_VERSION_STATE_VERSION: u32 = 1;
const OFFICIAL_CURRENT_LINK: &str = "current";
const OFFICIAL_VERSIONS_DIR: &str = "versions";
const OFFICIAL_STAGING_DIR: &str = ".staging";
const OFFICIAL_DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
const OFFICIAL_SYNC_SNAPSHOT_FILE: &str = "official-sync-snapshot.json";
const OFFICIAL_VERSION_STATE_FILE: &str = "official-version-state.json";
/// Server-info input for an official update run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -125,6 +128,11 @@ impl OfficialUpdateConfig {
self.output_root.join("official-bootstrap-cache.json")
}
/// Returns the persistent version-state path for this config.
pub fn version_state_path(&self) -> PathBuf {
self.output_root.join(OFFICIAL_VERSION_STATE_FILE)
}
/// Returns the lock path used for non-dry-run executions.
pub fn lock_path(&self) -> PathBuf {
self.output_root.join(".official-sync.lock")
@@ -181,6 +189,82 @@ pub struct OfficialUpdateSnapshot {
pub game_main_config_bootstrap: Option<GameMainConfigSnapshot>,
}
/// Persistent version state for an official resource output root.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialVersionState {
/// State schema version.
#[serde(default = "default_version_state_version")]
pub state_version: u32,
/// Last successfully published version.
#[serde(default)]
pub current_completed_version: Option<OfficialVersionRecord>,
/// Version currently being downloaded into staging.
#[serde(default)]
pub in_progress_version: Option<OfficialVersionRecord>,
/// Previously usable version before the latest successful publish.
#[serde(default)]
pub previous_available_version: Option<OfficialVersionRecord>,
/// Recent versions that failed after entering staging.
#[serde(default)]
pub failed_versions: Vec<OfficialFailedVersionRecord>,
/// Last time this state file was updated.
pub updated_unix_seconds: u64,
}
impl Default for OfficialVersionState {
fn default() -> Self {
Self {
state_version: OFFICIAL_VERSION_STATE_VERSION,
current_completed_version: None,
in_progress_version: None,
previous_available_version: None,
failed_versions: Vec::new(),
updated_unix_seconds: unix_seconds_now(),
}
}
}
/// One version tracked by `official-version-state.json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialVersionRecord {
/// Stable publish ID under `versions/<id>`.
pub id: String,
/// Selected app version.
pub app_version: String,
/// Selected bundle version, when present.
pub bundle_version: Option<String>,
/// Selected Addressables root.
pub addressables_root: String,
/// Resource root for this version. For in-progress versions this is a
/// staging path; for completed versions it is a versioned path.
pub resource_root: PathBuf,
/// Snapshot path associated with the resource root.
pub snapshot_path: PathBuf,
/// Staging path, when the version is still being downloaded.
#[serde(default)]
pub staging_path: Option<PathBuf>,
/// Versioned path, when known.
#[serde(default)]
pub version_path: Option<PathBuf>,
/// Start time for a staged pull.
#[serde(default)]
pub started_unix_seconds: Option<u64>,
/// Completion time for a published version.
#[serde(default)]
pub completed_unix_seconds: Option<u64>,
}
/// Failed version entry tracked after a staged pull or publish error.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialFailedVersionRecord {
/// Version that failed.
pub version: OfficialVersionRecord,
/// Human-readable failure reason.
pub error: String,
/// Failure time.
pub failed_unix_seconds: u64,
}
impl OfficialUpdateSnapshot {
/// Creates an update snapshot from a base sync snapshot and extended data.
pub fn new(
@@ -391,6 +475,8 @@ pub struct OfficialUpdateReport {
pub active_resource_root: PathBuf,
/// Atomic `current` pointer path.
pub current_path: PathBuf,
/// Persistent version-state path.
pub version_state_path: PathBuf,
/// Versioned release directory after a successful publish.
pub published_version_path: Option<PathBuf>,
/// Staging directory used during download before publish.
@@ -754,6 +840,10 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new("lock", "资源目录状态锁已获取"));
Some(lock)
};
let version_state_path = config.version_state_path();
if !config.dry_run {
recover_interrupted_version_state(&version_state_path)?;
}
check_shutdown_requested(&mut should_cancel)?;
let publish_layout = OfficialPublishLayout::new(&config.output_root);
@@ -1035,6 +1125,7 @@ impl OfficialUpdateService {
output_root: config.output_root.clone(),
active_resource_root: active_resource_root.clone(),
current_path: publish_layout.current_path.clone(),
version_state_path: version_state_path.clone(),
published_version_path: None,
staging_path: None,
snapshot_path: snapshot_path.clone(),
@@ -1079,6 +1170,27 @@ impl OfficialUpdateService {
"audit",
verification_progress_message(&report.verification_summary),
));
if !config.dry_run {
let current_record = version_record_for_snapshot(
&current_update_snapshot,
VersionRecordInput {
id: version_id_from_path(&active_resource_root)
.unwrap_or_else(|| fallback_version_id(&current_update_snapshot)),
resource_root: active_resource_root.clone(),
snapshot_path: snapshot_path.clone(),
staging_path: None,
version_path: Some(active_resource_root.clone()),
started_unix_seconds: None,
completed_unix_seconds: Some(unix_seconds_now()),
},
);
complete_version_state(
&version_state_path,
current_record,
&active_resource_root,
&snapshot_path,
)?;
}
progress(OfficialUpdateProgress::new(
"finish",
"资源已是最新;无需下载",
@@ -1129,11 +1241,22 @@ impl OfficialUpdateService {
publish_layout
.seed_staging_from_active(&active_resource_root, &publish_plan.staging_path)
.map_err(anyhow::Error::msg)?;
let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path);
let in_progress_record = prepare_in_progress_version_state(
&version_state_path,
&current_update_snapshot,
&publish_plan.id,
&active_resource_root,
previous_snapshot.as_ref(),
&publish_plan.staging_path,
&staging_snapshot_path,
)?;
let mut version_state_guard =
VersionStateGuard::new(version_state_path.clone(), in_progress_record);
let staging_fetcher = OfficialResourcePullService::with_curl_command(
&publish_plan.staging_path,
&config.curl_command,
);
let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path);
report.staging_path = Some(publish_plan.staging_path.clone());
report.snapshot_path = staging_snapshot_path.clone();
report.download_manifest = staging_fetcher.download_manifest_path();
@@ -1150,7 +1273,10 @@ impl OfficialUpdateService {
},
&mut should_cancel,
)
.map_err(anyhow::Error::msg)?;
.map_err(|error| anyhow::anyhow!(error))
.inspect_err(|error| {
let _ = version_state_guard.fail(&error.to_string());
})?;
progress(OfficialUpdateProgress::new(
"download",
format!(
@@ -1217,7 +1343,15 @@ impl OfficialUpdateService {
report.local_manifest_verified_count = final_audit.verified_count();
report.local_manifest_repair_needed_count = final_audit.repair_needed_count();
report.verification_summary = final_verification_summary;
report.snapshot_written = Some(final_snapshot_path);
report.snapshot_written = Some(final_snapshot_path.clone());
let completed_record = version_state_guard.record()?.clone();
complete_version_state(
&version_state_path,
completed_record,
&published_version_path,
&final_snapshot_path,
)?;
version_state_guard.commit();
progress(OfficialUpdateProgress::new(
"finish",
@@ -1505,6 +1639,11 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
&config.bootstrap_cache_path(),
"官方启动缓存",
)?;
ensure_safe_file_target(
&config.output_root,
&config.version_state_path(),
"官方版本状态",
)?;
ensure_safe_file_target(&config.output_root, &config.lock_path(), "官方同步锁")?;
Ok(())
}
@@ -1725,6 +1864,30 @@ pub fn write_snapshot(path: &Path, snapshot: &OfficialUpdateSnapshot) -> anyhow:
Ok(())
}
/// Reads the persistent official version state.
pub fn read_version_state(path: &Path) -> anyhow::Result<Option<OfficialVersionState>> {
let Some(data) = read_file_no_symlink(path, "官方版本状态").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
let state: OfficialVersionState = serde_json::from_slice(&data)?;
if state.state_version != OFFICIAL_VERSION_STATE_VERSION {
return Err(anyhow::anyhow!(
"不支持的官方版本状态 schema:{},当前版本={}",
state.state_version,
OFFICIAL_VERSION_STATE_VERSION
));
}
Ok(Some(state))
}
/// Writes the persistent official version state atomically.
pub fn write_version_state(path: &Path, state: &OfficialVersionState) -> anyhow::Result<()> {
let data = serde_json::to_vec_pretty(state)?;
write_file_atomic(path, &data, STATE_FILE_MODE, "官方版本状态").map_err(anyhow::Error::msg)?;
Ok(())
}
/// Reads an official bootstrap cache from disk.
pub fn read_bootstrap_cache(path: &Path) -> anyhow::Result<Option<OfficialBootstrapCache>> {
let Some(data) = read_file_no_symlink(path, "官方启动缓存").map_err(anyhow::Error::msg)?
@@ -1763,6 +1926,227 @@ fn default_bootstrap_cache_version() -> u32 {
OFFICIAL_BOOTSTRAP_CACHE_VERSION
}
fn default_version_state_version() -> u32 {
OFFICIAL_VERSION_STATE_VERSION
}
#[derive(Debug)]
struct VersionRecordInput {
id: String,
resource_root: PathBuf,
snapshot_path: PathBuf,
staging_path: Option<PathBuf>,
version_path: Option<PathBuf>,
started_unix_seconds: Option<u64>,
completed_unix_seconds: Option<u64>,
}
fn version_record_for_snapshot(
snapshot: &OfficialUpdateSnapshot,
input: VersionRecordInput,
) -> OfficialVersionRecord {
OfficialVersionRecord {
id: input.id,
app_version: snapshot.app_version.clone(),
bundle_version: snapshot.bundle_version.clone(),
addressables_root: snapshot.addressables_root.clone(),
resource_root: input.resource_root,
snapshot_path: input.snapshot_path,
staging_path: input.staging_path,
version_path: input.version_path,
started_unix_seconds: input.started_unix_seconds,
completed_unix_seconds: input.completed_unix_seconds,
}
}
fn recover_interrupted_version_state(path: &Path) -> anyhow::Result<()> {
let Some(mut state) = read_version_state(path)? else {
return Ok(());
};
let Some(version) = state.in_progress_version.take() else {
return Ok(());
};
state.failed_versions.push(OfficialFailedVersionRecord {
version,
error: "上一次官方资源同步在完成发布前中断".to_string(),
failed_unix_seconds: unix_seconds_now(),
});
trim_failed_versions(&mut state.failed_versions);
state.updated_unix_seconds = unix_seconds_now();
write_version_state(path, &state)
}
fn prepare_in_progress_version_state(
path: &Path,
snapshot: &OfficialUpdateSnapshot,
publish_id: &str,
active_resource_root: &Path,
previous_snapshot: Option<&OfficialUpdateSnapshot>,
staging_path: &Path,
snapshot_path: &Path,
) -> anyhow::Result<OfficialVersionRecord> {
let mut state = read_version_state(path)?.unwrap_or_default();
let fallback_current = previous_snapshot.map(|snapshot| {
version_record_for_snapshot(
snapshot,
VersionRecordInput {
id: version_id_from_path(active_resource_root)
.unwrap_or_else(|| fallback_version_id(snapshot)),
resource_root: active_resource_root.to_path_buf(),
snapshot_path: snapshot_path_for_state_path(snapshot, active_resource_root),
staging_path: None,
version_path: Some(active_resource_root.to_path_buf()),
started_unix_seconds: None,
completed_unix_seconds: Some(unix_seconds_now()),
},
)
});
if state.current_completed_version.is_none() {
state.current_completed_version = fallback_current;
}
let now = unix_seconds_now();
let record = version_record_for_snapshot(
snapshot,
VersionRecordInput {
id: publish_id.to_string(),
resource_root: staging_path.to_path_buf(),
snapshot_path: snapshot_path.to_path_buf(),
staging_path: Some(staging_path.to_path_buf()),
version_path: None,
started_unix_seconds: Some(now),
completed_unix_seconds: None,
},
);
state.in_progress_version = Some(record.clone());
state.updated_unix_seconds = now;
write_version_state(path, &state)?;
Ok(record)
}
fn complete_version_state(
path: &Path,
record: OfficialVersionRecord,
published_path: &Path,
snapshot_path: &Path,
) -> anyhow::Result<()> {
let mut state = read_version_state(path)?.unwrap_or_default();
let previous = state
.current_completed_version
.take()
.filter(|previous| previous.id != record.id);
if previous.is_some() {
state.previous_available_version = previous;
}
state.current_completed_version = Some(OfficialVersionRecord {
resource_root: published_path.to_path_buf(),
snapshot_path: snapshot_path.to_path_buf(),
staging_path: None,
version_path: Some(published_path.to_path_buf()),
completed_unix_seconds: Some(unix_seconds_now()),
..record
});
state.in_progress_version = None;
state.updated_unix_seconds = unix_seconds_now();
write_version_state(path, &state)
}
fn fail_version_state(
path: &Path,
record: OfficialVersionRecord,
error: &str,
) -> anyhow::Result<()> {
let mut state = read_version_state(path)?.unwrap_or_default();
state.in_progress_version = None;
state.failed_versions.push(OfficialFailedVersionRecord {
version: record,
error: error.to_string(),
failed_unix_seconds: unix_seconds_now(),
});
trim_failed_versions(&mut state.failed_versions);
state.updated_unix_seconds = unix_seconds_now();
write_version_state(path, &state)
}
struct VersionStateGuard {
path: PathBuf,
record: Option<OfficialVersionRecord>,
}
impl VersionStateGuard {
fn new(path: PathBuf, record: OfficialVersionRecord) -> Self {
Self {
path,
record: Some(record),
}
}
fn record(&self) -> anyhow::Result<&OfficialVersionRecord> {
self.record
.as_ref()
.ok_or_else(|| anyhow::anyhow!("官方版本状态事务已结束"))
}
fn fail(&mut self, error: &str) -> anyhow::Result<()> {
let Some(record) = self.record.take() else {
return Ok(());
};
fail_version_state(&self.path, record, error)
}
fn commit(&mut self) {
self.record = None;
}
}
impl Drop for VersionStateGuard {
fn drop(&mut self) {
let Some(record) = self.record.take() else {
return;
};
let _ = fail_version_state(
&self.path,
record,
"官方资源同步未完成;已将该版本记录为失败,保留当前可用版本",
);
}
}
fn trim_failed_versions(failed_versions: &mut Vec<OfficialFailedVersionRecord>) {
const MAX_FAILED_VERSIONS: usize = 8;
if failed_versions.len() > MAX_FAILED_VERSIONS {
let drop_count = failed_versions.len() - MAX_FAILED_VERSIONS;
failed_versions.drain(0..drop_count);
}
}
fn snapshot_path_for_state_path(
_snapshot: &OfficialUpdateSnapshot,
resource_root: &Path,
) -> PathBuf {
resource_root.join(OFFICIAL_SYNC_SNAPSHOT_FILE)
}
fn version_id_from_path(path: &Path) -> Option<String> {
path.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn fallback_version_id(snapshot: &OfficialUpdateSnapshot) -> String {
format!(
"{}-{}",
sanitize_publish_segment(&snapshot.app_version),
snapshot
.bundle_version
.as_deref()
.map(sanitize_publish_segment)
.unwrap_or_else(|| "no-bundle".to_string())
)
}
fn launcher_metadata_from_parts(
launcher_version: &str,
game_config: &YostarJpLauncherGameConfig,
@@ -2268,6 +2652,118 @@ mod tests {
assert_eq!(read_bootstrap_cache(&path).unwrap(), Some(cache));
}
#[test]
fn persists_version_state_json_round_trip() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("official-version-state.json");
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
let current = version_record_for_snapshot(
&snapshot,
VersionRecordInput {
id: "1.70.0-bundle-current".to_string(),
resource_root: temp.path().join("versions/current"),
snapshot_path: temp
.path()
.join("versions/current/official-sync-snapshot.json"),
staging_path: None,
version_path: Some(temp.path().join("versions/current")),
started_unix_seconds: None,
completed_unix_seconds: Some(10),
},
);
let failed = OfficialFailedVersionRecord {
version: current.clone(),
error: "fixture failure".to_string(),
failed_unix_seconds: 11,
};
let state = OfficialVersionState {
current_completed_version: Some(current),
failed_versions: vec![failed],
updated_unix_seconds: 12,
..OfficialVersionState::default()
};
write_version_state(&path, &state).unwrap();
assert_eq!(read_version_state(&path).unwrap(), Some(state));
}
#[test]
fn version_state_tracks_in_progress_success_and_failure() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("official-version-state.json");
let active = temp.path().join("versions/previous");
let staging = temp.path().join(".staging/current");
let staging_snapshot = staging.join("official-sync-snapshot.json");
let published = temp.path().join("versions/current");
let previous_snapshot = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("previous")],
None,
);
let current_snapshot = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("current")],
Some(&fixture_bootstrap()),
);
let in_progress = prepare_in_progress_version_state(
&path,
&current_snapshot,
"current",
&active,
Some(&previous_snapshot),
&staging,
&staging_snapshot,
)
.unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert_eq!(state.in_progress_version.as_ref().unwrap().id, "current");
assert_eq!(
state.current_completed_version.as_ref().unwrap().id,
"previous"
);
complete_version_state(
&path,
in_progress.clone(),
&published,
&published.join("official-sync-snapshot.json"),
)
.unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert_eq!(
state.current_completed_version.as_ref().unwrap().id,
"current"
);
assert!(state.in_progress_version.is_none());
assert_eq!(
state.previous_available_version.as_ref().unwrap().id,
"previous"
);
let failed = prepare_in_progress_version_state(
&path,
&current_snapshot,
"broken",
&published,
state
.current_completed_version
.as_ref()
.map(|_| &current_snapshot),
&temp.path().join(".staging/broken"),
&temp
.path()
.join(".staging/broken/official-sync-snapshot.json"),
)
.unwrap();
fail_version_state(&path, failed, "download 403").unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert!(state.in_progress_version.is_none());
assert_eq!(state.failed_versions.len(), 1);
assert_eq!(state.failed_versions[0].version.id, "broken");
assert!(state.failed_versions[0].error.contains("403"));
}
#[test]
fn update_rejects_dangerous_output_root_before_network_work() {
let config = OfficialUpdateConfig {
+4
View File
@@ -187,6 +187,8 @@ impl SqliteResourceRepository {
ResourceType::AssetBundle => "AssetBundle",
ResourceType::Manifest => "Manifest",
ResourceType::TableBundle => "TableBundle",
ResourceType::TextAsset => "TextAsset",
ResourceType::Media => "Media",
ResourceType::Other => "Other",
}
}
@@ -196,6 +198,8 @@ impl SqliteResourceRepository {
"AssetBundle" => Ok(ResourceType::AssetBundle),
"Manifest" => Ok(ResourceType::Manifest),
"TableBundle" => Ok(ResourceType::TableBundle),
"TextAsset" => Ok(ResourceType::TextAsset),
"Media" => Ok(ResourceType::Media),
"Other" => Ok(ResourceType::Other),
other => Err(bat_core::Error::Serialization(format!(
"Unknown resource type: {}",
@@ -0,0 +1,7 @@
{
"data_url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes",
"hash_url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.hash",
"data": "ExcelDB.db",
"official_hash": "0",
"expected_error_contains": "官方 hash 校验失败"
}
@@ -0,0 +1,7 @@
{
"url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/Forbidden.bytes",
"http_status": 403,
"failure_kind": "http_forbidden",
"retryable": false,
"expected_attempts": 1
}
@@ -0,0 +1,7 @@
{
"url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/Missing.bytes",
"http_status": 404,
"failure_kind": "http_not_found",
"retryable": false,
"expected_attempts": 1
}
@@ -2,6 +2,8 @@
"imported": [
{
"bytes": 143,
"category": "asset_bundle",
"resource_type": "AssetBundle",
"source_path": "synthetic/minimal.bundle",
"unityfs": {
"block_count": 1,
@@ -13,6 +15,9 @@
}
}
],
"category_counts": {
"asset_bundle": 1
},
"resource_count": 1,
"skipped": [
"synthetic/catalog.json"
@@ -7,9 +7,9 @@ use bat_adapters::official::yostar_jp::{
YostarJpResourceDiscoveryPlan, YostarJpResourceEndpointKind, YostarJpServerInfo,
};
use bat_infrastructure::{
build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService,
OfficialResourcePullService, OfficialUpdateConfig, OfficialUpdateProgress,
OfficialUpdateService, OfficialUpdateStatus,
build_official_pull_plan_from_platform_inventory, read_version_state,
OfficialGameMainConfigBootstrapService, OfficialResourcePullService, OfficialUpdateConfig,
OfficialUpdateProgress, OfficialUpdateService, OfficialUpdateStatus,
};
use std::collections::HashMap;
use std::fs;
@@ -252,15 +252,26 @@ fn official_update_first_run_pulls_without_pre_audit() {
.unwrap()
.file_type()
.is_symlink());
assert_eq!(
config
assert!(!config
.output_root
.join("official-download-manifest.json")
.exists(),
false
);
.exists());
assert!(published.join("official-download-manifest.json").exists());
assert!(published.join("official-sync-snapshot.json").exists());
let version_state = read_version_state(&report.version_state_path)
.unwrap()
.unwrap();
assert_eq!(
version_state
.current_completed_version
.as_ref()
.unwrap()
.version_path
.as_deref(),
Some(published.as_path())
);
assert!(version_state.in_progress_version.is_none());
assert!(version_state.failed_versions.is_empty());
}
#[test]
@@ -305,6 +316,18 @@ fn official_update_second_run_audits_existing_resources_before_reuse() {
.unwrap()
.file_type()
.is_symlink());
let version_state = read_version_state(&report.version_state_path)
.unwrap()
.unwrap();
assert_eq!(
version_state
.current_completed_version
.as_ref()
.unwrap()
.resource_root,
report.active_resource_root
);
assert!(version_state.in_progress_version.is_none());
}
struct TestHarness {
@@ -99,17 +99,20 @@ async fn imports_synthetic_addressables_catalog_bundle_against_golden() {
"imported": report.imported.iter().map(|imported| {
json!({
"bytes": imported.bytes,
"category": imported.category.as_str(),
"resource_type": format!("{:?}", imported.resource_type),
"source_path": imported.source_path,
"unityfs": {
"block_count": imported.unityfs.block_count,
"directories": imported.unityfs.directories,
"directory_count": imported.unityfs.directory_count,
"unity_version": imported.unityfs.unity_version,
}
"unityfs": imported.unityfs.as_ref().map(|unityfs| json!({
"block_count": unityfs.block_count,
"directories": unityfs.directories,
"directory_count": unityfs.directory_count,
"unity_version": unityfs.unity_version,
})),
})
}).collect::<Vec<_>>(),
"resource_count": indexed.len(),
"skipped": report.skipped,
"category_counts": report.category_counts,
});
let expected: serde_json::Value =
serde_json::from_str(include_str!("golden/synthetic_phase2_import.json")).unwrap();