mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 05:46:45 +08:00
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.
1134 lines
38 KiB
Rust
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, ¤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("<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, ¤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<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(¤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
|
|
);
|
|
}
|
|
}
|