Files
nyaKazuha 90307a3243 feat: track official versions and import resources
Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures.

Fixes #13

Fixes #12

Fixes #8
2026-07-15 19:53:10 +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
);
}
}