Files
BlueArchiveToolkit/infrastructure/examples/official_update_check.rs
T
nyaKazuha 789402c887 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.
2026-07-05 23:49:56 +08:00

1134 lines
38 KiB
Rust

use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
use bat_adapters::official::inventory::{
YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
};
use bat_adapters::official::yostar_jp::{
server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint,
YostarJpResourceEndpointKind, YostarJpServerInfo, YostarJpSyncSnapshot,
};
use bat_infrastructure::{
build_official_pull_plan_for_platform_inventory, build_official_sync_plan,
changed_endpoint_urls, default_official_platforms, OfficialGameMainConfigBootstrapService,
OfficialLauncherBootstrapService, OfficialResourcePullPlan, OfficialResourcePullService,
YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const OFFICIAL_UPDATE_SNAPSHOT_VERSION: u32 = 2;
const OFFICIAL_BOOTSTRAP_CACHE_VERSION: u32 = 1;
#[derive(Debug)]
struct CliArgs {
server_info_source: Option<ServerInfoSource>,
connection_group: Option<String>,
app_version: Option<String>,
launcher_version: String,
auto_discover: bool,
platforms: Option<Vec<PatchPlatform>>,
output_root: PathBuf,
snapshot_path: Option<PathBuf>,
curl_command: PathBuf,
dry_run: bool,
plan: bool,
force: bool,
audit_local: bool,
repair: bool,
}
#[derive(Debug)]
enum ServerInfoSource {
LocalPath(PathBuf),
OfficialFile(String),
OfficialUrl(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct OfficialUpdateSnapshot {
#[serde(default = "default_update_snapshot_version")]
snapshot_version: u32,
connection_group_name: String,
app_version: String,
bundle_version: Option<String>,
addressables_root: String,
endpoints: Vec<YostarJpResourceEndpoint>,
#[serde(default)]
endpoint_markers: Vec<OfficialEndpointMarkerSnapshot>,
#[serde(default)]
launcher_metadata: Option<LauncherMetadataSnapshot>,
#[serde(default)]
game_main_config_bootstrap: Option<GameMainConfigSnapshot>,
}
impl OfficialUpdateSnapshot {
fn new(
base: YostarJpSyncSnapshot,
endpoint_markers: Vec<OfficialEndpointMarkerSnapshot>,
bootstrap: Option<&ResolvedBootstrap>,
) -> Self {
Self {
snapshot_version: OFFICIAL_UPDATE_SNAPSHOT_VERSION,
connection_group_name: base.connection_group_name,
app_version: base.app_version,
bundle_version: base.bundle_version,
addressables_root: base.addressables_root,
endpoints: base.endpoints,
endpoint_markers,
launcher_metadata: bootstrap.map(|bootstrap| bootstrap.launcher_metadata.clone()),
game_main_config_bootstrap: bootstrap
.map(|bootstrap| bootstrap.game_main_config.clone()),
}
}
fn base_snapshot(&self) -> YostarJpSyncSnapshot {
YostarJpSyncSnapshot {
connection_group_name: self.connection_group_name.clone(),
app_version: self.app_version.clone(),
bundle_version: self.bundle_version.clone(),
addressables_root: self.addressables_root.clone(),
endpoints: self.endpoints.clone(),
}
}
fn addressables_marker_checked_count(&self) -> usize {
self.endpoint_markers
.iter()
.filter(|marker| marker.role == OfficialEndpointMarkerRole::AddressablesCatalogMarker)
.count()
}
fn unverified_marker_count(&self) -> usize {
self.addressables_marker_checked_count()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct OfficialEndpointMarkerSnapshot {
kind: YostarJpResourceEndpointKind,
platform: Option<PatchPlatform>,
url: String,
role: OfficialEndpointMarkerRole,
value: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
enum OfficialEndpointMarkerRole {
OfficialSeedHash,
AddressablesCatalogMarker,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct LauncherMetadataSnapshot {
launcher_version: String,
game_latest_version: String,
game_latest_file_path: String,
game_lowest_version: Option<String>,
game_start_exe_name: Option<String>,
game_start_params: Vec<String>,
manifest_url: String,
manifest_source: Option<String>,
manifest_file_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct GameMainConfigSnapshot {
server_info_data_url: Option<String>,
default_connection_group: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct OfficialBootstrapCache {
#[serde(default = "default_bootstrap_cache_version")]
cache_version: u32,
launcher_metadata: LauncherMetadataSnapshot,
game_main_config: GameMainConfigSnapshot,
}
#[derive(Debug, Clone)]
struct ResolvedBootstrap {
launcher_metadata: LauncherMetadataSnapshot,
game_main_config: GameMainConfigSnapshot,
cache_hit: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ExtendedSnapshotDelta {
endpoint_markers_changed: bool,
launcher_metadata_changed: bool,
game_main_config_changed: bool,
}
impl ExtendedSnapshotDelta {
fn has_changes(self) -> bool {
self.endpoint_markers_changed
|| self.launcher_metadata_changed
|| self.game_main_config_changed
}
}
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 fetcher =
OfficialResourcePullService::with_curl_command(&args.output_root, &args.curl_command);
let snapshot_path = args
.snapshot_path
.clone()
.unwrap_or_else(|| args.output_root.join("official-sync-snapshot.json"));
let bootstrap_cache_path = args.output_root.join("official-bootstrap-cache.json");
let bootstrap = if args.auto_discover {
Some(resolve_bootstrap(
&args.launcher_version,
&args.curl_command,
&bootstrap_cache_path,
!args.dry_run,
)?)
} else {
None
};
let app_version = args
.app_version
.clone()
.or_else(|| {
bootstrap
.as_ref()
.map(|bootstrap| bootstrap.launcher_metadata.game_latest_version.clone())
})
.ok_or_else(|| {
anyhow::anyhow!("missing --app-version; pass it explicitly or use --auto-discover")
})?;
let connection_group = args
.connection_group
.clone()
.or_else(|| {
bootstrap
.as_ref()
.and_then(|bootstrap| bootstrap.game_main_config.default_connection_group.clone())
})
.ok_or_else(|| {
anyhow::anyhow!("missing --connection-group; pass it explicitly or use --auto-discover")
})?;
let server_info_bytes = if let Some(source) = args.server_info_source.as_ref() {
load_server_info(source, &fetcher)?
} else {
let bootstrap = bootstrap.as_ref().ok_or_else(|| {
anyhow::anyhow!("missing server-info source; pass --server-info-url, --server-info-file, --server-info-path, or use --auto-discover")
})?;
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 current_snapshot = server_info
.sync_snapshot(&connection_group, &app_version, platforms)
.map_err(anyhow::Error::msg)?;
let endpoint_markers = collect_endpoint_markers(&fetcher, &current_snapshot)?;
let current_update_snapshot = OfficialUpdateSnapshot::new(
current_snapshot.clone(),
endpoint_markers,
bootstrap.as_ref(),
);
let previous_snapshot = read_snapshot(&snapshot_path)?;
let previous_base_snapshot = previous_snapshot
.as_ref()
.map(OfficialUpdateSnapshot::base_snapshot);
let sync_plan =
build_official_sync_plan(current_snapshot.clone(), previous_base_snapshot.as_ref());
let extended_delta =
diff_extended_snapshot(&current_update_snapshot, previous_snapshot.as_ref());
let changed_urls = changed_endpoint_urls(&sync_plan.delta);
let remote_should_download =
args.force || sync_plan.should_download() || extended_delta.has_changes();
let pull_plan = if args.audit_local || remote_should_download || args.plan {
Some(build_pull_plan(
&server_info,
&connection_group,
&app_version,
platforms,
&fetcher,
)?)
} else {
None
};
let local_audit = if args.audit_local {
Some(
fetcher
.audit_local_manifest(
pull_plan
.as_ref()
.ok_or_else(|| anyhow::anyhow!("local audit requires a pull plan"))?,
)
.map_err(anyhow::Error::msg)?,
)
} else {
None
};
let repair_needed = args.repair
&& local_audit
.as_ref()
.map(|audit| !audit.is_clean())
.unwrap_or(false);
let should_download = remote_should_download || repair_needed;
println!(
"connection_group={}",
current_snapshot.connection_group_name
);
println!("app_version={}", current_snapshot.app_version);
println!(
"bundle_version={}",
current_snapshot
.bundle_version
.as_deref()
.unwrap_or("<none>")
);
println!("addressables_root={}", current_snapshot.addressables_root);
println!("platforms={:?}", platforms);
println!("snapshot_path={}", snapshot_path.display());
println!(
"previous_snapshot={}",
if previous_snapshot.is_some() {
"present"
} else {
"missing"
}
);
println!("decision={:?}", sync_plan.decision);
println!("force={}", args.force);
println!("audit_local={}", args.audit_local);
println!("repair={}", args.repair);
println!("should_download={}", should_download);
println!("is_initial={}", sync_plan.delta.is_initial);
println!("changed_endpoint_count={}", changed_urls.len());
for url in &changed_urls {
println!("changed_endpoint_url={url}");
}
println!(
"endpoint_markers_changed={}",
extended_delta.endpoint_markers_changed
);
println!(
"launcher_metadata_changed={}",
extended_delta.launcher_metadata_changed
);
println!(
"game_main_config_changed={}",
extended_delta.game_main_config_changed
);
println!(
"addressables_marker_checked_count={}",
current_update_snapshot.addressables_marker_checked_count()
);
println!(
"unverified_marker_count={}",
current_update_snapshot.unverified_marker_count()
);
if let Some(bootstrap) = bootstrap.as_ref() {
println!("bootstrap_cache_hit={}", bootstrap.cache_hit);
println!("bootstrap_cache_path={}", bootstrap_cache_path.display());
}
if let Some(audit) = local_audit.as_ref() {
println!("local_manifest_verified_count={}", audit.verified_count());
println!(
"local_manifest_repair_needed_count={}",
audit.repair_needed_count()
);
} else {
println!("local_manifest_verified_count=0");
println!("local_manifest_repair_needed_count=0");
}
if !should_download {
println!("official_seed_hash_verified_count=0");
println!("update_status=up_to_date");
return Ok(());
}
println!("dry_run={}", args.dry_run);
if args.dry_run {
if args.plan {
let pull_plan = pull_plan
.as_ref()
.ok_or_else(|| anyhow::anyhow!("dry-run plan requested but no pull plan exists"))?;
let all_urls = pull_plan.all_urls().map_err(anyhow::Error::msg)?;
println!("download_url_count={}", all_urls.len());
for url in &all_urls {
println!("{url}");
}
}
println!("official_seed_hash_verified_count=0");
println!("update_status=would_download");
return Ok(());
}
let pull_plan = pull_plan
.as_ref()
.ok_or_else(|| anyhow::anyhow!("download requested but no pull plan exists"))?;
let report = fetcher.pull(&pull_plan).map_err(anyhow::Error::msg)?;
let final_audit = fetcher
.audit_local_manifest(pull_plan)
.map_err(anyhow::Error::msg)?;
write_snapshot(&snapshot_path, &current_update_snapshot)?;
println!("update_status=downloaded");
println!("resource_count={}", report.items.len());
println!("downloaded_count={}", report.downloaded_count());
println!("resumed_count={}", report.resumed_count());
println!("skipped_count={}", report.skipped_count());
println!("final_bytes={}", report.total_bytes());
println!("transferred_bytes={}", report.transferred_bytes());
println!(
"official_seed_hash_verified_count={}",
report.official_hash_verified_count()
);
println!(
"local_manifest_verified_count={}",
final_audit.verified_count()
);
println!(
"local_manifest_repair_needed_count={}",
final_audit.repair_needed_count()
);
println!(
"addressables_marker_checked_count={}",
current_update_snapshot.addressables_marker_checked_count()
);
println!(
"unverified_marker_count={}",
current_update_snapshot.unverified_marker_count()
);
println!(
"download_manifest={}",
fetcher.download_manifest_path().display()
);
println!("snapshot_written={}", snapshot_path.display());
Ok(())
}
fn build_pull_plan(
server_info: &YostarJpServerInfo,
connection_group: &str,
app_version: &str,
platforms: &[PatchPlatform],
fetcher: &OfficialResourcePullService,
) -> anyhow::Result<OfficialResourcePullPlan> {
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)?;
Ok(build_official_pull_plan_for_platform_inventory(
discovery, inventory, platforms,
))
}
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)
}
ServerInfoSource::OfficialUrl(url) => fetcher.fetch_bytes(url).map_err(anyhow::Error::msg),
}
}
fn default_update_snapshot_version() -> u32 {
OFFICIAL_UPDATE_SNAPSHOT_VERSION
}
fn default_bootstrap_cache_version() -> u32 {
OFFICIAL_BOOTSTRAP_CACHE_VERSION
}
fn collect_endpoint_markers(
fetcher: &OfficialResourcePullService,
snapshot: &YostarJpSyncSnapshot,
) -> anyhow::Result<Vec<OfficialEndpointMarkerSnapshot>> {
let mut markers = Vec::new();
for endpoint in &snapshot.endpoints {
let Some(role) = marker_role(endpoint.kind) else {
continue;
};
let bytes = fetcher
.fetch_bytes(&endpoint.url)
.map_err(anyhow::Error::msg)?;
let value = String::from_utf8_lossy(&bytes).trim().to_string();
markers.push(OfficialEndpointMarkerSnapshot {
kind: endpoint.kind,
platform: endpoint.platform,
url: endpoint.url.clone(),
role,
value,
});
}
Ok(markers)
}
fn marker_role(kind: YostarJpResourceEndpointKind) -> Option<OfficialEndpointMarkerRole> {
match kind {
YostarJpResourceEndpointKind::TableCatalogHash
| YostarJpResourceEndpointKind::BundlePackingInfoHash
| YostarJpResourceEndpointKind::MediaCatalogHash => {
Some(OfficialEndpointMarkerRole::OfficialSeedHash)
}
YostarJpResourceEndpointKind::AddressablesCatalogHash => {
Some(OfficialEndpointMarkerRole::AddressablesCatalogMarker)
}
_ => None,
}
}
fn diff_extended_snapshot(
current: &OfficialUpdateSnapshot,
previous: Option<&OfficialUpdateSnapshot>,
) -> ExtendedSnapshotDelta {
let Some(previous) = previous else {
return ExtendedSnapshotDelta {
endpoint_markers_changed: true,
launcher_metadata_changed: current.launcher_metadata.is_some(),
game_main_config_changed: current.game_main_config_bootstrap.is_some(),
};
};
ExtendedSnapshotDelta {
endpoint_markers_changed: current.endpoint_markers != previous.endpoint_markers,
launcher_metadata_changed: current.launcher_metadata != previous.launcher_metadata,
game_main_config_changed: current.game_main_config_bootstrap
!= previous.game_main_config_bootstrap,
}
}
fn read_snapshot(path: &Path) -> anyhow::Result<Option<OfficialUpdateSnapshot>> {
if !path.exists() {
return Ok(None);
}
let data = fs::read(path)?;
match serde_json::from_slice::<OfficialUpdateSnapshot>(&data) {
Ok(snapshot) => Ok(Some(snapshot)),
Err(update_error) => {
let legacy = serde_json::from_slice::<YostarJpSyncSnapshot>(&data).map_err(|legacy_error| {
anyhow::anyhow!(
"failed to parse official update snapshot as v2 ({update_error}) or legacy v1 ({legacy_error})"
)
})?;
Ok(Some(OfficialUpdateSnapshot::new(legacy, Vec::new(), None)))
}
}
}
fn write_snapshot(path: &Path, snapshot: &OfficialUpdateSnapshot) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_vec_pretty(snapshot)?;
fs::write(path, data)?;
Ok(())
}
fn read_bootstrap_cache(path: &Path) -> anyhow::Result<Option<OfficialBootstrapCache>> {
if !path.exists() {
return Ok(None);
}
let data = fs::read(path)?;
Ok(Some(serde_json::from_slice(&data)?))
}
fn write_bootstrap_cache(path: &Path, cache: &OfficialBootstrapCache) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_vec_pretty(cache)?;
fs::write(path, data)?;
Ok(())
}
fn launcher_metadata_from_parts(
launcher_version: &str,
game_config: &YostarJpLauncherGameConfig,
manifest_url: &YostarJpLauncherManifestUrl,
manifest: &YostarJpLauncherRemoteManifest,
) -> LauncherMetadataSnapshot {
LauncherMetadataSnapshot {
launcher_version: launcher_version.to_string(),
game_latest_version: game_config.game_latest_version.clone(),
game_latest_file_path: game_config.game_latest_file_path.clone(),
game_lowest_version: game_config.game_lowest_version.clone(),
game_start_exe_name: game_config.game_start_exe_name.clone(),
game_start_params: game_config.game_start_params.clone(),
manifest_url: manifest_url.url.clone(),
manifest_source: manifest.source.clone().filter(|value| !value.is_empty()),
manifest_file_count: manifest.files.len(),
}
}
fn game_main_config_snapshot(config: &YostarJpGameMainConfig) -> GameMainConfigSnapshot {
GameMainConfigSnapshot {
server_info_data_url: config.server_info_data_url.clone(),
default_connection_group: config.default_connection_group.clone(),
}
}
fn resolve_bootstrap(
launcher_version: &str,
curl_command: &Path,
cache_path: &Path,
write_cache: bool,
) -> anyhow::Result<ResolvedBootstrap> {
let launcher = OfficialLauncherBootstrapService::with_curl_command(
launcher_version,
curl_command.to_string_lossy().to_string(),
);
let (game_config, manifest_url, manifest) = launcher
.fetch_latest_remote_manifest()
.map_err(anyhow::Error::msg)?;
let launcher_metadata =
launcher_metadata_from_parts(launcher_version, &game_config, &manifest_url, &manifest);
if let Some(cache) = read_bootstrap_cache(cache_path)? {
if let Some(game_main_config) =
cached_game_main_config_for_metadata(&cache, &launcher_metadata)
{
return Ok(ResolvedBootstrap {
launcher_metadata,
game_main_config,
cache_hit: true,
});
}
}
let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands(
launcher_version,
curl_command.to_path_buf(),
"unzip",
);
let bootstrap = bootstrapper.fetch_bootstrap().map_err(anyhow::Error::msg)?;
let launcher_metadata = LauncherMetadataSnapshot {
launcher_version: launcher_version.to_string(),
game_latest_version: bootstrap.game_config.game_latest_version.clone(),
game_latest_file_path: bootstrap.game_config.game_latest_file_path.clone(),
game_lowest_version: bootstrap.game_config.game_lowest_version.clone(),
game_start_exe_name: bootstrap.game_config.game_start_exe_name.clone(),
game_start_params: bootstrap.game_config.game_start_params.clone(),
manifest_url: bootstrap.manifest_url.clone(),
manifest_source: bootstrap.manifest_source.clone(),
manifest_file_count: bootstrap.manifest_file_count,
};
let game_main_config = game_main_config_snapshot(&bootstrap.game_main_config);
if write_cache {
write_bootstrap_cache(
cache_path,
&OfficialBootstrapCache {
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION,
launcher_metadata: launcher_metadata.clone(),
game_main_config: game_main_config.clone(),
},
)?;
}
Ok(ResolvedBootstrap {
launcher_metadata,
game_main_config,
cache_hit: false,
})
}
fn cached_game_main_config_for_metadata(
cache: &OfficialBootstrapCache,
launcher_metadata: &LauncherMetadataSnapshot,
) -> Option<GameMainConfigSnapshot> {
if cache.cache_version == OFFICIAL_BOOTSTRAP_CACHE_VERSION
&& cache.launcher_metadata == *launcher_metadata
{
Some(cache.game_main_config.clone())
} else {
None
}
}
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> {
parse_args_from(env::args())
}
fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result<CliArgs> {
let mut args = raw_args.into_iter();
let binary = args
.next()
.unwrap_or_else(|| "official_update_check".to_string());
let mut parsed = CliArgs {
server_info_source: None,
connection_group: None,
app_version: None,
launcher_version: "1.7.2".to_string(),
auto_discover: false,
platforms: None,
output_root: PathBuf::from("official_update_output"),
snapshot_path: None,
curl_command: PathBuf::from("curl"),
dry_run: false,
plan: false,
force: false,
audit_local: true,
repair: true,
};
while let Some(flag) = args.next() {
match flag.as_str() {
"--auto-discover" => {
parsed.auto_discover = true;
}
"--server-info-url" => {
parsed.server_info_source = Some(ServerInfoSource::OfficialUrl(next_option_value(
&mut args, &flag,
)?));
}
"--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)?;
}
"--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)?);
}
"--snapshot" => {
parsed.snapshot_path = Some(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;
}
"--plan" => {
parsed.plan = true;
}
"--force" => {
parsed.force = true;
}
"--audit-local" => {
parsed.audit_local = true;
}
"--no-audit-local" => {
parsed.audit_local = false;
}
"--repair" => {
parsed.repair = true;
}
"--no-repair" => {
parsed.repair = false;
}
"--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} [--auto-discover | --server-info-url <official-url> | --server-info-file <official-json-name> | --server-info-path <local-json>] \
[--app-version 1.70.0] [--connection-group <name>] [--launcher-version 1.7.2] \
[--platforms Windows,Android] [--output official_update_output] [--snapshot official-sync-snapshot.json] \
[--curl curl] [--dry-run] [--plan] [--force] [--audit-local|--no-audit-local] [--repair|--no-repair]"
);
}
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}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(values: &[&str]) -> anyhow::Result<CliArgs> {
parse_args_from(values.iter().map(|value| value.to_string()))
}
fn fixture_base_snapshot() -> YostarJpSyncSnapshot {
YostarJpSyncSnapshot {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: Some("bundle".to_string()),
addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/root".to_string(),
endpoints: vec![
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalog,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/root/TableBundles/TableCatalog.bytes".to_string(),
},
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalogHash,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/root/TableBundles/TableCatalog.hash".to_string(),
},
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::AddressablesCatalogHash,
platform: Some(PatchPlatform::Windows),
url: "https://prod-clientpatch.bluearchiveyostar.com/root/Windows_PatchPack/catalog_StandaloneWindows64.hash".to_string(),
},
],
}
}
fn fixture_marker(value: &str) -> OfficialEndpointMarkerSnapshot {
OfficialEndpointMarkerSnapshot {
kind: YostarJpResourceEndpointKind::TableCatalogHash,
platform: None,
url:
"https://prod-clientpatch.bluearchiveyostar.com/root/TableBundles/TableCatalog.hash"
.to_string(),
role: OfficialEndpointMarkerRole::OfficialSeedHash,
value: value.to_string(),
}
}
fn fixture_bootstrap() -> ResolvedBootstrap {
ResolvedBootstrap {
launcher_metadata: LauncherMetadataSnapshot {
launcher_version: "1.7.2".to_string(),
game_latest_version: "1.70.0".to_string(),
game_latest_file_path: "BAJP_1.70.0.zip".to_string(),
game_lowest_version: Some("1.69.0".to_string()),
game_start_exe_name: Some("BlueArchive".to_string()),
game_start_params: vec!["--prod".to_string()],
manifest_url: "https://launcher-pkg-ba-jp.yo-star.com/manifest.json".to_string(),
manifest_source: Some("BAJP_1.70.0.zip".to_string()),
manifest_file_count: 42,
},
game_main_config: GameMainConfigSnapshot {
server_info_data_url: Some(
"https://yostar-serverinfo.bluearchiveyostar.com/prod.json".to_string(),
),
default_connection_group: Some("Prod-Audit".to_string()),
},
cache_hit: false,
}
}
#[test]
fn parses_auto_discover_update_check_args() {
let args = parse(&[
"official_update_check",
"--auto-discover",
"--platforms",
"Windows,Android",
"--snapshot",
"/tmp/snapshot.json",
"--dry-run",
"--plan",
])
.unwrap();
assert!(args.auto_discover);
assert_eq!(
args.platforms.as_deref(),
Some([PatchPlatform::Windows, PatchPlatform::Android].as_slice())
);
assert_eq!(
args.snapshot_path,
Some(PathBuf::from("/tmp/snapshot.json"))
);
assert!(args.dry_run);
assert!(args.plan);
assert!(args.audit_local);
assert!(args.repair);
}
#[test]
fn parses_explicit_snapshot_source_args() {
let args = parse(&[
"official_update_check",
"--server-info-url",
"https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json",
"--connection-group",
"Prod-Audit",
"--app-version",
"1.70.0",
"--no-audit-local",
"--no-repair",
])
.unwrap();
assert!(!args.auto_discover);
assert_eq!(args.connection_group.as_deref(), Some("Prod-Audit"));
assert_eq!(args.app_version.as_deref(), Some("1.70.0"));
assert!(!args.audit_local);
assert!(!args.repair);
assert!(matches!(
args.server_info_source,
Some(ServerInfoSource::OfficialUrl(ref url))
if url == "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json"
));
}
#[test]
fn persists_snapshot_json_round_trip() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("snapshot.json");
let bootstrap = fixture_bootstrap();
let snapshot = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("1234")],
Some(&bootstrap),
);
write_snapshot(&path, &snapshot).unwrap();
assert_eq!(read_snapshot(&path).unwrap(), Some(snapshot));
}
#[test]
fn reads_legacy_snapshot_json() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("snapshot.json");
let legacy = fixture_base_snapshot();
fs::write(&path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap();
let snapshot = read_snapshot(&path).unwrap().unwrap();
assert_eq!(snapshot.base_snapshot(), legacy);
assert!(snapshot.endpoint_markers.is_empty());
assert_eq!(snapshot.snapshot_version, OFFICIAL_UPDATE_SNAPSHOT_VERSION);
}
#[test]
fn marker_content_change_requires_download() {
let previous = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("1111")],
Some(&fixture_bootstrap()),
);
let current = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("2222")],
Some(&fixture_bootstrap()),
);
let delta = diff_extended_snapshot(&current, Some(&previous));
assert!(delta.endpoint_markers_changed);
assert!(delta.has_changes());
assert!(!delta.launcher_metadata_changed);
assert!(!delta.game_main_config_changed);
}
#[test]
fn persists_bootstrap_cache_json_round_trip() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("official-bootstrap-cache.json");
let bootstrap = fixture_bootstrap();
let cache = OfficialBootstrapCache {
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION,
launcher_metadata: bootstrap.launcher_metadata,
game_main_config: bootstrap.game_main_config,
};
write_bootstrap_cache(&path, &cache).unwrap();
assert_eq!(read_bootstrap_cache(&path).unwrap(), Some(cache));
}
#[test]
fn bootstrap_cache_hit_requires_same_metadata_and_version() {
let bootstrap = fixture_bootstrap();
let cache = OfficialBootstrapCache {
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION,
launcher_metadata: bootstrap.launcher_metadata.clone(),
game_main_config: bootstrap.game_main_config.clone(),
};
assert_eq!(
cached_game_main_config_for_metadata(&cache, &bootstrap.launcher_metadata),
Some(bootstrap.game_main_config.clone())
);
let mut changed_metadata = bootstrap.launcher_metadata.clone();
changed_metadata.game_latest_version = "1.71.0".to_string();
assert_eq!(
cached_game_main_config_for_metadata(&cache, &changed_metadata),
None
);
let stale_version_cache = OfficialBootstrapCache {
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION + 1,
..cache
};
assert_eq!(
cached_game_main_config_for_metadata(
&stale_version_cache,
&bootstrap.launcher_metadata
),
None
);
}
}