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:
@@ -7,13 +7,19 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bat-core = { path = "../core" }
|
||||
bat-adapters = { path = "../adapters" }
|
||||
bat-cas-engine = { path = "../crates/bat-cas-engine" }
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
async-trait.workspace = true
|
||||
md-5 = "0.10"
|
||||
hex = "0.4"
|
||||
tempfile = "3.14"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
tempfile = "3.14"
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -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)]
|
||||
|
||||
pub mod cas;
|
||||
pub mod import;
|
||||
pub mod official_game_main_config;
|
||||
pub mod official_download;
|
||||
pub mod official_launcher;
|
||||
pub mod official_pull;
|
||||
pub mod official_sync;
|
||||
pub mod resources;
|
||||
|
||||
pub use cas::FileSystemCasRepository;
|
||||
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
|
||||
pub use official_download::{
|
||||
OfficialResourcePullItem, OfficialResourcePullReport, OfficialResourcePullService,
|
||||
};
|
||||
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
||||
pub use official_launcher::{
|
||||
is_official_launcher_package_url, launcher_api_url, launcher_authorization_header,
|
||||
OfficialLauncherBootstrapService, YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig,
|
||||
YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
|
||||
};
|
||||
pub use official_pull::{
|
||||
build_official_pull_plan, build_official_pull_plan_for_platform_inventory,
|
||||
build_official_pull_plan_for_platforms, build_official_pull_plan_from_platform_inventory,
|
||||
OfficialResourcePullPlan,
|
||||
};
|
||||
pub use official_sync::{
|
||||
build_official_sync_plan, changed_endpoint_urls, classify_sync_decision,
|
||||
default_official_platforms, OfficialSyncDecision, OfficialSyncPlan,
|
||||
};
|
||||
pub use resources::InMemoryResourceRepository;
|
||||
|
||||
/// Infrastructure 版本号
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
@@ -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