mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:15:14 +08:00
feat: prepare experiment push package
This commit is contained in:
+21
-5
@@ -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
@@ -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
@@ -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 收敛。
|
||||||
|
|
||||||
优先待办:
|
优先待办:
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -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())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¤t).into_owned());
|
||||||
|
current.clear();
|
||||||
|
} else {
|
||||||
|
current.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.len() >= min_len {
|
||||||
|
strings.push(String::from_utf8_lossy(¤t).into_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
strings
|
||||||
|
}
|
||||||
|
|
||||||
|
fn candidate_file_names(value: &str, extensions: &[&str]) -> Vec<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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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. 是否能在官方客户端变动时只改适配层。
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
@@ -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-017:CI 未落地
|
### G-017:CI 未落地
|
||||||
|
|
||||||
影响:
|
影响:
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
- 再做一次提交前检查和差异审阅。
|
||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}"#;
|
}"#;
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¤t));
|
||||||
|
assert_eq!(plan.decision, OfficialSyncDecision::UpToDate);
|
||||||
|
assert!(!plan.should_download());
|
||||||
|
assert!(!plan.should_publish());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_bundle_version_only_change_as_download_only() {
|
||||||
|
let current = snapshot();
|
||||||
|
let previous = YostarJpSyncSnapshot {
|
||||||
|
bundle_version: Some("older".to_string()),
|
||||||
|
..current.clone()
|
||||||
|
};
|
||||||
|
let delta = current.diff(Some(&previous));
|
||||||
|
assert!(delta.bundle_version_changed);
|
||||||
|
assert_eq!(
|
||||||
|
classify_sync_decision(&delta),
|
||||||
|
OfficialSyncDecision::DownloadAndVerify
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exposes_changed_endpoint_urls() {
|
||||||
|
let mut current = snapshot();
|
||||||
|
current.endpoints[0].url = "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes?v=2".to_string();
|
||||||
|
let delta = current.diff(Some(&snapshot()));
|
||||||
|
let urls = changed_endpoint_urls(&delta);
|
||||||
|
assert_eq!(urls.len(), 1);
|
||||||
|
assert!(urls[0].contains("v=2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_discovery_endpoint_urls_stable() {
|
||||||
|
let plan = discovery_plan();
|
||||||
|
assert_eq!(stable_discovery_endpoints(&plan).len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user