Files
BlueArchiveToolkit/infrastructure/examples/official_pull_plan.rs
T

432 lines
14 KiB
Rust

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<ServerInfoSource>,
connection_group: Option<String>,
app_version: Option<String>,
launcher_version: String,
launcher_bootstrap: bool,
platforms: Option<Vec<PatchPlatform>>,
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("<none>")
);
println!(
"launcher_api_game_start_exe_name={}",
bootstrap
.game_config
.game_start_exe_name
.as_deref()
.unwrap_or("<none>")
);
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("<none>")
);
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("<none>")
);
println!(
"launcher_api_game_main_config_default_connection_group={}",
bootstrap
.game_main_config
.default_connection_group
.as_deref()
.unwrap_or("<none>")
);
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("<none>")
);
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<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)
}
}
}
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> {
let raw_args = env::args().collect::<Vec<_>>();
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<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} [--server-info-file <official-json-name> | --server-info-path <local-json>] \
[--app-version 1.70.0] \
[--launcher-bootstrap] [--launcher-version 1.7.2] [--connection-group <name>] \
[--platforms Windows,Android] [--output official_pull_output] [--curl curl] [--dry-run]"
);
}
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}")),
}
}