feat: add official resource sync pipeline

Add the production-facing official update service and bat-official-sync watch CLI for unattended resource synchronization.

Support launcher-resource discovery without installing the launcher, remote marker snapshots, local manifest audit and repair, official seed hash validation, bootstrap caching, richer Addressables coverage, SQLite resource persistence, and FFI JSON helpers.
This commit is contained in:
2026-07-05 23:49:56 +08:00
parent 99e66a48d7
commit 789402c887
35 changed files with 6262 additions and 177 deletions
@@ -3,8 +3,8 @@ 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,
is_official_yostar_jp_url, verified_official_platforms, PatchPlatform,
YostarJpResourceDiscoveryPlan, YostarJpResourceEndpointKind, YostarJpServerInfo,
};
use bat_infrastructure::{
build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService,
@@ -40,8 +40,14 @@ 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.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(),
@@ -54,7 +60,10 @@ fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() {
Some(TEST_SERVER_INFO_URL)
);
assert_eq!(
bootstrap.game_main_config.default_connection_group.as_deref(),
bootstrap
.game_main_config
.default_connection_group
.as_deref(),
Some(TEST_CONNECTION_GROUP)
);
assert_ne!(
@@ -66,6 +75,28 @@ fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() {
);
}
#[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();
@@ -100,9 +131,7 @@ fn official_pipeline_can_dry_run_and_pull_without_local_client() {
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().all(|url| !url.contains("bluearchive.cafe")));
assert!(all_urls
.iter()
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
@@ -132,6 +161,10 @@ struct TestHarness {
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();
@@ -145,11 +178,14 @@ impl TestHarness {
let curl_script = temp.path().join("fake-curl");
write_executable(
&curl_script,
&official_curl_script(&curl_log, &zip_fixture),
&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));
write_executable(
&unzip_script,
&launcher_unzip_script(&resources_assets_path),
);
Self {
temp,
@@ -159,7 +195,9 @@ impl TestHarness {
}
}
fn fetch_bootstrap(&self) -> bat_infrastructure::official_game_main_config::OfficialGameMainConfigBootstrap {
fn fetch_bootstrap(
&self,
) -> bat_infrastructure::official_game_main_config::OfficialGameMainConfigBootstrap {
OfficialGameMainConfigBootstrapService::with_commands(
TEST_LAUNCHER_VERSION,
&self.curl_script,
@@ -170,11 +208,17 @@ impl TestHarness {
}
fn fetcher(&self) -> OfficialResourcePullService {
OfficialResourcePullService::with_curl_command(self.temp.path().join("official-pull"), &self.curl_script)
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)
OfficialResourcePullService::with_curl_command(
self.temp.path().join("pulled"),
&self.curl_script,
)
}
}
@@ -185,7 +229,7 @@ fn write_executable(path: &Path, content: &str) {
fs::set_permissions(path, permissions).unwrap();
}
fn official_curl_script(curl_log: &Path, zip_fixture: &Path) -> String {
fn official_curl_script(curl_log: &Path, zip_fixture: &Path, manifest_source: &str) -> String {
format!(
r#"#!/usr/bin/env bash
set -euo pipefail
@@ -255,23 +299,23 @@ elif [[ "$url" == "{addressables_root}/Android_PatchPack/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"
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 "bundlepackinginfo-win-hash"
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 "mediacatalog-win-hash"
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 "bundlepackinginfo-android-hash"
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 "mediacatalog-android-hash"
emit_text "{android_media_catalog_hash}"
elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then
filename="${{url##*/}}"
if [[ "$filename" == *.zip ]]; then
@@ -292,10 +336,15 @@ fi
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,
launcher_manifest_source = manifest_source,
server_info_url = TEST_SERVER_INFO_URL,
connection_group = TEST_CONNECTION_GROUP,
addressables_root = TEST_ADDRESSABLES_ROOT
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"),
)
}