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, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpointKind, 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 fetch_bootstrap_falls_back_to_latest_file_path_when_manifest_source_is_not_zip() { let harness = TestHarness::new_with_manifest_source("/BlueArchive_JP-1.70.436321-game"); let bootstrap = harness.fetch_bootstrap(); assert_eq!( bootstrap.manifest_source.as_deref(), Some("/BlueArchive_JP-1.70.436321-game") ); assert_eq!( bootstrap.game_zip_url, format!( "https://launcher-pkg-ba-jp.yo-star.com/{}", TEST_LAUNCHER_LATEST_FILE_PATH ) ); let curl_log = fs::read_to_string(&harness.curl_log).unwrap(); assert!(curl_log.contains(TEST_LAUNCHER_LATEST_FILE_PATH)); assert!(!curl_log.contains(" /BlueArchive_JP-1.70.436321-game")); } #[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 { Self::new_with_manifest_source(TEST_LAUNCHER_MANIFEST_SOURCE) } fn new_with_manifest_source(manifest_source: &str) -> 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, manifest_source), ); 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, manifest_source: &str) -> 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 "{table_catalog_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 "{windows_bundle_catalog_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 "{windows_media_catalog_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 "{android_bundle_catalog_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 "{android_media_catalog_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 = manifest_source, server_info_url = TEST_SERVER_INFO_URL, connection_group = TEST_CONNECTION_GROUP, addressables_root = TEST_ADDRESSABLES_ROOT, table_catalog_hash = xxhash32(b"ExcelDB.db"), windows_bundle_catalog_hash = xxhash32(b"FullPatch_000.zip"), windows_media_catalog_hash = xxhash32(b"JP_Airi_Win.zip"), android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"), android_media_catalog_hash = xxhash32(b"JP_Airi_Android.zip"), ) } fn launcher_unzip_script(resources_assets_path: &Path) -> String { format!( r#"#!/usr/bin/env bash set -euo pipefail dest="" while [[ $# -gt 0 ]]; do case "$1" in -d) dest="$2" shift 2 ;; *) shift ;; esac done mkdir -p "$dest/nested" cp {} "$dest/nested/resources.assets" "#, shell_quote(resources_assets_path) ) } fn shell_quote(path: &Path) -> String { let mut quoted = String::from("'"); for ch in path.to_string_lossy().chars() { if ch == '\'' { quoted.push_str("'\\''"); } else { quoted.push(ch); } } quoted.push('\''); quoted } fn synthetic_resources_assets() -> Vec { let json = r#"{ "SkipTutorial": true, "Language": "ja-JP", "DefaultConnectionGroup": "Fixture-Audit", "ServerInfoDataUrl": "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" }"#; let encrypted = encrypt_game_main_config(json); let file = synthetic_serialized_file(&encrypted); let parsed = YostarJpGameMainConfig::from_resources_assets_bytes(&file).unwrap(); assert_eq!(parsed.skip_tutorial, Some(true)); assert_eq!(parsed.language.as_deref(), Some("ja-JP")); file } fn fetch_platform_inventory( fetcher: &OfficialResourcePullService, discovery: &YostarJpResourceDiscoveryPlan, ) -> YostarJpPlatformDownloadInventory { let mut table_catalog = None; let mut bundle_packing_infos = HashMap::>::new(); let mut media_catalogs = HashMap::>::new(); for endpoint in &discovery.endpoints { match endpoint.kind { YostarJpResourceEndpointKind::TableCatalog => { table_catalog = Some(fetcher.fetch_bytes(&endpoint.url).unwrap()); } YostarJpResourceEndpointKind::BundlePackingInfo => { let platform = endpoint.platform.unwrap(); bundle_packing_infos.insert(platform, fetcher.fetch_bytes(&endpoint.url).unwrap()); } YostarJpResourceEndpointKind::MediaCatalog => { let platform = endpoint.platform.unwrap(); media_catalogs.insert(platform, fetcher.fetch_bytes(&endpoint.url).unwrap()); } _ => {} } } let table_catalog = table_catalog.unwrap(); let platform_catalogs = verified_official_platforms() .into_iter() .map(|platform| { YostarJpPlatformCatalogInventory::from_catalog_bytes( platform, bundle_packing_infos.get(&platform).unwrap(), media_catalogs.get(&platform).unwrap(), ) }) .collect(); YostarJpPlatformDownloadInventory::from_catalog_bytes(&table_catalog, platform_catalogs) } fn encrypt_game_main_config(json: &str) -> Vec { let key = create_key("GameMainConfig"); let mut utf16 = json .encode_utf16() .flat_map(u16::to_le_bytes) .collect::>(); xor_bytes(&mut utf16, &key); utf16 } fn create_key(name: &str) -> Vec { let seed = xxhash32(name.as_bytes()); let mut mt = MersenneTwister::new(seed); mt.next_bytes(8) } fn xor_bytes(bytes: &mut [u8], key: &[u8]) { if key.is_empty() { return; } for (index, byte) in bytes.iter_mut().enumerate() { *byte ^= key[index % key.len()]; } } fn xxhash32(bytes: &[u8]) -> u32 { const PRIME1: u32 = 0x9E37_79B1; const PRIME2: u32 = 0x85EB_CA77; const PRIME3: u32 = 0xC2B2_AE3D; const PRIME4: u32 = 0x27D4_EB2F; const PRIME5: u32 = 0x1656_67B1; let len = bytes.len(); let mut index = 0usize; let mut hash = if len >= 16 { let mut v1 = PRIME1.wrapping_add(PRIME2); let mut v2 = PRIME2; let mut v3 = 0; let mut v4 = 0u32.wrapping_sub(PRIME1); while index + 16 <= len { v1 = round(v1, read_u32_le(bytes, index)); v2 = round(v2, read_u32_le(bytes, index + 4)); v3 = round(v3, read_u32_le(bytes, index + 8)); v4 = round(v4, read_u32_le(bytes, index + 12)); index += 16; } v1.rotate_left(1) .wrapping_add(v2.rotate_left(7)) .wrapping_add(v3.rotate_left(12)) .wrapping_add(v4.rotate_left(18)) } else { PRIME5 } .wrapping_add(len as u32); while index + 4 <= len { hash = hash .wrapping_add(read_u32_le(bytes, index).wrapping_mul(PRIME3)) .rotate_left(17) .wrapping_mul(PRIME4); index += 4; } while index < len { hash = hash .wrapping_add((bytes[index] as u32).wrapping_mul(PRIME5)) .rotate_left(11) .wrapping_mul(PRIME1); index += 1; } avalanche(hash) } fn round(acc: u32, input: u32) -> u32 { const PRIME2: u32 = 0x85EB_CA77; const PRIME1: u32 = 0x9E37_79B1; acc.wrapping_add(input.wrapping_mul(PRIME2)) .rotate_left(13) .wrapping_mul(PRIME1) } fn avalanche(mut hash: u32) -> u32 { hash ^= hash >> 15; hash = hash.wrapping_mul(0x85EB_CA6B); hash ^= hash >> 13; hash = hash.wrapping_mul(0xC2B2_AE35); hash ^= hash >> 16; hash } fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { u32::from_le_bytes([ bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3], ]) } struct MersenneTwister { mt: [u32; 624], index: usize, } impl MersenneTwister { fn new(seed: u32) -> Self { let mut mt = [0u32; 624]; mt[0] = seed; for i in 1..624 { mt[i] = 1812433253u32 .wrapping_mul(mt[i - 1] ^ (mt[i - 1] >> 30)) .wrapping_add(i as u32); } Self { mt, index: 624 } } fn next_u32(&mut self) -> u32 { if self.index >= 624 { self.twist(); } let mut y = self.mt[self.index]; self.index += 1; y ^= y >> 11; y ^= (y << 7) & 0x9D2C_5680; y ^= (y << 15) & 0xEFC6_0000; y ^= y >> 18; y } fn next_bytes(&mut self, len: usize) -> Vec { let mut out = Vec::with_capacity(len); while out.len() < len { let bytes = (self.next_u32() >> 1).to_le_bytes(); for byte in bytes { if out.len() == len { break; } out.push(byte); } } out } fn twist(&mut self) { const UPPER_MASK: u32 = 0x8000_0000; const LOWER_MASK: u32 = 0x7FFF_FFFF; const MATRIX_A: u32 = 0x9908_B0DF; for i in 0..624 { let x = (self.mt[i] & UPPER_MASK) | (self.mt[(i + 1) % 624] & LOWER_MASK); let mut x_a = x >> 1; if x & 1 != 0 { x_a ^= MATRIX_A; } self.mt[i] = self.mt[(i + 397) % 624] ^ x_a; } self.index = 0; } } fn synthetic_serialized_file(bytes: &[u8]) -> Vec { fn push_u32_be(data: &mut Vec, value: u32) { data.extend_from_slice(&value.to_be_bytes()); } fn push_u64_be(data: &mut Vec, value: u64) { data.extend_from_slice(&value.to_be_bytes()); } fn push_u32_le(data: &mut Vec, value: u32) { data.extend_from_slice(&value.to_le_bytes()); } fn push_i32_le(data: &mut Vec, value: i32) { data.extend_from_slice(&value.to_le_bytes()); } fn push_i64_le(data: &mut Vec, value: i64) { data.extend_from_slice(&value.to_le_bytes()); } fn push_u64_le(data: &mut Vec, value: u64) { data.extend_from_slice(&value.to_le_bytes()); } fn push_i16_le(data: &mut Vec, value: i16) { data.extend_from_slice(&value.to_le_bytes()); } fn align(data: &mut Vec, alignment: usize) { let remainder = data.len() % alignment; if remainder != 0 { data.resize(data.len() + alignment - remainder, 0); } } let mut object_data = Vec::new(); push_u32_le(&mut object_data, 14); object_data.extend_from_slice(b"GameMainConfig"); align(&mut object_data, 4); push_u32_le(&mut object_data, bytes.len() as u32); object_data.extend_from_slice(bytes); let mut metadata = Vec::new(); metadata.extend_from_slice(b"2021.3.56f2\0"); push_i32_le(&mut metadata, 19); metadata.push(0); push_i32_le(&mut metadata, 1); push_i32_le(&mut metadata, 49); metadata.push(0); push_i16_le(&mut metadata, 0); metadata.extend_from_slice(&[0; 16]); push_i32_le(&mut metadata, 1); align(&mut metadata, 4); push_i64_le(&mut metadata, 1); push_u64_le(&mut metadata, 0); push_u32_le(&mut metadata, object_data.len() as u32); push_i32_le(&mut metadata, 0); let header_len = 48usize; let data_offset = header_len + metadata.len(); let file_size = data_offset + object_data.len(); let mut file = Vec::new(); push_u32_be(&mut file, metadata.len() as u32); push_u32_be(&mut file, file_size as u32); push_u32_be(&mut file, 22); push_u32_be(&mut file, 0); file.push(0); file.extend_from_slice(&[0, 0, 0]); push_u32_be(&mut file, metadata.len() as u32); push_u64_be(&mut file, file_size as u64); push_u64_be(&mut file, data_offset as u64); push_u64_be(&mut file, 0); file.extend_from_slice(&metadata); file.extend_from_slice(&object_data); file }