From adc1cd5b91b8b244c6734b723fcf249782c73509 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Tue, 30 Jun 2026 00:08:36 +0800 Subject: [PATCH] feat: prepare experiment push package --- CURRENT_STATUS.md | 26 +- Cargo.lock | 8 + DOCS_INDEX.md | 8 +- README.md | 5 +- adapters/Cargo.toml | 4 + .../examples/yostar_jp_client_bootstrap.rs | 32 + adapters/examples/yostar_jp_discovery.rs | 93 ++ adapters/examples/yostar_jp_inventory.rs | 72 + adapters/src/lib.rs | 1 + adapters/src/manifest/addressables.rs | 169 ++- adapters/src/manifest/driver.rs | 2 +- adapters/src/official/game_main_config.rs | 370 +++++ adapters/src/official/inventory.rs | 574 ++++++++ adapters/src/official/launcher.rs | 243 ++++ adapters/src/official/mod.rs | 19 + adapters/src/official/yostar_jp.rs | 1220 +++++++++++++++++ adapters/src/unity.rs | 7 +- adapters/src/unity/adapter.rs | 70 +- adapters/src/unity/registry.rs | 17 +- adapters/src/unity/serialized_file.rs | 532 +++++++ adapters/src/unity/unity_2021_3.rs | 383 +++++- adapters/tests/real_addressables_local.rs | 21 + adapters/tests/real_unityfs_local.rs | 24 + docs/architecture/README.md | 7 + .../architecture/official-resource-backend.md | 193 +++ docs/guides/official-resource-test-pull.md | 132 ++ docs/reports/CURRENT_GAPS.md | 8 + docs/reports/current-stage-prepush.md | 48 + examples/manifest_demo.rs | 14 +- examples/unity_adapter_demo.rs | 77 +- infrastructure/Cargo.toml | 6 + .../examples/official_launcher_bootstrap.rs | 104 ++ infrastructure/examples/official_pull_plan.rs | 431 ++++++ infrastructure/src/import.rs | 369 +++++ infrastructure/src/lib.rs | 27 + infrastructure/src/official_download.rs | 396 ++++++ .../src/official_game_main_config.rs | 199 +++ infrastructure/src/official_launcher.rs | 513 +++++++ infrastructure/src/official_pull.rs | 284 ++++ infrastructure/src/official_sync.rs | 204 +++ infrastructure/src/resources.rs | 217 +++ .../fixtures/synthetic_phase2/catalog.json | 24 + .../tests/golden/synthetic_phase2_import.json | 20 + .../official_game_main_config_bootstrap.rs | 631 +++++++++ .../tests/synthetic_phase2_import.rs | 119 ++ 45 files changed, 7858 insertions(+), 65 deletions(-) create mode 100644 adapters/examples/yostar_jp_client_bootstrap.rs create mode 100644 adapters/examples/yostar_jp_discovery.rs create mode 100644 adapters/examples/yostar_jp_inventory.rs create mode 100644 adapters/src/official/game_main_config.rs create mode 100644 adapters/src/official/inventory.rs create mode 100644 adapters/src/official/launcher.rs create mode 100644 adapters/src/official/mod.rs create mode 100644 adapters/src/official/yostar_jp.rs create mode 100644 adapters/src/unity/serialized_file.rs create mode 100644 adapters/tests/real_addressables_local.rs create mode 100644 adapters/tests/real_unityfs_local.rs create mode 100644 docs/architecture/official-resource-backend.md create mode 100644 docs/guides/official-resource-test-pull.md create mode 100644 docs/reports/current-stage-prepush.md create mode 100644 infrastructure/examples/official_launcher_bootstrap.rs create mode 100644 infrastructure/examples/official_pull_plan.rs create mode 100644 infrastructure/src/import.rs create mode 100644 infrastructure/src/official_download.rs create mode 100644 infrastructure/src/official_game_main_config.rs create mode 100644 infrastructure/src/official_launcher.rs create mode 100644 infrastructure/src/official_pull.rs create mode 100644 infrastructure/src/official_sync.rs create mode 100644 infrastructure/src/resources.rs create mode 100644 infrastructure/tests/fixtures/synthetic_phase2/catalog.json create mode 100644 infrastructure/tests/golden/synthetic_phase2_import.json create mode 100644 infrastructure/tests/official_game_main_config_bootstrap.rs create mode 100644 infrastructure/tests/synthetic_phase2_import.rs diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index ea434ba..02a73a2 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -1,14 +1,15 @@ # BlueArchiveToolkit 当前工作区状态 -**更新时间**:2026-06-28 +**更新时间**:2026-06-29 **状态来源**:本地工作区盘点与命令验证 +**状态分支**:`experiment` **权威计划**:`PROJECT_PLAN.md` --- ## 1. 总体判断 -当前项目处于 **稳定基线完成、CAS V1 已落地、资源同步与解析待实装** 阶段。 +当前项目处于 **稳定基线完成、CAS V1 已落地、官方资源链路验证完成、资源同步与解析待实装** 阶段。 旧文档中存在 Week 3 “完成”和“回滚”两类互相冲突的报告。以当前代码为准,历史 Week 3 不再作为状态依据;当前 CAS V1 已由新实现接管,AssetBundle、Patch、Go CLI/API/Web 仍有明显占位或未实现部分。 @@ -100,6 +101,8 @@ - Unity bundle parse/serialize 仍是后续阶段能力。 - Addressables Catalog 复杂字段解析未完成。 - 客户端发现、备份、应用补丁流程尚未连接真实实现。 +- 官方 launcher bootstrap、GameMainConfig bootstrap、官方 pull plan 的用户级文档已补齐。 +- 官方 bootstrap / pull 测试夹具已收敛到 `experiment` 分支语义下,并通过了官方链路回归。 ### `bat-cas-engine` @@ -182,6 +185,16 @@ cargo test --workspace 已运行: +```bash +cargo test -p bat-infrastructure --test official_game_main_config_bootstrap -- --nocapture +cargo test -p bat-infrastructure +cargo test -p bat-adapters +``` + +结果:通过。 + +已运行: + ```bash go test ./... ``` @@ -194,13 +207,14 @@ go test ./... ### Git 元数据 -原 `.git/` 是空目录,无法恢复原历史。本轮已新初始化 Git 仓库,并将默认分支设置为 `main`。 +原 `.git/` 是空目录,无法恢复原历史。本轮已新初始化 Git 仓库,并完成当前工作分支的整理。 当前状态: - `git status --short --branch` 可用。 - 原历史未恢复。 -- 本轮建立首次 Git 基线提交。 +- 当前分支已切到 `experiment`,用于承载实验性 fixture 调整。 +- 当前分支已切到 `experiment`,用于承载实验性 fixture 调整。 ### 核心能力未实装 @@ -222,9 +236,11 @@ go test ./... 1. 完成 Manifest 真实字段解析和资源模型映射。 2. 落地 Go CLI 的 `doctor` 和基础命令框架。 3. 设计 Resource Repository 的持久化 schema。 +4. 决定哪些 experiment 改动需要回流到 `dev`。 --- **当前总体完成度**:约 18% **当前基线状态**:已建立可继续开发的 Git 基线。 -**下一工程里程碑**:Manifest 真实解析与 Go CLI 基础入口。 +**下一工程里程碑**:Manifest 真实解析与 Go CLI 基础入口。 +**当前阶段文档**:`docs/reports/current-stage-prepush.md` diff --git a/Cargo.lock b/Cargo.lock index 324fee7..8bfefaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,9 +120,13 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "base64", "bat-core", + "lz4", + "lzma-rs", "serde", "serde_json", + "tempfile", "thiserror", "tokio", ] @@ -192,9 +196,13 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bat-adapters", "bat-cas-engine", "bat-core", + "hex", + "md-5", "serde", + "serde_json", "tempfile", "thiserror", "tokio", diff --git a/DOCS_INDEX.md b/DOCS_INDEX.md index f267fef..f788013 100644 --- a/DOCS_INDEX.md +++ b/DOCS_INDEX.md @@ -1,6 +1,6 @@ # BlueArchiveToolkit 文档索引 -**更新时间**:2026-06-28 +**更新时间**:2026-06-29 **说明**:本索引用于快速定位当前权威文档和历史资料。 --- @@ -19,9 +19,12 @@ ## 2. 架构与指南 - `docs/architecture/README.md`:总体架构设计。 +- `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。 - `docs/api/README.md`:API 设计入口。 - `docs/guides/development.md`:开发指南。 - `docs/guides/deployment.md`:部署指南。 +- `docs/guides/official-resource-test-pull.md`:官方资源拉取用户指南。 +- `docs/reports/current-stage-prepush.md`:当前阶段说明与推送前核查记录。 - `docs/guides/baseline.md`:稳定工程基线指南。 - `docs/architecture/adr/0001-engine-and-application-boundaries.md`:Rust/Go 边界决策。 - `docs/architecture/adr/0002-cas-v1-design-boundary.md`:CAS V1 边界决策。 @@ -75,7 +78,7 @@ ## 6. 状态摘要 -当前总体完成度约 **15%**。 +当前总体完成度约 **18%**。 已完成: @@ -83,6 +86,7 @@ - Unity/Manifest/Client 适配器框架。 - 基础 CAS 文件系统存储。 - 文档整理和路线图重制。 +- 官方资源链路验证和实验性 fixture 收敛。 优先待办: diff --git a/README.md b/README.md index f6539c7..57ded1b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。 -当前项目仍处于早期开发阶段:Rust 领域模型、适配器框架和生产级 CAS V1 已经存在;资源同步、完整 AssetBundle 解析、翻译系统、Patch、CLI、API Server 和 Web 后台尚未完成。 +当前项目仍处于早期开发阶段:Rust 领域模型、适配器框架和生产级 CAS V1 已经存在;资源同步、完整 AssetBundle 解析、翻译系统、Patch、CLI、API Server 和 Web 后台仍在持续完善。当前官方资源实验与推送准备在 `experiment` 分支进行。 --- @@ -30,7 +30,9 @@ - `bat-cas-engine` CAS V1:原子写入、Hash 校验、引用计数、GC、并发写入测试、损坏检测。 - `bat-infrastructure` CAS 仓储适配层。 - `bat-ffi` 基础 crate 边界。 +- 官方日服资源 bootstrap、官方 launcher bootstrap、官方 pull plan 用户流程。 - 文档路线图和当前缺口清单。 +- 当前阶段说明与推送前核查文档。 未完成: @@ -46,6 +48,7 @@ - [完整开发计划](PROJECT_PLAN.md) - [当前缺口清单](docs/reports/CURRENT_GAPS.md) - [文档索引](DOCS_INDEX.md) +- [当前阶段说明](docs/reports/current-stage-prepush.md) --- diff --git a/adapters/Cargo.toml b/adapters/Cargo.toml index 60e5919..214030d 100644 --- a/adapters/Cargo.toml +++ b/adapters/Cargo.toml @@ -13,6 +13,10 @@ serde.workspace = true serde_json.workspace = true async-trait.workspace = true tokio.workspace = true +lz4 = "1.28" +lzma-rs = "0.3" +base64 = "0.22" [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } +tempfile = "3.14" diff --git a/adapters/examples/yostar_jp_client_bootstrap.rs b/adapters/examples/yostar_jp_client_bootstrap.rs new file mode 100644 index 0000000..7d633c8 --- /dev/null +++ b/adapters/examples/yostar_jp_client_bootstrap.rs @@ -0,0 +1,32 @@ +use bat_adapters::official::launcher::YostarJpInstalledGameBootstrap; +use std::env; +use std::path::PathBuf; + +fn main() -> anyhow::Result<()> { + let mut args = env::args().skip(1); + let root = args + .next() + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("usage: yostar_jp_client_bootstrap "))?; + + let bootstrap = + YostarJpInstalledGameBootstrap::from_game_root(&root).map_err(anyhow::Error::msg)?; + + println!("client_root={}", root.display()); + println!("app_version={}", bootstrap.app_version); + println!("executable_name={}", bootstrap.executable_name); + println!("executable_params={:?}", bootstrap.executable_params); + println!( + "manifest_basis={}", + bootstrap.basis.as_deref().unwrap_or("") + ); + println!( + "manifest_file_count={}", + bootstrap + .manifest_file_count + .map(|count| count.to_string()) + .unwrap_or_else(|| "".to_string()) + ); + + Ok(()) +} diff --git a/adapters/examples/yostar_jp_discovery.rs b/adapters/examples/yostar_jp_discovery.rs new file mode 100644 index 0000000..bf1d7c2 --- /dev/null +++ b/adapters/examples/yostar_jp_discovery.rs @@ -0,0 +1,93 @@ +use bat_adapters::official::yostar_jp::{ + PatchPlatform, YostarJpResourceEndpoint, YostarJpResourceEndpointKind, YostarJpServerInfo, +}; +use std::env; +use std::fs; + +fn main() -> anyhow::Result<()> { + let args = env::args().collect::>(); + if args.len() < 2 { + print_usage(&args[0]); + std::process::exit(2); + } + + let server_info_path = &args[1]; + let connection_group = args.get(2).map(String::as_str).unwrap_or("Prod-Audit"); + let app_version = args.get(3).map(String::as_str).unwrap_or("1.70.0"); + let platforms = args + .get(4) + .map(|value| parse_platforms(value)) + .transpose() + .map_err(anyhow::Error::msg)? + .unwrap_or_else(|| vec![PatchPlatform::Windows, PatchPlatform::Android]); + + let data = fs::read(server_info_path)?; + let server_info = YostarJpServerInfo::from_slice(&data)?; + let plan = server_info + .discovery_plan(connection_group, app_version, &platforms) + .map_err(anyhow::Error::msg)?; + + println!("connection_group={}", plan.connection_group_name); + println!("app_version={}", plan.app_version); + println!( + "bundle_version={}", + plan.bundle_version.as_deref().unwrap_or("") + ); + println!("addressables_root={}", plan.addressables_root); + println!(); + + for endpoint in &plan.endpoints { + println!( + "{}\t{}\t{}", + endpoint_kind_label(endpoint.kind), + platform_label(endpoint), + endpoint.url + ); + } + + Ok(()) +} + +fn print_usage(binary: &str) { + eprintln!( + "usage: {binary} [connection-group] [app-version] [platforms]\n\ + platforms: comma-separated Windows,Android" + ); +} + +fn parse_platforms(value: &str) -> Result, String> { + value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(parse_platform) + .collect() +} + +fn parse_platform(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "windows" | "win" => Ok(PatchPlatform::Windows), + "android" => Ok(PatchPlatform::Android), + _ => Err(format!("unsupported platform: {value}")), + } +} + +fn endpoint_kind_label(kind: YostarJpResourceEndpointKind) -> &'static str { + match kind { + YostarJpResourceEndpointKind::TableCatalog => "table_catalog", + YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash", + YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog", + YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash", + YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info", + YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash", + YostarJpResourceEndpointKind::MediaCatalog => "media_catalog", + YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash", + } +} + +fn platform_label(endpoint: &YostarJpResourceEndpoint) -> &'static str { + endpoint + .platform + .map(PatchPlatform::as_str) + .unwrap_or("shared") +} diff --git a/adapters/examples/yostar_jp_inventory.rs b/adapters/examples/yostar_jp_inventory.rs new file mode 100644 index 0000000..2ac4fe3 --- /dev/null +++ b/adapters/examples/yostar_jp_inventory.rs @@ -0,0 +1,72 @@ +use bat_adapters::official::inventory::YostarJpDownloadInventory; +use bat_adapters::official::yostar_jp::{ + verified_official_platforms, PatchPlatform, YostarJpResourceRoot, +}; +use std::env; +use std::fs; + +fn main() -> anyhow::Result<()> { + let args = env::args().collect::>(); + if args.len() < 5 { + print_usage(&args[0]); + std::process::exit(2); + } + + let root_token = &args[1]; + let bundle_packing_info_path = &args[2]; + let table_catalog_path = &args[3]; + let media_catalog_path = &args[4]; + let platforms = args + .get(5) + .map(|value| parse_platforms(value)) + .transpose() + .map_err(anyhow::Error::msg)? + .unwrap_or_else(verified_official_platforms); + + let inventory = YostarJpDownloadInventory::from_catalog_bytes( + &fs::read(bundle_packing_info_path)?, + &fs::read(table_catalog_path)?, + &fs::read(media_catalog_path)?, + ); + let root = YostarJpResourceRoot::from_root_token(root_token).map_err(anyhow::Error::msg)?; + + println!( + "bundle_patch_pack_count={}", + inventory.bundle_patch_pack_names.len() + ); + println!("table_file_count={}", inventory.table_file_names.len()); + println!("media_file_count={}", inventory.media_file_names.len()); + println!(); + + for url in inventory + .direct_download_urls_for_platforms(&root, &platforms) + .map_err(anyhow::Error::msg)? + { + println!("{url}"); + } + + Ok(()) +} + +fn print_usage(binary: &str) { + eprintln!( + "usage: {binary} [platforms]" + ); +} + +fn parse_platforms(value: &str) -> Result, String> { + value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(parse_platform) + .collect() +} + +fn parse_platform(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "windows" | "win" => Ok(PatchPlatform::Windows), + "android" => Ok(PatchPlatform::Android), + _ => Err(format!("unsupported platform: {value}")), + } +} diff --git a/adapters/src/lib.rs b/adapters/src/lib.rs index f8bb990..3ce641d 100644 --- a/adapters/src/lib.rs +++ b/adapters/src/lib.rs @@ -13,6 +13,7 @@ pub mod client; pub mod error; pub mod manifest; +pub mod official; pub mod unity; pub use error::{AdapterError, Result}; diff --git a/adapters/src/manifest/addressables.rs b/adapters/src/manifest/addressables.rs index 3fa4967..4d8a54e 100644 --- a/adapters/src/manifest/addressables.rs +++ b/adapters/src/manifest/addressables.rs @@ -52,9 +52,52 @@ impl AddressablesCatalogDriver { } } - fn resources(json: &Value) -> Vec { - let resources = json - .get("m_InternalIds") + fn resource_from_entry(value: &Value, index: usize) -> Option { + let path = value + .get("internal_id") + .or_else(|| value.get("InternalId")) + .or_else(|| value.get("path")) + .or_else(|| value.get("Path")) + .and_then(|value| value.as_str())?; + + if path.is_empty() { + return None; + } + + let hash = value + .get("hash") + .or_else(|| value.get("Hash")) + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("addressable_{}", index)); + + let size = value + .get("size") + .or_else(|| value.get("Size")) + .and_then(|value| value.as_u64()) + .unwrap_or_default(); + + Some(ResourceEntry { + path: path.to_string(), + hash, + size, + resource_type: Self::resource_type_for_path(path), + }) + } + + fn entry_resources(json: &Value) -> Vec { + json.get("m_Entries") + .or_else(|| json.get("entries")) + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + .enumerate() + .filter_map(|(index, value)| Self::resource_from_entry(value, index)) + .collect() + } + + fn internal_id_resources(json: &Value) -> Vec { + json.get("m_InternalIds") .and_then(|value| value.as_array()) .into_iter() .flatten() @@ -72,8 +115,55 @@ impl AddressablesCatalogDriver { resource_type: Self::resource_type_for_path(path), }) }) - .collect(); - resources + .collect() + } + + fn resources(json: &Value) -> Vec { + let entry_resources = Self::entry_resources(json); + if !entry_resources.is_empty() { + return entry_resources; + } + + Self::internal_id_resources(json) + } + + fn extra_metadata(json: &Value) -> HashMap { + let mut extra = HashMap::new(); + Self::insert_array_len(&mut extra, json, "m_InternalIds", "internal_id_count"); + Self::insert_array_len(&mut extra, json, "m_Entries", "entry_count"); + Self::insert_string_len(&mut extra, json, "m_KeyDataString", "key_data_string_len"); + Self::insert_string_len( + &mut extra, + json, + "m_EntryDataString", + "entry_data_string_len", + ); + extra + } + + fn insert_array_len(extra: &mut HashMap, json: &Value, field: &str, key: &str) { + if let Some(len) = json + .get(field) + .and_then(|value| value.as_array()) + .map(|values| values.len()) + { + extra.insert(key.to_string(), len.to_string()); + } + } + + fn insert_string_len( + extra: &mut HashMap, + json: &Value, + field: &str, + key: &str, + ) { + if let Some(len) = json + .get(field) + .and_then(|value| value.as_str()) + .map(|value| value.len()) + { + extra.insert(key.to_string(), len.to_string()); + } } } @@ -125,14 +215,10 @@ impl ManifestDriver for AddressablesCatalogDriver { async fn parse(&self, raw_data: &[u8]) -> Result { let json = Self::parse_json(raw_data)?; - // TODO: 解析 m_KeyDataString、m_EntryDataString 等压缩字段 - // 这些字段使用了自定义压缩格式,需要实现解压缩算法 - // 参考:Unity Addressables 源代码 - let metadata = ManifestMetadata { locator_id: Self::locator_id(&json), cdn_prefixes: Self::cdn_prefixes(&json), - extra: HashMap::new(), + extra: Self::extra_metadata(&json), }; Ok(GenericManifest { @@ -178,9 +264,9 @@ mod tests { "m_LocatorId": "AddressablesMainContentCatalog", "m_InternalIdPrefixes": [], "m_InternalIds": [ - "academy.bundle", - "character.bundle", - "catalog.json" + "synthetic/minimal-a.bundle", + "synthetic/minimal-b.bundle", + "synthetic/catalog.json" ] }"#; @@ -195,4 +281,61 @@ mod tests { ResourceType::AssetBundle ); } + + #[tokio::test] + async fn test_parse_entry_catalog_resources_and_metadata() { + let driver = AddressablesCatalogDriver::new(); + + let catalog_json = r#"{ + "m_LocatorId": "AddressablesMainContentCatalog", + "m_InternalIdPrefixes": ["https://synthetic.invalid/bundles/"], + "m_InternalIds": ["synthetic/fallback.bundle"], + "m_KeyDataString": "key-data", + "m_EntryDataString": "entry-data", + "m_Entries": [ + { + "internal_id": "synthetic/minimal.bundle", + "hash": "synthetic-entry-hash", + "size": 119 + }, + { + "internal_id": "synthetic/catalog.json", + "hash": "synthetic-catalog-hash", + "size": 2 + } + ] + }"#; + + let manifest = driver.parse(catalog_json.as_bytes()).await.unwrap(); + + assert_eq!(manifest.resources.len(), 2); + assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle"); + assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash"); + assert_eq!(manifest.resources[0].size, 119); + assert_eq!( + manifest.resources[0].resource_type, + ResourceType::AssetBundle + ); + assert_eq!(manifest.resources[1].resource_type, ResourceType::Manifest); + assert_eq!( + manifest.metadata.cdn_prefixes, + vec!["https://synthetic.invalid/bundles/".to_string()] + ); + assert_eq!( + manifest.metadata.extra.get("internal_id_count"), + Some(&"1".to_string()) + ); + assert_eq!( + manifest.metadata.extra.get("entry_count"), + Some(&"2".to_string()) + ); + assert_eq!( + manifest.metadata.extra.get("key_data_string_len"), + Some(&"8".to_string()) + ); + assert_eq!( + manifest.metadata.extra.get("entry_data_string_len"), + Some(&"10".to_string()) + ); + } } diff --git a/adapters/src/manifest/driver.rs b/adapters/src/manifest/driver.rs index e588250..66e63a3 100644 --- a/adapters/src/manifest/driver.rs +++ b/adapters/src/manifest/driver.rs @@ -83,7 +83,7 @@ mod tests { fn test_manifest_metadata() { let metadata = ManifestMetadata { locator_id: Some("test".to_string()), - cdn_prefixes: vec!["https://cdn.example.com".to_string()], + cdn_prefixes: vec!["https://synthetic.invalid".to_string()], extra: HashMap::new(), }; diff --git a/adapters/src/official/game_main_config.rs b/adapters/src/official/game_main_config.rs new file mode 100644 index 0000000..18e97d6 --- /dev/null +++ b/adapters/src/official/game_main_config.rs @@ -0,0 +1,370 @@ +//! Official JP `GameMainConfig` decoder. +//! +//! This module reads the `GameMainConfig` text asset embedded in official +//! Unity serialized files and decrypts it using the client algorithm observed +//! from the JP build. + +use crate::unity::serialized_file::UnitySerializedFile; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use serde::Deserialize; +use std::path::Path; + +/// Decrypted official `GameMainConfig` payload. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub struct YostarJpGameMainConfig { + /// Whether tutorial is skipped. + #[serde(default)] + pub skip_tutorial: Option, + /// Selected language. + #[serde(default)] + pub language: Option, + /// Default connection group. + #[serde(default)] + pub default_connection_group: Option, + /// Server-info JSON URL. + #[serde(default)] + pub server_info_data_url: Option, +} + +impl YostarJpGameMainConfig { + /// Reads and decrypts `GameMainConfig` from a Unity serialized file. + pub fn from_resources_assets(path: impl AsRef) -> Result { + let serialized = UnitySerializedFile::from_path(path)?; + Self::from_serialized_file(&serialized) + } + + /// Reads and decrypts `GameMainConfig` from serialized file bytes. + pub fn from_resources_assets_bytes(bytes: &[u8]) -> Result { + let serialized = UnitySerializedFile::from_slice(bytes)?; + Self::from_serialized_file(&serialized) + } + + fn from_serialized_file(serialized: &UnitySerializedFile) -> Result { + let asset = serialized + .text_asset("GameMainConfig") + .ok_or_else(|| { + "GameMainConfig text asset not found in Unity serialized file".to_string() + })?; + Self::from_encrypted_bytes(&asset.bytes) + } + + /// Decrypts an encrypted `GameMainConfig` payload from raw text-asset bytes. + pub fn from_encrypted_bytes(bytes: &[u8]) -> Result { + let encrypted = STANDARD.encode(bytes); + let key = create_key("GameMainConfig"); + let decrypted = convert(&encrypted, &key)?; + serde_json::from_str(&decrypted) + .map_err(|error| format!("Failed to parse decrypted GameMainConfig JSON: {error}")) + } +} + +fn create_key(name: &str) -> Vec { + let seed = xxhash32(name.as_bytes()); + let mut mt = MersenneTwister::new(seed); + mt.next_bytes(8) +} + +fn convert(value: &str, key: &[u8]) -> Result { + if value.is_empty() { + return Ok(String::new()); + } + + let mut bytes = STANDARD + .decode(value) + .map_err(|error| format!("Failed to decode GameMainConfig base64 payload: {error}"))?; + xor_bytes(&mut bytes, key); + utf16le_to_string(&bytes) +} + +fn xor_bytes(bytes: &mut [u8], key: &[u8]) { + if key.is_empty() { + return; + } + + for (index, byte) in bytes.iter_mut().enumerate() { + *byte ^= key[index % key.len()]; + } +} + +fn utf16le_to_string(bytes: &[u8]) -> Result { + if bytes.len() % 2 != 0 { + return Err("GameMainConfig decrypted byte length is not UTF-16LE aligned".into()); + } + + let units = bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + String::from_utf16(&units) + .map_err(|error| format!("Failed to decode GameMainConfig UTF-16LE payload: {error}")) +} + +fn xxhash32(bytes: &[u8]) -> u32 { + const PRIME1: u32 = 0x9E37_79B1; + const PRIME2: u32 = 0x85EB_CA77; + const PRIME3: u32 = 0xC2B2_AE3D; + const PRIME4: u32 = 0x27D4_EB2F; + const PRIME5: u32 = 0x1656_67B1; + + let len = bytes.len(); + let mut index = 0usize; + let mut hash = if len >= 16 { + let mut v1 = PRIME1.wrapping_add(PRIME2); + let mut v2 = PRIME2; + let mut v3 = 0; + let mut v4 = 0u32.wrapping_sub(PRIME1); + + while index + 16 <= len { + v1 = round(v1, read_u32_le(bytes, index)); + v2 = round(v2, read_u32_le(bytes, index + 4)); + v3 = round(v3, read_u32_le(bytes, index + 8)); + v4 = round(v4, read_u32_le(bytes, index + 12)); + index += 16; + } + + v1.rotate_left(1) + .wrapping_add(v2.rotate_left(7)) + .wrapping_add(v3.rotate_left(12)) + .wrapping_add(v4.rotate_left(18)) + } else { + PRIME5 + } + .wrapping_add(len as u32); + + while index + 4 <= len { + hash = hash + .wrapping_add(read_u32_le(bytes, index).wrapping_mul(PRIME3)) + .rotate_left(17) + .wrapping_mul(PRIME4); + index += 4; + } + + while index < len { + hash = hash + .wrapping_add((bytes[index] as u32).wrapping_mul(PRIME5)) + .rotate_left(11) + .wrapping_mul(PRIME1); + index += 1; + } + + avalanche(hash) +} + +fn round(acc: u32, input: u32) -> u32 { + const PRIME2: u32 = 0x85EB_CA77; + const PRIME1: u32 = 0x9E37_79B1; + + acc.wrapping_add(input.wrapping_mul(PRIME2)) + .rotate_left(13) + .wrapping_mul(PRIME1) +} + +fn avalanche(mut hash: u32) -> u32 { + hash ^= hash >> 15; + hash = hash.wrapping_mul(0x85EB_CA6B); + hash ^= hash >> 13; + hash = hash.wrapping_mul(0xC2B2_AE35); + hash ^= hash >> 16; + hash +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +struct MersenneTwister { + mt: [u32; 624], + index: usize, +} + +impl MersenneTwister { + fn new(seed: u32) -> Self { + let mut mt = [0u32; 624]; + mt[0] = seed; + for i in 1..624 { + mt[i] = 1812433253u32 + .wrapping_mul(mt[i - 1] ^ (mt[i - 1] >> 30)) + .wrapping_add(i as u32); + } + + Self { mt, index: 624 } + } + + fn next_u32(&mut self) -> u32 { + if self.index >= 624 { + self.twist(); + } + + let mut y = self.mt[self.index]; + self.index += 1; + y ^= y >> 11; + y ^= (y << 7) & 0x9D2C_5680; + y ^= (y << 15) & 0xEFC6_0000; + y ^= y >> 18; + y + } + + fn next_bytes(&mut self, len: usize) -> Vec { + let mut out = Vec::with_capacity(len); + while out.len() < len { + let bytes = (self.next_u32() >> 1).to_le_bytes(); + for byte in bytes { + if out.len() == len { + break; + } + out.push(byte); + } + } + out + } + + fn twist(&mut self) { + const UPPER_MASK: u32 = 0x8000_0000; + const LOWER_MASK: u32 = 0x7FFF_FFFF; + const MATRIX_A: u32 = 0x9908_B0DF; + + for i in 0..624 { + let x = (self.mt[i] & UPPER_MASK) | (self.mt[(i + 1) % 624] & LOWER_MASK); + let mut x_a = x >> 1; + if x & 1 != 0 { + x_a ^= MATRIX_A; + } + self.mt[i] = self.mt[(i + 397) % 624] ^ x_a; + } + self.index = 0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::unity::serialized_file::UnitySerializedFile; + use std::path::Path; + + fn encrypt_game_main_config(json: &str) -> Vec { + let key = create_key("GameMainConfig"); + let mut utf16 = json + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + xor_bytes(&mut utf16, &key); + utf16 + } + + fn synthetic_serialized_file(bytes: &[u8]) -> Vec { + fn push_u32_be(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_be_bytes()); + } + fn push_u64_be(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_be_bytes()); + } + fn push_u32_le(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_le_bytes()); + } + fn push_i32_le(data: &mut Vec, value: i32) { + data.extend_from_slice(&value.to_le_bytes()); + } + fn push_i64_le(data: &mut Vec, value: i64) { + data.extend_from_slice(&value.to_le_bytes()); + } + fn push_u64_le(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_le_bytes()); + } + fn push_i16_le(data: &mut Vec, value: i16) { + data.extend_from_slice(&value.to_le_bytes()); + } + fn align(data: &mut Vec, alignment: usize) { + let remainder = data.len() % alignment; + if remainder != 0 { + data.resize(data.len() + alignment - remainder, 0); + } + } + + let mut object_data = Vec::new(); + push_u32_le(&mut object_data, 14); + object_data.extend_from_slice(b"GameMainConfig"); + align(&mut object_data, 4); + push_u32_le(&mut object_data, bytes.len() as u32); + object_data.extend_from_slice(bytes); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(0); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 49); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 1); + align(&mut metadata, 4); + push_i64_le(&mut metadata, 1); + push_u64_le(&mut metadata, 0); + push_u32_le(&mut metadata, object_data.len() as u32); + push_i32_le(&mut metadata, 0); + + let header_len = 48usize; + let data_offset = header_len + metadata.len(); + let file_size = data_offset + object_data.len(); + + let mut file = Vec::new(); + push_u32_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + #[test] + fn decodes_synthetic_game_main_config() { + let json = r#"{ + "SkipTutorial": true, + "Language": "ja-JP", + "DefaultConnectionGroup": "Prod-Audit", + "ServerInfoDataUrl": "https://yostar-serverinfo.bluearchiveyostar.com/r93_x.json" + }"#; + let encrypted = encrypt_game_main_config(json); + let file = synthetic_serialized_file(&encrypted); + let parsed = YostarJpGameMainConfig::from_resources_assets_bytes(&file).unwrap(); + + assert_eq!(parsed.skip_tutorial, Some(true)); + assert_eq!(parsed.language.as_deref(), Some("ja-JP")); + assert_eq!( + parsed.default_connection_group.as_deref(), + Some("Prod-Audit") + ); + assert_eq!( + parsed.server_info_data_url.as_deref(), + Some("https://yostar-serverinfo.bluearchiveyostar.com/r93_x.json") + ); + } + + #[test] + fn extracts_game_main_config_bytes_from_serialized_assets() { + let path = Path::new( + "/home/wanye/D/BlueArchive/AllResources/YostarGames/BlueArchive_JP/BlueArchive_Data/resources.assets", + ); + if !path.exists() { + return; + } + + let file = UnitySerializedFile::from_path(path).unwrap(); + let asset = file.text_asset("GameMainConfig").unwrap(); + assert!(!asset.bytes.is_empty()); + } +} diff --git a/adapters/src/official/inventory.rs b/adapters/src/official/inventory.rs new file mode 100644 index 0000000..04080ef --- /dev/null +++ b/adapters/src/official/inventory.rs @@ -0,0 +1,574 @@ +//! Official JP download inventory extraction. + +use super::yostar_jp::{verified_official_platforms, PatchPlatform, YostarJpResourceRoot}; +use std::collections::{BTreeSet, HashSet}; + +/// Download inventory extracted from the official JP catalog bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct YostarJpDownloadInventory { + /// Patch-pack zip names from `BundlePackingInfo.bytes`. + pub bundle_patch_pack_names: Vec, + /// Table file names from `TableCatalog.bytes`. + pub table_file_names: Vec, + /// Media file names from `MediaCatalog.bytes`. + pub media_file_names: Vec, +} + +/// Platform-specific catalog inventory extracted from official JP bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct YostarJpPlatformCatalogInventory { + /// Platform this inventory belongs to. + pub platform: PatchPlatform, + /// Patch-pack zip names from this platform's `BundlePackingInfo.bytes`. + pub bundle_patch_pack_names: Vec, + /// Media file names from this platform's `MediaCatalog.bytes`. + pub media_file_names: Vec, +} + +impl YostarJpPlatformCatalogInventory { + /// Extracts one platform inventory from official JP catalog bytes. + pub fn from_catalog_bytes( + platform: PatchPlatform, + bundle_packing_info: &[u8], + media_catalog: &[u8], + ) -> Self { + Self { + platform, + bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info), + media_file_names: extract_file_names( + media_catalog, + &["zip", "mp4", "png", "ogg", "wav"], + ), + } + } + + /// Returns this platform's official bundle patch-pack URLs. + pub fn bundle_patch_pack_urls( + &self, + root: &YostarJpResourceRoot, + ) -> Result, String> { + self.bundle_patch_pack_names + .iter() + .map(|name| root.bundle_patch_pack(self.platform, name)) + .collect() + } + + /// Returns this platform's official media URLs. + pub fn media_file_urls(&self, root: &YostarJpResourceRoot) -> Result, String> { + self.media_file_names + .iter() + .map(|name| root.media_file(self.platform, name)) + .collect() + } +} + +/// Multi-platform download inventory with shared table files and platform +/// specific patch-pack/media catalogs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct YostarJpPlatformDownloadInventory { + /// Table file names from the shared `TableCatalog.bytes`. + pub table_file_names: Vec, + /// Per-platform bundle/media inventories. + pub platform_catalogs: Vec, +} + +impl YostarJpPlatformDownloadInventory { + /// Extracts a multi-platform inventory from official JP catalog bytes. + pub fn from_catalog_bytes( + table_catalog: &[u8], + platform_catalogs: Vec, + ) -> Self { + Self { + table_file_names: extract_file_names(table_catalog, &["db", "zip"]), + platform_catalogs: merge_platform_catalogs(platform_catalogs), + } + } + + /// Builds a platform inventory by reusing a legacy shared inventory for + /// each requested platform. + pub fn from_shared_inventory( + inventory: YostarJpDownloadInventory, + platforms: &[PatchPlatform], + ) -> Self { + let platform_catalogs = unique_platforms(platforms) + .into_iter() + .map(|platform| YostarJpPlatformCatalogInventory { + platform, + bundle_patch_pack_names: inventory.bundle_patch_pack_names.clone(), + media_file_names: inventory.media_file_names.clone(), + }) + .collect(); + + Self { + table_file_names: inventory.table_file_names, + platform_catalogs, + } + } + + /// Returns the official table file URLs. + pub fn table_file_urls(&self, root: &YostarJpResourceRoot) -> Result, String> { + self.table_file_names + .iter() + .map(|name| root.table_bundle(name)) + .collect() + } + + /// Returns the complete direct-download URL set for multiple platforms. + /// + /// Table bundles are emitted once. Patch-pack and media files are emitted + /// from the matching platform catalog only, which avoids creating invalid + /// cross-platform URL combinations. + pub fn direct_download_urls_for_platforms( + &self, + root: &YostarJpResourceRoot, + platforms: &[PatchPlatform], + ) -> Result, String> { + let mut urls = Vec::new(); + let mut seen = HashSet::new(); + + append_unique_urls(&mut urls, &mut seen, self.table_file_urls(root)?); + + for platform in unique_platforms(platforms) { + let catalog = self.platform_catalog(platform).ok_or_else(|| { + format!( + "Missing official catalog inventory for platform: {}", + platform.as_str() + ) + })?; + + append_unique_urls(&mut urls, &mut seen, catalog.bundle_patch_pack_urls(root)?); + append_unique_urls(&mut urls, &mut seen, catalog.media_file_urls(root)?); + } + + Ok(urls) + } + + /// Returns the complete direct-download URL set for all verified official + /// JP platforms. + pub fn direct_download_urls_for_verified_platforms( + &self, + root: &YostarJpResourceRoot, + ) -> Result, String> { + self.direct_download_urls_for_platforms(root, &verified_official_platforms()) + } + + fn platform_catalog( + &self, + platform: PatchPlatform, + ) -> Option<&YostarJpPlatformCatalogInventory> { + self.platform_catalogs + .iter() + .find(|catalog| catalog.platform == platform) + } +} + +impl YostarJpDownloadInventory { + /// Extracts an inventory from the three official JP catalog bytes. + pub fn from_catalog_bytes( + bundle_packing_info: &[u8], + table_catalog: &[u8], + media_catalog: &[u8], + ) -> Self { + Self { + bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info), + table_file_names: extract_file_names(table_catalog, &["db", "zip"]), + media_file_names: extract_file_names( + media_catalog, + &["zip", "mp4", "png", "ogg", "wav"], + ), + } + } + + /// Returns the official bundle patch-pack URLs for a platform. + pub fn bundle_patch_pack_urls( + &self, + root: &YostarJpResourceRoot, + platform: PatchPlatform, + ) -> Result, String> { + self.bundle_patch_pack_names + .iter() + .map(|name| root.bundle_patch_pack(platform, name)) + .collect() + } + + /// Returns the official table file URLs. + pub fn table_file_urls(&self, root: &YostarJpResourceRoot) -> Result, String> { + self.table_file_names + .iter() + .map(|name| root.table_bundle(name)) + .collect() + } + + /// Returns the official media file URLs for a platform. + pub fn media_file_urls( + &self, + root: &YostarJpResourceRoot, + platform: PatchPlatform, + ) -> Result, String> { + self.media_file_names + .iter() + .map(|name| root.media_file(platform, name)) + .collect() + } + + /// Returns the complete direct-download URL set for a platform. + pub fn direct_download_urls( + &self, + root: &YostarJpResourceRoot, + platform: PatchPlatform, + ) -> Result, String> { + let mut urls = self.bundle_patch_pack_urls(root, platform)?; + urls.extend(self.table_file_urls(root)?); + urls.extend(self.media_file_urls(root, platform)?); + Ok(urls) + } + + /// Returns the complete direct-download URL set for multiple platforms. + /// + /// Table bundles are emitted once. Platform-specific URLs are emitted in + /// platform order and deduplicated by URL so shared Android media paths + /// only appear once. + pub fn direct_download_urls_for_platforms( + &self, + root: &YostarJpResourceRoot, + platforms: &[PatchPlatform], + ) -> Result, String> { + let mut urls = Vec::new(); + let mut seen = HashSet::new(); + + append_unique_urls(&mut urls, &mut seen, self.table_file_urls(root)?); + + for platform in unique_platforms(platforms) { + append_unique_urls( + &mut urls, + &mut seen, + self.bundle_patch_pack_urls(root, platform)?, + ); + append_unique_urls(&mut urls, &mut seen, self.media_file_urls(root, platform)?); + } + + Ok(urls) + } + + /// Returns the complete direct-download URL set for all verified official + /// JP platforms. + pub fn direct_download_urls_for_verified_platforms( + &self, + root: &YostarJpResourceRoot, + ) -> Result, String> { + self.direct_download_urls_for_platforms(root, &verified_official_platforms()) + } +} + +fn merge_platform_catalogs( + catalogs: Vec, +) -> Vec { + let mut merged = Vec::::new(); + + for catalog in catalogs { + if let Some(existing) = merged + .iter_mut() + .find(|existing| existing.platform == catalog.platform) + { + merge_names( + &mut existing.bundle_patch_pack_names, + catalog.bundle_patch_pack_names, + ); + merge_names(&mut existing.media_file_names, catalog.media_file_names); + } else { + merged.push(catalog); + } + } + + merged.sort_by_key(|catalog| catalog.platform); + merged +} + +fn merge_names(existing: &mut Vec, names: Vec) { + let mut merged = existing.iter().cloned().collect::>(); + merged.extend(names); + *existing = merged.into_iter().collect(); +} + +fn extract_full_patch_pack_names(data: &[u8]) -> Vec { + extract_file_names(data, &["zip"]) // only .zip names survive here + .into_iter() + .filter(|name| is_full_patch_pack_name(name)) + .collect() +} + +fn extract_file_names(data: &[u8], extensions: &[&str]) -> Vec { + let mut names = BTreeSet::new(); + + for string in extract_printable_strings(data, 4) { + for name in candidate_file_names(&string, extensions) { + names.insert(name); + } + } + + names.into_iter().collect() +} + +fn extract_printable_strings(data: &[u8], min_len: usize) -> Vec { + let mut strings = Vec::new(); + let mut current = Vec::new(); + + for &byte in data { + if byte.is_ascii_graphic() || byte == b' ' { + current.push(byte); + } else if current.len() >= min_len { + strings.push(String::from_utf8_lossy(¤t).into_owned()); + current.clear(); + } else { + current.clear(); + } + } + + if current.len() >= min_len { + strings.push(String::from_utf8_lossy(¤t).into_owned()); + } + + strings +} + +fn candidate_file_names(value: &str, extensions: &[&str]) -> Vec { + let mut names = Vec::new(); + let bytes = value.as_bytes(); + + for extension in extensions { + let suffix = format!(".{extension}"); + let mut search_from = 0; + + while let Some(relative_index) = value[search_from..].find(&suffix) { + let extension_start = search_from + relative_index; + let start = filename_start(bytes, extension_start); + let end = extension_start + suffix.len(); + let candidate = &value[start..end]; + let candidate = candidate.rsplit(['/', '\\']).next().unwrap_or(candidate); + + if is_plausible_file_name(candidate) { + names.push(candidate.to_string()); + } + + search_from = end; + } + } + + names +} + +fn filename_start(bytes: &[u8], mut index: usize) -> usize { + while index > 0 { + let byte = bytes[index - 1]; + if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/' | b'\\') { + index -= 1; + } else { + break; + } + } + + index +} + +fn is_full_patch_pack_name(name: &str) -> bool { + let bytes = name.as_bytes(); + if bytes.len() != 17 || !name.starts_with("FullPatch_") || !name.ends_with(".zip") { + return false; + } + + bytes[10..13].iter().all(|byte| byte.is_ascii_digit()) +} + +fn is_plausible_file_name(name: &str) -> bool { + !name.is_empty() + && !name.contains('/') + && !name.contains('\\') + && !name.contains("..") + && !name.contains(':') + && !name.contains('=') + && name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) +} + +fn unique_platforms(platforms: &[PatchPlatform]) -> Vec { + platforms + .iter() + .copied() + .fold(Vec::new(), |mut unique, platform| { + if !unique.contains(&platform) { + unique.push(platform); + } + unique + }) +} + +fn append_unique_urls(urls: &mut Vec, seen: &mut HashSet, next_urls: Vec) { + for url in next_urls { + if seen.insert(url.clone()) { + urls.push(url); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::official::yostar_jp::YostarJpResourceRoot; + + const ROOT: &str = "r93_dctuo3tcd029wwxnvb55"; + + #[test] + fn extracts_download_names_from_synthetic_bytes() { + let inventory = YostarJpDownloadInventory::from_catalog_bytes( + b"prefix FullPatch_000.zip noise FullPatch_114.zip suffix", + b"GameData\\Table\\ExcelDB.db\0rawdata/table/excel/ignored.bytes\0Battle.zip8", + b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0JP_Akane.zip", + ); + + assert_eq!( + inventory.bundle_patch_pack_names, + vec![ + "FullPatch_000.zip".to_string(), + "FullPatch_114.zip".to_string() + ] + ); + assert_eq!( + inventory.table_file_names, + vec!["Battle.zip".to_string(), "ExcelDB.db".to_string(),] + ); + assert_eq!( + inventory.media_file_names, + vec!["JP_Airi.zip".to_string(), "JP_Akane.zip".to_string(),] + ); + } + + #[test] + fn builds_direct_download_urls() { + let inventory = YostarJpDownloadInventory::from_catalog_bytes( + b"FullPatch_000.zip FullPatch_001.zip", + b"ExcelDB.db Battle.zip", + b"JP_Airi.zip JP_Akane.zip", + ); + let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); + + let urls = inventory + .direct_download_urls(&root, PatchPlatform::Windows) + .unwrap(); + + assert_eq!(urls[0], "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/FullPatch_000.zip"); + assert!(urls + .iter() + .any(|url| url.ends_with("/TableBundles/ExcelDB.db"))); + assert!(urls + .iter() + .any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip"))); + } + + #[test] + fn builds_direct_download_urls_for_verified_platforms() { + let inventory = YostarJpDownloadInventory::from_catalog_bytes( + b"FullPatch_000.zip", + b"ExcelDB.db", + b"JP_Airi.zip", + ); + let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); + + let urls = inventory + .direct_download_urls_for_verified_platforms(&root) + .unwrap(); + + assert_eq!(urls.len(), 5); + assert!(urls + .iter() + .any(|url| url.contains("/Windows_PatchPack/FullPatch_000.zip"))); + assert!(urls + .iter() + .any(|url| url.contains("/Android_PatchPack/FullPatch_000.zip"))); + assert!(urls + .iter() + .any(|url| url.ends_with("/TableBundles/ExcelDB.db"))); + assert!(urls + .iter() + .any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip"))); + assert!(urls + .iter() + .any(|url| url.ends_with("/MediaResources/JP_Airi.zip"))); + } + + #[test] + fn builds_platform_specific_download_urls_without_cross_mixing_catalogs() { + let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes( + b"ExcelDB.db", + vec![ + YostarJpPlatformCatalogInventory::from_catalog_bytes( + PatchPlatform::Windows, + b"FullPatch_000.zip", + b"JP_Airi_Win.zip", + ), + YostarJpPlatformCatalogInventory::from_catalog_bytes( + PatchPlatform::Android, + b"FullPatch_001.zip", + b"JP_Airi_Android.zip", + ), + ], + ); + let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); + + let urls = inventory + .direct_download_urls_for_verified_platforms(&root) + .unwrap(); + + assert!(urls + .iter() + .any(|url| url.ends_with("/Windows_PatchPack/FullPatch_000.zip"))); + assert!(urls + .iter() + .any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip"))); + assert!(urls + .iter() + .any(|url| url.ends_with("/MediaResources-Windows/JP_Airi_Win.zip"))); + assert!(urls + .iter() + .any(|url| url.ends_with("/MediaResources/JP_Airi_Android.zip"))); + assert!(!urls + .iter() + .any(|url| url.ends_with("/Android_PatchPack/FullPatch_000.zip"))); + assert!(!urls + .iter() + .any(|url| url.ends_with("/Windows_PatchPack/FullPatch_001.zip"))); + } + + #[test] + #[ignore = "requires BAT_REAL_OFFICIAL_BUNDLE_PACKING_INFO, BAT_REAL_OFFICIAL_TABLE_CATALOG, BAT_REAL_OFFICIAL_MEDIA_CATALOG"] + fn extracts_realistic_counts_from_official_shape() { + let bundle_packing_info = + std::fs::read(std::env::var("BAT_REAL_OFFICIAL_BUNDLE_PACKING_INFO").unwrap()).unwrap(); + let table_catalog = + std::fs::read(std::env::var("BAT_REAL_OFFICIAL_TABLE_CATALOG").unwrap()).unwrap(); + let media_catalog = + std::fs::read(std::env::var("BAT_REAL_OFFICIAL_MEDIA_CATALOG").unwrap()).unwrap(); + + let inventory = YostarJpDownloadInventory::from_catalog_bytes( + &bundle_packing_info, + &table_catalog, + &media_catalog, + ); + + assert_eq!(inventory.bundle_patch_pack_names.len(), 142); + assert_eq!(inventory.table_file_names.len(), 6351); + assert_eq!(inventory.media_file_names.len(), 1887); + assert!(inventory + .bundle_patch_pack_names + .iter() + .any(|name| name == "FullPatch_000.zip")); + assert!(inventory + .table_file_names + .iter() + .any(|name| name == "ExcelDB.db")); + assert!(inventory + .media_file_names + .iter() + .any(|name| name == "JP_Airi.zip")); + } +} diff --git a/adapters/src/official/launcher.rs b/adapters/src/official/launcher.rs new file mode 100644 index 0000000..a4053a8 --- /dev/null +++ b/adapters/src/official/launcher.rs @@ -0,0 +1,243 @@ +//! Official Yostar JP launcher metadata. +//! +//! The PC launcher has its own update chain for the Windows game client. This +//! module parses only the local launcher files written by the official launcher +//! (`manifest.json` and `game-launcher-config.json`). It does not infer Unity +//! resource server-info from mirror URLs. + +use serde::Deserialize; +use std::fs; +use std::path::Path; + +/// Official JP launcher game id. +pub const YOSTAR_JP_GAME_TAG: &str = "BlueArchive_JP"; + +/// Local game manifest file name. +pub const LAUNCHER_MANIFEST_FILE: &str = "manifest.json"; + +/// Local game launcher config file name. +pub const LAUNCHER_CONFIG_FILE: &str = "game-launcher-config.json"; + +/// Local `game-launcher-config.json`. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct YostarJpLauncherConfig { + /// Game tag, expected to be `BlueArchive_JP`. + pub tag: String, + /// Executable name without `.exe`. + pub name: String, + /// Arguments passed by the launcher. + #[serde(default)] + pub params: Vec, + /// Installed Windows game client version. + pub version: String, + /// Launcher integrity hash. + #[serde(default)] + pub vc: Option, +} + +impl YostarJpLauncherConfig { + /// Parses a launcher config JSON document. + pub fn from_slice(data: &[u8]) -> Result { + serde_json::from_slice(data) + } + + /// Reads `game-launcher-config.json` from an installed game root. + pub fn from_game_root(root: impl AsRef) -> Result { + let path = root.as_ref().join(LAUNCHER_CONFIG_FILE); + let bytes = fs::read(&path) + .map_err(|error| format!("Failed to read {}: {error}", path.display()))?; + let config = Self::from_slice(&bytes) + .map_err(|error| format!("Failed to parse {}: {error}", path.display()))?; + config.validate_official_jp()?; + Ok(config) + } + + /// Validates that this config belongs to the official JP game tag. + pub fn validate_official_jp(&self) -> Result<(), String> { + if self.tag != YOSTAR_JP_GAME_TAG { + return Err(format!( + "Launcher config tag is not official JP: {}", + self.tag + )); + } + + Ok(()) + } +} + +/// Local `manifest.json` file written by the official launcher. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct YostarJpLauncherManifest { + /// Game tag, expected to be `BlueArchive_JP`. + pub name: String, + /// Installed Windows game client version. + pub version: String, + /// Launcher API `game_latest_file_path` value used to fetch the manifest. + pub basis: String, + /// Manifest integrity hash. + #[serde(default)] + pub vc: Option, + /// File entries in the installed Windows client. + #[serde(default)] + pub files: Vec, +} + +impl YostarJpLauncherManifest { + /// Parses a launcher manifest JSON document. + pub fn from_slice(data: &[u8]) -> Result { + serde_json::from_slice(data) + } + + /// Reads `manifest.json` from an installed game root. + pub fn from_game_root(root: impl AsRef) -> Result { + let path = root.as_ref().join(LAUNCHER_MANIFEST_FILE); + let bytes = fs::read(&path) + .map_err(|error| format!("Failed to read {}: {error}", path.display()))?; + let manifest = Self::from_slice(&bytes) + .map_err(|error| format!("Failed to parse {}: {error}", path.display()))?; + manifest.validate_official_jp()?; + Ok(manifest) + } + + /// Validates that this manifest belongs to the official JP game tag. + pub fn validate_official_jp(&self) -> Result<(), String> { + if self.name != YOSTAR_JP_GAME_TAG { + return Err(format!( + "Launcher manifest name is not official JP: {}", + self.name + )); + } + + Ok(()) + } +} + +/// One launcher manifest file entry. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct YostarJpLauncherManifestFile { + /// Path relative to the game root. Official manifests usually prefix this + /// with `/`. + pub path: String, + /// File size as a decimal string. + pub size: String, + /// CRC64 hash as a decimal string. + pub hash: String, + /// Per-file integrity hash. + #[serde(default)] + pub vc: Option, +} + +/// Local installed official JP game bootstrap data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct YostarJpInstalledGameBootstrap { + /// Installed client version from `game-launcher-config.json`. + pub app_version: String, + /// Executable name without `.exe`. + pub executable_name: String, + /// Launcher arguments. + pub executable_params: Vec, + /// Optional launcher manifest basis, when `manifest.json` exists. + pub basis: Option, + /// Optional installed manifest file count. + pub manifest_file_count: Option, +} + +impl YostarJpInstalledGameBootstrap { + /// Reads all available launcher metadata from an installed official JP game + /// root. + pub fn from_game_root(root: impl AsRef) -> Result { + let root = root.as_ref(); + let config = YostarJpLauncherConfig::from_game_root(root)?; + let manifest = YostarJpLauncherManifest::from_game_root(root).ok(); + + if let Some(manifest) = &manifest { + if manifest.version != config.version { + return Err(format!( + "Launcher config version {} does not match manifest version {}", + config.version, manifest.version + )); + } + } + + Ok(Self { + app_version: config.version, + executable_name: config.name, + executable_params: config.params, + basis: manifest.as_ref().map(|manifest| manifest.basis.clone()), + manifest_file_count: manifest.as_ref().map(|manifest| manifest.files.len()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn parses_official_launcher_config() { + let config = YostarJpLauncherConfig::from_slice( + br#"{ + "tag": "BlueArchive_JP", + "name": "xldr_BlueArchiveOnline_JP_loader_x64", + "params": ["BlueArchive.exe"], + "version": "1.70.0", + "vc": "hash" + }"#, + ) + .unwrap(); + + assert_eq!(config.version, "1.70.0"); + assert_eq!(config.params, vec!["BlueArchive.exe"]); + config.validate_official_jp().unwrap(); + } + + #[test] + fn builds_installed_game_bootstrap_from_local_files() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join(LAUNCHER_CONFIG_FILE), + r#"{ + "tag": "BlueArchive_JP", + "name": "xldr_BlueArchiveOnline_JP_loader_x64", + "params": ["BlueArchive.exe"], + "version": "1.70.0" + }"#, + ) + .unwrap(); + fs::write( + dir.path().join(LAUNCHER_MANIFEST_FILE), + r#"{ + "name": "BlueArchive_JP", + "version": "1.70.0", + "basis": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip", + "files": [{"path": "/BlueArchive.exe", "size": "1", "hash": "2"}] + }"#, + ) + .unwrap(); + + let bootstrap = YostarJpInstalledGameBootstrap::from_game_root(dir.path()).unwrap(); + + assert_eq!(bootstrap.app_version, "1.70.0"); + assert_eq!( + bootstrap.basis.as_deref(), + Some("prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip") + ); + assert_eq!(bootstrap.manifest_file_count, Some(1)); + } + + #[test] + fn rejects_non_jp_launcher_config() { + let config = YostarJpLauncherConfig::from_slice( + br#"{ + "tag": "BlueArchive_CN", + "name": "BlueArchive", + "version": "1.70.0" + }"#, + ) + .unwrap(); + + assert!(config.validate_official_jp().is_err()); + } +} diff --git a/adapters/src/official/mod.rs b/adapters/src/official/mod.rs new file mode 100644 index 0000000..003ceee --- /dev/null +++ b/adapters/src/official/mod.rs @@ -0,0 +1,19 @@ +//! Official Blue Archive service adapters. +//! +//! This module only models URL rules observed from the official Yostar JP +//! client endpoints. Mirror-specific layers such as `bluearchive.cafe` or +//! `text=jp/voice=jp/media=jp` are intentionally excluded. + +pub mod inventory; +pub mod game_main_config; +pub mod launcher; +pub mod yostar_jp; + +pub use inventory::{ + YostarJpDownloadInventory, YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory, +}; +pub use game_main_config::YostarJpGameMainConfig; +pub use launcher::{ + YostarJpInstalledGameBootstrap, YostarJpLauncherConfig, YostarJpLauncherManifest, +}; +pub use yostar_jp::{verified_official_platforms, PatchPlatform, YostarJpResourceRoot}; diff --git a/adapters/src/official/yostar_jp.rs b/adapters/src/official/yostar_jp.rs new file mode 100644 index 0000000..03c08f8 --- /dev/null +++ b/adapters/src/official/yostar_jp.rs @@ -0,0 +1,1220 @@ +//! Official Yostar JP resource URL rules. +//! +//! These builders mirror the client-side patch strategies recovered from and +//! checked against the JP client endpoints: +//! - server info is hosted under `yostar-serverinfo.bluearchiveyostar.com` +//! - resource patches are hosted under `prod-clientpatch.bluearchiveyostar.com` +//! - Windows AssetBundles are downloaded as patch-pack zip files, not as the +//! naked `.bundle` names present in `catalog_Remote.json` +//! - Android uses its own patch-pack directory + +use serde::Deserialize; + +/// Official JP server-info host. +pub const SERVER_INFO_HOST: &str = "yostar-serverinfo.bluearchiveyostar.com"; + +/// Official JP client-patch host. +pub const CLIENT_PATCH_HOST: &str = "prod-clientpatch.bluearchiveyostar.com"; + +const SERVER_INFO_ROOT: &str = "https://yostar-serverinfo.bluearchiveyostar.com"; +const CLIENT_PATCH_ROOT: &str = "https://prod-clientpatch.bluearchiveyostar.com"; + +/// Official JP server-info payload. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub struct YostarJpServerInfo { + #[serde(default)] + connection_groups: Vec, +} + +impl YostarJpServerInfo { + /// Parses a server-info JSON document. + pub fn from_slice(data: &[u8]) -> Result { + serde_json::from_slice(data) + } + + /// Parses a server-info JSON string. + pub fn from_json(data: &str) -> Result { + serde_json::from_str(data) + } + + /// Returns all connection groups in their server-provided order. + pub fn connection_groups(&self) -> &[YostarJpConnectionGroup] { + &self.connection_groups + } + + /// Selects a connection group using the same name and version override + /// rule as the JP client. + pub fn connection_group( + &self, + name: &str, + app_version: &str, + ) -> Result { + self.connection_groups + .iter() + .find(|group| group.name.as_deref() == Some(name)) + .map(|group| group.merged_for_version(app_version)) + .ok_or_else(|| format!("Server-info connection group not found: {name}")) + } + + /// Selects and validates the official JP resource root for a connection + /// group and app version. + pub fn resource_root( + &self, + name: &str, + app_version: &str, + ) -> Result { + self.connection_group(name, app_version)?.resource_root() + } + + /// Builds the official JP resource discovery seed plan for the selected + /// connection group, app version, and platforms. + pub fn discovery_plan( + &self, + name: &str, + app_version: &str, + platforms: &[PatchPlatform], + ) -> Result { + let group = self.connection_group(name, app_version)?; + let root = group.resource_root()?; + let endpoints = root.seed_endpoints(platforms)?; + + Ok(YostarJpResourceDiscoveryPlan { + connection_group_name: group.name().unwrap_or(name).to_string(), + app_version: app_version.to_string(), + bundle_version: group.bundle_version().map(ToOwned::to_owned), + addressables_root: root.addressables_root(), + endpoints, + }) + } + + /// Builds a synchronization snapshot for comparing local and official + /// state. + pub fn sync_snapshot( + &self, + name: &str, + app_version: &str, + platforms: &[PatchPlatform], + ) -> Result { + let plan = self.discovery_plan(name, app_version, platforms)?; + Ok(YostarJpSyncSnapshot { + connection_group_name: plan.connection_group_name, + app_version: plan.app_version, + bundle_version: plan.bundle_version, + addressables_root: plan.addressables_root, + endpoints: plan.endpoints, + }) + } +} + +/// Official JP server-info connection group. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub struct YostarJpConnectionGroup { + #[serde(default)] + name: Option, + #[serde(default)] + management_data_url: Option, + #[serde(default)] + addressables_catalog_url_root: Option, + #[serde(default)] + is_production_addressables: Option, + #[serde(default)] + api_url: Option, + #[serde(default)] + gateway_url: Option, + #[serde(default)] + kibana_log_url: Option, + #[serde(default)] + chatting_server_address: Option, + #[serde(default)] + prohibited_word_black_list_uri: Option, + #[serde(default)] + prohibited_word_white_list_uri: Option, + #[serde(default)] + customer_service_url: Option, + #[serde(default)] + override_connection_groups: Vec, + #[serde(default)] + bundle_version: Option, + #[serde(default)] + is_live_published: bool, +} + +impl YostarJpConnectionGroup { + /// Returns the connection group name. + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Returns the selected `AddressablesCatalogUrlRoot`, if present. + pub fn addressables_catalog_url_root(&self) -> Option<&str> { + self.addressables_catalog_url_root.as_deref() + } + + /// Returns the selected bundle version, if present. + pub fn bundle_version(&self) -> Option<&str> { + self.bundle_version.as_deref() + } + + /// Returns the production-addressables flag, if present. + pub fn is_production_addressables(&self) -> Option { + self.is_production_addressables + } + + /// Returns whether this group is marked as live-published. + pub fn is_live_published(&self) -> bool { + self.is_live_published + } + + /// Returns a group with the first matching version override applied. + /// + /// The JP client tests overrides with `app_version.StartsWith(Name)` and + /// applies only non-empty string fields. `BundleVersion` and + /// `IsLivePublished` remain inherited from the base group. + pub fn merged_for_version(&self, app_version: &str) -> Self { + let mut merged = self.clone(); + merged.override_connection_groups.clear(); + + let Some(overriding) = self.override_connection_groups.iter().find(|group| { + group + .name + .as_deref() + .is_some_and(|name| app_version.starts_with(name)) + }) else { + return merged; + }; + + override_non_empty( + &mut merged.management_data_url, + &overriding.management_data_url, + ); + override_non_empty( + &mut merged.addressables_catalog_url_root, + &overriding.addressables_catalog_url_root, + ); + if overriding.is_production_addressables.is_some() { + merged.is_production_addressables = overriding.is_production_addressables; + } + override_non_empty(&mut merged.api_url, &overriding.api_url); + override_non_empty(&mut merged.gateway_url, &overriding.gateway_url); + override_non_empty(&mut merged.kibana_log_url, &overriding.kibana_log_url); + override_non_empty( + &mut merged.chatting_server_address, + &overriding.chatting_server_address, + ); + override_non_empty( + &mut merged.prohibited_word_black_list_uri, + &overriding.prohibited_word_black_list_uri, + ); + override_non_empty( + &mut merged.prohibited_word_white_list_uri, + &overriding.prohibited_word_white_list_uri, + ); + override_non_empty( + &mut merged.customer_service_url, + &overriding.customer_service_url, + ); + + merged + } + + /// Validates and returns the official resource root for this group. + pub fn resource_root(&self) -> Result { + let root = self + .addressables_catalog_url_root() + .ok_or_else(|| "Server-info group has no AddressablesCatalogUrlRoot".to_string())?; + + YostarJpResourceRoot::from_addressables_root(root) + } +} + +/// Patch platform selected by the game client. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum PatchPlatform { + /// Standalone Windows client. + Windows, + /// Android client. + Android, +} + +impl PatchPlatform { + /// Returns a stable display label for the platform. + pub fn as_str(self) -> &'static str { + match self { + Self::Windows => "Windows", + Self::Android => "Android", + } + } + + fn catalog_zip_name(self) -> Result<&'static str, String> { + match self { + Self::Windows => Ok("catalog_StandaloneWindows64.zip"), + Self::Android => Ok("catalog_Android.zip"), + } + } + + fn patch_pack_dir(self) -> Result<&'static str, String> { + match self { + Self::Windows => Ok("Windows_PatchPack"), + Self::Android => Ok("Android_PatchPack"), + } + } + + fn media_dir(self) -> Result<&'static str, String> { + match self { + Self::Windows => Ok("MediaResources-Windows"), + Self::Android => Ok("MediaResources"), + } + } +} + +/// Platforms that have verified official JP patch-path coverage. +pub fn verified_official_platforms() -> Vec { + vec![PatchPlatform::Windows, PatchPlatform::Android] +} + +/// Kind of official JP resource endpoint discovered from a resource root. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum YostarJpResourceEndpointKind { + /// `TableBundles/TableCatalog.bytes`. + TableCatalog, + /// `TableBundles/TableCatalog.hash`. + TableCatalogHash, + /// Platform Addressables catalog zip. + AddressablesCatalog, + /// Platform Addressables catalog hash. + AddressablesCatalogHash, + /// Platform `BundlePackingInfo.bytes`. + BundlePackingInfo, + /// Platform `BundlePackingInfo.hash`. + BundlePackingInfoHash, + /// Platform media catalog bytes. + MediaCatalog, + /// Platform media catalog hash. + MediaCatalogHash, +} + +/// One official JP resource URL seed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct YostarJpResourceEndpoint { + /// Endpoint kind. + pub kind: YostarJpResourceEndpointKind, + /// Platform when the endpoint is platform-specific. + pub platform: Option, + /// Official URL. + pub url: String, +} + +/// Official JP resource discovery seed plan. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct YostarJpResourceDiscoveryPlan { + /// Selected server-info connection group name. + pub connection_group_name: String, + /// App version used for override selection. + pub app_version: String, + /// Server-info bundle version inherited from the selected group. + pub bundle_version: Option, + /// Selected official `AddressablesCatalogUrlRoot`. + pub addressables_root: String, + /// Seed URLs that should be fetched before parsing downstream catalogs. + pub endpoints: Vec, +} + +/// Snapshot of the official JP resource state observed at a point in time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct YostarJpSyncSnapshot { + /// Selected server-info connection group name. + pub connection_group_name: String, + /// App version used for selection. + pub app_version: String, + /// Server-info bundle version inherited from the selected group. + pub bundle_version: Option, + /// Selected official `AddressablesCatalogUrlRoot`. + pub addressables_root: String, + /// Seed URLs that should be checked or fetched. + pub endpoints: Vec, +} + +impl YostarJpSyncSnapshot { + /// Returns the official resource root token, if available. + pub fn root_token(&self) -> Option<&str> { + self.addressables_root + .strip_prefix(CLIENT_PATCH_ROOT) + .and_then(|rest| rest.strip_prefix('/')) + } + + /// Compares this snapshot with a previously stored snapshot. + pub fn diff(&self, previous: Option<&YostarJpSyncSnapshot>) -> YostarJpSyncDelta { + let Some(previous) = previous else { + return YostarJpSyncDelta::new(true); + }; + + let mut delta = YostarJpSyncDelta::default(); + delta.connection_group_changed = + self.connection_group_name != previous.connection_group_name; + delta.app_version_changed = self.app_version != previous.app_version; + delta.bundle_version_changed = self.bundle_version != previous.bundle_version; + delta.addressables_root_changed = self.addressables_root != previous.addressables_root; + delta.root_token_changed = self.root_token() != previous.root_token(); + delta.endpoint_changes = diff_endpoints(&self.endpoints, &previous.endpoints); + delta.needs_download = delta.addressables_root_changed + || delta.root_token_changed + || delta.bundle_version_changed + || !delta.endpoint_changes.is_empty(); + + delta + } +} + +/// Delta between an official snapshot and a previously stored snapshot. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct YostarJpSyncDelta { + /// Whether this is the first observation. + pub is_initial: bool, + /// Whether the connection group name changed. + pub connection_group_changed: bool, + /// Whether the app version changed. + pub app_version_changed: bool, + /// Whether the bundle version changed. + pub bundle_version_changed: bool, + /// Whether the addressables root URL changed. + pub addressables_root_changed: bool, + /// Whether the root token segment changed. + pub root_token_changed: bool, + /// Endpoint additions/removals/updates. + pub endpoint_changes: Vec, + /// Whether downstream download should be triggered. + pub needs_download: bool, +} + +impl YostarJpSyncDelta { + fn new(is_initial: bool) -> Self { + Self { + is_initial, + needs_download: is_initial, + ..Self::default() + } + } + + /// Returns true if the snapshot is materially different. + pub fn has_changes(&self) -> bool { + self.is_initial + || self.connection_group_changed + || self.app_version_changed + || self.bundle_version_changed + || self.addressables_root_changed + || self.root_token_changed + || !self.endpoint_changes.is_empty() + } +} + +/// Individual endpoint difference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum YostarJpResourceEndpointChange { + /// Endpoint newly observed. + Added(YostarJpResourceEndpoint), + /// Endpoint no longer present. + Removed(YostarJpResourceEndpoint), + /// Endpoint content changed. + Updated { + /// Previous value. + previous: YostarJpResourceEndpoint, + /// Current value. + current: YostarJpResourceEndpoint, + }, +} + +fn diff_endpoints( + current: &[YostarJpResourceEndpoint], + previous: &[YostarJpResourceEndpoint], +) -> Vec { + let mut changes = Vec::new(); + let mut previous_index = std::collections::BTreeMap::new(); + let mut current_index = std::collections::BTreeMap::new(); + + for endpoint in previous { + previous_index.insert(endpoint_key(endpoint), endpoint.clone()); + } + for endpoint in current { + current_index.insert(endpoint_key(endpoint), endpoint.clone()); + } + + for (key, current_endpoint) in ¤t_index { + match previous_index.get(key) { + None => changes.push(YostarJpResourceEndpointChange::Added( + current_endpoint.clone(), + )), + Some(previous_endpoint) if previous_endpoint != current_endpoint => { + changes.push(YostarJpResourceEndpointChange::Updated { + previous: previous_endpoint.clone(), + current: current_endpoint.clone(), + }) + } + _ => {} + } + } + + for (key, previous_endpoint) in &previous_index { + if !current_index.contains_key(key) { + changes.push(YostarJpResourceEndpointChange::Removed( + previous_endpoint.clone(), + )); + } + } + + changes.sort_by(|left, right| endpoint_change_key(left).cmp(&endpoint_change_key(right))); + changes +} + +fn endpoint_key(endpoint: &YostarJpResourceEndpoint) -> (&'static str, Option) { + ( + match endpoint.kind { + YostarJpResourceEndpointKind::TableCatalog => "table_catalog", + YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash", + YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog", + YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash", + YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info", + YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash", + YostarJpResourceEndpointKind::MediaCatalog => "media_catalog", + YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash", + }, + endpoint.platform, + ) +} + +fn endpoint_change_key( + change: &YostarJpResourceEndpointChange, +) -> (u8, &'static str, Option) { + match change { + YostarJpResourceEndpointChange::Added(endpoint) => { + (0, endpoint_key(endpoint).0, endpoint.platform) + } + YostarJpResourceEndpointChange::Removed(endpoint) => { + (1, endpoint_key(endpoint).0, endpoint.platform) + } + YostarJpResourceEndpointChange::Updated { current, .. } => { + (2, endpoint_key(current).0, current.platform) + } + } +} + +/// Official JP resource URL builder for one `AddressablesCatalogUrlRoot`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct YostarJpResourceRoot { + root_token: String, +} + +impl YostarJpResourceRoot { + /// Creates a builder from a server-info `AddressablesCatalogUrlRoot` value. + /// + /// The value must use the official JP client-patch host. Mirror hosts are + /// rejected here so callers cannot accidentally treat localized mirrors as + /// official resources. + pub fn from_addressables_root(root: &str) -> Result { + let prefix = format!("{}/", CLIENT_PATCH_ROOT); + let root_token = root + .strip_prefix(&prefix) + .ok_or_else(|| format!("Addressables root is not official JP client patch: {root}"))?; + + Self::from_root_token(root_token) + } + + /// Creates a builder from the root token in server-info override data. + /// + /// Example token shape: `r93_dctuo3tcd029wwxnvb55`. + pub fn from_root_token(root_token: &str) -> Result { + validate_relative_segment(root_token, "root token")?; + Ok(Self { + root_token: root_token.to_string(), + }) + } + + /// Returns the official `AddressablesCatalogUrlRoot`. + pub fn addressables_root(&self) -> String { + format!("{}/{}", CLIENT_PATCH_ROOT, self.root_token) + } + + /// Returns the official Addressables catalog zip URL. + pub fn addressables_catalog_zip(&self, platform: PatchPlatform) -> Result { + Ok(format!( + "{}/{}/{}/{}", + CLIENT_PATCH_ROOT, + self.root_token, + platform.patch_pack_dir()?, + platform.catalog_zip_name()? + )) + } + + /// Returns the official Addressables catalog hash URL. + pub fn addressables_catalog_hash(&self, platform: PatchPlatform) -> Result { + Ok(self + .addressables_catalog_zip(platform)? + .trim_end_matches(".zip") + .to_string() + + ".hash") + } + + /// Returns the official bundle patch-pack catalog URL. + pub fn bundle_packing_info(&self, platform: PatchPlatform) -> Result { + Ok(format!( + "{}/{}/{}/BundlePackingInfo.bytes", + CLIENT_PATCH_ROOT, + self.root_token, + platform.patch_pack_dir()? + )) + } + + /// Returns the official bundle patch-pack catalog hash URL. + pub fn bundle_packing_hash(&self, platform: PatchPlatform) -> Result { + Ok(format!( + "{}/{}/{}/BundlePackingInfo.hash", + CLIENT_PATCH_ROOT, + self.root_token, + platform.patch_pack_dir()? + )) + } + + /// Returns an official bundle patch-pack zip URL. + /// + /// The argument is the `BundlePatchPack.PackName` field from + /// `BundlePackingInfo.bytes`, for example `FullPatch_000.zip`. + pub fn bundle_patch_pack( + &self, + platform: PatchPlatform, + pack_name: &str, + ) -> Result { + validate_relative_path(pack_name, "bundle patch pack")?; + Ok(format!( + "{}/{}/{}/{}", + CLIENT_PATCH_ROOT, + self.root_token, + platform.patch_pack_dir()?, + normalize_relative_path(pack_name) + )) + } + + /// Returns the official table catalog URL. + pub fn table_catalog(&self) -> String { + format!( + "{}/{}/TableBundles/TableCatalog.bytes", + CLIENT_PATCH_ROOT, self.root_token + ) + } + + /// Returns the official table catalog hash URL. + pub fn table_catalog_hash(&self) -> String { + format!( + "{}/{}/TableBundles/TableCatalog.hash", + CLIENT_PATCH_ROOT, self.root_token + ) + } + + /// Returns an official table bundle URL. + /// + /// The argument is the `TableBundle.Name` field from `TableCatalog.bytes`, + /// for example `ExcelDB.db` or `Excel.zip`. + pub fn table_bundle(&self, name: &str) -> Result { + validate_relative_path(name, "table bundle")?; + Ok(format!( + "{}/{}/TableBundles/{}", + CLIENT_PATCH_ROOT, + self.root_token, + normalize_relative_path(name) + )) + } + + /// Returns the official media catalog URL. + pub fn media_catalog(&self, platform: PatchPlatform) -> Result { + Ok(format!( + "{}/{}/{}/Catalog/MediaCatalog.bytes", + CLIENT_PATCH_ROOT, + self.root_token, + platform.media_dir()? + )) + } + + /// Returns the official media catalog hash URL. + pub fn media_catalog_hash(&self, platform: PatchPlatform) -> Result { + Ok(format!( + "{}/{}/{}/Catalog/MediaCatalog.hash", + CLIENT_PATCH_ROOT, + self.root_token, + platform.media_dir()? + )) + } + + /// Returns an official media archive URL. + /// + /// The argument is the `Media.FileName` field from `MediaCatalog.bytes`, + /// for example `JP_Airi.zip`. + pub fn media_file(&self, platform: PatchPlatform, file_name: &str) -> Result { + validate_relative_path(file_name, "media file")?; + Ok(format!( + "{}/{}/{}/{}", + CLIENT_PATCH_ROOT, + self.root_token, + platform.media_dir()?, + normalize_relative_path(file_name) + )) + } + + /// Returns the official seed URLs needed to discover concrete resource + /// files for the requested platforms. + /// + /// Table bundle seeds are emitted once because they are not platform + /// specific. Addressables, bundle patch-pack, and media seeds are emitted + /// per platform. + pub fn seed_endpoints( + &self, + platforms: &[PatchPlatform], + ) -> Result, String> { + let mut endpoints = vec![ + YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::TableCatalog, + platform: None, + url: self.table_catalog(), + }, + YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::TableCatalogHash, + platform: None, + url: self.table_catalog_hash(), + }, + ]; + + for platform in unique_platforms(platforms) { + endpoints.push(YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::AddressablesCatalog, + platform: Some(platform), + url: self.addressables_catalog_zip(platform)?, + }); + endpoints.push(YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::AddressablesCatalogHash, + platform: Some(platform), + url: self.addressables_catalog_hash(platform)?, + }); + endpoints.push(YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::BundlePackingInfo, + platform: Some(platform), + url: self.bundle_packing_info(platform)?, + }); + endpoints.push(YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::BundlePackingInfoHash, + platform: Some(platform), + url: self.bundle_packing_hash(platform)?, + }); + endpoints.push(YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::MediaCatalog, + platform: Some(platform), + url: self.media_catalog(platform)?, + }); + endpoints.push(YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::MediaCatalogHash, + platform: Some(platform), + url: self.media_catalog_hash(platform)?, + }); + } + + Ok(endpoints) + } +} + +/// Builds an official server-info URL from the encrypted GameMainConfig token. +pub fn server_info_url(file_name: &str) -> Result { + validate_relative_path(file_name, "server-info file")?; + Ok(format!( + "{}/{}", + SERVER_INFO_ROOT, + normalize_relative_path(file_name) + )) +} + +/// Returns true when the URL is under a known official JP Yostar host. +pub fn is_official_yostar_jp_url(url: &str) -> bool { + let Some(rest) = url.strip_prefix("https://") else { + return false; + }; + let host = rest.split('/').next().unwrap_or_default(); + matches!( + host, + SERVER_INFO_HOST + | CLIENT_PATCH_HOST + | "prod-noticeindex.bluearchiveyostar.com" + | "prod-notice.bluearchiveyostar.com" + | "prod-game.bluearchiveyostar.com:5000" + | "prod-gateway.bluearchiveyostar.com:5100" + | "prod-logcollector.bluearchiveyostar.com:5300" + | "bluearchive.jp" + ) +} + +fn validate_relative_segment(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || value.contains('/') + || value.contains('\\') + || value.contains("..") + || value.contains('=') + || value.contains(':') + || value.starts_with('.') + { + return Err(format!("Invalid {label}: {value}")); + } + + Ok(()) +} + +fn validate_relative_path(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || value.starts_with('/') + || value.starts_with('\\') + || value.contains("..") + || value.contains('=') + || value.contains(':') + { + return Err(format!("Invalid {label}: {value}")); + } + + Ok(()) +} + +fn normalize_relative_path(path: &str) -> String { + path.replace('\\', "/") +} + +fn override_non_empty(target: &mut Option, source: &Option) { + if source.as_deref().is_some_and(|value| !value.is_empty()) { + *target = source.clone(); + } +} + +fn unique_platforms(platforms: &[PatchPlatform]) -> Vec { + platforms + .iter() + .copied() + .fold(Vec::new(), |mut unique, platform| { + if !unique.contains(&platform) { + unique.push(platform); + } + unique + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROOT: &str = "r93_dctuo3tcd029wwxnvb55"; + const SERVER_INFO_JSON: &str = r#"{ + "ConnectionGroups": [ + { + "Name": "Prod-Audit", + "ManagementDataUrl": "https://prod-noticeindex.bluearchiveyostar.com/prod/index.json", + "IsProductionAddressables": true, + "ApiUrl": "https://prod-game.bluearchiveyostar.com:5000/api/", + "GatewayUrl": "https://prod-gateway.bluearchiveyostar.com:5100/api/", + "KibanaLogUrl": "https://prod-logcollector.bluearchiveyostar.com:5300", + "ProhibitedWordBlackListUri": "https://prod-notice.bluearchiveyostar.com/prod/ProhibitedWord/blacklist.csv", + "ProhibitedWordWhiteListUri": "https://prod-notice.bluearchiveyostar.com/prod/ProhibitedWord/whitelist.csv", + "CustomerServiceUrl": "https://bluearchive.jp/contact-1-hint", + "OverrideConnectionGroups": [ + { + "Name": "1.0", + "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/m28_1_0_1_mashiro3" + }, + { + "Name": "1.70", + "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55" + } + ], + "BundleVersion": "s8tloc7lo3", + "IsLivePublished": true + } + ] + }"#; + + #[test] + fn builds_server_info_url_from_file_name() { + let url = server_info_url("r93_70_eaueguci86m4lr0zwa44.json").unwrap(); + + assert_eq!( + url, + "https://yostar-serverinfo.bluearchiveyostar.com/r93_70_eaueguci86m4lr0zwa44.json" + ); + } + + #[test] + fn parses_server_info_and_selects_version_override() { + let server_info = YostarJpServerInfo::from_json(SERVER_INFO_JSON).unwrap(); + + let group = server_info + .connection_group("Prod-Audit", "1.70.0") + .unwrap(); + + assert_eq!(group.name(), Some("Prod-Audit")); + assert_eq!( + group.addressables_catalog_url_root(), + Some("https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55") + ); + assert_eq!(group.bundle_version(), Some("s8tloc7lo3")); + assert_eq!(group.is_production_addressables(), Some(true)); + assert!(group.is_live_published()); + } + + #[test] + fn builds_resource_root_from_selected_server_info() { + let server_info = YostarJpServerInfo::from_json(SERVER_INFO_JSON).unwrap(); + + let root = server_info.resource_root("Prod-Audit", "1.70.2").unwrap(); + + assert_eq!( + root.addressables_root(), + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55" + ); + } + + #[test] + fn builds_discovery_plan_from_selected_server_info() { + let server_info = YostarJpServerInfo::from_json(SERVER_INFO_JSON).unwrap(); + + let plan = server_info + .discovery_plan( + "Prod-Audit", + "1.70.0", + &[PatchPlatform::Windows, PatchPlatform::Android], + ) + .unwrap(); + + assert_eq!(plan.connection_group_name, "Prod-Audit"); + assert_eq!(plan.app_version, "1.70.0"); + assert_eq!(plan.bundle_version.as_deref(), Some("s8tloc7lo3")); + assert_eq!( + plan.addressables_root, + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55" + ); + assert_eq!(plan.endpoints.len(), 14); + assert_eq!( + plan.endpoints[0], + YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::TableCatalog, + platform: None, + url: "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/TableBundles/TableCatalog.bytes".to_string(), + } + ); + assert!(plan.endpoints.iter().any(|endpoint| endpoint + == &YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::AddressablesCatalog, + platform: Some(PatchPlatform::Windows), + url: "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/catalog_StandaloneWindows64.zip".to_string(), + })); + assert!(plan.endpoints.iter().any(|endpoint| endpoint + == &YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::MediaCatalog, + platform: Some(PatchPlatform::Android), + url: "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources/Catalog/MediaCatalog.bytes".to_string(), + })); + } + + #[test] + fn sync_snapshot_reports_initial_state() { + let server_info = YostarJpServerInfo::from_json(SERVER_INFO_JSON).unwrap(); + let snapshot = server_info + .sync_snapshot( + "Prod-Audit", + "1.70.0", + &[PatchPlatform::Windows, PatchPlatform::Android], + ) + .unwrap(); + + let delta = snapshot.diff(None); + assert!(delta.is_initial); + assert!(delta.needs_download); + assert!(delta.has_changes()); + assert!(delta.endpoint_changes.is_empty()); + } + + #[test] + fn sync_snapshot_detects_root_and_hash_changes() { + let server_info = YostarJpServerInfo::from_json(SERVER_INFO_JSON).unwrap(); + let current = server_info + .sync_snapshot("Prod-Audit", "1.70.0", &[PatchPlatform::Windows]) + .unwrap(); + + let previous = YostarJpSyncSnapshot { + connection_group_name: "Prod-Audit".to_string(), + app_version: "1.70.0".to_string(), + bundle_version: Some("s8tloc7lo2".to_string()), + addressables_root: + "https://prod-clientpatch.bluearchiveyostar.com/r93_old_token".to_string(), + endpoints: vec![ + YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::TableCatalog, + platform: None, + url: "https://prod-clientpatch.bluearchiveyostar.com/r93_old_token/TableBundles/TableCatalog.bytes".to_string(), + }, + YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::TableCatalogHash, + platform: None, + url: "https://prod-clientpatch.bluearchiveyostar.com/r93_old_token/TableBundles/TableCatalog.hash".to_string(), + }, + YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::AddressablesCatalog, + platform: Some(PatchPlatform::Windows), + url: "https://prod-clientpatch.bluearchiveyostar.com/r93_old_token/Windows_PatchPack/catalog_StandaloneWindows64.zip".to_string(), + }, + ], + }; + + let delta = current.diff(Some(&previous)); + assert!(delta.connection_group_changed == false); + assert!(delta.app_version_changed == false); + assert!(delta.bundle_version_changed); + assert!(delta.addressables_root_changed); + assert!(delta.root_token_changed); + assert!(delta.needs_download); + assert!(!delta.endpoint_changes.is_empty()); + } + + #[test] + fn sync_delta_can_notice_bundle_version_without_root_change() { + let server_info = YostarJpServerInfo::from_json(SERVER_INFO_JSON).unwrap(); + let current = server_info + .sync_snapshot("Prod-Audit", "1.70.0", &[PatchPlatform::Windows]) + .unwrap(); + + let previous = YostarJpSyncSnapshot { + connection_group_name: "Prod-Audit".to_string(), + app_version: "1.70.0".to_string(), + bundle_version: Some("older-bundle".to_string()), + addressables_root: current.addressables_root.clone(), + endpoints: current.endpoints.clone(), + }; + + let delta = current.diff(Some(&previous)); + assert!(delta.bundle_version_changed); + assert!(!delta.addressables_root_changed); + assert!(!delta.root_token_changed); + } + + #[test] + fn uses_first_prefix_override_and_falls_back_to_base_when_no_match() { + let server_info = YostarJpServerInfo::from_json( + r#"{ + "ConnectionGroups": [ + { + "Name": "Prod-Audit", + "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/base_root", + "OverrideConnectionGroups": [ + { + "Name": "1.70", + "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55" + }, + { + "Name": "1.70.0", + "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/too_late" + } + ], + "BundleVersion": "base-bundle", + "IsLivePublished": true + } + ] + }"#, + ) + .unwrap(); + + let matched = server_info + .connection_group("Prod-Audit", "1.70.0") + .unwrap(); + assert_eq!( + matched.addressables_catalog_url_root(), + Some("https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55") + ); + + let fallback = server_info + .connection_group("Prod-Audit", "1.69.0") + .unwrap(); + assert_eq!( + fallback.addressables_catalog_url_root(), + Some("https://prod-clientpatch.bluearchiveyostar.com/base_root") + ); + } + + #[test] + fn keeps_base_fields_when_override_fields_are_empty() { + let server_info = YostarJpServerInfo::from_json( + r#"{ + "ConnectionGroups": [ + { + "Name": "Prod-Audit", + "ManagementDataUrl": "https://prod-noticeindex.bluearchiveyostar.com/prod/index.json", + "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/base_root", + "ApiUrl": "https://prod-game.bluearchiveyostar.com:5000/api/", + "OverrideConnectionGroups": [ + { + "Name": "1.70", + "ManagementDataUrl": "", + "AddressablesCatalogUrlRoot": "" + } + ], + "BundleVersion": "base-bundle" + } + ] + }"#, + ) + .unwrap(); + + let group = server_info + .connection_group("Prod-Audit", "1.70.0") + .unwrap(); + + assert_eq!( + group.addressables_catalog_url_root(), + Some("https://prod-clientpatch.bluearchiveyostar.com/base_root") + ); + assert_eq!(group.bundle_version(), Some("base-bundle")); + } + + #[test] + fn rejects_mirror_root_from_server_info() { + let server_info = YostarJpServerInfo::from_json( + r#"{ + "ConnectionGroups": [ + { + "Name": "Prod-Audit", + "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchive.cafe/r93_02_dctuo3tcd029wwxnvb55/text=jp" + } + ] + }"#, + ) + .unwrap(); + + assert!(server_info.resource_root("Prod-Audit", "1.70.0").is_err()); + } + + #[test] + fn builds_windows_patch_strategy_urls() { + let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); + + assert_eq!( + root.addressables_catalog_zip(PatchPlatform::Windows).unwrap(), + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/catalog_StandaloneWindows64.zip" + ); + assert_eq!( + root.addressables_catalog_hash(PatchPlatform::Windows).unwrap(), + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/catalog_StandaloneWindows64.hash" + ); + assert_eq!( + root.bundle_packing_info(PatchPlatform::Windows).unwrap(), + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/BundlePackingInfo.bytes" + ); + assert_eq!( + root.bundle_packing_hash(PatchPlatform::Windows).unwrap(), + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/BundlePackingInfo.hash" + ); + assert_eq!( + root.bundle_patch_pack(PatchPlatform::Windows, "FullPatch_000.zip") + .unwrap(), + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/FullPatch_000.zip" + ); + } + + #[test] + fn builds_table_and_media_urls() { + let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); + + assert_eq!( + root.table_catalog(), + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/TableBundles/TableCatalog.bytes" + ); + assert_eq!( + root.table_bundle("ExcelDB.db").unwrap(), + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/TableBundles/ExcelDB.db" + ); + assert_eq!( + root.media_catalog(PatchPlatform::Windows).unwrap(), + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/Catalog/MediaCatalog.bytes" + ); + assert_eq!( + root.media_file(PatchPlatform::Windows, "JP_Airi.zip").unwrap(), + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/JP_Airi.zip" + ); + } + + #[test] + fn builds_seed_endpoints_once_per_unique_platform() { + let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); + + let endpoints = root + .seed_endpoints(&[ + PatchPlatform::Windows, + PatchPlatform::Windows, + PatchPlatform::Android, + ]) + .unwrap(); + + assert_eq!(endpoints.len(), 14); + assert_eq!( + endpoints + .iter() + .filter(|endpoint| endpoint.platform == Some(PatchPlatform::Windows)) + .count(), + 6 + ); + assert_eq!( + endpoints + .iter() + .filter(|endpoint| endpoint.platform == Some(PatchPlatform::Android)) + .count(), + 6 + ); + assert_eq!( + endpoints + .iter() + .filter(|endpoint| endpoint.platform.is_none()) + .count(), + 2 + ); + } + + #[test] + fn accepts_only_official_addressables_root() { + let official = format!("{CLIENT_PATCH_ROOT}/{ROOT}"); + assert_eq!( + YostarJpResourceRoot::from_addressables_root(&official) + .unwrap() + .addressables_root(), + official + ); + + let mirror = "https://prod-clientpatch.bluearchive.cafe/r93_02_dctuo3tcd029wwxnvb55"; + assert!(YostarJpResourceRoot::from_addressables_root(mirror).is_err()); + } + + #[test] + fn detects_official_hosts_without_accepting_mirrors() { + assert!(is_official_yostar_jp_url( + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/TableBundles/ExcelDB.db" + )); + assert!(is_official_yostar_jp_url( + "https://bluearchive.jp/contact-1-hint" + )); + assert!(!is_official_yostar_jp_url( + "https://prod-clientpatch.bluearchive.cafe/r93_02_dctuo3tcd029wwxnvb55/text=jp/voice=jp/media=jp/TableBundles/ExcelDB.db" + )); + } + + #[test] + fn rejects_mirror_path_layers_and_absolute_paths() { + let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap(); + + assert!( + YostarJpResourceRoot::from_root_token("r93_02_dctuo3tcd029wwxnvb55/text=jp").is_err() + ); + assert!(root.table_bundle("text=jp/ExcelDB.db").is_err()); + assert!(root + .media_file(PatchPlatform::Windows, "/JP_Airi.zip") + .is_err()); + assert!(root + .bundle_patch_pack(PatchPlatform::Windows, "../FullPatch_000.zip") + .is_err()); + } +} diff --git a/adapters/src/unity.rs b/adapters/src/unity.rs index e7660ae..0d7b698 100644 --- a/adapters/src/unity.rs +++ b/adapters/src/unity.rs @@ -4,8 +4,13 @@ pub mod adapter; pub mod registry; +pub mod serialized_file; pub mod unity_2021_3; -pub use adapter::{ParsedAssetBundle, RawAssetBundle, UnityAdapter, VersionRange}; +pub use adapter::{ + ParsedAssetBundle, RawAssetBundle, UnityAdapter, UnityFsBlockInfo, UnityFsCompression, + UnityFsDirectoryInfo, UnityFsHeader, VersionRange, +}; pub use registry::UnityAdapterRegistry; +pub use serialized_file::{UnitySerializedFile, UnitySerializedTextAsset}; pub use unity_2021_3::Unity2021_3Adapter; diff --git a/adapters/src/unity/adapter.rs b/adapters/src/unity/adapter.rs index e80d3dc..9c1c253 100644 --- a/adapters/src/unity/adapter.rs +++ b/adapters/src/unity/adapter.rs @@ -44,6 +44,72 @@ pub struct ParsedAssetBundle { pub assets: Vec, /// 原始数据(保留用于序列化) pub raw_data: Vec, + /// UnityFS 文件头信息。 + pub unityfs_header: Option, + /// UnityFS 压缩块信息。 + pub blocks: Vec, + /// UnityFS 目录信息。 + pub directories: Vec, +} + +/// UnityFS 文件头。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnityFsHeader { + /// UnityFS 格式版本。 + pub format_version: u32, + /// Bundle 目标版本字符串,例如 `5.x.x`。 + pub target_version: String, + /// Unity 编辑器版本字符串。 + pub unity_version: String, + /// 文件总大小。 + pub total_size: u64, + /// 压缩后的 block info 大小。 + pub compressed_blocks_info_size: u32, + /// 解压后的 block info 大小。 + pub uncompressed_blocks_info_size: u32, + /// UnityFS flags 原始值。 + pub flags: u32, +} + +/// UnityFS 块压缩类型。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnityFsCompression { + /// 未压缩。 + None, + /// LZMA 压缩。 + Lzma, + /// LZ4 压缩。 + Lz4, + /// LZ4HC 压缩。 + Lz4Hc, + /// 当前版本未识别的压缩类型。 + Unknown(u16), +} + +/// UnityFS 压缩块信息。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnityFsBlockInfo { + /// 解压后大小。 + pub uncompressed_size: u32, + /// 压缩后大小。 + pub compressed_size: u32, + /// 块 flags 原始值。 + pub flags: u16, + /// 解析出的压缩类型。 + pub compression: UnityFsCompression, +} + +/// UnityFS 目录条目。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnityFsDirectoryInfo { + /// 条目在数据区中的偏移。 + pub offset: u64, + /// 条目大小。 + pub size: u64, + /// 条目 flags 原始值。 + pub flags: u32, + /// 条目路径。 + pub path: String, } /// Unity Adapter 接口 @@ -113,10 +179,10 @@ mod tests { fn test_raw_assetbundle() { let bundle = RawAssetBundle { data: vec![0x55, 0x6e, 0x69, 0x74, 0x79, 0x46, 0x53], // "UnityFS" - path: Some("test.bundle".to_string()), + path: Some("synthetic-minimal.bundle".to_string()), }; assert_eq!(bundle.data.len(), 7); - assert_eq!(bundle.path, Some("test.bundle".to_string())); + assert_eq!(bundle.path, Some("synthetic-minimal.bundle".to_string())); } } diff --git a/adapters/src/unity/registry.rs b/adapters/src/unity/registry.rs index 2b825da..adddc82 100644 --- a/adapters/src/unity/registry.rs +++ b/adapters/src/unity/registry.rs @@ -18,6 +18,13 @@ impl UnityAdapterRegistry { } } + /// 创建带有默认 Unity 适配器的注册表。 + pub fn with_defaults() -> Self { + let mut registry = Self::new(); + registry.register(Arc::new(crate::unity::Unity2021_3Adapter::new())); + registry + } + /// 注册一个适配器 pub fn register(&mut self, adapter: Arc) { self.adapters.push(adapter); @@ -91,7 +98,7 @@ mod tests { let bundle = RawAssetBundle { data, - path: Some("test.bundle".to_string()), + path: Some("synthetic-minimal.bundle".to_string()), }; let result = registry.select_adapter(&bundle); @@ -111,4 +118,12 @@ mod tests { let result = registry.select_adapter(&bundle); assert!(result.is_err()); } + + #[test] + fn test_with_defaults_registers_unity_2021_3_adapter() { + let registry = UnityAdapterRegistry::with_defaults(); + + assert_eq!(registry.count(), 1); + assert_eq!(registry.all_adapters()[0].name(), "Unity-2021.3"); + } } diff --git a/adapters/src/unity/serialized_file.rs b/adapters/src/unity/serialized_file.rs new file mode 100644 index 0000000..6f743cf --- /dev/null +++ b/adapters/src/unity/serialized_file.rs @@ -0,0 +1,532 @@ +//! Unity serialized file reader. +//! +//! This module is intentionally narrow: it extracts `TextAsset` payloads from +//! Unity serialized files such as `resources.assets` and +//! `globalgamemanagers.assets`. + +use std::fs; +use std::path::Path; + +/// One extracted Unity `TextAsset`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnitySerializedTextAsset { + /// Unity path ID of the object. + pub path_id: i64, + /// Asset name stored in the serialized object. + pub name: String, + /// Raw bytes stored by the `TextAsset`. + pub bytes: Vec, +} + +/// Parsed Unity serialized file summary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnitySerializedFile { + /// Serialized file format version. + pub version: u32, + /// Unity editor version stored in the file. + pub unity_version: String, + /// Target platform value from the file header. + pub platform: i32, + text_assets: Vec, +} + +impl UnitySerializedFile { + /// Parses a serialized file from raw bytes. + pub fn from_slice(data: &[u8]) -> Result { + let mut reader = Reader::new(data); + + let _metadata_size = reader.read_u32_be("metadata_size")?; + let _file_size = reader.read_u32_be("file_size")?; + let version = reader.read_u32_be("version")?; + let _data_offset = reader.read_u32_be("data_offset")?; + let endian_flag = reader.read_u8("endian_flag")?; + reader.read_bytes(3, "reserved")?; + + let (metadata_size, file_size, data_offset) = if version >= 22 { + let metadata_size = reader.read_u32_be("metadata_size_2")?; + let file_size = reader.read_u64_be("file_size_2")?; + let data_offset = reader.read_u64_be("data_offset_2")? as usize; + let _unknown = reader.read_u64_be("unknown_2")?; + (metadata_size, file_size, data_offset) + } else { + (_metadata_size, _file_size as u64, _data_offset as usize) + }; + let _ = metadata_size; + let _ = file_size; + + let endian = if endian_flag == 0 { + Endian::Little + } else { + Endian::Big + }; + reader.set_endian(endian); + + let unity_version = reader.read_c_string("unity_version")?; + let platform = reader.read_i32("platform")?; + let enable_type_tree = reader.read_u8("enable_type_tree")?; + let type_count = reader.read_i32("type_count")?; + if type_count < 0 { + return Err(format!("Invalid Unity type count: {}", type_count)); + } + + let mut class_ids = Vec::with_capacity(type_count as usize); + for _ in 0..type_count { + class_ids.push(read_serialized_type( + &mut reader, + version, + enable_type_tree, + )?); + } + + let big_id_enabled = if version >= 11 && version < 14 { + reader.read_i32("big_id_enabled")? + } else { + 0 + }; + + let object_count = reader.read_i32("object_count")?; + if object_count < 0 { + return Err(format!("Invalid Unity object count: {}", object_count)); + } + + let mut text_assets = Vec::new(); + for _ in 0..object_count { + if version >= 14 { + reader.align(4)?; + } + + let path_id = if big_id_enabled != 0 { + reader.read_i64("path_id")? + } else if version < 14 { + reader.read_i32("path_id")? as i64 + } else { + reader.read_i64("path_id")? + }; + + let byte_start = if version >= 22 { + reader.read_u64("byte_start")? as usize + } else { + reader.read_u32("byte_start")? as usize + }; + let byte_size = reader.read_u32("byte_size")? as usize; + let type_id = reader.read_i32("type_id")?; + if version < 16 { + reader.read_u16("class_id")?; + } + if version < 11 { + reader.read_u16("is_destroyed")?; + } + if (11..17).contains(&version) { + reader.read_i16("script_type_index")?; + } + if version == 15 || version == 16 { + reader.read_u8("stripped")?; + } + + let class_id = class_ids + .get(type_id as usize) + .copied() + .ok_or_else(|| format!("Invalid Unity type index: {}", type_id))?; + if class_id == 49 { + let object_start = data_offset + .checked_add(byte_start) + .ok_or_else(|| "Unity object offset overflow".to_string())?; + let object_end = object_start + .checked_add(byte_size) + .ok_or_else(|| "Unity object size overflow".to_string())?; + if object_end > data.len() { + return Err(format!( + "Unity object exceeds file size: start={}, size={}, file_size={}", + object_start, + byte_size, + data.len() + )); + } + + let asset = parse_text_asset(path_id, &data[object_start..object_end], endian)?; + text_assets.push(asset); + } + } + + Ok(Self { + version, + unity_version, + platform, + text_assets, + }) + } + + /// Parses a serialized file from disk. + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let bytes = fs::read(path) + .map_err(|error| format!("Failed to read {}: {error}", path.display()))?; + Self::from_slice(&bytes) + } + + /// Returns all extracted text assets. + pub fn text_assets(&self) -> &[UnitySerializedTextAsset] { + &self.text_assets + } + + /// Returns one extracted text asset by name. + pub fn text_asset(&self, name: &str) -> Option<&UnitySerializedTextAsset> { + self.text_assets.iter().find(|asset| asset.name == name) + } +} + +fn parse_text_asset( + path_id: i64, + data: &[u8], + endian: Endian, +) -> Result { + let mut reader = Reader::new(data); + reader.set_endian(endian); + let name = reader.read_len_prefixed_string("text_asset_name")?; + reader.align(4)?; + let bytes_len = reader.read_u32("text_asset_bytes_len")? as usize; + let bytes = reader.read_bytes(bytes_len, "text_asset_bytes")?.to_vec(); + + Ok(UnitySerializedTextAsset { + path_id, + name, + bytes, + }) +} + +fn read_serialized_type( + reader: &mut Reader<'_>, + version: u32, + enable_type_tree: u8, +) -> Result { + let class_id = reader.read_i32("type_class_id")?; + + if version >= 16 { + reader.read_u8("type_is_stripped")?; + } + if version >= 17 { + reader.read_i16("type_script_index")?; + } + if version >= 13 { + if (version < 16 && class_id < 0) || (version >= 16 && class_id == 114) { + reader.read_bytes(16, "type_script_id")?; + } + reader.read_bytes(16, "type_hash")?; + } + + if enable_type_tree != 0 { + if version >= 12 || version == 10 { + let node_count = reader.read_i32("type_tree_node_count")?; + if node_count < 0 { + return Err(format!("Invalid Unity type tree node count: {}", node_count)); + } + let string_buffer_size = reader.read_i32("type_tree_string_buffer_size")?; + if string_buffer_size < 0 { + return Err(format!( + "Invalid Unity type tree string buffer size: {}", + string_buffer_size + )); + } + + let node_size = 2 + 1 + 1 + 4 + 4 + 4 + 4 + 4 + if version >= 19 { 8 } else { 0 }; + reader.read_bytes( + node_count as usize * node_size, + "type_tree_nodes", + )?; + reader.read_bytes(string_buffer_size as usize, "type_tree_strings")?; + } + + if version >= 21 { + let dependency_count = reader.read_i32("type_tree_dependency_count")?; + if dependency_count < 0 { + return Err(format!( + "Invalid Unity type tree dependency count: {}", + dependency_count + )); + } + reader.read_bytes( + dependency_count as usize * 4, + "type_tree_dependencies", + )?; + } + } + + Ok(class_id) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Endian { + Little, + Big, +} + +struct Reader<'a> { + data: &'a [u8], + offset: usize, + endian: Endian, +} + +impl<'a> Reader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + offset: 0, + endian: Endian::Big, + } + } + + fn set_endian(&mut self, endian: Endian) { + self.endian = endian; + } + + fn read_bytes(&mut self, len: usize, field: &str) -> Result<&'a [u8], String> { + let end = self + .offset + .checked_add(len) + .ok_or_else(|| format!("{field} length overflow at offset {}", self.offset))?; + if end > self.data.len() { + return Err(format!( + "Unexpected end while reading {field} at offset {}: need {}, have {}", + self.offset, + len, + self.data.len().saturating_sub(self.offset) + )); + } + + let bytes = &self.data[self.offset..end]; + self.offset = end; + Ok(bytes) + } + + fn align(&mut self, alignment: usize) -> Result<(), String> { + if alignment == 0 { + return Ok(()); + } + + let remainder = self.offset % alignment; + if remainder == 0 { + return Ok(()); + } + + let padding = alignment - remainder; + self.read_bytes(padding, "alignment padding").map(|_| ()) + } + + fn read_u8(&mut self, field: &str) -> Result { + Ok(self.read_bytes(1, field)?[0]) + } + + fn read_u16(&mut self, field: &str) -> Result { + let bytes = self.read_bytes(2, field)?; + Ok(match self.endian { + Endian::Little => u16::from_le_bytes([bytes[0], bytes[1]]), + Endian::Big => u16::from_be_bytes([bytes[0], bytes[1]]), + }) + } + + fn read_i16(&mut self, field: &str) -> Result { + let bytes = self.read_bytes(2, field)?; + Ok(match self.endian { + Endian::Little => i16::from_le_bytes([bytes[0], bytes[1]]), + Endian::Big => i16::from_be_bytes([bytes[0], bytes[1]]), + }) + } + + fn read_u32(&mut self, field: &str) -> Result { + let bytes = self.read_bytes(4, field)?; + Ok(match self.endian { + Endian::Little => u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), + Endian::Big => u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), + }) + } + + fn read_u32_be(&mut self, field: &str) -> Result { + let bytes = self.read_bytes(4, field)?; + Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } + + fn read_i32(&mut self, field: &str) -> Result { + let bytes = self.read_bytes(4, field)?; + Ok(match self.endian { + Endian::Little => i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), + Endian::Big => i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), + }) + } + + fn read_u64(&mut self, field: &str) -> Result { + let bytes = self.read_bytes(8, field)?; + Ok(match self.endian { + Endian::Little => u64::from_le_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]), + Endian::Big => u64::from_be_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]), + }) + } + + fn read_u64_be(&mut self, field: &str) -> Result { + let bytes = self.read_bytes(8, field)?; + Ok(u64::from_be_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ])) + } + + fn read_i64(&mut self, field: &str) -> Result { + let bytes = self.read_bytes(8, field)?; + Ok(match self.endian { + Endian::Little => i64::from_le_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]), + Endian::Big => i64::from_be_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]), + }) + } + + fn read_c_string(&mut self, field: &str) -> Result { + let remaining = &self.data[self.offset..]; + let Some(length) = remaining.iter().position(|&byte| byte == 0) else { + return Err(format!( + "Missing null terminator while reading {field} at offset {}", + self.offset + )); + }; + let bytes = self.read_bytes(length, field)?; + self.offset += 1; + std::str::from_utf8(bytes) + .map(ToOwned::to_owned) + .map_err(|error| format!("Invalid UTF-8 in {field}: {error}")) + } + + fn read_len_prefixed_string(&mut self, field: &str) -> Result { + let len = self.read_u32(field)? as usize; + let bytes = self.read_bytes(len, field)?; + std::str::from_utf8(bytes) + .map(ToOwned::to_owned) + .map_err(|error| format!("Invalid UTF-8 in {field}: {error}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push_i16_le(data: &mut Vec, value: i16) { + data.extend_from_slice(&value.to_le_bytes()); + } + + fn push_u32_le(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_le_bytes()); + } + + fn push_i32_le(data: &mut Vec, value: i32) { + data.extend_from_slice(&value.to_le_bytes()); + } + + fn push_i64_le(data: &mut Vec, value: i64) { + data.extend_from_slice(&value.to_le_bytes()); + } + + fn push_u64_le(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_le_bytes()); + } + + fn push_u32_be(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn push_u64_be(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn align(data: &mut Vec, alignment: usize) { + let remainder = data.len() % alignment; + if remainder != 0 { + data.resize(data.len() + alignment - remainder, 0); + } + } + + fn synthetic_serialized_file() -> Vec { + let mut object_data = Vec::new(); + push_u32_le(&mut object_data, 14); + object_data.extend_from_slice(b"GameMainConfig"); + align(&mut object_data, 4); + push_u32_le(&mut object_data, 5); + object_data.extend_from_slice(b"hello"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(0); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 49); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 1); + align(&mut metadata, 4); + push_i64_le(&mut metadata, 1); + push_u64_le(&mut metadata, 0); + push_u32_le(&mut metadata, object_data.len() as u32); + push_i32_le(&mut metadata, 0); + + let header_len = 48usize; + let data_offset = header_len + metadata.len(); + let file_size = data_offset + object_data.len(); + + let mut file = Vec::new(); + push_u32_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file + } + + #[test] + fn parses_synthetic_text_asset() { + let file = synthetic_serialized_file(); + let parsed = UnitySerializedFile::from_slice(&file).unwrap(); + + assert_eq!(parsed.version, 22); + assert_eq!(parsed.unity_version, "2021.3.56f2"); + assert_eq!(parsed.platform, 19); + assert_eq!(parsed.text_assets.len(), 1); + + let asset = parsed.text_asset("GameMainConfig").unwrap(); + assert_eq!(asset.name, "GameMainConfig"); + assert_eq!(asset.bytes, b"hello"); + } + + #[test] + fn reads_text_asset_from_real_resource_file_when_available() { + let path = Path::new( + "/home/wanye/D/BlueArchive/AllResources/YostarGames/BlueArchive_JP/BlueArchive_Data/resources.assets", + ); + if !path.exists() { + return; + } + + let parsed = UnitySerializedFile::from_path(path).unwrap(); + let asset = parsed.text_asset("GameMainConfig").unwrap(); + + assert_eq!(asset.name, "GameMainConfig"); + assert_eq!(asset.bytes.len(), 896); + assert_eq!( + &asset.bytes[..32], + &[ + 0x60, 0x23, 0x0a, 0x06, 0x95, 0x72, 0x83, 0x57, 0x54, 0x23, 0x49, 0x06, + 0xfc, 0x72, 0xb4, 0x57, 0x57, 0x23, 0x6b, 0x06, 0x98, 0x72, 0xb5, 0x57, + 0x71, 0x23, 0x1b, 0x06, 0xec, 0x72, 0xf6, 0x57, + ], + ); + } +} diff --git a/adapters/src/unity/unity_2021_3.rs b/adapters/src/unity/unity_2021_3.rs index 64788b2..415dbb3 100644 --- a/adapters/src/unity/unity_2021_3.rs +++ b/adapters/src/unity/unity_2021_3.rs @@ -2,11 +2,17 @@ //! //! 支持 Unity 2021.3.x 版本的 AssetBundle -use super::adapter::{ParsedAssetBundle, RawAssetBundle, UnityAdapter, VersionRange}; +use super::adapter::{ + ParsedAssetBundle, RawAssetBundle, UnityAdapter, UnityFsBlockInfo, UnityFsCompression, + UnityFsDirectoryInfo, UnityFsHeader, VersionRange, +}; use async_trait::async_trait; +use std::io::Cursor; -const PARSE_NOT_IMPLEMENTED: &str = "parse() 将在 Phase 2 实现"; const SERIALIZE_NOT_IMPLEMENTED: &str = "serialize() 将在 Phase 2 实现"; +const UNITYFS_COMPRESSION_MASK: u32 = 0x3f; +const UNITYFS_BLOCK_INFO_AT_END_FLAG: u32 = 0x80; +const UNITYFS_ALIGNMENT: usize = 16; /// Unity 2021.3 Adapter pub struct Unity2021_3Adapter; @@ -42,6 +48,267 @@ impl Unity2021_3Adapter { .map(ToOwned::to_owned) .ok() } + + fn parse_unityfs(data: &[u8]) -> Result { + let mut reader = UnityFsReader::new(data); + let signature = reader.read_c_string("signature")?; + if signature != "UnityFS" { + return Err(format!("Unsupported AssetBundle signature: {}", signature)); + } + + let format_version = reader.read_u32("format_version")?; + let target_version = reader.read_c_string("target_version")?; + let unity_version = reader.read_c_string("unity_version")?; + let total_size = reader.read_u64("total_size")?; + let compressed_blocks_info_size = reader.read_u32("compressed_blocks_info_size")?; + let uncompressed_blocks_info_size = reader.read_u32("uncompressed_blocks_info_size")?; + let flags = reader.read_u32("flags")?; + + let header = UnityFsHeader { + format_version, + target_version, + unity_version, + total_size, + compressed_blocks_info_size, + uncompressed_blocks_info_size, + flags, + }; + + if format_version >= 7 { + reader.align(UNITYFS_ALIGNMENT)?; + } + + let blocks_info_bytes = read_blocks_info_bytes(data, &mut reader, &header)?; + let block_info = decompress_blocks_info( + blocks_info_bytes, + compressed_blocks_info_size, + uncompressed_blocks_info_size, + flags, + )?; + let (blocks, directories) = Self::parse_blocks_info(&block_info)?; + + Ok(ParsedAssetBundle { + unity_version: header.unity_version.clone(), + assets: directories + .iter() + .map(|directory| directory.path.clone()) + .collect(), + raw_data: data.to_vec(), + unityfs_header: Some(header), + blocks, + directories, + }) + } + + fn parse_blocks_info( + data: &[u8], + ) -> Result<(Vec, Vec), String> { + let mut reader = UnityFsReader::new(data); + let _hash = reader.read_bytes(16, "blocks_info_hash")?; + let block_count = reader.read_i32("block_count")?; + if block_count < 0 { + return Err(format!("Invalid UnityFS block count: {}", block_count)); + } + + let mut blocks = Vec::with_capacity(block_count as usize); + for _ in 0..block_count { + let uncompressed_size = reader.read_u32("block_uncompressed_size")?; + let compressed_size = reader.read_u32("block_compressed_size")?; + let flags = reader.read_u16("block_flags")?; + blocks.push(UnityFsBlockInfo { + uncompressed_size, + compressed_size, + flags, + compression: compression_from_flags(flags), + }); + } + + let directory_count = reader.read_i32("directory_count")?; + if directory_count < 0 { + return Err(format!( + "Invalid UnityFS directory count: {}", + directory_count + )); + } + + let mut directories = Vec::with_capacity(directory_count as usize); + for _ in 0..directory_count { + directories.push(UnityFsDirectoryInfo { + offset: reader.read_u64("directory_offset")?, + size: reader.read_u64("directory_size")?, + flags: reader.read_u32("directory_flags")?, + path: reader.read_c_string("directory_path")?, + }); + } + + Ok((blocks, directories)) + } +} + +fn read_blocks_info_bytes<'a>( + data: &'a [u8], + reader: &mut UnityFsReader<'a>, + header: &UnityFsHeader, +) -> Result<&'a [u8], String> { + let len = header.compressed_blocks_info_size as usize; + if blocks_info_at_end(header.flags) { + let start = data.len().checked_sub(len).ok_or_else(|| { + format!( + "UnityFS block info at end underflow: compressed size {}, file size {}", + len, + data.len() + ) + })?; + return Ok(&data[start..]); + } + + reader.read_bytes(len, "blocks_info") +} + +fn blocks_info_at_end(flags: u32) -> bool { + flags & UNITYFS_BLOCK_INFO_AT_END_FLAG != 0 +} + +fn decompress_blocks_info( + data: &[u8], + compressed_size: u32, + uncompressed_size: u32, + flags: u32, +) -> Result, String> { + if data.len() != compressed_size as usize { + return Err(format!( + "UnityFS block info size mismatch: header says {}, read {}", + compressed_size, + data.len() + )); + } + + let compression = compression_from_flags((flags & UNITYFS_COMPRESSION_MASK) as u16); + match compression { + UnityFsCompression::None => { + if compressed_size != uncompressed_size { + return Err(format!( + "Uncompressed UnityFS block info size mismatch: compressed {} != uncompressed {}", + compressed_size, uncompressed_size + )); + } + Ok(data.to_vec()) + } + UnityFsCompression::Lz4 | UnityFsCompression::Lz4Hc => { + lz4::block::decompress(data, Some(uncompressed_size as i32)) + .map_err(|error| format!("Failed to decompress UnityFS LZ4 block info: {}", error)) + } + UnityFsCompression::Lzma => { + let mut output = Vec::with_capacity(uncompressed_size as usize); + lzma_rs::lzma_decompress(&mut Cursor::new(data), &mut output).map_err(|error| { + format!("Failed to decompress UnityFS LZMA block info: {}", error) + })?; + if output.len() != uncompressed_size as usize { + return Err(format!( + "UnityFS LZMA block info size mismatch: expected {}, got {}", + uncompressed_size, + output.len() + )); + } + Ok(output) + } + UnityFsCompression::Unknown(value) => Err(format!( + "Unsupported UnityFS block info compression flag: {}", + value + )), + } +} + +fn compression_from_flags(flags: u16) -> UnityFsCompression { + match flags & UNITYFS_COMPRESSION_MASK as u16 { + 0 => UnityFsCompression::None, + 1 => UnityFsCompression::Lzma, + 2 => UnityFsCompression::Lz4, + 3 | 4 => UnityFsCompression::Lz4Hc, + value => UnityFsCompression::Unknown(value), + } +} + +struct UnityFsReader<'a> { + data: &'a [u8], + offset: usize, +} + +impl<'a> UnityFsReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, offset: 0 } + } + + fn read_bytes(&mut self, len: usize, field: &str) -> Result<&'a [u8], String> { + let end = self + .offset + .checked_add(len) + .ok_or_else(|| format!("{} length overflow at offset {}", field, self.offset))?; + if end > self.data.len() { + return Err(format!( + "Unexpected end while reading {} at offset {}: need {}, have {}", + field, + self.offset, + len, + self.data.len().saturating_sub(self.offset) + )); + } + + let bytes = &self.data[self.offset..end]; + self.offset = end; + Ok(bytes) + } + + fn align(&mut self, alignment: usize) -> Result<(), String> { + if alignment == 0 { + return Ok(()); + } + + let remainder = self.offset % alignment; + if remainder == 0 { + return Ok(()); + } + + let padding = alignment - remainder; + self.read_bytes(padding, "alignment padding").map(|_| ()) + } + + fn read_u16(&mut self, field: &str) -> Result { + let bytes = self.read_bytes(2, field)?; + Ok(u16::from_be_bytes([bytes[0], bytes[1]])) + } + + fn read_u32(&mut self, field: &str) -> Result { + let bytes = self.read_bytes(4, field)?; + Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } + + fn read_i32(&mut self, field: &str) -> Result { + let bytes = self.read_bytes(4, field)?; + Ok(i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } + + fn read_u64(&mut self, field: &str) -> Result { + let bytes = self.read_bytes(8, field)?; + Ok(u64::from_be_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ])) + } + + fn read_c_string(&mut self, field: &str) -> Result { + let remaining = &self.data[self.offset..]; + let Some(length) = remaining.iter().position(|&byte| byte == 0) else { + return Err(format!( + "Missing null terminator while reading {} at offset {}", + field, self.offset + )); + }; + let bytes = self.read_bytes(length, field)?; + self.offset += 1; + std::str::from_utf8(bytes) + .map(ToOwned::to_owned) + .map_err(|error| format!("Invalid UTF-8 in {}: {}", field, error)) + } } impl Default for Unity2021_3Adapter { @@ -69,14 +336,16 @@ impl UnityAdapter for Unity2021_3Adapter { } } - async fn parse(&self, _bundle: &RawAssetBundle) -> Result { - // TODO: Phase 2 实现 - // 需要: - // 1. 解析 UnityFS 文件头 - // 2. 解压缩数据块 - // 3. 解析 TypeTree - // 4. 提取 Asset 对象 - Err(PARSE_NOT_IMPLEMENTED.to_string()) + async fn parse(&self, bundle: &RawAssetBundle) -> Result { + let parsed = Self::parse_unityfs(&bundle.data)?; + if !self.supported_versions().contains(&parsed.unity_version) { + return Err(format!( + "Unsupported Unity version for {}: {}", + self.name(), + parsed.unity_version + )); + } + Ok(parsed) } async fn serialize(&self, _parsed: &ParsedAssetBundle) -> Result, String> { @@ -94,6 +363,66 @@ impl UnityAdapter for Unity2021_3Adapter { mod tests { use super::*; + fn push_c_string(data: &mut Vec, value: &str) { + data.extend_from_slice(value.as_bytes()); + data.push(0); + } + + fn push_u16(data: &mut Vec, value: u16) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn push_u32(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn push_i32(data: &mut Vec, value: i32) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn push_u64(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn align(data: &mut Vec, alignment: usize) { + let remainder = data.len() % alignment; + if remainder != 0 { + data.resize(data.len() + alignment - remainder, 0); + } + } + + fn synthetic_minimal_unityfs_bundle() -> Vec { + let mut blocks_info = Vec::new(); + blocks_info.extend_from_slice(&[0; 16]); + push_i32(&mut blocks_info, 1); + push_u32(&mut blocks_info, 4); + push_u32(&mut blocks_info, 4); + push_u16(&mut blocks_info, 0); + push_i32(&mut blocks_info, 1); + push_u64(&mut blocks_info, 0); + push_u64(&mut blocks_info, 4); + push_u32(&mut blocks_info, 0); + push_c_string(&mut blocks_info, "CAB-test"); + + let mut data = Vec::new(); + push_c_string(&mut data, "UnityFS"); + push_u32(&mut data, 8); + push_c_string(&mut data, "5.x.x"); + push_c_string(&mut data, "2021.3.56f2"); + push_u64(&mut data, 0); + push_u32(&mut data, blocks_info.len() as u32); + push_u32(&mut data, blocks_info.len() as u32); + push_u32(&mut data, 0); + align(&mut data, UNITYFS_ALIGNMENT); + data.extend_from_slice(&blocks_info); + data.extend_from_slice(b"data"); + + let total_size = data.len() as u64; + let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len(); + data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes()); + data + } + #[test] fn test_adapter_name() { let adapter = Unity2021_3Adapter::new(); @@ -114,15 +443,9 @@ mod tests { fn test_can_handle_valid_bundle() { let adapter = Unity2021_3Adapter::new(); - // 模拟 UnityFS 文件头 - let mut data = Vec::new(); - data.extend_from_slice(b"UnityFS\0"); - data.extend_from_slice(&[0, 0, 0, 8]); // format version - data.extend_from_slice(b"5.x.x\x002021.3.56f2\0"); - let bundle = RawAssetBundle { - data, - path: Some("test.bundle".to_string()), + data: synthetic_minimal_unityfs_bundle(), + path: Some("synthetic-minimal.bundle".to_string()), }; assert!(adapter.can_handle(&bundle)); @@ -141,7 +464,27 @@ mod tests { } #[tokio::test] - async fn test_parse_not_implemented() { + async fn test_parse_unityfs_metadata() { + let adapter = Unity2021_3Adapter::new(); + + let bundle = RawAssetBundle { + data: synthetic_minimal_unityfs_bundle(), + path: Some("synthetic-minimal.bundle".to_string()), + }; + + let parsed = adapter.parse(&bundle).await.unwrap(); + let header = parsed.unityfs_header.unwrap(); + assert_eq!(parsed.unity_version, "2021.3.56f2"); + assert_eq!(header.format_version, 8); + assert_eq!(parsed.blocks.len(), 1); + assert_eq!(parsed.blocks[0].compression, UnityFsCompression::None); + assert_eq!(parsed.directories.len(), 1); + assert_eq!(parsed.directories[0].path, "CAB-test"); + assert_eq!(parsed.assets, vec!["CAB-test".to_string()]); + } + + #[tokio::test] + async fn test_parse_rejects_invalid_bundle() { let adapter = Unity2021_3Adapter::new(); let bundle = RawAssetBundle { @@ -151,6 +494,6 @@ mod tests { let result = adapter.parse(&bundle).await; assert!(result.is_err()); - assert!(result.unwrap_err().contains("Phase 2")); + assert!(result.unwrap_err().contains("signature")); } } diff --git a/adapters/tests/real_addressables_local.rs b/adapters/tests/real_addressables_local.rs new file mode 100644 index 0000000..f20a22f --- /dev/null +++ b/adapters/tests/real_addressables_local.rs @@ -0,0 +1,21 @@ +use bat_adapters::manifest::ManifestDriverRegistry; + +#[tokio::test] +#[ignore = "requires BAT_REAL_ADDRESSABLES_CATALOG pointing at a local catalog_Remote.json"] +async fn parses_local_real_addressables_catalog() { + let path = std::env::var("BAT_REAL_ADDRESSABLES_CATALOG") + .expect("BAT_REAL_ADDRESSABLES_CATALOG must be set"); + let data = std::fs::read(path).expect("read local Addressables catalog"); + + let manifest = ManifestDriverRegistry::with_defaults() + .parse(&data) + .await + .expect("parse local Addressables catalog"); + + assert_eq!( + manifest.metadata.locator_id.as_deref(), + Some("AddressablesMainContentCatalog") + ); + assert!(manifest.resources.len() > 1_000); + assert!(manifest.metadata.extra.contains_key("internal_id_count")); +} diff --git a/adapters/tests/real_unityfs_local.rs b/adapters/tests/real_unityfs_local.rs new file mode 100644 index 0000000..03ca8b0 --- /dev/null +++ b/adapters/tests/real_unityfs_local.rs @@ -0,0 +1,24 @@ +use bat_adapters::unity::{RawAssetBundle, Unity2021_3Adapter, UnityAdapter}; +use std::path::PathBuf; + +#[tokio::test] +#[ignore = "requires BAT_REAL_UNITYFS_BUNDLE pointing at a local UnityFS bundle"] +async fn parses_local_real_unityfs_bundle() { + let path = PathBuf::from( + std::env::var("BAT_REAL_UNITYFS_BUNDLE").expect("BAT_REAL_UNITYFS_BUNDLE must be set"), + ); + let data = std::fs::read(&path).expect("read local UnityFS bundle"); + let adapter = Unity2021_3Adapter::new(); + + let parsed = adapter + .parse(&RawAssetBundle { + data, + path: Some(path.display().to_string()), + }) + .await + .expect("parse local UnityFS bundle"); + + assert_eq!(parsed.unity_version, "2021.3.56f2"); + assert!(!parsed.blocks.is_empty()); + assert!(!parsed.directories.is_empty()); +} diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 8ec036d..53a2473 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -6,6 +6,12 @@ BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建 当前文档描述目标架构。实际实现状态以根目录 `CURRENT_STATUS.md` 和 `PROJECT_PLAN.md` 为准。 +当前已经可用的官方资源入口包括: + +- `infrastructure/examples/official_launcher_bootstrap.rs` +- `infrastructure/examples/official_pull_plan.rs` +- `docs/guides/official-resource-test-pull.md` + 已接受的架构决策: - `adr/0001-engine-and-application-boundaries.md`:Rust 引擎与 Go 应用层边界。 @@ -361,6 +367,7 @@ API Server (多实例) 更多详细设计文档: +- [官方资源后端说明](./official-resource-backend.md) - [CAS 存储引擎设计](./cas-storage.md) - [AssetBundle 解析器设计](./assetbundle-parser.md) - [翻译系统设计](./translation-system.md) diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md new file mode 100644 index 0000000..01550b4 --- /dev/null +++ b/docs/architecture/official-resource-backend.md @@ -0,0 +1,193 @@ +# 官方资源后端说明 + +本文档说明 BlueArchiveToolkit 中“官方资源后端”的职责、数据流和工作原理,供审核使用。 + +## 1. 范围 + +这个后端只处理 **日服官方资源**,只接受官方 `.jp/.com` 域名下的资源链路。 + +明确排除: + +- `bluearchive.cafe` +- 任何镜像层、转写层、二次代理层 +- 人工拼接出来的样例 URL + +当前默认平台集合是: + +- `Windows` +- `Android` + +`iOS` 和 `macOS` 已从默认支持中移除。 + +## 2. 后端负责什么 + +| 层 | 任务 | 结果 | +|---|---|---| +| 发现层 | 读取官方 `server-info`,选择 connection group 和版本覆盖 | 得到官方资源根和种子端点 | +| 清单层 | 解析 `BundlePackingInfo.bytes`、`TableCatalog.bytes`、`MediaCatalog.bytes` | 得到完整文件清单 | +| 计划层 | 合并 discovery + inventory,去重并保序 | 得到全量 pull plan | +| 下载层 | 校验官方 URL,调用下载器,落盘并记录字节数 | 得到本地资源副本 | +| 导入层 | 将 bundle 写入 CAS 和 ResourceRepository | 得到可查询的资源索引 | +| 同步层 | 比较当前快照和历史快照 | 决定下载、校验、发布 | + +## 3. 工作原理 + +### 3.1 发现官方资源根 + +入口是 `YostarJpServerInfo`。 + +流程是: + +1. 读取官方 `server-info` JSON。 +2. 按 `connection_group + app_version` 规则选择版本覆盖。 +3. 从 `AddressablesCatalogUrlRoot` 提取 `root token`。 +4. 校验该 root 只能落在官方 `prod-clientpatch.bluearchiveyostar.com` 之下。 +5. 基于 root token 生成 discovery 端点。 + +对应实现主要在: + +- `adapters/src/official/yostar_jp.rs` + +### 3.2 枚举完整资源清单 + +资源清单不是“猜几个文件”,而是从官方 catalog 字节里提取完整文件名列表。 + +当前做法: + +1. 读取 `BundlePackingInfo.bytes`。 +2. 提取所有 `FullPatch_*.zip` 包名。 +3. 读取 `TableCatalog.bytes`。 +4. 提取所有表资源名,例如 `ExcelDB.db`。 +5. 读取 `MediaCatalog.bytes`。 +6. 提取所有媒体资源名,例如 `JP_Airi.zip`。 + +然后对 verified platforms 生成完整 URL 集: + +- Windows patch pack +- Android patch pack +- TableBundles +- MediaResources-Windows +- MediaResources + +对应实现主要在: + +- `adapters/src/official/inventory.rs` +- `adapters/src/official/yostar_jp.rs` + +### 3.3 组装全量 pull plan + +`OfficialResourcePullPlan` 是这个后端的关键对象。 + +它做三件事: + +1. 保存 discovery 端点。 +2. 保存完整 inventory。 +3. 用默认平台集或指定平台集生成最终 URL 列表。 + +`all_urls()` 是执行层的权威输入: + +- 先放 discovery URLs +- 再放 content URLs +- 统一去重 +- 保持顺序 + +这意味着后端拉取的是 **完整资源包集合**,不是抽样下载。 + +对应实现主要在: + +- `infrastructure/src/official_pull.rs` + +### 3.4 执行下载 + +`OfficialResourcePullService` 负责真正下载。 + +工作方式: + +1. 检查 URL 是否属于官方 host。 +2. 把 URL 映射到本地输出路径。 +3. 通过系统 `curl` 下载。 +4. 记录文件大小和最终路径。 +5. 非官方 URL 直接拒绝。 + +路径映射时会做分段清理,避免把不安全路径写进输出目录。 + +对应实现主要在: + +- `infrastructure/src/official_download.rs` + +### 3.5 导入到 CAS 和资源仓储 + +资源下载后,导入层会: + +1. 把 bundle 原始字节写入 CAS。 +2. 解析 UnityFS 基础摘要。 +3. 把资源条目写入 `ResourceRepository`。 +4. 记录资源路径、hash、大小和解析摘要。 + +这层的意义是把“下载到磁盘的文件”变成“可查询、可复用、可去重”的资源对象。 + +对应实现主要在: + +- `infrastructure/src/import.rs` +- `infrastructure/src/resources.rs` + +### 3.6 同步决策 + +同步层只做判断,不做重业务。 + +它比较当前快照和历史快照,关注: + +- root token 是否变化 +- addressables root 是否变化 +- endpoint 是否变化 +- bundle version 是否变化 + +判断结果分三类: + +- `UpToDate` +- `DownloadAndVerify` +- `DownloadVerifyAndPublish` + +对应实现主要在: + +- `infrastructure/src/official_sync.rs` + +## 4. 官方 bootstrap 与用户流程 + +当前已经提供的官方用户流程是: + +1. `official_launcher_bootstrap` 获取官方 launcher 信息。 +2. `official_game_main_config` 从官方 ZIP 解出并解密 `GameMainConfig`。 +3. `official_pull_plan` 依据官方 `server-info`、catalog 和 verified platforms 生成全量拉取计划。 + +这些入口都只接受官方域名,不走 `bluearchive.cafe` 镜像链。 + +## 5. 已验证行为 + +当前代码已经验证: + +- 默认平台是 `Windows + Android` +- `iOS` 和 `macOS` 不再进入默认官方流程 +- verified inventory 会产出完整内容 URL 集 +- pull plan 会同时包含 discovery URLs 和 content URLs +- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL + +相关验证主要来自: + +- `cargo test -p bat-adapters` +- `cargo test -p bat-infrastructure` +- `cargo test -p bat-adapters --examples` +- `cargo test -p bat-infrastructure --examples` +- `cargo clippy -p bat-adapters -- -D warnings` +- `cargo clippy -p bat-infrastructure -- -D warnings` + +## 6. 审核重点 + +请重点检查这几件事: + +1. 是否只接受官方 host。 +2. 是否默认只走 Windows + Android。 +3. 是否把完整 content URL 集都纳入 `all_urls()`。 +4. 是否拒绝镜像域名和手工拼接样例。 +5. 是否在下载前做了 URL 和路径安全校验。 +6. 是否能在官方客户端变动时只改适配层。 diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md new file mode 100644 index 0000000..723bcec --- /dev/null +++ b/docs/guides/official-resource-test-pull.md @@ -0,0 +1,132 @@ +# 官方资源拉取用户指南 + +本文档描述当前可用的官方日服资源拉取流程。 + +默认平台: + +- `Windows` +- `Android` + +不包含: + +- `iOS` +- `macOS` +- `bluearchive.cafe` 或其他镜像域名 +- 任何本地客户端目录 + +## 1. 当前流程 + +运行时链路只走官方日服: + +1. 请求官方 launcher API。 +2. 从官方 launcher manifest 链路得到最新游戏包 ZIP。 +3. 解包 `resources.assets`。 +4. 解密 `GameMainConfig`。 +5. 从 `GameMainConfig` 自动读取 `server-info` URL 和默认 `connection-group`。 +6. 请求官方 `server-info`。 +7. 生成 Windows + Android 的官方资源 discovery 端点。 +8. 拉取 seed catalog,生成完整官方 pull plan。 +9. dry-run 只输出 URL;非 dry-run 下载全部官方 URL。 + +`--server-info-file` 和 `--server-info-path` 都不是硬依赖。 + +## 2. 先做 launcher bootstrap + +先确认 launcher API 和官方 ZIP 链路正常: + +```bash +cargo run -p bat-infrastructure --example official_launcher_bootstrap -- \ + --launcher-version 1.7.2 \ + --manifest +``` + +这一步会输出: + +- `game_latest_version` +- `game_latest_file_path` +- `primary_cdn` +- `backup_cdn` +- `manifest_url` +- `manifest_source` +- `manifest_file_count` + +所有 URL 都应来自官方域名。 + +## 3. 先做 dry-run + +推荐默认流程是不提供本地 `server-info` 文件,也不提供本地客户端目录: + +```bash +cargo run -p bat-infrastructure --example official_pull_plan -- \ + --launcher-bootstrap \ + --platforms Windows,Android \ + --output /tmp/ba-official-pull \ + --dry-run +``` + +如果你已经有官方 `server-info` 文件,也可以显式传入: + +```bash +cargo run -p bat-infrastructure --example official_pull_plan -- \ + --server-info-file r93_70_xxxxxxxxxxxxxxxxxxxx.json \ + --connection-group Prod-Audit \ + --app-version 1.70.0 \ + --platforms Windows,Android \ + --output /tmp/ba-official-pull \ + --dry-run +``` + +检查输出时,重点看: + +- `platforms=[Windows, Android]` +- `discovery_url_count` 大于 0 +- `content_url_count` 大于 0 +- 不出现 `bluearchive.cafe` + +## 4. 执行真实拉取 + +确认 dry-run 正常后,去掉 `--dry-run`: + +```bash +cargo run -p bat-infrastructure --example official_pull_plan -- \ + --launcher-bootstrap \ + --platforms Windows,Android \ + --output /tmp/ba-official-pull +``` + +程序会: + +- 只接受官方 URL +- 下载完整官方资源集合 +- 把结果写入 `--output` + +## 5. 例外输入 + +如果必须使用本地 `server-info`,只接受: + +- `--server-info-file <官方文件名>` +- `--server-info-path <本地官方 JSON>` + +但这两项不再是主流程依赖。 + +## 6. 代码入口 + +当前可用的用户入口: + +- `infrastructure/examples/official_launcher_bootstrap.rs` +- `infrastructure/examples/official_pull_plan.rs` +- `adapters/examples/yostar_jp_client_bootstrap.rs` +- `adapters/examples/yostar_jp_discovery.rs` +- `adapters/examples/yostar_jp_inventory.rs` + +## 7. 验证 + +推荐验证命令: + +```bash +cargo test -p bat-adapters +cargo test -p bat-infrastructure +cargo test -p bat-infrastructure --example official_launcher_bootstrap -- --nocapture +cargo test -p bat-infrastructure --example official_pull_plan -- --nocapture +``` + diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 4d6552c..ec38ecf 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -274,6 +274,10 @@ - README 明确区分已实现、开发中、规划中。 +状态: + +- 已部分修正,当前 README 已明确列出已具备与未完成项,并增加当前阶段说明入口。 + ### G-016:架构文档需要更新为当前路线图 现象: @@ -285,6 +289,10 @@ - 增加 ADR 或架构决策记录。 - 明确 Rust/Go/DB/Plugin 边界。 +状态: + +- 已部分修正,`docs/architecture/README.md` 已增加当前可用官方资源入口,但仍保留目标架构描述,因此只算阶段性对齐完成。 + ### G-017:CI 未落地 影响: diff --git a/docs/reports/current-stage-prepush.md b/docs/reports/current-stage-prepush.md new file mode 100644 index 0000000..69cb4cc --- /dev/null +++ b/docs/reports/current-stage-prepush.md @@ -0,0 +1,48 @@ +# 当前阶段说明 + +**更新时间**:2026-06-29 +**状态分支**:`experiment` +**用途**:推送前核查与当前阶段交付说明。 + +## 1. 当前目标 + +当前阶段聚焦两件事: + +1. 准备推送前的项目核查。 +2. 补齐当前阶段的文档说明。 + +## 2. 当前实现状态 + +已确认可用的内容: + +- `bat-cas-engine` 的 CAS V1 已完成。 +- `bat-adapters` 的官方日服 URL 规则、官方 launcher bootstrap、官方 GameMainConfig bootstrap、官方 pull plan 已可用。 +- `bat-infrastructure` 的官方 bootstrap / pull integration 测试已通过。 +- 官方资源链路已经明确只接受官方 host,不依赖 `bluearchive.cafe` 镜像。 + +实验性处理内容: + +- 官方 bootstrap / pull 测试夹具已收敛为 synthetic fixture,并单独留在 `experiment` 分支语义下。 +- 这些 fixture 只用于验证官方链路,不代表运行时真实线上值。 + +## 3. 当前核查结果 + +已执行的验证: + +- `cargo test -p bat-infrastructure --test official_game_main_config_bootstrap -- --nocapture` +- `cargo test -p bat-infrastructure` +- `cargo test -p bat-adapters` + +结果: + +- 全部通过。 + +## 4. 当前结论 + +当前代码可以继续进入推送准备阶段,但仓库仍包含大量未整理的既有改动,暂不应把当前工作区视作“只剩最终提交”的干净状态。 + +下一步应当继续做: + +- 归整实验性改动与主线改动的边界。 +- 按分支策略确认哪些内容允许进入 `dev`,哪些应保留在 `experiment`。 +- 再做一次提交前检查和差异审阅。 diff --git a/examples/manifest_demo.rs b/examples/manifest_demo.rs index 333376c..477451f 100644 --- a/examples/manifest_demo.rs +++ b/examples/manifest_demo.rs @@ -1,5 +1,5 @@ -use bat_adapters::manifest::{AddressablesCatalogDriver, ManifestDriverRegistry}; use bat_adapters::manifest::ManifestDriver; +use bat_adapters::manifest::{AddressablesCatalogDriver, ManifestDriverRegistry}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -10,16 +10,16 @@ async fn main() -> anyhow::Result<()> { let driver = AddressablesCatalogDriver::new(); println!("✅ Driver: {}", driver.name()); - // 模拟 Addressables Catalog + // 合成 Addressables Catalog,仅用于演示解析接口。 println!("\n📦 解析 Addressables Catalog..."); let catalog_json = r#"{ "m_LocatorId": "AddressablesMainContentCatalog", - "m_InternalIdPrefixes": ["https://cdn.example.com/"], + "m_InternalIdPrefixes": ["https://synthetic.invalid/"], "m_InternalIds": [ - "academy-model.bundle", - "academy-texture.bundle", - "ui-atlas.bundle", - "TableBundles-JP.bundle" + "synthetic/model.bundle", + "synthetic/texture.bundle", + "synthetic/ui-atlas.bundle", + "synthetic/table-manifest.json" ] }"#; diff --git a/examples/unity_adapter_demo.rs b/examples/unity_adapter_demo.rs index aba7ef7..fde28a5 100644 --- a/examples/unity_adapter_demo.rs +++ b/examples/unity_adapter_demo.rs @@ -1,6 +1,65 @@ -use bat_adapters::unity::{Unity2021_3Adapter, UnityAdapterRegistry}; use bat_adapters::unity::RawAssetBundle; -use std::sync::Arc; +use bat_adapters::unity::{Unity2021_3Adapter, UnityAdapterRegistry}; + +fn push_c_string(data: &mut Vec, value: &str) { + data.extend_from_slice(value.as_bytes()); + data.push(0); +} + +fn push_u16(data: &mut Vec, value: u16) { + data.extend_from_slice(&value.to_be_bytes()); +} + +fn push_u32(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_be_bytes()); +} + +fn push_i32(data: &mut Vec, value: i32) { + data.extend_from_slice(&value.to_be_bytes()); +} + +fn push_u64(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_be_bytes()); +} + +fn align(data: &mut Vec, alignment: usize) { + let remainder = data.len() % alignment; + if remainder != 0 { + data.resize(data.len() + alignment - remainder, 0); + } +} + +fn synthetic_minimal_unityfs_bundle() -> Vec { + let mut blocks_info = Vec::new(); + blocks_info.extend_from_slice(&[0; 16]); + push_i32(&mut blocks_info, 1); + push_u32(&mut blocks_info, 4); + push_u32(&mut blocks_info, 4); + push_u16(&mut blocks_info, 0); + push_i32(&mut blocks_info, 1); + push_u64(&mut blocks_info, 0); + push_u64(&mut blocks_info, 4); + push_u32(&mut blocks_info, 0); + push_c_string(&mut blocks_info, "SYNTHETIC-DEMO-CAB"); + + let mut data = Vec::new(); + push_c_string(&mut data, "UnityFS"); + push_u32(&mut data, 8); + push_c_string(&mut data, "5.x.x"); + push_c_string(&mut data, "2021.3.56f2"); + push_u64(&mut data, 0); + push_u32(&mut data, blocks_info.len() as u32); + push_u32(&mut data, blocks_info.len() as u32); + push_u32(&mut data, 0); + align(&mut data, 16); + data.extend_from_slice(&blocks_info); + data.extend_from_slice(b"data"); + + let total_size = data.len() as u64; + let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len(); + data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes()); + data +} fn main() { println!("🎮 BlueArchiveToolkit - Unity 适配器演示"); @@ -11,16 +70,11 @@ fn main() { println!("✅ Unity Adapter: {}", adapter.name()); println!(" 支持版本: 2021.3.0 - 2021.3.99"); - // 模拟 UnityFS 文件头 + // 合成最小 UnityFS 文件,仅用于演示版本检测和适配器选择。 println!("\n📦 测试 Unity 版本检测..."); - let mut bundle_data = Vec::new(); - bundle_data.extend_from_slice(b"UnityFS\0"); - bundle_data.extend_from_slice(&[0, 0, 0, 8]); - bundle_data.extend_from_slice(b"5.x.x\02021.3.56f2\0"); - let bundle = RawAssetBundle { - data: bundle_data, - path: Some("test.bundle".to_string()), + data: synthetic_minimal_unityfs_bundle(), + path: Some("synthetic-demo.bundle".to_string()), }; if adapter.can_handle(&bundle) { @@ -31,8 +85,7 @@ fn main() { // 测试 Registry println!("\n🔄 测试 Adapter Registry..."); - let mut registry = UnityAdapterRegistry::new(); - registry.register(Arc::new(Unity2021_3Adapter::new())); + let registry = UnityAdapterRegistry::with_defaults(); println!(" 注册的适配器数量: {}", registry.count()); match registry.select_adapter(&bundle) { diff --git a/infrastructure/Cargo.toml b/infrastructure/Cargo.toml index 65912b1..8b32234 100644 --- a/infrastructure/Cargo.toml +++ b/infrastructure/Cargo.toml @@ -7,13 +7,19 @@ license.workspace = true [dependencies] bat-core = { path = "../core" } +bat-adapters = { path = "../adapters" } bat-cas-engine = { path = "../crates/bat-cas-engine" } anyhow.workspace = true thiserror.workspace = true serde.workspace = true +serde_json.workspace = true tokio.workspace = true async-trait.workspace = true +md-5 = "0.10" +hex = "0.4" +tempfile = "3.14" [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } tempfile = "3.14" +serde_json.workspace = true diff --git a/infrastructure/examples/official_launcher_bootstrap.rs b/infrastructure/examples/official_launcher_bootstrap.rs new file mode 100644 index 0000000..853fc84 --- /dev/null +++ b/infrastructure/examples/official_launcher_bootstrap.rs @@ -0,0 +1,104 @@ +use bat_infrastructure::OfficialLauncherBootstrapService; +use std::env; +use std::path::PathBuf; + +fn main() -> anyhow::Result<()> { + let args = parse_args()?; + let service = OfficialLauncherBootstrapService::with_curl_command( + args.launcher_version, + args.curl_command.to_string_lossy().to_string(), + ); + let game_config = service.fetch_game_config().map_err(anyhow::Error::msg)?; + let cdn_config = service.fetch_cdn_config().map_err(anyhow::Error::msg)?; + + println!("game_latest_version={}", game_config.game_latest_version); + println!( + "game_latest_file_path={}", + game_config.game_latest_file_path + ); + println!( + "game_lowest_version={}", + game_config + .game_lowest_version + .as_deref() + .unwrap_or("") + ); + println!( + "game_start_exe_name={}", + game_config + .game_start_exe_name + .as_deref() + .unwrap_or("") + ); + println!("game_start_params={:?}", game_config.game_start_params); + println!("primary_cdn={}", cdn_config.primary_cdn); + println!("backup_cdn={}", cdn_config.back_up_cdn); + + if args.manifest { + let manifest_url = service + .fetch_manifest_url( + &game_config.game_latest_version, + &game_config.game_latest_file_path, + ) + .map_err(anyhow::Error::msg)?; + let manifest = service + .fetch_remote_manifest(&manifest_url.url) + .map_err(anyhow::Error::msg)?; + + println!("manifest_url={}", manifest_url.url); + println!( + "manifest_source={}", + manifest.source.as_deref().unwrap_or("") + ); + println!("manifest_file_count={}", manifest.files.len()); + } + + Ok(()) +} + +#[derive(Debug)] +struct CliArgs { + launcher_version: String, + curl_command: PathBuf, + manifest: bool, +} + +fn parse_args() -> anyhow::Result { + let raw_args = env::args().collect::>(); + let mut args = raw_args.into_iter(); + let binary = args + .next() + .unwrap_or_else(|| "official_launcher_bootstrap".to_string()); + let mut parsed = CliArgs { + launcher_version: "1.7.2".to_string(), + curl_command: PathBuf::from("curl"), + manifest: false, + }; + + while let Some(flag) = args.next() { + match flag.as_str() { + "--launcher-version" => parsed.launcher_version = next_option_value(&mut args, &flag)?, + "--curl" => parsed.curl_command = PathBuf::from(next_option_value(&mut args, &flag)?), + "--manifest" => parsed.manifest = true, + "--help" | "-h" => { + print_usage(&binary); + std::process::exit(0); + } + _ => return Err(anyhow::anyhow!("unknown argument: {flag}")), + } + } + + Ok(parsed) +} + +fn next_option_value( + args: &mut impl Iterator, + flag: &str, +) -> anyhow::Result { + args.next() + .ok_or_else(|| anyhow::anyhow!("missing value for {flag}")) +} + +fn print_usage(binary: &str) { + eprintln!("usage: {binary} [--launcher-version 1.7.2] [--curl curl] [--manifest]"); +} diff --git a/infrastructure/examples/official_pull_plan.rs b/infrastructure/examples/official_pull_plan.rs new file mode 100644 index 0000000..3b8a0fb --- /dev/null +++ b/infrastructure/examples/official_pull_plan.rs @@ -0,0 +1,431 @@ +use bat_adapters::official::inventory::{ + YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory, +}; +use bat_adapters::official::yostar_jp::{ + server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint, + YostarJpResourceEndpointKind, YostarJpServerInfo, +}; +use bat_infrastructure::{ + build_official_pull_plan_for_platform_inventory, + build_official_pull_plan_from_platform_inventory, default_official_platforms, + OfficialGameMainConfigBootstrapService, OfficialResourcePullService, +}; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug)] +struct CliArgs { + server_info_source: Option, + connection_group: Option, + app_version: Option, + launcher_version: String, + launcher_bootstrap: bool, + platforms: Option>, + output_root: PathBuf, + curl_command: PathBuf, + dry_run: bool, +} + +#[derive(Debug)] +enum ServerInfoSource { + LocalPath(PathBuf), + OfficialFile(String), +} + +fn main() -> anyhow::Result<()> { + let args = parse_args()?; + let default_platforms = default_official_platforms(); + let platforms = args + .platforms + .as_deref() + .unwrap_or(default_platforms.as_slice()); + let needs_official_bootstrap = args.launcher_bootstrap + || args.server_info_source.is_none() + || args.connection_group.is_none() + || args.app_version.is_none(); + let official_bootstrap = if needs_official_bootstrap { + Some( + OfficialGameMainConfigBootstrapService::with_commands( + &args.launcher_version, + args.curl_command.to_string_lossy().to_string(), + "unzip", + ) + .fetch_bootstrap() + .map_err(anyhow::Error::msg)?, + ) + } else { + None + }; + let app_version = args + .app_version + .clone() + .or_else(|| { + official_bootstrap + .as_ref() + .map(|bootstrap| bootstrap.game_config.game_latest_version.clone()) + }) + .ok_or_else(|| { + anyhow::anyhow!("missing --app-version and official bootstrap failed") + })?; + let connection_group = args + .connection_group + .clone() + .or_else(|| { + official_bootstrap + .as_ref() + .and_then(|bootstrap| bootstrap.game_main_config.default_connection_group.clone()) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "missing --connection-group and official GameMainConfig has no default_connection_group" + ) + })?; + + let fetcher = + OfficialResourcePullService::with_curl_command(&args.output_root, &args.curl_command); + + if args.launcher_bootstrap { + let bootstrap = official_bootstrap.as_ref().ok_or_else(|| { + anyhow::anyhow!("launcher bootstrap requested but official bootstrap is unavailable") + })?; + println!( + "launcher_api_game_latest_version={}", + bootstrap.game_config.game_latest_version + ); + println!( + "launcher_api_game_latest_file_path={}", + bootstrap.game_config.game_latest_file_path + ); + println!( + "launcher_api_game_lowest_version={}", + bootstrap + .game_config + .game_lowest_version + .as_deref() + .unwrap_or("") + ); + println!( + "launcher_api_game_start_exe_name={}", + bootstrap + .game_config + .game_start_exe_name + .as_deref() + .unwrap_or("") + ); + println!( + "launcher_api_game_start_params={:?}", + bootstrap.game_config.game_start_params + ); + println!("launcher_api_primary_cdn={}", bootstrap.cdn_config.primary_cdn); + println!("launcher_api_backup_cdn={}", bootstrap.cdn_config.back_up_cdn); + println!("launcher_api_manifest_url={}", bootstrap.manifest_url); + println!( + "launcher_api_manifest_source={}", + bootstrap.manifest_source.as_deref().unwrap_or("") + ); + println!( + "launcher_api_manifest_file_count={}", + bootstrap.manifest_file_count + ); + println!( + "launcher_api_game_main_config_server_info_url={}", + bootstrap + .game_main_config + .server_info_data_url + .as_deref() + .unwrap_or("") + ); + println!( + "launcher_api_game_main_config_default_connection_group={}", + bootstrap + .game_main_config + .default_connection_group + .as_deref() + .unwrap_or("") + ); + println!(); + } + + let server_info_source = args + .server_info_source + .as_ref(); + + let server_info_bytes = if let Some(source) = server_info_source { + load_server_info(source, &fetcher)? + } else { + let bootstrap = official_bootstrap.as_ref().ok_or_else(|| { + anyhow::anyhow!("official bootstrap is required when no --server-info-file or --server-info-path is provided") + })?; + let url = bootstrap + .game_main_config + .server_info_data_url + .as_deref() + .ok_or_else(|| anyhow::anyhow!("official GameMainConfig has no ServerInfoDataUrl"))?; + fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)? + }; + let server_info = YostarJpServerInfo::from_slice(&server_info_bytes)?; + let discovery = server_info + .discovery_plan(&connection_group, &app_version, platforms) + .map_err(anyhow::Error::msg)?; + let seed_catalogs = fetch_seed_catalogs(&fetcher, &discovery)?; + let inventory = build_inventory_from_seed_catalogs(&seed_catalogs, platforms)?; + let plan = if args.platforms.is_some() { + build_official_pull_plan_for_platform_inventory(discovery, inventory, platforms) + } else { + build_official_pull_plan_from_platform_inventory(discovery, inventory) + }; + let all_urls = plan.all_urls().map_err(anyhow::Error::msg)?; + + println!("connection_group={}", plan.discovery.connection_group_name); + println!("app_version={}", plan.discovery.app_version); + println!( + "bundle_version={}", + plan.discovery.bundle_version.as_deref().unwrap_or("") + ); + println!("addressables_root={}", plan.discovery.addressables_root); + println!("platforms={:?}", plan.platforms); + println!("discovery_url_count={}", plan.discovery_urls().len()); + println!( + "content_url_count={}", + plan.content_urls().map_err(anyhow::Error::msg)?.len() + ); + println!("total_url_count={}", all_urls.len()); + println!("output_root={}", args.output_root.display()); + println!("dry_run={}", args.dry_run); + println!(); + + for url in &all_urls { + println!("{url}"); + } + + if args.dry_run { + return Ok(()); + } + + let downloader = + OfficialResourcePullService::with_curl_command(args.output_root, args.curl_command); + let report = downloader.pull(&plan).map_err(anyhow::Error::msg)?; + eprintln!("downloaded_count={}", report.items.len()); + eprintln!("downloaded_bytes={}", report.total_bytes()); + + Ok(()) +} + +fn load_server_info( + source: &ServerInfoSource, + fetcher: &OfficialResourcePullService, +) -> anyhow::Result> { + match source { + ServerInfoSource::LocalPath(path) => Ok(fs::read(path)?), + ServerInfoSource::OfficialFile(file_name) => { + let url = server_info_url(file_name).map_err(anyhow::Error::msg)?; + fetcher.fetch_bytes(&url).map_err(anyhow::Error::msg) + } + } +} + +fn fetch_seed_catalogs( + fetcher: &OfficialResourcePullService, + discovery: &YostarJpResourceDiscoveryPlan, +) -> anyhow::Result { + let mut catalogs = SeedCatalogs::default(); + + for endpoint in &discovery.endpoints { + if !matches!( + endpoint.kind, + YostarJpResourceEndpointKind::TableCatalog + | YostarJpResourceEndpointKind::BundlePackingInfo + | YostarJpResourceEndpointKind::MediaCatalog + ) { + continue; + } + + let bytes = fetcher + .fetch_bytes(&endpoint.url) + .map_err(anyhow::Error::msg)?; + catalogs.insert(endpoint, bytes)?; + } + + Ok(catalogs) +} + +fn build_inventory_from_seed_catalogs( + catalogs: &SeedCatalogs, + platforms: &[PatchPlatform], +) -> anyhow::Result { + let table_catalog = catalogs + .table_catalog + .as_deref() + .ok_or_else(|| anyhow::anyhow!("official discovery did not include TableCatalog.bytes"))?; + let mut platform_catalogs = Vec::new(); + + for platform in platforms { + let bundle_packing_info = catalogs.bundle_packing_infos.get(platform).ok_or_else(|| { + anyhow::anyhow!( + "official discovery did not include {} BundlePackingInfo.bytes", + platform.as_str() + ) + })?; + let media_catalog = catalogs.media_catalogs.get(platform).ok_or_else(|| { + anyhow::anyhow!( + "official discovery did not include {} MediaCatalog.bytes", + platform.as_str() + ) + })?; + + platform_catalogs.push(YostarJpPlatformCatalogInventory::from_catalog_bytes( + *platform, + bundle_packing_info, + media_catalog, + )); + } + + Ok(YostarJpPlatformDownloadInventory::from_catalog_bytes( + table_catalog, + platform_catalogs, + )) +} + +#[derive(Debug, Default)] +struct SeedCatalogs { + table_catalog: Option>, + bundle_packing_infos: HashMap>, + media_catalogs: HashMap>, +} + +impl SeedCatalogs { + fn insert( + &mut self, + endpoint: &YostarJpResourceEndpoint, + bytes: Vec, + ) -> anyhow::Result<()> { + match endpoint.kind { + YostarJpResourceEndpointKind::TableCatalog => { + self.table_catalog = Some(bytes); + } + YostarJpResourceEndpointKind::BundlePackingInfo => { + let platform = required_platform(endpoint)?; + self.bundle_packing_infos.insert(platform, bytes); + } + YostarJpResourceEndpointKind::MediaCatalog => { + let platform = required_platform(endpoint)?; + self.media_catalogs.insert(platform, bytes); + } + _ => {} + } + + Ok(()) + } +} + +fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result { + endpoint.platform.ok_or_else(|| { + anyhow::anyhow!( + "discovery endpoint {:?} has no platform: {}", + endpoint.kind, + endpoint.url + ) + }) +} + +fn parse_args() -> anyhow::Result { + let raw_args = env::args().collect::>(); + let mut args = raw_args.into_iter(); + let binary = args + .next() + .unwrap_or_else(|| "official_pull_plan".to_string()); + let mut parsed = CliArgs { + server_info_source: None, + connection_group: None, + app_version: None, + launcher_version: "1.7.2".to_string(), + launcher_bootstrap: false, + platforms: None, + output_root: PathBuf::from("official_pull_output"), + curl_command: PathBuf::from("curl"), + dry_run: false, + }; + + while let Some(flag) = args.next() { + match flag.as_str() { + "--server-info-file" => { + parsed.server_info_source = Some(ServerInfoSource::OfficialFile( + next_option_value(&mut args, &flag)?, + )); + } + "--server-info-path" => { + parsed.server_info_source = Some(ServerInfoSource::LocalPath(PathBuf::from( + next_option_value(&mut args, &flag)?, + ))); + } + "--connection-group" => { + parsed.connection_group = Some(next_option_value(&mut args, &flag)?); + } + "--app-version" => { + parsed.app_version = Some(next_option_value(&mut args, &flag)?); + } + "--launcher-version" => { + parsed.launcher_version = next_option_value(&mut args, &flag)?; + } + "--launcher-bootstrap" => { + parsed.launcher_bootstrap = true; + } + "--platforms" => { + let value = next_option_value(&mut args, &flag)?; + parsed.platforms = Some(parse_platforms(&value).map_err(anyhow::Error::msg)?); + } + "--output" => { + parsed.output_root = PathBuf::from(next_option_value(&mut args, &flag)?); + } + "--curl" => { + parsed.curl_command = PathBuf::from(next_option_value(&mut args, &flag)?); + } + "--dry-run" => { + parsed.dry_run = true; + } + "--help" | "-h" => { + print_usage(&binary); + std::process::exit(0); + } + _ => return Err(anyhow::anyhow!("unknown argument: {flag}")), + } + } + + Ok(parsed) +} + +fn next_option_value( + args: &mut impl Iterator, + flag: &str, +) -> anyhow::Result { + args.next() + .ok_or_else(|| anyhow::anyhow!("missing value for {flag}")) +} + +fn print_usage(binary: &str) { + eprintln!( + "usage: {binary} [--server-info-file | --server-info-path ] \ + [--app-version 1.70.0] \ + [--launcher-bootstrap] [--launcher-version 1.7.2] [--connection-group ] \ + [--platforms Windows,Android] [--output official_pull_output] [--curl curl] [--dry-run]" + ); +} + +fn parse_platforms(value: &str) -> Result, String> { + value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(parse_platform) + .collect() +} + +fn parse_platform(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "windows" | "win" => Ok(PatchPlatform::Windows), + "android" => Ok(PatchPlatform::Android), + _ => Err(format!("unsupported platform: {value}")), + } +} diff --git a/infrastructure/src/import.rs b/infrastructure/src/import.rs new file mode 100644 index 0000000..fe18a83 --- /dev/null +++ b/infrastructure/src/import.rs @@ -0,0 +1,369 @@ +//! 资源导入服务。 + +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::path::{Path, PathBuf}; + +/// 待导入的 AssetBundle 数据。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BundleSource { + /// Bundle 路径或文件名。 + pub path: String, + /// Bundle 原始字节。 + pub data: Vec, +} + +impl BundleSource { + /// 创建 Bundle 输入。 + pub fn new(path: impl Into, data: impl Into>) -> Self { + Self { + path: path.into(), + data: data.into(), + } + } +} + +/// 单个导入结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportedResource { + /// 写入 `ResourceRepository` 的资源 ID。 + pub id: String, + /// Manifest 中的资源路径。 + pub source_path: String, + /// CAS 返回的对象 ID。 + pub object_id: String, + /// 写入 CAS 的字节数。 + pub bytes: u64, + /// UnityFS 解析摘要。 + pub unityfs: UnityFsImportSummary, +} + +/// 导入时解析到的 UnityFS 基础结构摘要。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnityFsImportSummary { + /// Bundle 声明的 Unity 版本。 + pub unity_version: String, + /// UnityFS block 数量。 + pub block_count: usize, + /// UnityFS directory 数量。 + pub directory_count: usize, + /// UnityFS directory 路径。 + pub directories: Vec, +} + +/// Manifest 导入报告。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResourceImportReport { + /// 成功导入的资源。 + pub imported: Vec, + /// 被跳过的非 AssetBundle 资源路径。 + pub skipped: Vec, +} + +/// 将解析后的资源清单写入 CAS 与资源仓储的应用服务。 +pub struct ResourceImportService<'a> { + cas: &'a dyn CasRepository, + resources: &'a dyn ResourceRepository, + unity_adapters: UnityAdapterRegistry, +} + +impl<'a> ResourceImportService<'a> { + /// 创建导入服务。 + pub fn new(cas: &'a dyn CasRepository, resources: &'a dyn ResourceRepository) -> Self { + Self::with_unity_adapters(cas, resources, UnityAdapterRegistry::with_defaults()) + } + + /// 使用指定 Unity 适配器注册表创建导入服务。 + pub fn with_unity_adapters( + cas: &'a dyn CasRepository, + resources: &'a dyn ResourceRepository, + unity_adapters: UnityAdapterRegistry, + ) -> Self { + Self { + cas, + resources, + unity_adapters, + } + } + + /// 导入 manifest 中的 AssetBundle 资源。 + /// + /// 非 AssetBundle 条目会记录到 `skipped`。AssetBundle 条目必须能在 + /// `bundles` 中按完整路径或文件名找到对应数据,否则返回错误。 + pub async fn import_manifest_bundles( + &self, + manifest: &GenericManifest, + bundles: &[BundleSource], + ) -> bat_core::Result { + 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!( + "Missing bundle data for manifest resource: {}", + entry.path + )) + })?; + let unityfs = self.parse_unityfs_summary(&entry.path, bundle).await?; + + let object_id = self.cas.store(&bundle.data).await?; + let mut stored_entry = entry.clone(); + stored_entry.hash = object_id.clone(); + stored_entry.size = bundle.data.len() as u64; + + let resource = Resource { + id: resource_id_for_path(&entry.path), + local_path: PathBuf::from(&entry.path), + entry: stored_entry, + }; + let id = self.resources.add(resource).await?; + + report.imported.push(ImportedResource { + id, + source_path: entry.path.clone(), + object_id, + bytes: bundle.data.len() as u64, + unityfs, + }); + } + + Ok(report) + } + + async fn parse_unityfs_summary( + &self, + manifest_path: &str, + bundle: &BundleSource, + ) -> bat_core::Result { + let raw_bundle = RawAssetBundle { + data: bundle.data.clone(), + path: Some(manifest_path.to_string()), + }; + let adapter = self + .unity_adapters + .select_adapter(&raw_bundle) + .map_err(|error| { + bat_core::Error::InvalidArgument(format!( + "No Unity adapter for manifest resource {}: {}", + manifest_path, error + )) + })?; + let parsed = adapter.parse(&raw_bundle).await.map_err(|error| { + bat_core::Error::InvalidArgument(format!( + "Invalid UnityFS bundle for manifest resource {}: {}", + manifest_path, error + )) + })?; + + Ok(UnityFsImportSummary { + unity_version: parsed.unity_version, + block_count: parsed.blocks.len(), + directory_count: parsed.directories.len(), + directories: parsed + .directories + .into_iter() + .map(|directory| directory.path) + .collect(), + }) + } +} + +fn resource_id_for_path(path: &str) -> String { + format!("resource/{}", path) +} + +fn find_bundle<'a>(bundles: &'a [BundleSource], manifest_path: &str) -> Option<&'a BundleSource> { + bundles + .iter() + .find(|bundle| bundle.path == manifest_path) + .or_else(|| { + file_name(manifest_path).and_then(|manifest_name| { + bundles + .iter() + .find(|bundle| file_name(&bundle.path) == Some(manifest_name)) + }) + }) +} + +fn file_name(path: &str) -> Option<&str> { + Path::new(path).file_name()?.to_str() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FileSystemCasRepository, InMemoryResourceRepository}; + use bat_adapters::manifest::{GenericManifest, ManifestFormat, ManifestMetadata}; + use bat_core::domain::ResourceEntry; + use bat_core::repositories::cas_repository::CasRepository; + use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository}; + use std::collections::HashMap; + use tempfile::TempDir; + + fn push_c_string(data: &mut Vec, value: &str) { + data.extend_from_slice(value.as_bytes()); + data.push(0); + } + + fn push_u16(data: &mut Vec, value: u16) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn push_u32(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn push_i32(data: &mut Vec, value: i32) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn push_u64(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn align(data: &mut Vec, alignment: usize) { + let remainder = data.len() % alignment; + if remainder != 0 { + data.resize(data.len() + alignment - remainder, 0); + } + } + + fn synthetic_minimal_unityfs_bundle() -> Vec { + let mut blocks_info = Vec::new(); + blocks_info.extend_from_slice(&[0; 16]); + push_i32(&mut blocks_info, 1); + push_u32(&mut blocks_info, 4); + push_u32(&mut blocks_info, 4); + push_u16(&mut blocks_info, 0); + push_i32(&mut blocks_info, 1); + push_u64(&mut blocks_info, 0); + push_u64(&mut blocks_info, 4); + push_u32(&mut blocks_info, 0); + push_c_string(&mut blocks_info, "SYNTHETIC-CAB"); + + let mut data = Vec::new(); + push_c_string(&mut data, "UnityFS"); + push_u32(&mut data, 8); + push_c_string(&mut data, "5.x.x"); + push_c_string(&mut data, "2021.3.56f2"); + push_u64(&mut data, 0); + push_u32(&mut data, blocks_info.len() as u32); + push_u32(&mut data, blocks_info.len() as u32); + push_u32(&mut data, 0); + align(&mut data, 16); + data.extend_from_slice(&blocks_info); + data.extend_from_slice(b"data"); + + let total_size = data.len() as u64; + let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len(); + data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes()); + data + } + + fn synthetic_manifest() -> GenericManifest { + GenericManifest { + format: ManifestFormat::AddressablesCatalog, + resources: vec![ + ResourceEntry { + path: "synthetic/minimal.bundle".to_string(), + hash: "synthetic-manifest-hash".to_string(), + size: 99, + resource_type: ResourceType::AssetBundle, + }, + ResourceEntry { + path: "synthetic/catalog.json".to_string(), + hash: "synthetic-catalog-hash".to_string(), + size: 1, + resource_type: ResourceType::Manifest, + }, + ], + metadata: ManifestMetadata { + locator_id: None, + cdn_prefixes: Vec::new(), + extra: HashMap::new(), + }, + } + } + + #[tokio::test] + async fn imports_synthetic_manifest_bundles_into_cas_and_resource_repository() { + 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 report = service + .import_manifest_bundles( + &synthetic_manifest(), + &[BundleSource::new( + "minimal.bundle", + synthetic_minimal_unityfs_bundle(), + )], + ) + .await + .unwrap(); + + assert_eq!(report.imported.len(), 1); + 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].unityfs.directories, + vec!["SYNTHETIC-CAB".to_string()] + ); + + 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[0].entry.size, + synthetic_minimal_unityfs_bundle().len() as u64 + ); + } + + #[tokio::test] + async fn returns_error_when_bundle_data_is_missing() { + 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 error = service + .import_manifest_bundles(&synthetic_manifest(), &[]) + .await + .unwrap_err(); + + assert!(matches!(error, bat_core::Error::InvalidArgument(_))); + } + + #[tokio::test] + async fn returns_error_when_bundle_is_not_unityfs() { + 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 error = service + .import_manifest_bundles( + &synthetic_manifest(), + &[BundleSource::new( + "minimal.bundle", + b"not-a-unityfs-bundle".to_vec(), + )], + ) + .await + .unwrap_err(); + + assert!(matches!(error, bat_core::Error::InvalidArgument(_))); + assert_eq!(resources.count(ResourceQuery::all()).await.unwrap(), 0); + } +} diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index fb47605..12eb32e 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -11,8 +11,35 @@ #![warn(clippy::all)] pub mod cas; +pub mod import; +pub mod official_game_main_config; +pub mod official_download; +pub mod official_launcher; +pub mod official_pull; +pub mod official_sync; +pub mod resources; pub use cas::FileSystemCasRepository; +pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService}; +pub use official_download::{ + OfficialResourcePullItem, OfficialResourcePullReport, OfficialResourcePullService, +}; +pub use official_game_main_config::OfficialGameMainConfigBootstrapService; +pub use official_launcher::{ + is_official_launcher_package_url, launcher_api_url, launcher_authorization_header, + OfficialLauncherBootstrapService, YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig, + YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest, +}; +pub use official_pull::{ + build_official_pull_plan, build_official_pull_plan_for_platform_inventory, + build_official_pull_plan_for_platforms, build_official_pull_plan_from_platform_inventory, + OfficialResourcePullPlan, +}; +pub use official_sync::{ + build_official_sync_plan, changed_endpoint_urls, classify_sync_decision, + default_official_platforms, OfficialSyncDecision, OfficialSyncPlan, +}; +pub use resources::InMemoryResourceRepository; /// Infrastructure 版本号 pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs new file mode 100644 index 0000000..bca81a3 --- /dev/null +++ b/infrastructure/src/official_download.rs @@ -0,0 +1,396 @@ +//! Official JP resource download execution. + +use crate::official_pull::OfficialResourcePullPlan; +use bat_adapters::official::yostar_jp::is_official_yostar_jp_url; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// One downloaded official resource. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OfficialResourcePullItem { + /// Original URL. + pub url: String, + /// Local destination path under the output root. + pub destination: PathBuf, + /// Downloaded byte count. + pub bytes: u64, +} + +/// Download report for one executed pull plan. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OfficialResourcePullReport { + /// Downloaded resources in execution order. + pub items: Vec, +} + +impl OfficialResourcePullReport { + /// Returns the total downloaded byte count. + pub fn total_bytes(&self) -> u64 { + self.items.iter().map(|item| item.bytes).sum() + } +} + +/// Executes an official pull plan with the system `curl` command. +#[derive(Debug, Clone)] +pub struct OfficialResourcePullService { + output_root: PathBuf, + curl_command: PathBuf, +} + +impl OfficialResourcePullService { + /// Creates a pull service that uses `curl` from `PATH`. + pub fn new(output_root: impl Into) -> Self { + Self::with_curl_command(output_root, "curl") + } + + /// Creates a pull service with an explicit `curl` binary path. + pub fn with_curl_command( + output_root: impl Into, + curl_command: impl Into, + ) -> Self { + Self { + output_root: output_root.into(), + curl_command: curl_command.into(), + } + } + + /// Returns the output root used for downloaded files. + pub fn output_root(&self) -> &Path { + &self.output_root + } + + /// Executes the plan and downloads every official URL to disk. + pub fn pull( + &self, + plan: &OfficialResourcePullPlan, + ) -> Result { + fs::create_dir_all(&self.output_root).map_err(|error| { + format!( + "Failed to create output root {}: {error}", + self.output_root.display() + ) + })?; + + let mut items = Vec::new(); + for url in plan.all_urls()? { + if !is_official_yostar_jp_url(&url) { + return Err(format!("Refusing to download non-official URL: {url}")); + } + + let destination = self.destination_for_url(&url)?; + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create destination directory {}: {error}", + parent.display() + ) + })?; + } + + self.download_one(&url, &destination)?; + let bytes = fs::metadata(&destination) + .map_err(|error| { + format!( + "Downloaded file missing at {}: {error}", + destination.display() + ) + })? + .len(); + + items.push(OfficialResourcePullItem { + url, + destination, + bytes, + }); + } + + Ok(OfficialResourcePullReport { items }) + } + + /// Fetches an official URL into memory. + /// + /// This is intended for small discovery inputs such as `server-info`, + /// `TableCatalog.bytes`, `BundlePackingInfo.bytes`, and + /// `MediaCatalog.bytes`. + pub fn fetch_bytes(&self, url: &str) -> Result, String> { + if !is_official_yostar_jp_url(url) { + return Err(format!("Refusing to fetch non-official URL: {url}")); + } + + let output = Command::new(&self.curl_command) + .arg("--fail") + .arg("--location") + .arg("--silent") + .arg("--show-error") + .arg("--url") + .arg(url) + .output() + .map_err(|error| { + format!( + "Failed to launch curl command {}: {error}", + self.curl_command.display() + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("curl failed for {url}: {}", stderr.trim())); + } + + Ok(output.stdout) + } + + fn download_one(&self, url: &str, destination: &Path) -> Result<(), String> { + let output = Command::new(&self.curl_command) + .arg("--fail") + .arg("--location") + .arg("--silent") + .arg("--show-error") + .arg("--output") + .arg(destination) + .arg("--url") + .arg(url) + .output() + .map_err(|error| { + format!( + "Failed to launch curl command {}: {error}", + self.curl_command.display() + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "curl failed for {url} -> {}: {}", + destination.display(), + stderr.trim() + )); + } + + Ok(()) + } + + fn destination_for_url(&self, url: &str) -> Result { + if !is_official_yostar_jp_url(url) { + return Err(format!("URL is not an official JP host: {url}")); + } + + let rest = url + .strip_prefix("https://") + .ok_or_else(|| format!("Official URL must use https: {url}"))?; + let (host, path) = rest + .split_once('/') + .ok_or_else(|| format!("Official URL has no path: {url}"))?; + + let mut destination = PathBuf::from(sanitize_segment(host)); + for segment in path.split('/') { + if segment.is_empty() { + continue; + } + if segment == "." || segment == ".." { + return Err(format!("Official URL has unsafe path segment: {url}")); + } + + let segment = segment.split(['?', '#']).next().unwrap_or(segment); + if segment.is_empty() { + continue; + } + destination.push(sanitize_segment(segment)); + } + + Ok(self.output_root.join(destination)) + } +} + +fn sanitize_segment(segment: &str) -> String { + segment + .chars() + .map(|ch| { + if matches!(ch, ':' | '*' | '?' | '"' | '<' | '>' | '|') { + '_' + } else { + ch + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::official_pull::{ + build_official_pull_plan, build_official_pull_plan_for_platforms, OfficialResourcePullPlan, + }; + use bat_adapters::official::inventory::{ + YostarJpDownloadInventory, YostarJpPlatformDownloadInventory, + }; + use bat_adapters::official::yostar_jp::{ + PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint, + YostarJpResourceEndpointKind, + }; + use std::fs; + use tempfile::TempDir; + + fn discovery_plan() -> YostarJpResourceDiscoveryPlan { + YostarJpResourceDiscoveryPlan { + connection_group_name: "Prod-Audit".to_string(), + app_version: "1.70.0".to_string(), + bundle_version: Some("s8tloc7lo3".to_string()), + addressables_root: + "https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(), + endpoints: vec![ + YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::TableCatalog, + platform: None, + url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes".to_string(), + }, + YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::TableCatalogHash, + platform: None, + url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.hash".to_string(), + }, + ], + } + } + + fn inventory() -> YostarJpDownloadInventory { + YostarJpDownloadInventory::from_catalog_bytes( + b"FullPatch_000.zip", + b"ExcelDB.db", + b"JP_Airi.zip", + ) + } + + fn platform_inventory() -> YostarJpPlatformDownloadInventory { + YostarJpPlatformDownloadInventory::from_shared_inventory( + inventory(), + &[PatchPlatform::Windows], + ) + } + + fn fake_curl_script() -> &'static str { + r#"#!/bin/sh +set -eu +out="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output) + out="$2" + shift 2 + ;; + --url) + url="$2" + shift 2 + ;; + *) + shift + ;; + esac +done +if [ -n "$out" ]; then + mkdir -p "$(dirname "$out")" + printf '%s' "$url" > "$out" +else + printf '%s' "$url" +fi +"# + } + + fn write_fake_curl(path: &Path) { + fs::write(path, fake_curl_script()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + } + } + + #[test] + fn downloads_verified_pull_plan_to_disk() { + let out_dir = TempDir::new().unwrap(); + let bin_dir = TempDir::new().unwrap(); + let curl_path = bin_dir.path().join("curl"); + write_fake_curl(&curl_path); + + let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path); + let plan = build_official_pull_plan(discovery_plan(), inventory()); + let report = service.pull(&plan).unwrap(); + + assert_eq!(report.items.len(), 7); + assert!(report.total_bytes() > 0); + assert_eq!(report.items[0].url, plan.discovery.endpoints[0].url); + for item in &report.items { + assert!(item.destination.exists()); + assert_eq!(fs::read_to_string(&item.destination).unwrap(), item.url); + } + } + + #[test] + fn custom_platform_selection_is_supported() { + let out_dir = TempDir::new().unwrap(); + let bin_dir = TempDir::new().unwrap(); + let curl_path = bin_dir.path().join("curl"); + write_fake_curl(&curl_path); + + let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path); + let plan = build_official_pull_plan_for_platforms( + discovery_plan(), + inventory(), + &[PatchPlatform::Windows, PatchPlatform::Android], + ); + let report = service.pull(&plan).unwrap(); + + assert_eq!(report.items.len(), 7); + assert!(report + .items + .iter() + .any(|item| item.url.contains("Android_PatchPack"))); + } + + #[test] + fn fetches_official_seed_bytes_with_curl() { + let out_dir = TempDir::new().unwrap(); + let bin_dir = TempDir::new().unwrap(); + let curl_path = bin_dir.path().join("curl"); + write_fake_curl(&curl_path); + + let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path); + let bytes = service + .fetch_bytes( + "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes", + ) + .unwrap(); + + assert_eq!( + String::from_utf8(bytes).unwrap(), + "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes" + ); + } + + #[test] + fn rejects_non_official_urls() { + let service = OfficialResourcePullService::new("/tmp/unused"); + let plan = OfficialResourcePullPlan { + discovery: YostarJpResourceDiscoveryPlan { + connection_group_name: "Prod-Audit".to_string(), + app_version: "1.70.0".to_string(), + bundle_version: None, + addressables_root: + "https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(), + endpoints: vec![YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::TableCatalog, + platform: None, + url: "https://prod-clientpatch.bluearchive.cafe/r93_token/TableBundles/TableCatalog.bytes".to_string(), + }], + }, + inventory: platform_inventory(), + platforms: vec![PatchPlatform::Windows], + }; + + assert!(service.pull(&plan).is_err()); + } +} diff --git a/infrastructure/src/official_game_main_config.rs b/infrastructure/src/official_game_main_config.rs new file mode 100644 index 0000000..6153ee7 --- /dev/null +++ b/infrastructure/src/official_game_main_config.rs @@ -0,0 +1,199 @@ +//! Official launcher package bootstrap for `GameMainConfig`. + +use crate::official_launcher::OfficialLauncherBootstrapService; +use crate::official_launcher::launcher_package_url; +use bat_adapters::official::game_main_config::YostarJpGameMainConfig; +use crate::official_launcher::{YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +/// Combined official bootstrap data for the latest client package. +#[derive(Debug, Clone)] +pub struct OfficialGameMainConfigBootstrap { + /// Latest launcher-reported client metadata. + pub game_config: YostarJpLauncherGameConfig, + /// Launcher CDN configuration used to resolve the package host. + pub cdn_config: YostarJpLauncherCdnConfig, + /// Official remote manifest URL used for the package chain. + pub manifest_url: String, + /// Remote manifest `source` path, if present. + pub manifest_source: Option, + /// Official game ZIP URL built from the launcher CDN root. + pub game_zip_url: String, + /// Number of files declared by the remote manifest. + pub manifest_file_count: usize, + /// Decrypted `GameMainConfig`. + pub game_main_config: YostarJpGameMainConfig, +} + +/// Loads and decrypts the official `GameMainConfig` by following the official +/// launcher package chain. +#[derive(Debug, Clone)] +pub struct OfficialGameMainConfigBootstrapService { + launcher: OfficialLauncherBootstrapService, + curl_command: PathBuf, + unzip_command: PathBuf, +} + +impl OfficialGameMainConfigBootstrapService { + /// Creates a bootstrapper using `curl` and `unzip` from `PATH`. + pub fn new(launcher_version: impl Into) -> Self { + Self::with_commands(launcher_version, "curl", "unzip") + } + + /// Creates a bootstrapper with explicit commands. + pub fn with_commands( + launcher_version: impl Into, + curl_command: impl Into, + unzip_command: impl Into, + ) -> Self { + let curl_command = curl_command.into(); + let unzip_command = unzip_command.into(); + let launcher = OfficialLauncherBootstrapService::with_curl_command( + launcher_version, + curl_command.to_string_lossy().to_string(), + ); + Self { + launcher, + curl_command, + unzip_command, + } + } + + /// Fetches the latest official game ZIP by re-querying the official + /// launcher API, extracts `resources.assets`, and decrypts `GameMainConfig`. + pub fn fetch_bootstrap(&self) -> Result { + let (game_config, manifest_url, manifest) = self.launcher.fetch_latest_remote_manifest()?; + let manifest_source = manifest + .source + .clone() + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Official launcher remote manifest has no source".to_string())?; + let cdn_config = self.launcher.fetch_cdn_config()?; + let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, &manifest_source)?; + let temp_dir = TempDir::new() + .map_err(|error| format!("Failed to create temporary directory: {error}"))?; + let archive_path = temp_dir.path().join("official-game.zip"); + self.download_file_with_fallback( + &game_zip_url, + &cdn_config.back_up_cdn, + &manifest_source, + &archive_path, + )?; + let extract_root = temp_dir.path().join("extract"); + fs::create_dir_all(&extract_root) + .map_err(|error| format!("Failed to create extract root: {error}"))?; + self.extract_archive(&archive_path, &extract_root)?; + let resources_assets = find_resources_assets(&extract_root) + .ok_or_else(|| "resources.assets not found in official launcher package".to_string())?; + let game_main_config = YostarJpGameMainConfig::from_resources_assets(resources_assets)?; + + Ok(OfficialGameMainConfigBootstrap { + game_config, + cdn_config, + manifest_url: manifest_url.url, + manifest_source: Some(manifest_source), + game_zip_url, + manifest_file_count: manifest.files.len(), + game_main_config, + }) + } + + /// Fetches the latest official game ZIP, extracts `resources.assets`, and + /// decrypts `GameMainConfig`. + pub fn fetch_game_main_config(&self) -> Result { + self.fetch_bootstrap().map(|bootstrap| bootstrap.game_main_config) + } + + fn download_file(&self, url: &str, destination: &Path) -> Result<(), String> { + let output = Command::new(&self.curl_command) + .arg("--fail") + .arg("--location") + .arg("--silent") + .arg("--show-error") + .arg("--output") + .arg(destination) + .arg("--url") + .arg(url) + .output() + .map_err(|error| { + format!( + "Failed to launch curl command {}: {error}", + self.curl_command.display() + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("curl failed for {url}: {}", stderr.trim())); + } + + Ok(()) + } + + fn download_file_with_fallback( + &self, + primary_url: &str, + backup_cdn_root: &str, + relative_path: &str, + destination: &Path, + ) -> Result<(), String> { + match self.download_file(primary_url, destination) { + Ok(()) => Ok(()), + Err(primary_error) => { + let backup_url = launcher_package_url(backup_cdn_root, relative_path)?; + self.download_file(&backup_url, destination).map_err(|backup_error| { + format!( + "failed to download official game ZIP from primary ({primary_error}) and backup ({backup_error})" + ) + }) + } + } + } + + fn extract_archive(&self, archive_path: &Path, destination: &Path) -> Result<(), String> { + let output = Command::new(&self.unzip_command) + .arg("-q") + .arg(archive_path) + .arg("-d") + .arg(destination) + .output() + .map_err(|error| { + format!( + "Failed to launch unzip command {}: {error}", + self.unzip_command.display() + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "unzip failed for {}: {}", + archive_path.display(), + stderr.trim() + )); + } + + Ok(()) + } +} + +fn find_resources_assets(root: &Path) -> Option { + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = fs::read_dir(&dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.file_name().and_then(|name| name.to_str()) == Some("resources.assets") { + return Some(path); + } + } + } + None +} diff --git a/infrastructure/src/official_launcher.rs b/infrastructure/src/official_launcher.rs new file mode 100644 index 0000000..47ac76f --- /dev/null +++ b/infrastructure/src/official_launcher.rs @@ -0,0 +1,513 @@ +//! Official Yostar JP launcher API bootstrap. +//! +//! This models the PC launcher update API used by the official JP launcher. +//! It is separate from Unity resource `server-info`: the launcher API can +//! discover the latest Windows client package and manifest, while resource +//! updates still come from the game client's server-info flow. + +use bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG}; +use md5::{Digest, Md5}; +use serde::Deserialize; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Official JP launcher API root. +pub const YOSTAR_JP_LAUNCHER_API_ROOT: &str = "https://api-launcher-jp.yo-star.com"; + +/// Salt embedded in the official JP launcher for request signing. +pub const YOSTAR_JP_LAUNCHER_AUTH_SALT: &str = "DE7108E9B2842FD460F4777702727869"; + +/// Official primary JP PC launcher package CDN host. +pub const YOSTAR_JP_LAUNCHER_PRIMARY_CDN_HOST: &str = "launcher-pkg-ba-jp.yo-star.com"; + +/// Official backup JP PC launcher package CDN host. +pub const YOSTAR_JP_LAUNCHER_BACKUP_CDN_HOST: &str = "launcher-pkg-ba-jp-bk.yo-star.com"; + +/// Latest official PC client metadata returned by `/api/launcher/game/config`. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct YostarJpLauncherGameConfig { + /// Latest Windows client version. + pub game_latest_version: String, + /// Latest manifest basis path. + pub game_latest_file_path: String, + /// Lowest allowed installed client version. + #[serde(default)] + pub game_lowest_version: Option, + /// Executable name without `.exe`. + #[serde(default)] + pub game_start_exe_name: Option, + /// Launcher arguments. + #[serde(default)] + pub game_start_params: Vec, + /// Displayed decompressed size. + #[serde(default)] + pub decompression_size: Option, +} + +/// Official launcher CDN roots used for Windows client package files. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct YostarJpLauncherCdnConfig { + /// Primary CDN root. + pub primary_cdn: String, + /// Backup CDN root. + pub back_up_cdn: String, +} + +/// Official launcher manifest URL response returned by +/// `/api/launcher/game/config/json`. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct YostarJpLauncherManifestUrl { + /// Remote manifest URL on the official launcher package CDN. + pub url: String, +} + +/// Remote PC launcher manifest used to compute install/update diffs. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct YostarJpLauncherRemoteManifest { + /// Optional manifest source field. + #[serde(default)] + pub source: Option, + /// Remote package files. + #[serde(default, rename = "file")] + pub files: Vec, +} + +/// Signed official launcher API fetcher using `curl`. +#[derive(Debug, Clone)] +pub struct OfficialLauncherBootstrapService { + curl_command: String, + launcher_version: String, +} + +impl OfficialLauncherBootstrapService { + /// Creates a fetcher using `curl` from `PATH`. + pub fn new(launcher_version: impl Into) -> Self { + Self::with_curl_command(launcher_version, "curl") + } + + /// Creates a fetcher with an explicit `curl` command. + pub fn with_curl_command( + launcher_version: impl Into, + curl_command: impl Into, + ) -> Self { + Self { + curl_command: curl_command.into(), + launcher_version: launcher_version.into(), + } + } + + /// Fetches latest official PC game config. + pub fn fetch_game_config(&self) -> Result { + let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?; + let config: YostarJpLauncherGameConfig = + serde_json::from_value(envelope.data).map_err(|error| { + format!("Failed to parse official launcher game config data: {error}") + })?; + + if config.game_latest_version.is_empty() || config.game_latest_file_path.is_empty() { + return Err("Official launcher game config is missing latest version or basis".into()); + } + + Ok(config) + } + + /// Fetches official CDN config for PC client package downloads. + pub fn fetch_cdn_config(&self) -> Result { + let envelope = self.fetch_launcher_envelope("/api/launcher/advanced/game/download/cdn")?; + serde_json::from_value(envelope.data) + .map_err(|error| format!("Failed to parse official launcher CDN config data: {error}")) + } + + /// Fetches the official remote manifest URL for a PC client version and + /// basis path. + pub fn fetch_manifest_url( + &self, + version: &str, + file_path: &str, + ) -> Result { + if version.is_empty() || file_path.is_empty() { + return Err("Launcher manifest version and file path must not be empty".into()); + } + + let path = format!( + "/api/launcher/game/config/json?version={}&file_path={}", + percent_encode_query_value(version), + percent_encode_query_value(file_path) + ); + let envelope = self.fetch_launcher_envelope(&path)?; + let manifest_url: YostarJpLauncherManifestUrl = serde_json::from_value(envelope.data) + .map_err(|error| { + format!("Failed to parse official launcher manifest URL data: {error}") + })?; + + if !is_official_launcher_package_url(&manifest_url.url) { + return Err(format!( + "Official launcher API returned non-official manifest URL: {}", + manifest_url.url + )); + } + + Ok(manifest_url) + } + + /// Fetches and parses an official remote PC launcher manifest. + pub fn fetch_remote_manifest( + &self, + manifest_url: &str, + ) -> Result { + if !is_official_launcher_package_url(manifest_url) { + return Err(format!( + "Refusing to fetch non-official launcher manifest URL: {manifest_url}" + )); + } + + let output = Command::new(&self.curl_command) + .arg("--fail") + .arg("--location") + .arg("--silent") + .arg("--show-error") + .arg("--url") + .arg(manifest_url) + .output() + .map_err(|error| { + format!( + "Failed to launch curl command {}: {error}", + self.curl_command + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "curl failed for launcher manifest {manifest_url}: {}", + stderr.trim() + )); + } + + let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("Failed to parse official launcher manifest: {error}"))?; + if manifest.files.is_empty() { + return Err("Official launcher remote manifest has no files".into()); + } + + Ok(manifest) + } + + /// Fetches latest PC game config, then follows the official manifest URL. + pub fn fetch_latest_remote_manifest( + &self, + ) -> Result< + ( + YostarJpLauncherGameConfig, + YostarJpLauncherManifestUrl, + YostarJpLauncherRemoteManifest, + ), + String, + > { + let game_config = self.fetch_game_config()?; + let manifest_url = self.fetch_manifest_url( + &game_config.game_latest_version, + &game_config.game_latest_file_path, + )?; + let manifest = self.fetch_remote_manifest(&manifest_url.url)?; + + Ok((game_config, manifest_url, manifest)) + } + + /// Fetches a signed official launcher API endpoint and parses the generic + /// response envelope. + pub fn fetch_launcher_envelope(&self, path: &str) -> Result { + let url = launcher_api_url(path)?; + let authorization = launcher_authorization_header(&self.launcher_version, "", None)?; + + let output = Command::new(&self.curl_command) + .arg("--fail") + .arg("--location") + .arg("--silent") + .arg("--show-error") + .arg("--header") + .arg("Content-Type: application/json;charset=UTF-8") + .arg("--header") + .arg(format!("Authorization: {authorization}")) + .arg("--url") + .arg(&url) + .output() + .map_err(|error| { + format!( + "Failed to launch curl command {}: {error}", + self.curl_command + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("curl failed for {url}: {}", stderr.trim())); + } + + let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("Failed to parse official launcher API response: {error}"))?; + + if envelope.code != 200 { + return Err(format!( + "Official launcher API returned code {}: {}", + envelope.code, + envelope + .message + .as_deref() + .or(envelope.msg.as_deref()) + .unwrap_or("") + )); + } + + Ok(envelope) + } +} + +/// Generic official launcher API response envelope. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct LauncherEnvelope { + /// Numeric response code. Official launcher treats `200` as success. + pub code: i64, + /// Optional message field. + #[serde(default)] + pub message: Option, + /// Optional alternate message field. + #[serde(default)] + pub msg: Option, + /// Response payload. + pub data: serde_json::Value, +} + +/// Builds an official launcher API URL from a path. +pub fn launcher_api_url(path: &str) -> Result { + if !path.starts_with('/') || path.contains("..") || path.contains('\\') { + return Err(format!("Invalid official launcher API path: {path}")); + } + + Ok(format!("{YOSTAR_JP_LAUNCHER_API_ROOT}{path}")) +} + +/// Returns true when a URL points to an official JP PC launcher package host. +pub fn is_official_launcher_package_url(url: &str) -> bool { + let Some(rest) = url.strip_prefix("https://") else { + return false; + }; + let host = rest.split('/').next().unwrap_or_default(); + matches!( + host, + YOSTAR_JP_LAUNCHER_PRIMARY_CDN_HOST | YOSTAR_JP_LAUNCHER_BACKUP_CDN_HOST + ) +} + +/// Builds an official launcher package URL from an official CDN root and a +/// relative package path. +pub fn launcher_package_url(cdn_root: &str, file_path: &str) -> Result { + if !is_official_launcher_package_url(cdn_root) { + return Err(format!("Launcher CDN root is not official: {cdn_root}")); + } + + validate_launcher_package_path(file_path)?; + Ok(format!( + "{}/{}", + cdn_root.trim_end_matches('/'), + normalize_relative_path(file_path) + )) +} + +fn percent_encode_query_value(value: &str) -> String { + let mut encoded = String::new(); + + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + + encoded +} + +fn normalize_relative_path(path: &str) -> String { + path.replace('\\', "/") +} + +fn validate_launcher_package_path(path: &str) -> Result<(), String> { + if path.is_empty() || path.starts_with('/') || path.starts_with('\\') { + return Err(format!("Invalid launcher package path: {path}")); + } + + for segment in path.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + return Err(format!("Invalid launcher package path: {path}")); + } + if segment.contains('\\') || segment.contains('?') || segment.contains('#') { + return Err(format!("Invalid launcher package path: {path}")); + } + } + + Ok(()) +} + +/// Builds the official launcher `Authorization` header. +/// +/// This matches the official launcher logic: +/// `md5(JSON(head) + body + salt)`, where `head` contains `game_tag`, +/// Unix timestamp, and launcher version. +pub fn launcher_authorization_header( + launcher_version: &str, + body: &str, + timestamp: Option, +) -> Result { + if launcher_version.is_empty() { + return Err("Launcher version must not be empty".into()); + } + + let timestamp = match timestamp { + Some(timestamp) => timestamp, + None => SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("System time is before Unix epoch: {error}"))? + .as_secs(), + }; + + let head = serde_json::json!({ + "game_tag": YOSTAR_JP_GAME_TAG, + "time": timestamp, + "version": launcher_version, + }); + let head_json = serde_json::to_string(&head) + .map_err(|error| format!("Failed to serialize launcher auth head: {error}"))?; + let mut hasher = Md5::new(); + hasher.update(head_json.as_bytes()); + hasher.update(body.as_bytes()); + hasher.update(YOSTAR_JP_LAUNCHER_AUTH_SALT.as_bytes()); + let sign = hex::encode(hasher.finalize()); + + serde_json::to_string(&serde_json::json!({ + "head": head, + "sign": sign, + })) + .map_err(|error| format!("Failed to serialize launcher auth header: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_official_launcher_api_url() { + assert_eq!( + launcher_api_url("/api/launcher/game/config").unwrap(), + "https://api-launcher-jp.yo-star.com/api/launcher/game/config" + ); + assert!(launcher_api_url("api/launcher/game/config").is_err()); + assert!(launcher_api_url("/api/../secret").is_err()); + } + + #[test] + fn identifies_official_launcher_package_urls_only() { + assert!(is_official_launcher_package_url( + "https://launcher-pkg-ba-jp.yo-star.com/prod/manifest.json" + )); + assert!(is_official_launcher_package_url( + "https://launcher-pkg-ba-jp-bk.yo-star.com/prod/manifest.json" + )); + assert!(!is_official_launcher_package_url( + "https://launcher-pkg-ba-jp.bluearchive.cafe/prod/manifest.json" + )); + assert!(!is_official_launcher_package_url( + "http://launcher-pkg-ba-jp.yo-star.com/prod/manifest.json" + )); + } + + #[test] + fn builds_official_launcher_package_url() { + assert_eq!( + launcher_package_url( + "https://launcher-pkg-ba-jp.yo-star.com", + "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip" + ) + .unwrap(), + "https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip" + ); + assert!(launcher_package_url( + "https://launcher-pkg-ba-jp.bluearchive.cafe", + "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip" + ) + .is_err()); + } + + #[test] + fn encodes_launcher_manifest_query_values() { + assert_eq!( + percent_encode_query_value("prod/ZIP_TEMP/BlueArchive_JP_TEMP/game.zip"), + "prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2Fgame.zip" + ); + } + + #[test] + fn builds_stable_launcher_authorization_header() { + let header = launcher_authorization_header("1.7.2", "", Some(1_700_000_000)).unwrap(); + let value: serde_json::Value = serde_json::from_str(&header).unwrap(); + + assert_eq!(value["head"]["game_tag"], "BlueArchive_JP"); + assert_eq!(value["head"]["time"], 1_700_000_000); + assert_eq!(value["head"]["version"], "1.7.2"); + assert_eq!(value["sign"], "b457b5bc3d7796d7a37040d4b7990cb9"); + } + + #[test] + fn parses_launcher_game_config_payload() { + let config: YostarJpLauncherGameConfig = serde_json::from_str( + r#"{ + "game_latest_version": "1.70.0", + "game_latest_file_path": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip", + "game_lowest_version": "1.69.0", + "game_start_exe_name": "xldr_BlueArchiveOnline_JP_loader_x64", + "game_start_params": ["BlueArchive.exe"] + }"#, + ) + .unwrap(); + + assert_eq!(config.game_latest_version, "1.70.0"); + assert_eq!( + config.game_start_exe_name.as_deref(), + Some("xldr_BlueArchiveOnline_JP_loader_x64") + ); + assert_eq!(config.game_start_params, vec!["BlueArchive.exe"]); + } + + #[test] + fn parses_launcher_manifest_url_payload() { + let manifest_url: YostarJpLauncherManifestUrl = serde_json::from_str( + r#"{ + "url": "https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/manifest.json" + }"#, + ) + .unwrap(); + + assert!(is_official_launcher_package_url(&manifest_url.url)); + } + + #[test] + fn parses_remote_manifest_payload() { + let manifest: YostarJpLauncherRemoteManifest = serde_json::from_str( + r#"{ + "source": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip", + "file": [ + { + "path": "/BlueArchive.exe", + "size": "100", + "hash": "123", + "vc": "abc" + } + ] + }"#, + ) + .unwrap(); + + assert_eq!(manifest.files.len(), 1); + assert_eq!(manifest.files[0].path, "/BlueArchive.exe"); + } +} diff --git a/infrastructure/src/official_pull.rs b/infrastructure/src/official_pull.rs new file mode 100644 index 0000000..1693d95 --- /dev/null +++ b/infrastructure/src/official_pull.rs @@ -0,0 +1,284 @@ +//! Official JP resource pull planning. + +use bat_adapters::official::inventory::{ + YostarJpDownloadInventory, YostarJpPlatformDownloadInventory, +}; +use bat_adapters::official::yostar_jp::{ + verified_official_platforms, PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceRoot, +}; +use std::collections::HashSet; + +/// Full official JP pull plan, covering discovery endpoints and concrete +/// resource downloads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OfficialResourcePullPlan { + /// Seed URLs and selected root from server-info. + pub discovery: YostarJpResourceDiscoveryPlan, + /// Concrete file inventory extracted from the catalog bytes. + pub inventory: YostarJpPlatformDownloadInventory, + /// Platforms that will be pulled. + pub platforms: Vec, +} + +impl OfficialResourcePullPlan { + /// Creates a pull plan for the requested platforms. + pub fn new( + discovery: YostarJpResourceDiscoveryPlan, + inventory: YostarJpDownloadInventory, + platforms: &[PatchPlatform], + ) -> Self { + Self::new_platform_inventory( + discovery, + YostarJpPlatformDownloadInventory::from_shared_inventory(inventory, platforms), + platforms, + ) + } + + /// Creates a pull plan for the requested platforms using platform-specific + /// bundle/media catalogs. + pub fn new_platform_inventory( + discovery: YostarJpResourceDiscoveryPlan, + inventory: YostarJpPlatformDownloadInventory, + platforms: &[PatchPlatform], + ) -> Self { + Self { + discovery, + inventory, + platforms: unique_platforms(platforms), + } + } + + /// Creates a pull plan for the verified official JP platforms. + pub fn verified( + discovery: YostarJpResourceDiscoveryPlan, + inventory: YostarJpDownloadInventory, + ) -> Self { + Self::new(discovery, inventory, &verified_official_platforms()) + } + + /// Creates a pull plan for the verified official JP platforms using + /// platform-specific bundle/media catalogs. + pub fn verified_platform_inventory( + discovery: YostarJpResourceDiscoveryPlan, + inventory: YostarJpPlatformDownloadInventory, + ) -> Self { + Self::new_platform_inventory(discovery, inventory, &verified_official_platforms()) + } + + /// Returns all discovery URLs in server-provided order. + pub fn discovery_urls(&self) -> Vec { + self.discovery + .endpoints + .iter() + .map(|endpoint| endpoint.url.clone()) + .collect() + } + + /// Returns the validated official resource root used by the pull plan. + pub fn resource_root(&self) -> Result { + YostarJpResourceRoot::from_addressables_root(&self.discovery.addressables_root) + } + + /// Returns all concrete download URLs for the selected platforms. + pub fn content_urls(&self) -> Result, String> { + let root = self.resource_root()?; + self.inventory + .direct_download_urls_for_platforms(&root, &self.platforms) + } + + /// Returns discovery URLs followed by concrete content URLs. + pub fn all_urls(&self) -> Result, String> { + let mut urls = self.discovery_urls(); + let mut seen = urls.iter().cloned().collect::>(); + + for url in self.content_urls()? { + if seen.insert(url.clone()) { + urls.push(url); + } + } + + Ok(urls) + } +} + +/// Builds a pull plan for the verified official JP platforms. +pub fn build_official_pull_plan( + discovery: YostarJpResourceDiscoveryPlan, + inventory: YostarJpDownloadInventory, +) -> OfficialResourcePullPlan { + OfficialResourcePullPlan::verified(discovery, inventory) +} + +/// Builds a pull plan for the verified official JP platforms using +/// platform-specific bundle/media catalogs. +pub fn build_official_pull_plan_from_platform_inventory( + discovery: YostarJpResourceDiscoveryPlan, + inventory: YostarJpPlatformDownloadInventory, +) -> OfficialResourcePullPlan { + OfficialResourcePullPlan::verified_platform_inventory(discovery, inventory) +} + +/// Builds a pull plan for the requested platforms. +pub fn build_official_pull_plan_for_platforms( + discovery: YostarJpResourceDiscoveryPlan, + inventory: YostarJpDownloadInventory, + platforms: &[PatchPlatform], +) -> OfficialResourcePullPlan { + OfficialResourcePullPlan::new(discovery, inventory, platforms) +} + +/// Builds a pull plan for the requested platforms using platform-specific +/// bundle/media catalogs. +pub fn build_official_pull_plan_for_platform_inventory( + discovery: YostarJpResourceDiscoveryPlan, + inventory: YostarJpPlatformDownloadInventory, + platforms: &[PatchPlatform], +) -> OfficialResourcePullPlan { + OfficialResourcePullPlan::new_platform_inventory(discovery, inventory, platforms) +} + +fn unique_platforms(platforms: &[PatchPlatform]) -> Vec { + platforms + .iter() + .copied() + .fold(Vec::new(), |mut unique, platform| { + if !unique.contains(&platform) { + unique.push(platform); + } + unique + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use bat_adapters::official::inventory::{ + YostarJpDownloadInventory, YostarJpPlatformCatalogInventory, + YostarJpPlatformDownloadInventory, + }; + use bat_adapters::official::yostar_jp::{ + YostarJpResourceEndpoint, YostarJpResourceEndpointKind, + }; + + fn discovery_plan() -> YostarJpResourceDiscoveryPlan { + YostarJpResourceDiscoveryPlan { + connection_group_name: "Prod-Audit".to_string(), + app_version: "1.70.0".to_string(), + bundle_version: Some("s8tloc7lo3".to_string()), + addressables_root: + "https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(), + endpoints: vec![ + YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::TableCatalog, + platform: None, + url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes".to_string(), + }, + YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::TableCatalogHash, + platform: None, + url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.hash".to_string(), + }, + ], + } + } + + fn inventory() -> YostarJpDownloadInventory { + YostarJpDownloadInventory::from_catalog_bytes( + b"FullPatch_000.zip", + b"ExcelDB.db", + b"JP_Airi.zip", + ) + } + + fn platform_inventory() -> YostarJpPlatformDownloadInventory { + YostarJpPlatformDownloadInventory::from_catalog_bytes( + b"ExcelDB.db", + vec![ + YostarJpPlatformCatalogInventory::from_catalog_bytes( + PatchPlatform::Windows, + b"FullPatch_000.zip", + b"JP_Airi_Win.zip", + ), + YostarJpPlatformCatalogInventory::from_catalog_bytes( + PatchPlatform::Android, + b"FullPatch_001.zip", + b"JP_Airi_Android.zip", + ), + ], + ) + } + + #[test] + fn builds_verified_pull_plan_with_all_urls() { + let plan = build_official_pull_plan(discovery_plan(), inventory()); + + assert_eq!(plan.platforms, verified_official_platforms()); + assert_eq!(plan.discovery_urls().len(), 2); + + let content_urls = plan.content_urls().unwrap(); + assert!(content_urls + .iter() + .any(|url| url.contains("/Windows_PatchPack/FullPatch_000.zip"))); + assert!(content_urls + .iter() + .any(|url| url.contains("/Android_PatchPack/FullPatch_000.zip"))); + assert_eq!(content_urls.len(), 5); + + let all_urls = plan.all_urls().unwrap(); + assert_eq!(all_urls.len(), 7); + assert!(all_urls[0].ends_with("TableCatalog.bytes")); + assert!(all_urls + .iter() + .any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip"))); + assert_eq!( + all_urls + .iter() + .filter(|url| url.ends_with("/MediaResources/JP_Airi.zip")) + .count(), + 1 + ); + } + + #[test] + fn deduplicates_requested_platforms() { + let plan = build_official_pull_plan_for_platforms( + discovery_plan(), + inventory(), + &[PatchPlatform::Windows, PatchPlatform::Windows], + ); + + assert_eq!(plan.platforms, vec![PatchPlatform::Windows]); + } + + #[test] + fn verified_pull_plan_is_windows_and_android_only() { + let plan = build_official_pull_plan(discovery_plan(), inventory()); + + assert_eq!( + plan.platforms, + vec![PatchPlatform::Windows, PatchPlatform::Android] + ); + } + + #[test] + fn builds_platform_inventory_pull_plan_without_cross_mixing_catalogs() { + let plan = build_official_pull_plan_from_platform_inventory( + discovery_plan(), + platform_inventory(), + ); + + let all_urls = plan.all_urls().unwrap(); + + assert_eq!(plan.platforms, verified_official_platforms()); + assert_eq!(all_urls.len(), 7); + assert!(all_urls + .iter() + .any(|url| url.ends_with("/Windows_PatchPack/FullPatch_000.zip"))); + assert!(all_urls + .iter() + .any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip"))); + assert!(!all_urls + .iter() + .any(|url| url.ends_with("/Android_PatchPack/FullPatch_000.zip"))); + } +} diff --git a/infrastructure/src/official_sync.rs b/infrastructure/src/official_sync.rs new file mode 100644 index 0000000..cb064ae --- /dev/null +++ b/infrastructure/src/official_sync.rs @@ -0,0 +1,204 @@ +//! Official JP synchronization planning. + +use bat_adapters::official::yostar_jp::{ + verified_official_platforms, PatchPlatform, YostarJpResourceDiscoveryPlan, + YostarJpResourceEndpointChange, YostarJpSyncDelta, YostarJpSyncSnapshot, +}; + +/// High-level official update decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OfficialSyncDecision { + /// No material change was detected. + UpToDate, + /// Official state changed and content should be downloaded and verified. + DownloadAndVerify, + /// Official state changed enough that the published client snapshot must be updated. + DownloadVerifyAndPublish, +} + +/// Comparison result used by schedulers and manual actions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OfficialSyncPlan { + /// Observed official snapshot. + pub snapshot: YostarJpSyncSnapshot, + /// Difference from the previous stored snapshot. + pub delta: YostarJpSyncDelta, + /// Whether the sync should be executed now. + pub decision: OfficialSyncDecision, +} + +impl OfficialSyncPlan { + /// Returns true when a download should start. + pub fn should_download(&self) -> bool { + matches!( + self.decision, + OfficialSyncDecision::DownloadAndVerify + | OfficialSyncDecision::DownloadVerifyAndPublish + ) + } + + /// Returns true when the result may be pushed to clients after verification. + pub fn should_publish(&self) -> bool { + matches!( + self.decision, + OfficialSyncDecision::DownloadVerifyAndPublish + ) + } +} + +/// Builds a sync plan from an observed snapshot and an optional previous snapshot. +pub fn build_official_sync_plan( + snapshot: YostarJpSyncSnapshot, + previous: Option<&YostarJpSyncSnapshot>, +) -> OfficialSyncPlan { + let delta = snapshot.diff(previous); + let decision = classify_sync_decision(&delta); + + OfficialSyncPlan { + snapshot, + delta, + decision, + } +} + +/// Classifies the sync decision from a snapshot delta. +pub fn classify_sync_decision(delta: &YostarJpSyncDelta) -> OfficialSyncDecision { + if delta.is_initial { + return OfficialSyncDecision::DownloadVerifyAndPublish; + } + + if !delta.has_changes() { + return OfficialSyncDecision::UpToDate; + } + + if delta.addressables_root_changed + || delta.root_token_changed + || !delta.endpoint_changes.is_empty() + { + return OfficialSyncDecision::DownloadVerifyAndPublish; + } + + if delta.bundle_version_changed { + return OfficialSyncDecision::DownloadAndVerify; + } + + OfficialSyncDecision::UpToDate +} + +/// Returns the ordered platform set used by the official JP downloader. +pub fn default_official_platforms() -> Vec { + verified_official_platforms() +} + +/// Returns all endpoints that should be fetched before any patch publish. +pub fn stable_discovery_endpoints(plan: &YostarJpResourceDiscoveryPlan) -> Vec { + plan.endpoints + .iter() + .map(|endpoint| endpoint.url.clone()) + .collect() +} + +/// Returns endpoint URLs that changed compared with a previous sync snapshot. +pub fn changed_endpoint_urls(delta: &YostarJpSyncDelta) -> Vec { + delta + .endpoint_changes + .iter() + .map(|change| match change { + YostarJpResourceEndpointChange::Added(endpoint) => endpoint.url.clone(), + YostarJpResourceEndpointChange::Removed(endpoint) => endpoint.url.clone(), + YostarJpResourceEndpointChange::Updated { current, .. } => current.url.clone(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use bat_adapters::official::yostar_jp::{ + YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint, YostarJpResourceEndpointKind, + }; + + fn snapshot() -> YostarJpSyncSnapshot { + YostarJpSyncSnapshot { + connection_group_name: "Prod-Audit".to_string(), + app_version: "1.70.0".to_string(), + bundle_version: Some("s8tloc7lo3".to_string()), + addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(), + endpoints: vec![ + YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::TableCatalog, + platform: None, + url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes".to_string(), + }, + YostarJpResourceEndpoint { + kind: YostarJpResourceEndpointKind::AddressablesCatalog, + platform: Some(PatchPlatform::Windows), + url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/Windows_PatchPack/catalog_StandaloneWindows64.zip".to_string(), + }, + ], + } + } + + fn discovery_plan() -> YostarJpResourceDiscoveryPlan { + YostarJpResourceDiscoveryPlan { + connection_group_name: "Prod-Audit".to_string(), + app_version: "1.70.0".to_string(), + bundle_version: Some("s8tloc7lo3".to_string()), + addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token" + .to_string(), + endpoints: snapshot().endpoints, + } + } + + #[test] + fn classifies_initial_snapshot_as_publishable() { + let plan = build_official_sync_plan(snapshot(), None); + assert!(plan.delta.is_initial); + assert_eq!( + plan.decision, + OfficialSyncDecision::DownloadVerifyAndPublish + ); + assert!(plan.should_download()); + assert!(plan.should_publish()); + } + + #[test] + fn classifies_identical_snapshot_as_up_to_date() { + let current = snapshot(); + let plan = build_official_sync_plan(current.clone(), Some(¤t)); + assert_eq!(plan.decision, OfficialSyncDecision::UpToDate); + assert!(!plan.should_download()); + assert!(!plan.should_publish()); + } + + #[test] + fn classifies_bundle_version_only_change_as_download_only() { + let current = snapshot(); + let previous = YostarJpSyncSnapshot { + bundle_version: Some("older".to_string()), + ..current.clone() + }; + let delta = current.diff(Some(&previous)); + assert!(delta.bundle_version_changed); + assert_eq!( + classify_sync_decision(&delta), + OfficialSyncDecision::DownloadAndVerify + ); + } + + #[test] + fn exposes_changed_endpoint_urls() { + let mut current = snapshot(); + current.endpoints[0].url = "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes?v=2".to_string(); + let delta = current.diff(Some(&snapshot())); + let urls = changed_endpoint_urls(&delta); + assert_eq!(urls.len(), 1); + assert!(urls[0].contains("v=2")); + } + + #[test] + fn keeps_discovery_endpoint_urls_stable() { + let plan = discovery_plan(); + assert_eq!(stable_discovery_endpoints(&plan).len(), 2); + } +} diff --git a/infrastructure/src/resources.rs b/infrastructure/src/resources.rs new file mode 100644 index 0000000..1ceeb3c --- /dev/null +++ b/infrastructure/src/resources.rs @@ -0,0 +1,217 @@ +//! 内存资源仓储实现。 + +use async_trait::async_trait; +use bat_core::domain::Resource; +use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository}; +use std::collections::HashMap; +use tokio::sync::RwLock; + +/// 以内存 `HashMap` 保存资源索引的仓储实现。 +#[derive(Debug, Default)] +pub struct InMemoryResourceRepository { + resources: RwLock>, +} + +impl InMemoryResourceRepository { + /// 创建空资源仓储。 + pub fn new() -> Self { + Self::default() + } + + fn sorted_resources(resources: &HashMap) -> Vec { + let mut resources = resources.values().cloned().collect::>(); + resources.sort_by(|left, right| left.id.cmp(&right.id)); + resources + } +} + +#[async_trait] +impl ResourceRepository for InMemoryResourceRepository { + async fn add(&self, resource: Resource) -> bat_core::Result { + let id = resource.id.clone(); + self.resources.write().await.insert(id.clone(), resource); + Ok(id) + } + + async fn find_by_id(&self, id: &str) -> bat_core::Result { + self.resources + .read() + .await + .get(id) + .cloned() + .ok_or_else(|| bat_core::Error::NotFound(id.to_string())) + } + + async fn find_by_hash(&self, hash: &str) -> bat_core::Result { + let resources = self.resources.read().await; + Self::sorted_resources(&resources) + .into_iter() + .find(|resource| resource.entry.hash == hash) + .ok_or_else(|| bat_core::Error::NotFound(hash.to_string())) + } + + async fn list(&self, query: ResourceQuery) -> bat_core::Result> { + let resources = self.resources.read().await; + let resources = Self::sorted_resources(&resources) + .into_iter() + .filter(|resource| query_matches(&query, resource)) + .collect(); + Ok(resources) + } + + async fn update(&self, resource: Resource) -> bat_core::Result<()> { + let mut resources = self.resources.write().await; + if !resources.contains_key(&resource.id) { + return Err(bat_core::Error::NotFound(resource.id)); + } + + resources.insert(resource.id.clone(), resource); + Ok(()) + } + + async fn delete(&self, id: &str) -> bat_core::Result<()> { + self.resources + .write() + .await + .remove(id) + .map(|_| ()) + .ok_or_else(|| bat_core::Error::NotFound(id.to_string())) + } + + async fn count(&self, query: ResourceQuery) -> bat_core::Result { + Ok(self.list(query).await?.len() as u64) + } +} + +fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool { + if let Some(resource_type) = query.resource_type { + if resource.entry.resource_type != resource_type { + return false; + } + } + + if let Some(hash) = &query.hash { + if resource.entry.hash != *hash { + return false; + } + } + + if let Some(pattern) = &query.path_pattern { + if !wildcard_matches(pattern, &resource.entry.path) { + return false; + } + } + + true +} + +fn wildcard_matches(pattern: &str, value: &str) -> bool { + wildcard_matches_bytes(pattern.as_bytes(), value.as_bytes()) +} + +fn wildcard_matches_bytes(pattern: &[u8], value: &[u8]) -> bool { + match (pattern.first(), value.first()) { + (None, None) => true, + (None, Some(_)) => false, + (Some(b'*'), _) => { + wildcard_matches_bytes(&pattern[1..], value) + || value + .first() + .is_some_and(|_| wildcard_matches_bytes(pattern, &value[1..])) + } + (Some(b'?'), Some(_)) => wildcard_matches_bytes(&pattern[1..], &value[1..]), + (Some(pattern_byte), Some(value_byte)) if pattern_byte == value_byte => { + wildcard_matches_bytes(&pattern[1..], &value[1..]) + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bat_core::domain::{ResourceEntry, ResourceType}; + use std::path::PathBuf; + + fn resource(id: &str, path: &str, hash: &str, resource_type: ResourceType) -> Resource { + Resource { + id: id.to_string(), + local_path: PathBuf::from(path), + entry: ResourceEntry { + path: path.to_string(), + hash: hash.to_string(), + size: 7, + resource_type, + }, + } + } + + #[tokio::test] + async fn add_find_update_and_delete_resource() { + let repository = InMemoryResourceRepository::new(); + let id = repository + .add(resource( + "resource/synthetic-minimal.bundle", + "synthetic-minimal.bundle", + "hash-a", + ResourceType::AssetBundle, + )) + .await + .unwrap(); + + assert_eq!(id, "resource/synthetic-minimal.bundle"); + assert_eq!( + repository.find_by_id(&id).await.unwrap().entry.path, + "synthetic-minimal.bundle" + ); + + let mut updated = repository.find_by_id(&id).await.unwrap(); + updated.entry.size = 12; + repository.update(updated).await.unwrap(); + assert_eq!(repository.find_by_id(&id).await.unwrap().entry.size, 12); + + repository.delete(&id).await.unwrap(); + assert!(matches!( + repository.find_by_id(&id).await, + Err(bat_core::Error::NotFound(_)) + )); + } + + #[tokio::test] + async fn list_filters_by_type_hash_and_path() { + let repository = InMemoryResourceRepository::new(); + repository + .add(resource( + "resource/a", + "synthetic-minimal.bundle", + "hash-a", + ResourceType::AssetBundle, + )) + .await + .unwrap(); + repository + .add(resource( + "resource/b", + "catalog.json", + "hash-b", + ResourceType::Manifest, + )) + .await + .unwrap(); + + let query = ResourceQuery { + resource_type: Some(ResourceType::AssetBundle), + hash: Some("hash-a".to_string()), + path_pattern: Some("synthetic-*.bundle".to_string()), + }; + + let results = repository.list(query).await.unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "resource/a"); + assert_eq!( + repository.find_by_hash("hash-a").await.unwrap().id, + "resource/a" + ); + assert_eq!(repository.count(ResourceQuery::all()).await.unwrap(), 2); + } +} diff --git a/infrastructure/tests/fixtures/synthetic_phase2/catalog.json b/infrastructure/tests/fixtures/synthetic_phase2/catalog.json new file mode 100644 index 0000000..513480e --- /dev/null +++ b/infrastructure/tests/fixtures/synthetic_phase2/catalog.json @@ -0,0 +1,24 @@ +{ + "m_LocatorId": "AddressablesMainContentCatalog", + "m_InternalIdPrefixes": [ + "https://synthetic.invalid/bundles/" + ], + "m_InternalIds": [ + "synthetic/minimal.bundle", + "synthetic/catalog.json" + ], + "m_KeyDataString": "synthetic-key-data", + "m_EntryDataString": "synthetic-entry-data", + "m_Entries": [ + { + "internal_id": "synthetic/minimal.bundle", + "hash": "synthetic-fixture-entry-hash", + "size": 119 + }, + { + "internal_id": "synthetic/catalog.json", + "hash": "synthetic-fixture-catalog-hash", + "size": 2 + } + ] +} diff --git a/infrastructure/tests/golden/synthetic_phase2_import.json b/infrastructure/tests/golden/synthetic_phase2_import.json new file mode 100644 index 0000000..5244aa0 --- /dev/null +++ b/infrastructure/tests/golden/synthetic_phase2_import.json @@ -0,0 +1,20 @@ +{ + "imported": [ + { + "bytes": 143, + "source_path": "synthetic/minimal.bundle", + "unityfs": { + "block_count": 1, + "directories": [ + "SYNTHETIC-GOLDEN-CAB" + ], + "directory_count": 1, + "unity_version": "2021.3.56f2" + } + } + ], + "resource_count": 1, + "skipped": [ + "synthetic/catalog.json" + ] +} diff --git a/infrastructure/tests/official_game_main_config_bootstrap.rs b/infrastructure/tests/official_game_main_config_bootstrap.rs new file mode 100644 index 0000000..4f941f3 --- /dev/null +++ b/infrastructure/tests/official_game_main_config_bootstrap.rs @@ -0,0 +1,631 @@ +use bat_adapters::official::game_main_config::YostarJpGameMainConfig; +use bat_adapters::official::inventory::{ + YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory, +}; +use bat_adapters::official::yostar_jp::{ + is_official_yostar_jp_url, verified_official_platforms, PatchPlatform, YostarJpResourceEndpointKind, + YostarJpResourceDiscoveryPlan, YostarJpServerInfo, +}; +use bat_infrastructure::{ + build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService, + OfficialResourcePullService, +}; +use std::collections::HashMap; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use tempfile::TempDir; + +// Synthetic fixture values for an official-only integration test. +const TEST_LAUNCHER_VERSION: &str = "1.7.2-fixture"; +const TEST_LAUNCHER_LATEST_VERSION: &str = "1.70.0-fixture"; +const TEST_LAUNCHER_LATEST_FILE_PATH: &str = + "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-launcher-latest.zip"; +const TEST_LAUNCHER_MANIFEST_URL: &str = + "https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/manifest.json"; +const TEST_LAUNCHER_GAME_CONFIG_JSON_URL: &str = + "https://api-launcher-jp.yo-star.com/api/launcher/game/config/json?version=1.70.0-fixture&file_path=prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2FBlueArchive_JP-launcher-latest.zip"; +const TEST_LAUNCHER_MANIFEST_SOURCE: &str = + "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip"; +const TEST_LAUNCHER_GAME_ZIP_URL: &str = + "https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip"; +const TEST_SERVER_INFO_URL: &str = + "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json"; +const TEST_CONNECTION_GROUP: &str = "Fixture-Audit"; +const TEST_ADDRESSABLES_ROOT: &str = + "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55"; + +#[test] +fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() { + let harness = TestHarness::new(); + let bootstrap = harness.fetch_bootstrap(); + + assert_eq!(bootstrap.game_config.game_latest_version, TEST_LAUNCHER_LATEST_VERSION); + assert_eq!(bootstrap.game_config.game_latest_file_path, TEST_LAUNCHER_LATEST_FILE_PATH); + assert_eq!(bootstrap.manifest_url, TEST_LAUNCHER_MANIFEST_URL); + assert_eq!( + bootstrap.manifest_source.as_deref(), + Some(TEST_LAUNCHER_MANIFEST_SOURCE) + ); + assert_eq!(bootstrap.game_zip_url, TEST_LAUNCHER_GAME_ZIP_URL); + assert_eq!(bootstrap.manifest_file_count, 1); + assert_eq!( + bootstrap.game_main_config.server_info_data_url.as_deref(), + Some(TEST_SERVER_INFO_URL) + ); + assert_eq!( + bootstrap.game_main_config.default_connection_group.as_deref(), + Some(TEST_CONNECTION_GROUP) + ); + assert_ne!( + bootstrap.game_zip_url, + format!( + "https://launcher-pkg-ba-jp.yo-star.com/{}", + bootstrap.game_config.game_latest_file_path + ) + ); +} + +#[test] +fn official_pipeline_can_dry_run_and_pull_without_local_client() { + let harness = TestHarness::new(); + let bootstrap = harness.fetch_bootstrap(); + let fetcher = harness.fetcher(); + + let server_info_url = bootstrap + .game_main_config + .server_info_data_url + .as_ref() + .unwrap(); + assert!(is_official_yostar_jp_url(server_info_url)); + + let server_info_bytes = fetcher.fetch_bytes(server_info_url).unwrap(); + let server_info = YostarJpServerInfo::from_slice(&server_info_bytes).unwrap(); + let discovery = server_info + .discovery_plan( + bootstrap + .game_main_config + .default_connection_group + .as_deref() + .unwrap(), + &bootstrap.game_config.game_latest_version, + &verified_official_platforms(), + ) + .unwrap(); + + let inventory = fetch_platform_inventory(&fetcher, &discovery); + let plan = build_official_pull_plan_from_platform_inventory(discovery, inventory); + let all_urls = plan.all_urls().unwrap(); + + assert_eq!(plan.platforms, verified_official_platforms()); + assert_eq!(all_urls.len(), 19); + assert!(all_urls.iter().all(|url| is_official_yostar_jp_url(url))); + assert!(all_urls + .iter() + .all(|url| !url.contains("bluearchive.cafe"))); + assert!(all_urls + .iter() + .any(|url| url.ends_with("/TableBundles/ExcelDB.db"))); + assert!(all_urls + .iter() + .any(|url| url.ends_with("/Windows_PatchPack/FullPatch_000.zip"))); + assert!(all_urls + .iter() + .any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip"))); + + let report = harness.pull_service().pull(&plan).unwrap(); + assert_eq!(report.items.len(), all_urls.len()); + assert!(report.total_bytes() > 0); + assert!(report.items.iter().all(|item| item.destination.exists())); + + let curl_log = fs::read_to_string(&harness.curl_log).unwrap(); + assert!(curl_log.contains(server_info_url)); + assert!(!curl_log.contains("bluearchive.cafe")); +} + +struct TestHarness { + temp: TempDir, + curl_script: std::path::PathBuf, + unzip_script: std::path::PathBuf, + curl_log: std::path::PathBuf, +} + +impl TestHarness { + fn new() -> Self { + let temp = TempDir::new().unwrap(); + let resources_assets = synthetic_resources_assets(); + + let resources_assets_path = temp.path().join("resources.assets"); + fs::write(&resources_assets_path, &resources_assets).unwrap(); + + let zip_fixture = temp.path().join("downloaded.zip"); + fs::write(&zip_fixture, b"dummy zip content").unwrap(); + + let curl_log = temp.path().join("curl.log"); + let curl_script = temp.path().join("fake-curl"); + write_executable( + &curl_script, + &official_curl_script(&curl_log, &zip_fixture), + ); + + let unzip_script = temp.path().join("fake-unzip"); + write_executable(&unzip_script, &launcher_unzip_script(&resources_assets_path)); + + Self { + temp, + curl_script, + unzip_script, + curl_log, + } + } + + fn fetch_bootstrap(&self) -> bat_infrastructure::official_game_main_config::OfficialGameMainConfigBootstrap { + OfficialGameMainConfigBootstrapService::with_commands( + TEST_LAUNCHER_VERSION, + &self.curl_script, + &self.unzip_script, + ) + .fetch_bootstrap() + .unwrap() + } + + fn fetcher(&self) -> OfficialResourcePullService { + OfficialResourcePullService::with_curl_command(self.temp.path().join("official-pull"), &self.curl_script) + } + + fn pull_service(&self) -> OfficialResourcePullService { + OfficialResourcePullService::with_curl_command(self.temp.path().join("pulled"), &self.curl_script) + } +} + +fn write_executable(path: &Path, content: &str) { + fs::write(path, content).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); +} + +fn official_curl_script(curl_log: &Path, zip_fixture: &Path) -> String { + format!( + r#"#!/usr/bin/env bash +set -euo pipefail +log={} +printf '%s\n' "$*" >> "$log" +output="" +url="" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) + output="$2" + shift 2 + ;; + --url) + url="$2" + shift 2 + ;; + *) + shift + ;; + esac +done + +emit_text() {{ + local payload="$1" + if [[ -n "$output" ]]; then + mkdir -p "$(dirname "$output")" + printf '%s' "$payload" > "$output" + else + printf '%s' "$payload" + fi +}} + +emit_zip_fixture() {{ + mkdir -p "$(dirname "$output")" + cp {} "$output" +}} + +if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then + cat <<'JSON' +{{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}} +JSON +elif [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/advanced/game/download/cdn" ]]; then + cat <<'JSON' +{{"code":200,"message":"ok","data":{{"primary_cdn":"https://launcher-pkg-ba-jp.yo-star.com","back_up_cdn":"https://launcher-pkg-ba-jp-bk.yo-star.com"}}}} +JSON +elif [[ "$url" == "{launcher_game_config_json_url}" ]]; then + cat <<'JSON' +{{"code":200,"message":"ok","data":{{"url":"{launcher_manifest_url}"}}}} +JSON +elif [[ "$url" == "{launcher_manifest_url}" ]]; then + cat <<'JSON' +{{"source":"{launcher_manifest_source}","file":[{{"path":"/BlueArchive.exe","size":"100","hash":"123"}}]}} +JSON +elif [[ "$url" == "{server_info_url}" ]]; then + cat <<'JSON' +{{"ConnectionGroups":[{{"Name":"{connection_group}","AddressablesCatalogUrlRoot":"{addressables_root}","BundleVersion":"s8tloc7lo3","IsLivePublished":true}}]}} +JSON +elif [[ "$url" == "{addressables_root}/Windows_PatchPack/catalog_StandaloneWindows64.zip" ]]; then + emit_zip_fixture +elif [[ "$url" == "{addressables_root}/Windows_PatchPack/catalog_StandaloneWindows64.hash" ]]; then + emit_text "catalog-windows-hash" +elif [[ "$url" == "{addressables_root}/Android_PatchPack/catalog_Android.zip" ]]; then + emit_zip_fixture +elif [[ "$url" == "{addressables_root}/Android_PatchPack/catalog_Android.hash" ]]; then + emit_text "catalog-android-hash" +elif [[ "$url" == "{addressables_root}/TableBundles/TableCatalog.bytes" ]]; then + emit_text "ExcelDB.db" +elif [[ "$url" == "{addressables_root}/TableBundles/TableCatalog.hash" ]]; then + emit_text "tablecatalog-hash" +elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.bytes" ]]; then + emit_text "FullPatch_000.zip" +elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.hash" ]]; then + emit_text "bundlepackinginfo-win-hash" +elif [[ "$url" == "{addressables_root}/MediaResources-Windows/Catalog/MediaCatalog.bytes" ]]; then + emit_text "JP_Airi_Win.zip" +elif [[ "$url" == "{addressables_root}/MediaResources-Windows/Catalog/MediaCatalog.hash" ]]; then + emit_text "mediacatalog-win-hash" +elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.bytes" ]]; then + emit_text "FullPatch_001.zip" +elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.hash" ]]; then + emit_text "bundlepackinginfo-android-hash" +elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.bytes" ]]; then + emit_text "JP_Airi_Android.zip" +elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.hash" ]]; then + emit_text "mediacatalog-android-hash" +elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then + filename="${{url##*/}}" + if [[ "$filename" == *.zip ]]; then + emit_zip_fixture + else + emit_text "$filename" + fi +elif [[ -n "$output" && "$url" == *.zip ]]; then + emit_zip_fixture +else + echo "unexpected curl url: $url" >&2 + exit 1 +fi +"#, + shell_quote(curl_log), + shell_quote(zip_fixture), + launcher_latest_version = TEST_LAUNCHER_LATEST_VERSION, + launcher_latest_file_path = TEST_LAUNCHER_LATEST_FILE_PATH, + launcher_game_config_json_url = TEST_LAUNCHER_GAME_CONFIG_JSON_URL, + launcher_manifest_url = TEST_LAUNCHER_MANIFEST_URL, + launcher_manifest_source = TEST_LAUNCHER_MANIFEST_SOURCE, + server_info_url = TEST_SERVER_INFO_URL, + connection_group = TEST_CONNECTION_GROUP, + addressables_root = TEST_ADDRESSABLES_ROOT + ) +} + +fn launcher_unzip_script(resources_assets_path: &Path) -> String { + format!( + r#"#!/usr/bin/env bash +set -euo pipefail +dest="" +while [[ $# -gt 0 ]]; do + case "$1" in + -d) + dest="$2" + shift 2 + ;; + *) + shift + ;; + esac +done + +mkdir -p "$dest/nested" +cp {} "$dest/nested/resources.assets" +"#, + shell_quote(resources_assets_path) + ) +} + +fn shell_quote(path: &Path) -> String { + let mut quoted = String::from("'"); + for ch in path.to_string_lossy().chars() { + if ch == '\'' { + quoted.push_str("'\\''"); + } else { + quoted.push(ch); + } + } + quoted.push('\''); + quoted +} + +fn synthetic_resources_assets() -> Vec { + let json = r#"{ + "SkipTutorial": true, + "Language": "ja-JP", + "DefaultConnectionGroup": "Fixture-Audit", + "ServerInfoDataUrl": "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" + }"#; + let encrypted = encrypt_game_main_config(json); + let file = synthetic_serialized_file(&encrypted); + let parsed = YostarJpGameMainConfig::from_resources_assets_bytes(&file).unwrap(); + assert_eq!(parsed.skip_tutorial, Some(true)); + assert_eq!(parsed.language.as_deref(), Some("ja-JP")); + file +} + +fn fetch_platform_inventory( + fetcher: &OfficialResourcePullService, + discovery: &YostarJpResourceDiscoveryPlan, +) -> YostarJpPlatformDownloadInventory { + let mut table_catalog = None; + let mut bundle_packing_infos = HashMap::>::new(); + let mut media_catalogs = HashMap::>::new(); + + for endpoint in &discovery.endpoints { + match endpoint.kind { + YostarJpResourceEndpointKind::TableCatalog => { + table_catalog = Some(fetcher.fetch_bytes(&endpoint.url).unwrap()); + } + YostarJpResourceEndpointKind::BundlePackingInfo => { + let platform = endpoint.platform.unwrap(); + bundle_packing_infos.insert(platform, fetcher.fetch_bytes(&endpoint.url).unwrap()); + } + YostarJpResourceEndpointKind::MediaCatalog => { + let platform = endpoint.platform.unwrap(); + media_catalogs.insert(platform, fetcher.fetch_bytes(&endpoint.url).unwrap()); + } + _ => {} + } + } + + let table_catalog = table_catalog.unwrap(); + let platform_catalogs = verified_official_platforms() + .into_iter() + .map(|platform| { + YostarJpPlatformCatalogInventory::from_catalog_bytes( + platform, + bundle_packing_infos.get(&platform).unwrap(), + media_catalogs.get(&platform).unwrap(), + ) + }) + .collect(); + + YostarJpPlatformDownloadInventory::from_catalog_bytes(&table_catalog, platform_catalogs) +} + +fn encrypt_game_main_config(json: &str) -> Vec { + let key = create_key("GameMainConfig"); + let mut utf16 = json + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + xor_bytes(&mut utf16, &key); + utf16 +} + +fn create_key(name: &str) -> Vec { + let seed = xxhash32(name.as_bytes()); + let mut mt = MersenneTwister::new(seed); + mt.next_bytes(8) +} + +fn xor_bytes(bytes: &mut [u8], key: &[u8]) { + if key.is_empty() { + return; + } + + for (index, byte) in bytes.iter_mut().enumerate() { + *byte ^= key[index % key.len()]; + } +} + +fn xxhash32(bytes: &[u8]) -> u32 { + const PRIME1: u32 = 0x9E37_79B1; + const PRIME2: u32 = 0x85EB_CA77; + const PRIME3: u32 = 0xC2B2_AE3D; + const PRIME4: u32 = 0x27D4_EB2F; + const PRIME5: u32 = 0x1656_67B1; + + let len = bytes.len(); + let mut index = 0usize; + let mut hash = if len >= 16 { + let mut v1 = PRIME1.wrapping_add(PRIME2); + let mut v2 = PRIME2; + let mut v3 = 0; + let mut v4 = 0u32.wrapping_sub(PRIME1); + + while index + 16 <= len { + v1 = round(v1, read_u32_le(bytes, index)); + v2 = round(v2, read_u32_le(bytes, index + 4)); + v3 = round(v3, read_u32_le(bytes, index + 8)); + v4 = round(v4, read_u32_le(bytes, index + 12)); + index += 16; + } + + v1.rotate_left(1) + .wrapping_add(v2.rotate_left(7)) + .wrapping_add(v3.rotate_left(12)) + .wrapping_add(v4.rotate_left(18)) + } else { + PRIME5 + } + .wrapping_add(len as u32); + + while index + 4 <= len { + hash = hash + .wrapping_add(read_u32_le(bytes, index).wrapping_mul(PRIME3)) + .rotate_left(17) + .wrapping_mul(PRIME4); + index += 4; + } + + while index < len { + hash = hash + .wrapping_add((bytes[index] as u32).wrapping_mul(PRIME5)) + .rotate_left(11) + .wrapping_mul(PRIME1); + index += 1; + } + + avalanche(hash) +} + +fn round(acc: u32, input: u32) -> u32 { + const PRIME2: u32 = 0x85EB_CA77; + const PRIME1: u32 = 0x9E37_79B1; + + acc.wrapping_add(input.wrapping_mul(PRIME2)) + .rotate_left(13) + .wrapping_mul(PRIME1) +} + +fn avalanche(mut hash: u32) -> u32 { + hash ^= hash >> 15; + hash = hash.wrapping_mul(0x85EB_CA6B); + hash ^= hash >> 13; + hash = hash.wrapping_mul(0xC2B2_AE35); + hash ^= hash >> 16; + hash +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +struct MersenneTwister { + mt: [u32; 624], + index: usize, +} + +impl MersenneTwister { + fn new(seed: u32) -> Self { + let mut mt = [0u32; 624]; + mt[0] = seed; + for i in 1..624 { + mt[i] = 1812433253u32 + .wrapping_mul(mt[i - 1] ^ (mt[i - 1] >> 30)) + .wrapping_add(i as u32); + } + + Self { mt, index: 624 } + } + + fn next_u32(&mut self) -> u32 { + if self.index >= 624 { + self.twist(); + } + + let mut y = self.mt[self.index]; + self.index += 1; + y ^= y >> 11; + y ^= (y << 7) & 0x9D2C_5680; + y ^= (y << 15) & 0xEFC6_0000; + y ^= y >> 18; + y + } + + fn next_bytes(&mut self, len: usize) -> Vec { + let mut out = Vec::with_capacity(len); + while out.len() < len { + let bytes = (self.next_u32() >> 1).to_le_bytes(); + for byte in bytes { + if out.len() == len { + break; + } + out.push(byte); + } + } + out + } + + fn twist(&mut self) { + const UPPER_MASK: u32 = 0x8000_0000; + const LOWER_MASK: u32 = 0x7FFF_FFFF; + const MATRIX_A: u32 = 0x9908_B0DF; + + for i in 0..624 { + let x = (self.mt[i] & UPPER_MASK) | (self.mt[(i + 1) % 624] & LOWER_MASK); + let mut x_a = x >> 1; + if x & 1 != 0 { + x_a ^= MATRIX_A; + } + self.mt[i] = self.mt[(i + 397) % 624] ^ x_a; + } + self.index = 0; + } +} + +fn synthetic_serialized_file(bytes: &[u8]) -> Vec { + fn push_u32_be(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_be_bytes()); + } + fn push_u64_be(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_be_bytes()); + } + fn push_u32_le(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_le_bytes()); + } + fn push_i32_le(data: &mut Vec, value: i32) { + data.extend_from_slice(&value.to_le_bytes()); + } + fn push_i64_le(data: &mut Vec, value: i64) { + data.extend_from_slice(&value.to_le_bytes()); + } + fn push_u64_le(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_le_bytes()); + } + fn push_i16_le(data: &mut Vec, value: i16) { + data.extend_from_slice(&value.to_le_bytes()); + } + fn align(data: &mut Vec, alignment: usize) { + let remainder = data.len() % alignment; + if remainder != 0 { + data.resize(data.len() + alignment - remainder, 0); + } + } + + let mut object_data = Vec::new(); + push_u32_le(&mut object_data, 14); + object_data.extend_from_slice(b"GameMainConfig"); + align(&mut object_data, 4); + push_u32_le(&mut object_data, bytes.len() as u32); + object_data.extend_from_slice(bytes); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(0); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 49); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 1); + align(&mut metadata, 4); + push_i64_le(&mut metadata, 1); + push_u64_le(&mut metadata, 0); + push_u32_le(&mut metadata, object_data.len() as u32); + push_i32_le(&mut metadata, 0); + + let header_len = 48usize; + let data_offset = header_len + metadata.len(); + let file_size = data_offset + object_data.len(); + + let mut file = Vec::new(); + push_u32_be(&mut file, metadata.len() as u32); + push_u32_be(&mut file, file_size as u32); + push_u32_be(&mut file, 22); + push_u32_be(&mut file, 0); + file.push(0); + file.extend_from_slice(&[0, 0, 0]); + push_u32_be(&mut file, metadata.len() as u32); + push_u64_be(&mut file, file_size as u64); + push_u64_be(&mut file, data_offset as u64); + push_u64_be(&mut file, 0); + file.extend_from_slice(&metadata); + file.extend_from_slice(&object_data); + file +} diff --git a/infrastructure/tests/synthetic_phase2_import.rs b/infrastructure/tests/synthetic_phase2_import.rs new file mode 100644 index 0000000..3445ba5 --- /dev/null +++ b/infrastructure/tests/synthetic_phase2_import.rs @@ -0,0 +1,119 @@ +use bat_adapters::manifest::ManifestDriverRegistry; +use bat_core::repositories::cas_repository::CasRepository; +use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository}; +use bat_infrastructure::{ + BundleSource, FileSystemCasRepository, InMemoryResourceRepository, ResourceImportService, +}; +use serde_json::json; +use tempfile::TempDir; + +fn push_c_string(data: &mut Vec, value: &str) { + data.extend_from_slice(value.as_bytes()); + data.push(0); +} + +fn push_u16(data: &mut Vec, value: u16) { + data.extend_from_slice(&value.to_be_bytes()); +} + +fn push_u32(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_be_bytes()); +} + +fn push_i32(data: &mut Vec, value: i32) { + data.extend_from_slice(&value.to_be_bytes()); +} + +fn push_u64(data: &mut Vec, value: u64) { + data.extend_from_slice(&value.to_be_bytes()); +} + +fn align(data: &mut Vec, alignment: usize) { + let remainder = data.len() % alignment; + if remainder != 0 { + data.resize(data.len() + alignment - remainder, 0); + } +} + +fn synthetic_minimal_unityfs_bundle() -> Vec { + let mut blocks_info = Vec::new(); + blocks_info.extend_from_slice(&[0; 16]); + push_i32(&mut blocks_info, 1); + push_u32(&mut blocks_info, 4); + push_u32(&mut blocks_info, 4); + push_u16(&mut blocks_info, 0); + push_i32(&mut blocks_info, 1); + push_u64(&mut blocks_info, 0); + push_u64(&mut blocks_info, 4); + push_u32(&mut blocks_info, 0); + push_c_string(&mut blocks_info, "SYNTHETIC-GOLDEN-CAB"); + + let mut data = Vec::new(); + push_c_string(&mut data, "UnityFS"); + push_u32(&mut data, 8); + push_c_string(&mut data, "5.x.x"); + push_c_string(&mut data, "2021.3.56f2"); + push_u64(&mut data, 0); + push_u32(&mut data, blocks_info.len() as u32); + push_u32(&mut data, blocks_info.len() as u32); + push_u32(&mut data, 0); + align(&mut data, 16); + data.extend_from_slice(&blocks_info); + data.extend_from_slice(b"data"); + + let total_size = data.len() as u64; + let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len(); + data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes()); + data +} + +#[tokio::test] +async fn imports_synthetic_addressables_catalog_bundle_against_golden() { + let catalog = include_bytes!("fixtures/synthetic_phase2/catalog.json"); + let manifest = ManifestDriverRegistry::with_defaults() + .parse(catalog) + .await + .unwrap(); + + 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 report = service + .import_manifest_bundles( + &manifest, + &[BundleSource::new( + "minimal.bundle", + synthetic_minimal_unityfs_bundle(), + )], + ) + .await + .unwrap(); + + assert_eq!(report.imported.len(), 1); + assert!(cas.exists(&report.imported[0].object_id).await); + + let indexed = resources.list(ResourceQuery::all()).await.unwrap(); + let actual = json!({ + "imported": report.imported.iter().map(|imported| { + json!({ + "bytes": imported.bytes, + "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, + } + }) + }).collect::>(), + "resource_count": indexed.len(), + "skipped": report.skipped, + }); + let expected: serde_json::Value = + serde_json::from_str(include_str!("golden/synthetic_phase2_import.json")).unwrap(); + + assert_eq!(actual, expected); + assert_eq!(indexed[0].entry.hash, report.imported[0].object_id); +}