use bat_adapters::official::inventory::{ YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory, }; use bat_adapters::official::yostar_jp::{ server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint, YostarJpResourceEndpointKind, YostarJpServerInfo, }; use bat_infrastructure::{ build_official_pull_plan_for_platform_inventory, build_official_pull_plan_from_platform_inventory, default_official_platforms, OfficialGameMainConfigBootstrapService, OfficialResourcePullService, }; use std::collections::HashMap; use std::env; use std::fs; use std::path::PathBuf; #[derive(Debug)] struct CliArgs { server_info_source: Option, connection_group: Option, app_version: Option, launcher_version: String, launcher_bootstrap: bool, platforms: Option>, output_root: PathBuf, curl_command: PathBuf, dry_run: bool, } #[derive(Debug)] enum ServerInfoSource { LocalPath(PathBuf), OfficialFile(String), } 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 needs_official_bootstrap = args.launcher_bootstrap || args.server_info_source.is_none() || args.connection_group.is_none() || args.app_version.is_none(); let official_bootstrap = if needs_official_bootstrap { Some( OfficialGameMainConfigBootstrapService::with_commands( &args.launcher_version, args.curl_command.to_string_lossy().to_string(), "unzip", ) .fetch_bootstrap() .map_err(anyhow::Error::msg)?, ) } else { None }; let app_version = args .app_version .clone() .or_else(|| { official_bootstrap .as_ref() .map(|bootstrap| bootstrap.game_config.game_latest_version.clone()) }) .ok_or_else(|| { anyhow::anyhow!("missing --app-version and official bootstrap failed") })?; let connection_group = args .connection_group .clone() .or_else(|| { official_bootstrap .as_ref() .and_then(|bootstrap| bootstrap.game_main_config.default_connection_group.clone()) }) .ok_or_else(|| { anyhow::anyhow!( "missing --connection-group and official GameMainConfig has no default_connection_group" ) })?; let fetcher = OfficialResourcePullService::with_curl_command(&args.output_root, &args.curl_command); if args.launcher_bootstrap { let bootstrap = official_bootstrap.as_ref().ok_or_else(|| { anyhow::anyhow!("launcher bootstrap requested but official bootstrap is unavailable") })?; println!( "launcher_api_game_latest_version={}", bootstrap.game_config.game_latest_version ); println!( "launcher_api_game_latest_file_path={}", bootstrap.game_config.game_latest_file_path ); println!( "launcher_api_game_lowest_version={}", bootstrap .game_config .game_lowest_version .as_deref() .unwrap_or("") ); println!( "launcher_api_game_start_exe_name={}", bootstrap .game_config .game_start_exe_name .as_deref() .unwrap_or("") ); println!( "launcher_api_game_start_params={:?}", bootstrap.game_config.game_start_params ); println!("launcher_api_primary_cdn={}", bootstrap.cdn_config.primary_cdn); println!("launcher_api_backup_cdn={}", bootstrap.cdn_config.back_up_cdn); println!("launcher_api_manifest_url={}", bootstrap.manifest_url); println!( "launcher_api_manifest_source={}", bootstrap.manifest_source.as_deref().unwrap_or("") ); println!( "launcher_api_manifest_file_count={}", bootstrap.manifest_file_count ); println!( "launcher_api_game_main_config_server_info_url={}", bootstrap .game_main_config .server_info_data_url .as_deref() .unwrap_or("") ); println!( "launcher_api_game_main_config_default_connection_group={}", bootstrap .game_main_config .default_connection_group .as_deref() .unwrap_or("") ); println!(); } let server_info_source = args .server_info_source .as_ref(); let server_info_bytes = if let Some(source) = server_info_source { load_server_info(source, &fetcher)? } else { let bootstrap = official_bootstrap.as_ref().ok_or_else(|| { anyhow::anyhow!("official bootstrap is required when no --server-info-file or --server-info-path is provided") })?; 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 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)?; let plan = if args.platforms.is_some() { build_official_pull_plan_for_platform_inventory(discovery, inventory, platforms) } else { build_official_pull_plan_from_platform_inventory(discovery, inventory) }; let all_urls = plan.all_urls().map_err(anyhow::Error::msg)?; println!("connection_group={}", plan.discovery.connection_group_name); println!("app_version={}", plan.discovery.app_version); println!( "bundle_version={}", plan.discovery.bundle_version.as_deref().unwrap_or("") ); println!("addressables_root={}", plan.discovery.addressables_root); println!("platforms={:?}", plan.platforms); println!("discovery_url_count={}", plan.discovery_urls().len()); println!( "content_url_count={}", plan.content_urls().map_err(anyhow::Error::msg)?.len() ); println!("total_url_count={}", all_urls.len()); println!("output_root={}", args.output_root.display()); println!("dry_run={}", args.dry_run); println!(); for url in &all_urls { println!("{url}"); } if args.dry_run { return Ok(()); } let downloader = OfficialResourcePullService::with_curl_command(args.output_root, args.curl_command); let report = downloader.pull(&plan).map_err(anyhow::Error::msg)?; eprintln!("downloaded_count={}", report.items.len()); eprintln!("downloaded_bytes={}", report.total_bytes()); Ok(()) } 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) } } } 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 { let raw_args = env::args().collect::>(); let mut args = raw_args.into_iter(); let binary = args .next() .unwrap_or_else(|| "official_pull_plan".to_string()); let mut parsed = CliArgs { server_info_source: None, connection_group: None, app_version: None, launcher_version: "1.7.2".to_string(), launcher_bootstrap: false, platforms: None, output_root: PathBuf::from("official_pull_output"), curl_command: PathBuf::from("curl"), dry_run: false, }; while let Some(flag) = args.next() { match flag.as_str() { "--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)?; } "--launcher-bootstrap" => { parsed.launcher_bootstrap = true; } "--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)?); } "--curl" => { parsed.curl_command = PathBuf::from(next_option_value(&mut args, &flag)?); } "--dry-run" => { parsed.dry_run = true; } "--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} [--server-info-file | --server-info-path ] \ [--app-version 1.70.0] \ [--launcher-bootstrap] [--launcher-version 1.7.2] [--connection-group ] \ [--platforms Windows,Android] [--output official_pull_output] [--curl curl] [--dry-run]" ); } 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}")), } }