feat: prepare experiment push package

This commit is contained in:
2026-06-30 00:08:36 +08:00
parent 3c00659691
commit adc1cd5b91
45 changed files with 7858 additions and 65 deletions
+21 -5
View File
@@ -1,14 +1,15 @@
# BlueArchiveToolkit 当前工作区状态 # BlueArchiveToolkit 当前工作区状态
**更新时间**2026-06-28 **更新时间**2026-06-29
**状态来源**:本地工作区盘点与命令验证 **状态来源**:本地工作区盘点与命令验证
**状态分支**`experiment`
**权威计划**`PROJECT_PLAN.md` **权威计划**`PROJECT_PLAN.md`
--- ---
## 1. 总体判断 ## 1. 总体判断
当前项目处于 **稳定基线完成、CAS V1 已落地、资源同步与解析待实装** 阶段。 当前项目处于 **稳定基线完成、CAS V1 已落地、官方资源链路验证完成、资源同步与解析待实装** 阶段。
旧文档中存在 Week 3 “完成”和“回滚”两类互相冲突的报告。以当前代码为准,历史 Week 3 不再作为状态依据;当前 CAS V1 已由新实现接管,AssetBundle、Patch、Go CLI/API/Web 仍有明显占位或未实现部分。 旧文档中存在 Week 3 “完成”和“回滚”两类互相冲突的报告。以当前代码为准,历史 Week 3 不再作为状态依据;当前 CAS V1 已由新实现接管,AssetBundle、Patch、Go CLI/API/Web 仍有明显占位或未实现部分。
@@ -100,6 +101,8 @@
- Unity bundle parse/serialize 仍是后续阶段能力。 - Unity bundle parse/serialize 仍是后续阶段能力。
- Addressables Catalog 复杂字段解析未完成。 - Addressables Catalog 复杂字段解析未完成。
- 客户端发现、备份、应用补丁流程尚未连接真实实现。 - 客户端发现、备份、应用补丁流程尚未连接真实实现。
- 官方 launcher bootstrap、GameMainConfig bootstrap、官方 pull plan 的用户级文档已补齐。
- 官方 bootstrap / pull 测试夹具已收敛到 `experiment` 分支语义下,并通过了官方链路回归。
### `bat-cas-engine` ### `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 ```bash
go test ./... go test ./...
``` ```
@@ -194,13 +207,14 @@ go test ./...
### Git 元数据 ### Git 元数据
`.git/` 是空目录,无法恢复原历史。本轮已新初始化 Git 仓库,并将默认分支设置为 `main` `.git/` 是空目录,无法恢复原历史。本轮已新初始化 Git 仓库,并完成当前工作分支的整理
当前状态: 当前状态:
- `git status --short --branch` 可用。 - `git status --short --branch` 可用。
- 原历史未恢复。 - 原历史未恢复。
- 本轮建立首次 Git 基线提交 - 当前分支已切到 `experiment`,用于承载实验性 fixture 调整
- 当前分支已切到 `experiment`,用于承载实验性 fixture 调整。
### 核心能力未实装 ### 核心能力未实装
@@ -222,9 +236,11 @@ go test ./...
1. 完成 Manifest 真实字段解析和资源模型映射。 1. 完成 Manifest 真实字段解析和资源模型映射。
2. 落地 Go CLI 的 `doctor` 和基础命令框架。 2. 落地 Go CLI 的 `doctor` 和基础命令框架。
3. 设计 Resource Repository 的持久化 schema。 3. 设计 Resource Repository 的持久化 schema。
4. 决定哪些 experiment 改动需要回流到 `dev`
--- ---
**当前总体完成度**:约 18% **当前总体完成度**:约 18%
**当前基线状态**:已建立可继续开发的 Git 基线。 **当前基线状态**:已建立可继续开发的 Git 基线。
**下一工程里程碑**Manifest 真实解析与 Go CLI 基础入口。 **下一工程里程碑**Manifest 真实解析与 Go CLI 基础入口。
**当前阶段文档**`docs/reports/current-stage-prepush.md`
Generated
+8
View File
@@ -120,9 +120,13 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"base64",
"bat-core", "bat-core",
"lz4",
"lzma-rs",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"thiserror", "thiserror",
"tokio", "tokio",
] ]
@@ -192,9 +196,13 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"bat-adapters",
"bat-cas-engine", "bat-cas-engine",
"bat-core", "bat-core",
"hex",
"md-5",
"serde", "serde",
"serde_json",
"tempfile", "tempfile",
"thiserror", "thiserror",
"tokio", "tokio",
+6 -2
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 文档索引 # BlueArchiveToolkit 文档索引
**更新时间**2026-06-28 **更新时间**2026-06-29
**说明**:本索引用于快速定位当前权威文档和历史资料。 **说明**:本索引用于快速定位当前权威文档和历史资料。
--- ---
@@ -19,9 +19,12 @@
## 2. 架构与指南 ## 2. 架构与指南
- `docs/architecture/README.md`:总体架构设计。 - `docs/architecture/README.md`:总体架构设计。
- `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。
- `docs/api/README.md`API 设计入口。 - `docs/api/README.md`API 设计入口。
- `docs/guides/development.md`:开发指南。 - `docs/guides/development.md`:开发指南。
- `docs/guides/deployment.md`:部署指南。 - `docs/guides/deployment.md`:部署指南。
- `docs/guides/official-resource-test-pull.md`:官方资源拉取用户指南。
- `docs/reports/current-stage-prepush.md`:当前阶段说明与推送前核查记录。
- `docs/guides/baseline.md`:稳定工程基线指南。 - `docs/guides/baseline.md`:稳定工程基线指南。
- `docs/architecture/adr/0001-engine-and-application-boundaries.md`Rust/Go 边界决策。 - `docs/architecture/adr/0001-engine-and-application-boundaries.md`Rust/Go 边界决策。
- `docs/architecture/adr/0002-cas-v1-design-boundary.md`CAS V1 边界决策。 - `docs/architecture/adr/0002-cas-v1-design-boundary.md`CAS V1 边界决策。
@@ -75,7 +78,7 @@
## 6. 状态摘要 ## 6. 状态摘要
当前总体完成度约 **15%** 当前总体完成度约 **18%**
已完成: 已完成:
@@ -83,6 +86,7 @@
- Unity/Manifest/Client 适配器框架。 - Unity/Manifest/Client 适配器框架。
- 基础 CAS 文件系统存储。 - 基础 CAS 文件系统存储。
- 文档整理和路线图重制。 - 文档整理和路线图重制。
- 官方资源链路验证和实验性 fixture 收敛。
优先待办: 优先待办:
+4 -1
View File
@@ -2,7 +2,7 @@
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。 **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-cas-engine` CAS V1:原子写入、Hash 校验、引用计数、GC、并发写入测试、损坏检测。
- `bat-infrastructure` CAS 仓储适配层。 - `bat-infrastructure` CAS 仓储适配层。
- `bat-ffi` 基础 crate 边界。 - `bat-ffi` 基础 crate 边界。
- 官方日服资源 bootstrap、官方 launcher bootstrap、官方 pull plan 用户流程。
- 文档路线图和当前缺口清单。 - 文档路线图和当前缺口清单。
- 当前阶段说明与推送前核查文档。
未完成: 未完成:
@@ -46,6 +48,7 @@
- [完整开发计划](PROJECT_PLAN.md) - [完整开发计划](PROJECT_PLAN.md)
- [当前缺口清单](docs/reports/CURRENT_GAPS.md) - [当前缺口清单](docs/reports/CURRENT_GAPS.md)
- [文档索引](DOCS_INDEX.md) - [文档索引](DOCS_INDEX.md)
- [当前阶段说明](docs/reports/current-stage-prepush.md)
--- ---
+4
View File
@@ -13,6 +13,10 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
async-trait.workspace = true async-trait.workspace = true
tokio.workspace = true tokio.workspace = true
lz4 = "1.28"
lzma-rs = "0.3"
base64 = "0.22"
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] } tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3.14"
@@ -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 <official-game-root>"))?;
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("<none>")
);
println!(
"manifest_file_count={}",
bootstrap
.manifest_file_count
.map(|count| count.to_string())
.unwrap_or_else(|| "<none>".to_string())
);
Ok(())
}
+93
View File
@@ -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::<Vec<_>>();
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("<none>")
);
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} <server-info.json> [connection-group] [app-version] [platforms]\n\
platforms: comma-separated Windows,Android"
);
}
fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(parse_platform)
.collect()
}
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
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")
}
+72
View File
@@ -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::<Vec<_>>();
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} <root-token> <BundlePackingInfo.bytes> <TableCatalog.bytes> <MediaCatalog.bytes> [platforms]"
);
}
fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(parse_platform)
.collect()
}
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
match value.to_ascii_lowercase().as_str() {
"windows" | "win" => Ok(PatchPlatform::Windows),
"android" => Ok(PatchPlatform::Android),
_ => Err(format!("unsupported platform: {value}")),
}
}
+1
View File
@@ -13,6 +13,7 @@
pub mod client; pub mod client;
pub mod error; pub mod error;
pub mod manifest; pub mod manifest;
pub mod official;
pub mod unity; pub mod unity;
pub use error::{AdapterError, Result}; pub use error::{AdapterError, Result};
+156 -13
View File
@@ -52,9 +52,52 @@ impl AddressablesCatalogDriver {
} }
} }
fn resources(json: &Value) -> Vec<ResourceEntry> { fn resource_from_entry(value: &Value, index: usize) -> Option<ResourceEntry> {
let resources = json let path = value
.get("m_InternalIds") .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<ResourceEntry> {
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<ResourceEntry> {
json.get("m_InternalIds")
.and_then(|value| value.as_array()) .and_then(|value| value.as_array())
.into_iter() .into_iter()
.flatten() .flatten()
@@ -72,8 +115,55 @@ impl AddressablesCatalogDriver {
resource_type: Self::resource_type_for_path(path), resource_type: Self::resource_type_for_path(path),
}) })
}) })
.collect(); .collect()
resources }
fn resources(json: &Value) -> Vec<ResourceEntry> {
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<String, String> {
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<String, String>, 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<String, String>,
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<GenericManifest, String> { async fn parse(&self, raw_data: &[u8]) -> Result<GenericManifest, String> {
let json = Self::parse_json(raw_data)?; let json = Self::parse_json(raw_data)?;
// TODO: 解析 m_KeyDataString、m_EntryDataString 等压缩字段
// 这些字段使用了自定义压缩格式,需要实现解压缩算法
// 参考:Unity Addressables 源代码
let metadata = ManifestMetadata { let metadata = ManifestMetadata {
locator_id: Self::locator_id(&json), locator_id: Self::locator_id(&json),
cdn_prefixes: Self::cdn_prefixes(&json), cdn_prefixes: Self::cdn_prefixes(&json),
extra: HashMap::new(), extra: Self::extra_metadata(&json),
}; };
Ok(GenericManifest { Ok(GenericManifest {
@@ -178,9 +264,9 @@ mod tests {
"m_LocatorId": "AddressablesMainContentCatalog", "m_LocatorId": "AddressablesMainContentCatalog",
"m_InternalIdPrefixes": [], "m_InternalIdPrefixes": [],
"m_InternalIds": [ "m_InternalIds": [
"academy.bundle", "synthetic/minimal-a.bundle",
"character.bundle", "synthetic/minimal-b.bundle",
"catalog.json" "synthetic/catalog.json"
] ]
}"#; }"#;
@@ -195,4 +281,61 @@ mod tests {
ResourceType::AssetBundle 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())
);
}
} }
+1 -1
View File
@@ -83,7 +83,7 @@ mod tests {
fn test_manifest_metadata() { fn test_manifest_metadata() {
let metadata = ManifestMetadata { let metadata = ManifestMetadata {
locator_id: Some("test".to_string()), 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(), extra: HashMap::new(),
}; };
+370
View File
@@ -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<bool>,
/// Selected language.
#[serde(default)]
pub language: Option<String>,
/// Default connection group.
#[serde(default)]
pub default_connection_group: Option<String>,
/// Server-info JSON URL.
#[serde(default)]
pub server_info_data_url: Option<String>,
}
impl YostarJpGameMainConfig {
/// Reads and decrypts `GameMainConfig` from a Unity serialized file.
pub fn from_resources_assets(path: impl AsRef<Path>) -> Result<Self, String> {
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<Self, String> {
let serialized = UnitySerializedFile::from_slice(bytes)?;
Self::from_serialized_file(&serialized)
}
fn from_serialized_file(serialized: &UnitySerializedFile) -> Result<Self, String> {
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<Self, String> {
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<u8> {
let seed = xxhash32(name.as_bytes());
let mut mt = MersenneTwister::new(seed);
mt.next_bytes(8)
}
fn convert(value: &str, key: &[u8]) -> Result<String, String> {
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<String, String> {
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::<Vec<_>>();
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<u8> {
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<u8> {
let key = create_key("GameMainConfig");
let mut utf16 = json
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>();
xor_bytes(&mut utf16, &key);
utf16
}
fn synthetic_serialized_file(bytes: &[u8]) -> Vec<u8> {
fn push_u32_be(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64_be(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i64_le(data: &mut Vec<u8>, value: i64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}
fn align(data: &mut Vec<u8>, 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());
}
}
+574
View File
@@ -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<String>,
/// Table file names from `TableCatalog.bytes`.
pub table_file_names: Vec<String>,
/// Media file names from `MediaCatalog.bytes`.
pub media_file_names: Vec<String>,
}
/// 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<String>,
/// Media file names from this platform's `MediaCatalog.bytes`.
pub media_file_names: Vec<String>,
}
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<Vec<String>, 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<Vec<String>, 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<String>,
/// Per-platform bundle/media inventories.
pub platform_catalogs: Vec<YostarJpPlatformCatalogInventory>,
}
impl YostarJpPlatformDownloadInventory {
/// Extracts a multi-platform inventory from official JP catalog bytes.
pub fn from_catalog_bytes(
table_catalog: &[u8],
platform_catalogs: Vec<YostarJpPlatformCatalogInventory>,
) -> 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, String> {
self.direct_download_urls_for_platforms(root, &verified_official_platforms())
}
}
fn merge_platform_catalogs(
catalogs: Vec<YostarJpPlatformCatalogInventory>,
) -> Vec<YostarJpPlatformCatalogInventory> {
let mut merged = Vec::<YostarJpPlatformCatalogInventory>::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<String>, names: Vec<String>) {
let mut merged = existing.iter().cloned().collect::<BTreeSet<_>>();
merged.extend(names);
*existing = merged.into_iter().collect();
}
fn extract_full_patch_pack_names(data: &[u8]) -> Vec<String> {
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<String> {
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<String> {
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(&current).into_owned());
current.clear();
} else {
current.clear();
}
}
if current.len() >= min_len {
strings.push(String::from_utf8_lossy(&current).into_owned());
}
strings
}
fn candidate_file_names(value: &str, extensions: &[&str]) -> Vec<String> {
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<PatchPlatform> {
platforms
.iter()
.copied()
.fold(Vec::new(), |mut unique, platform| {
if !unique.contains(&platform) {
unique.push(platform);
}
unique
})
}
fn append_unique_urls(urls: &mut Vec<String>, seen: &mut HashSet<String>, next_urls: Vec<String>) {
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"));
}
}
+243
View File
@@ -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<String>,
/// Installed Windows game client version.
pub version: String,
/// Launcher integrity hash.
#[serde(default)]
pub vc: Option<String>,
}
impl YostarJpLauncherConfig {
/// Parses a launcher config JSON document.
pub fn from_slice(data: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(data)
}
/// Reads `game-launcher-config.json` from an installed game root.
pub fn from_game_root(root: impl AsRef<Path>) -> Result<Self, String> {
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<String>,
/// File entries in the installed Windows client.
#[serde(default)]
pub files: Vec<YostarJpLauncherManifestFile>,
}
impl YostarJpLauncherManifest {
/// Parses a launcher manifest JSON document.
pub fn from_slice(data: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(data)
}
/// Reads `manifest.json` from an installed game root.
pub fn from_game_root(root: impl AsRef<Path>) -> Result<Self, String> {
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<String>,
}
/// 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<String>,
/// Optional launcher manifest basis, when `manifest.json` exists.
pub basis: Option<String>,
/// Optional installed manifest file count.
pub manifest_file_count: Option<usize>,
}
impl YostarJpInstalledGameBootstrap {
/// Reads all available launcher metadata from an installed official JP game
/// root.
pub fn from_game_root(root: impl AsRef<Path>) -> Result<Self, String> {
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());
}
}
+19
View File
@@ -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};
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -4,8 +4,13 @@
pub mod adapter; pub mod adapter;
pub mod registry; pub mod registry;
pub mod serialized_file;
pub mod unity_2021_3; 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 registry::UnityAdapterRegistry;
pub use serialized_file::{UnitySerializedFile, UnitySerializedTextAsset};
pub use unity_2021_3::Unity2021_3Adapter; pub use unity_2021_3::Unity2021_3Adapter;
+68 -2
View File
@@ -44,6 +44,72 @@ pub struct ParsedAssetBundle {
pub assets: Vec<String>, pub assets: Vec<String>,
/// 原始数据(保留用于序列化) /// 原始数据(保留用于序列化)
pub raw_data: Vec<u8>, pub raw_data: Vec<u8>,
/// UnityFS 文件头信息。
pub unityfs_header: Option<UnityFsHeader>,
/// UnityFS 压缩块信息。
pub blocks: Vec<UnityFsBlockInfo>,
/// UnityFS 目录信息。
pub directories: Vec<UnityFsDirectoryInfo>,
}
/// 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 接口 /// Unity Adapter 接口
@@ -113,10 +179,10 @@ mod tests {
fn test_raw_assetbundle() { fn test_raw_assetbundle() {
let bundle = RawAssetBundle { let bundle = RawAssetBundle {
data: vec![0x55, 0x6e, 0x69, 0x74, 0x79, 0x46, 0x53], // "UnityFS" 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.data.len(), 7);
assert_eq!(bundle.path, Some("test.bundle".to_string())); assert_eq!(bundle.path, Some("synthetic-minimal.bundle".to_string()));
} }
} }
+16 -1
View File
@@ -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<dyn UnityAdapter>) { pub fn register(&mut self, adapter: Arc<dyn UnityAdapter>) {
self.adapters.push(adapter); self.adapters.push(adapter);
@@ -91,7 +98,7 @@ mod tests {
let bundle = RawAssetBundle { let bundle = RawAssetBundle {
data, data,
path: Some("test.bundle".to_string()), path: Some("synthetic-minimal.bundle".to_string()),
}; };
let result = registry.select_adapter(&bundle); let result = registry.select_adapter(&bundle);
@@ -111,4 +118,12 @@ mod tests {
let result = registry.select_adapter(&bundle); let result = registry.select_adapter(&bundle);
assert!(result.is_err()); 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");
}
} }
+532
View File
@@ -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<u8>,
}
/// 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<UnitySerializedTextAsset>,
}
impl UnitySerializedFile {
/// Parses a serialized file from raw bytes.
pub fn from_slice(data: &[u8]) -> Result<Self, String> {
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<Path>) -> Result<Self, String> {
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<UnitySerializedTextAsset, String> {
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<i32, String> {
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<u8, String> {
Ok(self.read_bytes(1, field)?[0])
}
fn read_u16(&mut self, field: &str) -> Result<u16, String> {
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<i16, String> {
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<u32, String> {
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<u32, String> {
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<i32, String> {
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<u64, String> {
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<u64, String> {
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<i64, String> {
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<String, String> {
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<String, String> {
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<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i64_le(data: &mut Vec<u8>, value: i64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u32_be(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64_be(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn synthetic_serialized_file() -> Vec<u8> {
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,
],
);
}
}
+363 -20
View File
@@ -2,11 +2,17 @@
//! //!
//! 支持 Unity 2021.3.x 版本的 AssetBundle //! 支持 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 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 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 /// Unity 2021.3 Adapter
pub struct Unity2021_3Adapter; pub struct Unity2021_3Adapter;
@@ -42,6 +48,267 @@ impl Unity2021_3Adapter {
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.ok() .ok()
} }
fn parse_unityfs(data: &[u8]) -> Result<ParsedAssetBundle, String> {
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<UnityFsBlockInfo>, Vec<UnityFsDirectoryInfo>), 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<Vec<u8>, 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<u16, String> {
let bytes = self.read_bytes(2, field)?;
Ok(u16::from_be_bytes([bytes[0], bytes[1]]))
}
fn read_u32(&mut self, field: &str) -> Result<u32, String> {
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<i32, String> {
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<u64, String> {
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<String, String> {
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 { impl Default for Unity2021_3Adapter {
@@ -69,14 +336,16 @@ impl UnityAdapter for Unity2021_3Adapter {
} }
} }
async fn parse(&self, _bundle: &RawAssetBundle) -> Result<ParsedAssetBundle, String> { async fn parse(&self, bundle: &RawAssetBundle) -> Result<ParsedAssetBundle, String> {
// TODO: Phase 2 实现 let parsed = Self::parse_unityfs(&bundle.data)?;
// 需要: if !self.supported_versions().contains(&parsed.unity_version) {
// 1. 解析 UnityFS 文件头 return Err(format!(
// 2. 解压缩数据块 "Unsupported Unity version for {}: {}",
// 3. 解析 TypeTree self.name(),
// 4. 提取 Asset 对象 parsed.unity_version
Err(PARSE_NOT_IMPLEMENTED.to_string()) ));
}
Ok(parsed)
} }
async fn serialize(&self, _parsed: &ParsedAssetBundle) -> Result<Vec<u8>, String> { async fn serialize(&self, _parsed: &ParsedAssetBundle) -> Result<Vec<u8>, String> {
@@ -94,6 +363,66 @@ impl UnityAdapter for Unity2021_3Adapter {
mod tests { mod tests {
use super::*; use super::*;
fn push_c_string(data: &mut Vec<u8>, value: &str) {
data.extend_from_slice(value.as_bytes());
data.push(0);
}
fn push_u16(data: &mut Vec<u8>, value: u16) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_i32(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn synthetic_minimal_unityfs_bundle() -> Vec<u8> {
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] #[test]
fn test_adapter_name() { fn test_adapter_name() {
let adapter = Unity2021_3Adapter::new(); let adapter = Unity2021_3Adapter::new();
@@ -114,15 +443,9 @@ mod tests {
fn test_can_handle_valid_bundle() { fn test_can_handle_valid_bundle() {
let adapter = Unity2021_3Adapter::new(); 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 { let bundle = RawAssetBundle {
data, data: synthetic_minimal_unityfs_bundle(),
path: Some("test.bundle".to_string()), path: Some("synthetic-minimal.bundle".to_string()),
}; };
assert!(adapter.can_handle(&bundle)); assert!(adapter.can_handle(&bundle));
@@ -141,7 +464,27 @@ mod tests {
} }
#[tokio::test] #[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 adapter = Unity2021_3Adapter::new();
let bundle = RawAssetBundle { let bundle = RawAssetBundle {
@@ -151,6 +494,6 @@ mod tests {
let result = adapter.parse(&bundle).await; let result = adapter.parse(&bundle).await;
assert!(result.is_err()); assert!(result.is_err());
assert!(result.unwrap_err().contains("Phase 2")); assert!(result.unwrap_err().contains("signature"));
} }
} }
+21
View File
@@ -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"));
}
+24
View File
@@ -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());
}
+7
View File
@@ -6,6 +6,12 @@ BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建
当前文档描述目标架构。实际实现状态以根目录 `CURRENT_STATUS.md``PROJECT_PLAN.md` 为准。 当前文档描述目标架构。实际实现状态以根目录 `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 应用层边界。 - `adr/0001-engine-and-application-boundaries.md`:Rust 引擎与 Go 应用层边界。
@@ -361,6 +367,7 @@ API Server (多实例)
更多详细设计文档: 更多详细设计文档:
- [官方资源后端说明](./official-resource-backend.md)
- [CAS 存储引擎设计](./cas-storage.md) - [CAS 存储引擎设计](./cas-storage.md)
- [AssetBundle 解析器设计](./assetbundle-parser.md) - [AssetBundle 解析器设计](./assetbundle-parser.md)
- [翻译系统设计](./translation-system.md) - [翻译系统设计](./translation-system.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. 是否能在官方客户端变动时只改适配层。
+132
View File
@@ -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
```
+8
View File
@@ -274,6 +274,10 @@
- README 明确区分已实现、开发中、规划中。 - README 明确区分已实现、开发中、规划中。
状态:
- 已部分修正,当前 README 已明确列出已具备与未完成项,并增加当前阶段说明入口。
### G-016:架构文档需要更新为当前路线图 ### G-016:架构文档需要更新为当前路线图
现象: 现象:
@@ -285,6 +289,10 @@
- 增加 ADR 或架构决策记录。 - 增加 ADR 或架构决策记录。
- 明确 Rust/Go/DB/Plugin 边界。 - 明确 Rust/Go/DB/Plugin 边界。
状态:
- 已部分修正,`docs/architecture/README.md` 已增加当前可用官方资源入口,但仍保留目标架构描述,因此只算阶段性对齐完成。
### G-017CI 未落地 ### G-017CI 未落地
影响: 影响:
+48
View File
@@ -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`
- 再做一次提交前检查和差异审阅。
+7 -7
View File
@@ -1,5 +1,5 @@
use bat_adapters::manifest::{AddressablesCatalogDriver, ManifestDriverRegistry};
use bat_adapters::manifest::ManifestDriver; use bat_adapters::manifest::ManifestDriver;
use bat_adapters::manifest::{AddressablesCatalogDriver, ManifestDriverRegistry};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
@@ -10,16 +10,16 @@ async fn main() -> anyhow::Result<()> {
let driver = AddressablesCatalogDriver::new(); let driver = AddressablesCatalogDriver::new();
println!("✅ Driver: {}", driver.name()); println!("✅ Driver: {}", driver.name());
// 模拟 Addressables Catalog // 合成 Addressables Catalog,仅用于演示解析接口。
println!("\n📦 解析 Addressables Catalog..."); println!("\n📦 解析 Addressables Catalog...");
let catalog_json = r#"{ let catalog_json = r#"{
"m_LocatorId": "AddressablesMainContentCatalog", "m_LocatorId": "AddressablesMainContentCatalog",
"m_InternalIdPrefixes": ["https://cdn.example.com/"], "m_InternalIdPrefixes": ["https://synthetic.invalid/"],
"m_InternalIds": [ "m_InternalIds": [
"academy-model.bundle", "synthetic/model.bundle",
"academy-texture.bundle", "synthetic/texture.bundle",
"ui-atlas.bundle", "synthetic/ui-atlas.bundle",
"TableBundles-JP.bundle" "synthetic/table-manifest.json"
] ]
}"#; }"#;
+65 -12
View File
@@ -1,6 +1,65 @@
use bat_adapters::unity::{Unity2021_3Adapter, UnityAdapterRegistry};
use bat_adapters::unity::RawAssetBundle; use bat_adapters::unity::RawAssetBundle;
use std::sync::Arc; use bat_adapters::unity::{Unity2021_3Adapter, UnityAdapterRegistry};
fn push_c_string(data: &mut Vec<u8>, value: &str) {
data.extend_from_slice(value.as_bytes());
data.push(0);
}
fn push_u16(data: &mut Vec<u8>, value: u16) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_i32(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn synthetic_minimal_unityfs_bundle() -> Vec<u8> {
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() { fn main() {
println!("🎮 BlueArchiveToolkit - Unity 适配器演示"); println!("🎮 BlueArchiveToolkit - Unity 适配器演示");
@@ -11,16 +70,11 @@ fn main() {
println!("✅ Unity Adapter: {}", adapter.name()); println!("✅ Unity Adapter: {}", adapter.name());
println!(" 支持版本: 2021.3.0 - 2021.3.99"); println!(" 支持版本: 2021.3.0 - 2021.3.99");
// 模拟 UnityFS 文件 // 合成最小 UnityFS 文件,仅用于演示版本检测和适配器选择。
println!("\n📦 测试 Unity 版本检测..."); 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 { let bundle = RawAssetBundle {
data: bundle_data, data: synthetic_minimal_unityfs_bundle(),
path: Some("test.bundle".to_string()), path: Some("synthetic-demo.bundle".to_string()),
}; };
if adapter.can_handle(&bundle) { if adapter.can_handle(&bundle) {
@@ -31,8 +85,7 @@ fn main() {
// 测试 Registry // 测试 Registry
println!("\n🔄 测试 Adapter Registry..."); println!("\n🔄 测试 Adapter Registry...");
let mut registry = UnityAdapterRegistry::new(); let registry = UnityAdapterRegistry::with_defaults();
registry.register(Arc::new(Unity2021_3Adapter::new()));
println!(" 注册的适配器数量: {}", registry.count()); println!(" 注册的适配器数量: {}", registry.count());
match registry.select_adapter(&bundle) { match registry.select_adapter(&bundle) {
+6
View File
@@ -7,13 +7,19 @@ license.workspace = true
[dependencies] [dependencies]
bat-core = { path = "../core" } bat-core = { path = "../core" }
bat-adapters = { path = "../adapters" }
bat-cas-engine = { path = "../crates/bat-cas-engine" } bat-cas-engine = { path = "../crates/bat-cas-engine" }
anyhow.workspace = true anyhow.workspace = true
thiserror.workspace = true thiserror.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
async-trait.workspace = true async-trait.workspace = true
md-5 = "0.10"
hex = "0.4"
tempfile = "3.14"
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] } tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3.14" tempfile = "3.14"
serde_json.workspace = true
@@ -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("<none>")
);
println!(
"game_start_exe_name={}",
game_config
.game_start_exe_name
.as_deref()
.unwrap_or("<none>")
);
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("<none>")
);
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<CliArgs> {
let raw_args = env::args().collect::<Vec<_>>();
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<Item = String>,
flag: &str,
) -> anyhow::Result<String> {
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]");
}
@@ -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<ServerInfoSource>,
connection_group: Option<String>,
app_version: Option<String>,
launcher_version: String,
launcher_bootstrap: bool,
platforms: Option<Vec<PatchPlatform>>,
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("<none>")
);
println!(
"launcher_api_game_start_exe_name={}",
bootstrap
.game_config
.game_start_exe_name
.as_deref()
.unwrap_or("<none>")
);
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("<none>")
);
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("<none>")
);
println!(
"launcher_api_game_main_config_default_connection_group={}",
bootstrap
.game_main_config
.default_connection_group
.as_deref()
.unwrap_or("<none>")
);
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("<none>")
);
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<Vec<u8>> {
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<SeedCatalogs> {
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<YostarJpPlatformDownloadInventory> {
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<Vec<u8>>,
bundle_packing_infos: HashMap<PatchPlatform, Vec<u8>>,
media_catalogs: HashMap<PatchPlatform, Vec<u8>>,
}
impl SeedCatalogs {
fn insert(
&mut self,
endpoint: &YostarJpResourceEndpoint,
bytes: Vec<u8>,
) -> 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<PatchPlatform> {
endpoint.platform.ok_or_else(|| {
anyhow::anyhow!(
"discovery endpoint {:?} has no platform: {}",
endpoint.kind,
endpoint.url
)
})
}
fn parse_args() -> anyhow::Result<CliArgs> {
let raw_args = env::args().collect::<Vec<_>>();
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<Item = String>,
flag: &str,
) -> anyhow::Result<String> {
args.next()
.ok_or_else(|| anyhow::anyhow!("missing value for {flag}"))
}
fn print_usage(binary: &str) {
eprintln!(
"usage: {binary} [--server-info-file <official-json-name> | --server-info-path <local-json>] \
[--app-version 1.70.0] \
[--launcher-bootstrap] [--launcher-version 1.7.2] [--connection-group <name>] \
[--platforms Windows,Android] [--output official_pull_output] [--curl curl] [--dry-run]"
);
}
fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(parse_platform)
.collect()
}
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
match value.to_ascii_lowercase().as_str() {
"windows" | "win" => Ok(PatchPlatform::Windows),
"android" => Ok(PatchPlatform::Android),
_ => Err(format!("unsupported platform: {value}")),
}
}
+369
View File
@@ -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<u8>,
}
impl BundleSource {
/// 创建 Bundle 输入。
pub fn new(path: impl Into<String>, data: impl Into<Vec<u8>>) -> 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<String>,
}
/// Manifest 导入报告。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourceImportReport {
/// 成功导入的资源。
pub imported: Vec<ImportedResource>,
/// 被跳过的非 AssetBundle 资源路径。
pub skipped: Vec<String>,
}
/// 将解析后的资源清单写入 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<ResourceImportReport> {
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<UnityFsImportSummary> {
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<u8>, value: &str) {
data.extend_from_slice(value.as_bytes());
data.push(0);
}
fn push_u16(data: &mut Vec<u8>, value: u16) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_i32(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn synthetic_minimal_unityfs_bundle() -> Vec<u8> {
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);
}
}
+27
View File
@@ -11,8 +11,35 @@
#![warn(clippy::all)] #![warn(clippy::all)]
pub mod cas; 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 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 版本号 /// Infrastructure 版本号
pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+396
View File
@@ -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<OfficialResourcePullItem>,
}
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<PathBuf>) -> 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<PathBuf>,
curl_command: impl Into<PathBuf>,
) -> 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<OfficialResourcePullReport, String> {
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<Vec<u8>, 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<PathBuf, String> {
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());
}
}
@@ -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<String>,
/// 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<String>) -> Self {
Self::with_commands(launcher_version, "curl", "unzip")
}
/// Creates a bootstrapper with explicit commands.
pub fn with_commands(
launcher_version: impl Into<String>,
curl_command: impl Into<PathBuf>,
unzip_command: impl Into<PathBuf>,
) -> 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<OfficialGameMainConfigBootstrap, String> {
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<YostarJpGameMainConfig, String> {
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<PathBuf> {
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
}
+513
View File
@@ -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<String>,
/// Executable name without `.exe`.
#[serde(default)]
pub game_start_exe_name: Option<String>,
/// Launcher arguments.
#[serde(default)]
pub game_start_params: Vec<String>,
/// Displayed decompressed size.
#[serde(default)]
pub decompression_size: Option<String>,
}
/// 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<String>,
/// Remote package files.
#[serde(default, rename = "file")]
pub files: Vec<YostarJpLauncherManifestFile>,
}
/// 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<String>) -> 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<String>,
curl_command: impl Into<String>,
) -> 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<YostarJpLauncherGameConfig, String> {
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<YostarJpLauncherCdnConfig, String> {
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<YostarJpLauncherManifestUrl, String> {
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<YostarJpLauncherRemoteManifest, String> {
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<LauncherEnvelope, String> {
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("<no message>")
));
}
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<String>,
/// Optional alternate message field.
#[serde(default)]
pub msg: Option<String>,
/// Response payload.
pub data: serde_json::Value,
}
/// Builds an official launcher API URL from a path.
pub fn launcher_api_url(path: &str) -> Result<String, String> {
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<String, String> {
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<u64>,
) -> Result<String, String> {
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");
}
}
+284
View File
@@ -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<PatchPlatform>,
}
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<String> {
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, String> {
YostarJpResourceRoot::from_addressables_root(&self.discovery.addressables_root)
}
/// Returns all concrete download URLs for the selected platforms.
pub fn content_urls(&self) -> Result<Vec<String>, 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<Vec<String>, String> {
let mut urls = self.discovery_urls();
let mut seen = urls.iter().cloned().collect::<HashSet<_>>();
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<PatchPlatform> {
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")));
}
}
+204
View File
@@ -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<PatchPlatform> {
verified_official_platforms()
}
/// Returns all endpoints that should be fetched before any patch publish.
pub fn stable_discovery_endpoints(plan: &YostarJpResourceDiscoveryPlan) -> Vec<String> {
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<String> {
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(&current));
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);
}
}
+217
View File
@@ -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<HashMap<String, Resource>>,
}
impl InMemoryResourceRepository {
/// 创建空资源仓储。
pub fn new() -> Self {
Self::default()
}
fn sorted_resources(resources: &HashMap<String, Resource>) -> Vec<Resource> {
let mut resources = resources.values().cloned().collect::<Vec<_>>();
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<String> {
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<Resource> {
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<Resource> {
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<Vec<Resource>> {
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<u64> {
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);
}
}
@@ -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
}
]
}
@@ -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"
]
}
@@ -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<u8> {
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::<PatchPlatform, Vec<u8>>::new();
let mut media_catalogs = HashMap::<PatchPlatform, Vec<u8>>::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<u8> {
let key = create_key("GameMainConfig");
let mut utf16 = json
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>();
xor_bytes(&mut utf16, &key);
utf16
}
fn create_key(name: &str) -> Vec<u8> {
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<u8> {
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<u8> {
fn push_u32_be(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64_be(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i64_le(data: &mut Vec<u8>, value: i64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}
fn align(data: &mut Vec<u8>, 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
}
@@ -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<u8>, value: &str) {
data.extend_from_slice(value.as_bytes());
data.push(0);
}
fn push_u16(data: &mut Vec<u8>, value: u16) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_i32(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn synthetic_minimal_unityfs_bundle() -> Vec<u8> {
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::<Vec<_>>(),
"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);
}