mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 04:06:44 +08:00
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user