mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 05:25:14 +08:00
Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures. Fixes #13 Fixes #12 Fixes #8
940 lines
32 KiB
Rust
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(¢ral_size.to_le_bytes());
|
|
bytes.extend_from_slice(¢ral_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_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
|
|
}
|