feat: add official resource sync pipeline

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.
This commit is contained in:
2026-07-05 23:49:56 +08:00
parent 99e66a48d7
commit 789402c887
35 changed files with 6262 additions and 177 deletions
+113 -32
View File
@@ -22,6 +22,7 @@ struct CliArgs {
app_version: Option<String>,
launcher_version: String,
launcher_bootstrap: bool,
auto_discover: bool,
platforms: Option<Vec<PatchPlatform>>,
output_root: PathBuf,
curl_command: PathBuf,
@@ -32,6 +33,7 @@ struct CliArgs {
enum ServerInfoSource {
LocalPath(PathBuf),
OfficialFile(String),
OfficialUrl(String),
}
fn main() -> anyhow::Result<()> {
@@ -41,11 +43,8 @@ fn main() -> anyhow::Result<()> {
.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 {
let metadata_bootstrap_enabled = args.auto_discover || args.launcher_bootstrap;
let official_bootstrap = if metadata_bootstrap_enabled {
Some(
OfficialGameMainConfigBootstrapService::with_commands(
&args.launcher_version,
@@ -66,9 +65,7 @@ fn main() -> anyhow::Result<()> {
.as_ref()
.map(|bootstrap| bootstrap.game_config.game_latest_version.clone())
})
.ok_or_else(|| {
anyhow::anyhow!("missing --app-version and official bootstrap failed")
})?;
.ok_or_else(|| anyhow::anyhow!("missing --app-version; production mode does not infer the game version from official metadata unless --auto-discover is set"))?;
let connection_group = args
.connection_group
.clone()
@@ -79,27 +76,27 @@ fn main() -> anyhow::Result<()> {
})
.ok_or_else(|| {
anyhow::anyhow!(
"missing --connection-group and official GameMainConfig has no default_connection_group"
"missing --connection-group; production mode does not infer the connection group from GameMainConfig unless --auto-discover is set"
)
})?;
let fetcher =
OfficialResourcePullService::with_curl_command(&args.output_root, &args.curl_command);
if args.launcher_bootstrap {
if metadata_bootstrap_enabled {
let bootstrap = official_bootstrap.as_ref().ok_or_else(|| {
anyhow::anyhow!("launcher bootstrap requested but official bootstrap is unavailable")
anyhow::anyhow!("metadata bootstrap requested but official bootstrap is unavailable")
})?;
println!(
"launcher_api_game_latest_version={}",
"metadata_bootstrap_game_latest_version={}",
bootstrap.game_config.game_latest_version
);
println!(
"launcher_api_game_latest_file_path={}",
"metadata_bootstrap_game_latest_file_path={}",
bootstrap.game_config.game_latest_file_path
);
println!(
"launcher_api_game_lowest_version={}",
"metadata_bootstrap_game_lowest_version={}",
bootstrap
.game_config
.game_lowest_version
@@ -107,7 +104,7 @@ fn main() -> anyhow::Result<()> {
.unwrap_or("<none>")
);
println!(
"launcher_api_game_start_exe_name={}",
"metadata_bootstrap_game_start_exe_name={}",
bootstrap
.game_config
.game_start_exe_name
@@ -115,22 +112,28 @@ fn main() -> anyhow::Result<()> {
.unwrap_or("<none>")
);
println!(
"launcher_api_game_start_params={:?}",
"metadata_bootstrap_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={}",
"metadata_bootstrap_primary_cdn={}",
bootstrap.cdn_config.primary_cdn
);
println!(
"metadata_bootstrap_backup_cdn={}",
bootstrap.cdn_config.back_up_cdn
);
println!("metadata_bootstrap_manifest_url={}", bootstrap.manifest_url);
println!(
"metadata_bootstrap_manifest_source={}",
bootstrap.manifest_source.as_deref().unwrap_or("<none>")
);
println!(
"launcher_api_manifest_file_count={}",
"metadata_bootstrap_manifest_file_count={}",
bootstrap.manifest_file_count
);
println!(
"launcher_api_game_main_config_server_info_url={}",
"metadata_bootstrap_game_main_config_server_info_url={}",
bootstrap
.game_main_config
.server_info_data_url
@@ -138,7 +141,7 @@ fn main() -> anyhow::Result<()> {
.unwrap_or("<none>")
);
println!(
"launcher_api_game_main_config_default_connection_group={}",
"metadata_bootstrap_game_main_config_default_connection_group={}",
bootstrap
.game_main_config
.default_connection_group
@@ -148,15 +151,13 @@ fn main() -> anyhow::Result<()> {
println!();
}
let server_info_source = args
.server_info_source
.as_ref();
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")
anyhow::anyhow!("missing server-info source; pass --server-info-url, --server-info-file, or --server-info-path, or explicitly opt in with --auto-discover")
})?;
let url = bootstrap
.game_main_config
@@ -207,8 +208,20 @@ fn main() -> anyhow::Result<()> {
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());
eprintln!("resource_count={}", report.items.len());
eprintln!("downloaded_count={}", report.downloaded_count());
eprintln!("resumed_count={}", report.resumed_count());
eprintln!("skipped_count={}", report.skipped_count());
eprintln!("final_bytes={}", report.total_bytes());
eprintln!("transferred_bytes={}", report.transferred_bytes());
eprintln!(
"official_hash_verified_count={}",
report.official_hash_verified_count()
);
eprintln!(
"download_manifest={}",
downloader.download_manifest_path().display()
);
Ok(())
}
@@ -223,6 +236,7 @@ fn load_server_info(
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),
}
}
@@ -331,7 +345,10 @@ fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result<Patc
}
fn parse_args() -> anyhow::Result<CliArgs> {
let raw_args = env::args().collect::<Vec<_>>();
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()
@@ -342,6 +359,7 @@ fn parse_args() -> anyhow::Result<CliArgs> {
app_version: None,
launcher_version: "1.7.2".to_string(),
launcher_bootstrap: false,
auto_discover: false,
platforms: None,
output_root: PathBuf::from("official_pull_output"),
curl_command: PathBuf::from("curl"),
@@ -350,6 +368,11 @@ fn parse_args() -> anyhow::Result<CliArgs> {
while let Some(flag) = args.next() {
match flag.as_str() {
"--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)?,
@@ -372,6 +395,9 @@ fn parse_args() -> anyhow::Result<CliArgs> {
"--launcher-bootstrap" => {
parsed.launcher_bootstrap = true;
}
"--auto-discover" => {
parsed.auto_discover = true;
}
"--platforms" => {
let value = next_option_value(&mut args, &flag)?;
parsed.platforms = Some(parse_platforms(&value).map_err(anyhow::Error::msg)?);
@@ -406,9 +432,9 @@ fn next_option_value(
fn print_usage(binary: &str) {
eprintln!(
"usage: {binary} [--server-info-file <official-json-name> | --server-info-path <local-json>] \
"usage: {binary} [--server-info-url <official-url> | --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>] \
[--connection-group <name>] [--auto-discover] [--launcher-bootstrap] [--launcher-version 1.7.2] \
[--platforms Windows,Android] [--output official_pull_output] [--curl curl] [--dry-run]"
);
}
@@ -429,3 +455,58 @@ fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
_ => 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()))
}
#[test]
fn parses_explicit_official_server_info_url_without_launcher_bootstrap() {
let args = parse(&[
"official_pull_plan",
"--server-info-url",
"https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json",
"--connection-group",
"Prod-Audit",
"--app-version",
"1.70.0",
"--dry-run",
])
.unwrap();
assert!(!args.launcher_bootstrap);
assert_eq!(args.connection_group.as_deref(), Some("Prod-Audit"));
assert_eq!(args.app_version.as_deref(), Some("1.70.0"));
assert!(matches!(
args.server_info_source,
Some(ServerInfoSource::OfficialUrl(ref url))
if url == "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json"
));
}
#[test]
fn auto_discover_is_explicit_opt_in() {
let args = parse(&["official_pull_plan", "--auto-discover", "--dry-run"]).unwrap();
assert!(args.auto_discover);
assert!(!args.launcher_bootstrap);
assert!(args.server_info_source.is_none());
assert!(args.connection_group.is_none());
assert!(args.app_version.is_none());
}
#[test]
fn launcher_bootstrap_alias_is_explicit_opt_in() {
let args = parse(&["official_pull_plan", "--launcher-bootstrap", "--dry-run"]).unwrap();
assert!(!args.auto_discover);
assert!(args.launcher_bootstrap);
assert!(args.server_info_source.is_none());
assert!(args.connection_group.is_none());
assert!(args.app_version.is_none());
}
}