Files
BlueArchiveToolkit/infrastructure/tests/official_game_main_config_bootstrap.rs

940 lines
32 KiB
Rust

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, read_version_state,
OfficialGameMainConfigBootstrapService, OfficialResourcePullService, OfficialUpdateConfig,
OfficialUpdateProgress, OfficialUpdateService, OfficialUpdateStatus,
};
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_LAUNCHER_DIRECTORY_SOURCE: &str = "/BlueArchive_JP-1.70.436321-game";
const TEST_LAUNCHER_RESOURCES_ASSETS_URL: &str =
"https://launcher-pkg-ba-jp.yo-star.com/BlueArchive_JP-1.70.436321-game/BlueArchive_Data/resources.assets";
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, 2);
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_uses_directory_manifest_resources_assets_when_source_is_not_zip() {
let harness = TestHarness::new_with_manifest_source(TEST_LAUNCHER_DIRECTORY_SOURCE);
let bootstrap = harness.fetch_bootstrap();
assert_eq!(
bootstrap.manifest_source.as_deref(),
Some(TEST_LAUNCHER_DIRECTORY_SOURCE)
);
assert_eq!(bootstrap.game_zip_url, TEST_LAUNCHER_RESOURCES_ASSETS_URL);
let curl_log = fs::read_to_string(&harness.curl_log).unwrap();
assert!(curl_log.contains(TEST_LAUNCHER_RESOURCES_ASSETS_URL));
assert!(!curl_log.contains(&format!(
"https://launcher-pkg-ba-jp.yo-star.com/{TEST_LAUNCHER_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"));
}
#[test]
fn official_update_service_emits_human_progress_events() {
let harness = TestHarness::new();
let config = OfficialUpdateConfig {
auto_discover: true,
launcher_version: TEST_LAUNCHER_VERSION.to_string(),
output_root: harness.temp.path().join("sync-output"),
curl_command: harness.curl_script.clone(),
unzip_command: harness.unzip_script.clone(),
dry_run: true,
plan: true,
..OfficialUpdateConfig::default()
};
let mut events = Vec::<OfficialUpdateProgress>::new();
let report = OfficialUpdateService::new()
.run_with_progress(&config, |event| events.push(event))
.unwrap();
assert_eq!(report.update_status, OfficialUpdateStatus::WouldDownload);
assert_eq!(report.download_url_count, Some(19));
assert!(events.iter().any(|event| event.stage == "start"));
assert!(events.iter().any(|event| event.stage == "launcher"));
assert!(events.iter().any(|event| event.stage == "server-info"));
assert!(events.iter().any(|event| event.stage == "marker"));
assert!(events.iter().any(|event| event.stage == "catalog"));
assert!(events.iter().any(|event| event.stage == "dry-run"));
assert!(events.iter().any(|event| event.stage == "finish"));
assert!(events
.iter()
.any(|event| event.message.contains("拉取计划包含 19 个官方 URL")));
}
#[test]
fn official_update_first_run_pulls_without_pre_audit() {
let harness = TestHarness::new();
let config = harness.sync_config("first-run-output");
let mut events = Vec::<OfficialUpdateProgress>::new();
let report = OfficialUpdateService::new()
.run_with_progress(&config, |event| events.push(event))
.unwrap();
assert_eq!(report.update_status, OfficialUpdateStatus::Downloaded);
assert_eq!(report.resource_count, Some(19));
assert_eq!(report.downloaded_count, 19);
assert_eq!(report.skipped_count, 0);
assert_eq!(report.local_manifest_repair_needed_count, 0);
assert_eq!(
report
.verification_summary
.local_manifest_blake3_verified_count,
19
);
assert_eq!(report.verification_summary.official_hash_verified_count, 5);
assert_eq!(report.verification_summary.zip_structure_verified_count, 6);
assert!(report
.verification_summary
.official_hash_scope
.contains("xxHash32"));
assert!(report
.verification_summary
.local_manifest_blake3_scope
.contains("BLAKE3"));
assert!(report
.verification_summary
.zip_structure_scope
.contains("central directory"));
assert!(events.iter().any(|event| {
event.stage == "local-state" && event.message.contains("是否已有本地资源=false")
}));
assert!(events
.iter()
.any(|event| { event.stage == "audit" && event.message.contains("跳过本地审计") }));
assert!(events
.iter()
.any(|event| { event.stage == "decision" && event.message.contains("首次拉取=true") }));
assert!(events.iter().any(|event| {
event.stage == "download"
&& event.message.contains("下载进度:总体")
&& event.message.contains("单文件")
}));
assert!(events.iter().any(|event| {
event.stage == "audit"
&& event.message.contains("校验结果:官方 .hash=5")
&& event.message.contains("本地 BLAKE3 通过=19")
&& event.message.contains("ZIP 结构通过=6")
}));
let published = report.published_version_path.as_ref().unwrap();
assert!(published.exists());
assert!(report
.staging_path
.as_ref()
.is_some_and(|path| !path.exists()));
assert!(config.output_root.join("current").exists());
assert!(fs::symlink_metadata(config.output_root.join("current"))
.unwrap()
.file_type()
.is_symlink());
assert!(!config
.output_root
.join("official-download-manifest.json")
.exists());
assert!(published.join("official-download-manifest.json").exists());
assert!(published.join("official-sync-snapshot.json").exists());
let version_state = read_version_state(&report.version_state_path)
.unwrap()
.unwrap();
assert_eq!(
version_state
.current_completed_version
.as_ref()
.unwrap()
.version_path
.as_deref(),
Some(published.as_path())
);
assert!(version_state.in_progress_version.is_none());
assert!(version_state.failed_versions.is_empty());
}
#[test]
fn official_update_second_run_audits_existing_resources_before_reuse() {
let harness = TestHarness::new();
let config = harness.sync_config("second-run-output");
OfficialUpdateService::new().run(&config).unwrap();
let mut events = Vec::<OfficialUpdateProgress>::new();
let report = OfficialUpdateService::new()
.run_with_progress(&config, |event| events.push(event))
.unwrap();
assert_eq!(report.update_status, OfficialUpdateStatus::UpToDate);
assert_eq!(report.downloaded_count, 0);
assert_eq!(report.local_manifest_verified_count, 19);
assert_eq!(report.local_manifest_repair_needed_count, 0);
assert_eq!(
report
.verification_summary
.local_manifest_blake3_verified_count,
19
);
assert_eq!(report.verification_summary.zip_structure_verified_count, 6);
assert!(events.iter().any(|event| {
event.stage == "local-state" && event.message.contains("是否已有本地资源=true")
}));
assert!(events.iter().any(|event| {
event.stage == "audit" && event.message.contains("审计本地下载 manifest")
}));
assert!(events.iter().any(|event| {
event.stage == "audit"
&& event.message.contains("校验结果:官方 .hash=0")
&& event.message.contains("本地 BLAKE3 通过=19")
&& event.message.contains("ZIP 结构通过=6")
}));
assert!(events.iter().any(|event| {
event.stage == "decision" && event.message.contains("首次拉取=false")
}));
assert!(config.output_root.join("current").exists());
assert!(fs::symlink_metadata(config.output_root.join("current"))
.unwrap()
.file_type()
.is_symlink());
let version_state = read_version_state(&report.version_state_path)
.unwrap()
.unwrap();
assert_eq!(
version_state
.current_completed_version
.as_ref()
.unwrap()
.resource_root,
report.active_resource_root
);
assert!(version_state.in_progress_version.is_none());
}
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, one_file_zip(b"fixture.txt", b"ok")).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,
&resources_assets_path,
resources_assets.len(),
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 sync_config(&self, output_name: &str) -> OfficialUpdateConfig {
OfficialUpdateConfig {
auto_discover: true,
launcher_version: TEST_LAUNCHER_VERSION.to_string(),
output_root: self.temp.path().join(output_name),
curl_command: self.curl_script.clone(),
unzip_command: self.unzip_script.clone(),
..OfficialUpdateConfig::default()
}
}
}
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 one_file_zip(name: &[u8], data: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
bytes.extend_from_slice(&20u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
bytes.extend_from_slice(&(name.len() as u16).to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(name);
bytes.extend_from_slice(data);
let central_offset = bytes.len() as u32;
bytes.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
bytes.extend_from_slice(&20u16.to_le_bytes());
bytes.extend_from_slice(&20u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
bytes.extend_from_slice(&(name.len() as u16).to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(name);
let central_size = bytes.len() as u32 - central_offset;
bytes.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&1u16.to_le_bytes());
bytes.extend_from_slice(&1u16.to_le_bytes());
bytes.extend_from_slice(&central_size.to_le_bytes());
bytes.extend_from_slice(&central_offset.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes
}
fn official_curl_script(
curl_log: &Path,
zip_fixture: &Path,
resources_assets_fixture: &Path,
resources_assets_size: usize,
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"
}}
emit_resources_assets_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"}},{{"path":"/BlueArchive_Data/resources.assets","size":"{resources_assets_size}","hash":"456"}}]}}
JSON
elif [[ "$url" == "{launcher_resources_assets_url}" ]]; then
emit_resources_assets_fixture
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 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),
shell_quote(resources_assets_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,
launcher_resources_assets_url = TEST_LAUNCHER_RESOURCES_ASSETS_URL,
resources_assets_size = resources_assets_size,
server_info_url = TEST_SERVER_INFO_URL,
connection_group = TEST_CONNECTION_GROUP,
addressables_root = TEST_ADDRESSABLES_ROOT,
table_catalog_hash = xxhash32(b"ExcelDB.db 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<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_CA77);
hash ^= hash >> 13;
hash = hash.wrapping_mul(0xC2B2_AE3D);
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
}