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, connection_group: Option, app_version: Option, launcher_version: String, auto_discover: bool, platforms: Option>, output_root: PathBuf, snapshot_path: Option, 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, addressables_root: String, endpoints: Vec, #[serde(default)] endpoint_markers: Vec, #[serde(default)] launcher_metadata: Option, #[serde(default)] game_main_config_bootstrap: Option, } impl OfficialUpdateSnapshot { fn new( base: YostarJpSyncSnapshot, endpoint_markers: Vec, 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, 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, game_start_exe_name: Option, game_start_params: Vec, manifest_url: String, manifest_source: Option, manifest_file_count: usize, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct GameMainConfigSnapshot { server_info_data_url: Option, default_connection_group: Option, } #[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, ¤t_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(¤t_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("") ); 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, ¤t_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 { 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> { 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> { 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 { 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> { if !path.exists() { return Ok(None); } let data = fs::read(path)?; match serde_json::from_slice::(&data) { Ok(snapshot) => Ok(Some(snapshot)), Err(update_error) => { let legacy = serde_json::from_slice::(&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> { 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 { 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 { 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 { 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 { 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>, bundle_packing_infos: HashMap>, media_catalogs: HashMap>, } impl SeedCatalogs { fn insert( &mut self, endpoint: &YostarJpResourceEndpoint, bytes: Vec, ) -> 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 { endpoint.platform.ok_or_else(|| { anyhow::anyhow!( "discovery endpoint {:?} has no platform: {}", endpoint.kind, endpoint.url ) }) } fn parse_args() -> anyhow::Result { parse_args_from(env::args()) } fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result { 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, flag: &str, ) -> anyhow::Result { args.next() .ok_or_else(|| anyhow::anyhow!("missing value for {flag}")) } fn print_usage(binary: &str) { eprintln!( "usage: {binary} [--auto-discover | --server-info-url | --server-info-file | --server-info-path ] \ [--app-version 1.70.0] [--connection-group ] [--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, String> { value .split(',') .map(str::trim) .filter(|part| !part.is_empty()) .map(parse_platform) .collect() } fn parse_platform(value: &str) -> Result { 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 { 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(¤t, 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 ); } }