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
+6
View File
@@ -5,6 +5,10 @@ edition.workspace = true
authors.workspace = true
license.workspace = true
[[bin]]
name = "bat-official-sync"
path = "src/bin/bat_official_sync.rs"
[dependencies]
bat-core = { path = "../core" }
bat-adapters = { path = "../adapters" }
@@ -13,11 +17,13 @@ anyhow.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
blake3.workspace = true
tokio.workspace = true
async-trait.workspace = true
md-5 = "0.10"
hex = "0.4"
tempfile = "3.14"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
+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());
}
}
File diff suppressed because it is too large Load Diff
+447
View File
@@ -0,0 +1,447 @@
use bat_adapters::official::yostar_jp::PatchPlatform;
use bat_infrastructure::{
OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateService, OfficialUpdateStatus,
};
use serde::Serialize;
use std::env;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
const EXIT_ERROR: i32 = 1;
const EXIT_LOCKED: i32 = 75;
const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60;
fn main() {
match run() {
Ok(()) => {}
Err(error) => {
let exit_code = if error.to_string().contains("locked") {
EXIT_LOCKED
} else {
EXIT_ERROR
};
let payload = ErrorReport {
status: "error",
exit_code,
error: error.to_string(),
next_retry_seconds: None,
};
eprintln!(
"{}",
serde_json::to_string_pretty(&payload)
.unwrap_or_else(|_| "{\"status\":\"error\"}".to_string())
);
std::process::exit(exit_code);
}
}
}
fn run() -> anyhow::Result<()> {
let options = parse_args()?;
if options.watch {
run_watch(options)?;
} else {
let report = OfficialUpdateService::new().run(&options.config)?;
if should_print_status(report.update_status, options.quiet_up_to_date) {
println!("{}", serde_json::to_string_pretty(&report)?);
}
}
Ok(())
}
#[derive(Debug, Serialize)]
struct ErrorReport<'a> {
status: &'a str,
exit_code: i32,
error: String,
#[serde(skip_serializing_if = "Option::is_none")]
next_retry_seconds: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CliOptions {
config: OfficialUpdateConfig,
watch: bool,
interval: Duration,
quiet_up_to_date: bool,
quiet_up_to_date_explicit: bool,
}
impl Default for CliOptions {
fn default() -> Self {
Self {
config: OfficialUpdateConfig::default(),
watch: false,
interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS),
quiet_up_to_date: false,
quiet_up_to_date_explicit: false,
}
}
}
fn run_watch(options: CliOptions) -> anyhow::Result<()> {
if options.config.dry_run {
return Err(anyhow::anyhow!("watch mode cannot be used with --dry-run"));
}
if options.interval.is_zero() {
return Err(anyhow::anyhow!("watch interval must be greater than zero"));
}
let service = OfficialUpdateService::new();
loop {
match service.run(&options.config) {
Ok(report) => {
if should_print_status(report.update_status, options.quiet_up_to_date) {
println!("{}", serde_json::to_string_pretty(&report)?);
}
}
Err(error) => {
let payload = ErrorReport {
status: "error",
exit_code: EXIT_ERROR,
error: error.to_string(),
next_retry_seconds: Some(options.interval.as_secs()),
};
eprintln!("{}", serde_json::to_string_pretty(&payload)?);
}
}
thread::sleep(options.interval);
}
}
fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> bool {
!(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate)
}
fn parse_args() -> anyhow::Result<CliOptions> {
parse_args_from(env::args())
}
fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result<CliOptions> {
let mut args = raw_args.into_iter();
let binary = args
.next()
.unwrap_or_else(|| "bat-official-sync".to_string());
let mut options = CliOptions::default();
while let Some(flag) = args.next() {
match flag.as_str() {
"--auto-discover" => {
options.config.auto_discover = true;
}
"--server-info-url" => {
options.config.server_info_source = Some(OfficialServerInfoSource::OfficialUrl(
next_option_value(&mut args, &flag)?,
));
}
"--server-info-file" => {
options.config.server_info_source = Some(OfficialServerInfoSource::OfficialFile(
next_option_value(&mut args, &flag)?,
));
}
"--server-info-path" => {
options.config.server_info_source = Some(OfficialServerInfoSource::LocalPath(
PathBuf::from(next_option_value(&mut args, &flag)?),
));
}
"--connection-group" => {
options.config.connection_group = Some(next_option_value(&mut args, &flag)?);
}
"--app-version" => {
options.config.app_version = Some(next_option_value(&mut args, &flag)?);
}
"--launcher-version" => {
options.config.launcher_version = next_option_value(&mut args, &flag)?;
}
"--platforms" => {
let value = next_option_value(&mut args, &flag)?;
options.config.platforms =
Some(parse_platforms(&value).map_err(anyhow::Error::msg)?);
}
"--output" => {
options.config.output_root = PathBuf::from(next_option_value(&mut args, &flag)?);
}
"--snapshot" => {
options.config.snapshot_path =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
}
"--curl" => {
options.config.curl_command = PathBuf::from(next_option_value(&mut args, &flag)?);
}
"--unzip" => {
options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?);
}
"--dry-run" => {
options.config.dry_run = true;
}
"--plan" => {
options.config.plan = true;
}
"--force" => {
options.config.force = true;
}
"--audit-local" => {
options.config.audit_local = true;
}
"--no-audit-local" => {
options.config.audit_local = false;
}
"--repair" => {
options.config.repair = true;
}
"--no-repair" => {
options.config.repair = false;
}
"--watch" => {
options.watch = true;
}
"--interval" => {
options.interval = parse_duration(&next_option_value(&mut args, &flag)?)?;
}
"--interval-seconds" => {
let seconds = next_option_value(&mut args, &flag)?
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("invalid seconds for {flag}: {error}"))?;
options.interval = Duration::from_secs(seconds);
}
"--quiet-up-to-date" => {
options.quiet_up_to_date = true;
options.quiet_up_to_date_explicit = true;
}
"--no-quiet-up-to-date" => {
options.quiet_up_to_date = false;
options.quiet_up_to_date_explicit = true;
}
"--help" | "-h" => {
print_usage(&binary);
std::process::exit(0);
}
_ => return Err(anyhow::anyhow!("unknown argument: {flag}")),
}
}
if options.watch && options.config.dry_run {
return Err(anyhow::anyhow!("watch mode cannot be used with --dry-run"));
}
if options.interval.is_zero() {
return Err(anyhow::anyhow!("watch interval must be greater than zero"));
}
if options.watch && !options.quiet_up_to_date_explicit {
options.quiet_up_to_date = true;
}
Ok(options)
}
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] [--unzip unzip] [--dry-run] [--plan] [--force] [--audit-local|--no-audit-local] [--repair|--no-repair] \
[--watch] [--interval 1h|60m|3600s] [--quiet-up-to-date|--no-quiet-up-to-date]"
);
}
fn parse_duration(value: &str) -> anyhow::Result<Duration> {
let value = value.trim();
if value.is_empty() {
return Err(anyhow::anyhow!("duration must not be empty"));
}
let (number, multiplier) = if let Some(number) = value.strip_suffix("ms") {
let millis = number
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("invalid millisecond duration {value}: {error}"))?;
return Ok(Duration::from_millis(millis));
} else if let Some(number) = value.strip_suffix('s') {
(number, 1)
} else if let Some(number) = value.strip_suffix('m') {
(number, 60)
} else if let Some(number) = value.strip_suffix('h') {
(number, 60 * 60)
} else {
(value, 1)
};
let amount = number
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("invalid duration {value}: {error}"))?;
Ok(Duration::from_secs(amount.saturating_mul(multiplier)))
}
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<CliOptions> {
parse_args_from(values.iter().map(|value| value.to_string()))
}
#[test]
fn parses_auto_discover_sync_args() {
let options = parse(&[
"bat-official-sync",
"--auto-discover",
"--platforms",
"Windows,Android",
"--snapshot",
"/tmp/snapshot.json",
"--dry-run",
"--plan",
])
.unwrap();
let config = options.config;
assert!(config.auto_discover);
assert_eq!(
config.platforms.as_deref(),
Some([PatchPlatform::Windows, PatchPlatform::Android].as_slice())
);
assert_eq!(
config.snapshot_path,
Some(PathBuf::from("/tmp/snapshot.json"))
);
assert!(config.dry_run);
assert!(config.plan);
assert!(config.audit_local);
assert!(config.repair);
assert!(!options.watch);
assert_eq!(
options.interval,
Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS)
);
assert!(!options.quiet_up_to_date);
assert!(!options.quiet_up_to_date_explicit);
}
#[test]
fn parses_explicit_source_and_disable_repair() {
let options = parse(&[
"bat-official-sync",
"--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",
"--unzip",
"/usr/bin/unzip",
])
.unwrap();
let config = options.config;
assert!(!config.auto_discover);
assert_eq!(config.connection_group.as_deref(), Some("Prod-Audit"));
assert_eq!(config.app_version.as_deref(), Some("1.70.0"));
assert!(!config.audit_local);
assert!(!config.repair);
assert_eq!(config.unzip_command, PathBuf::from("/usr/bin/unzip"));
assert!(matches!(
config.server_info_source,
Some(OfficialServerInfoSource::OfficialUrl(ref url))
if url == "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json"
));
}
#[test]
fn parses_watch_defaults_to_one_hour_and_quiet_up_to_date() {
let options = parse(&[
"bat-official-sync",
"--auto-discover",
"--watch",
"--interval",
"30m",
])
.unwrap();
assert!(options.watch);
assert_eq!(options.interval, Duration::from_secs(30 * 60));
assert!(options.quiet_up_to_date);
assert!(!options.quiet_up_to_date_explicit);
}
#[test]
fn explicit_no_quiet_up_to_date_overrides_watch_default() {
let options = parse(&[
"bat-official-sync",
"--watch",
"--no-quiet-up-to-date",
"--interval",
"1h",
])
.unwrap();
assert!(options.watch);
assert!(!options.quiet_up_to_date);
assert!(options.quiet_up_to_date_explicit);
}
#[test]
fn rejects_watch_dry_run_and_zero_interval() {
let error = parse(&["bat-official-sync", "--watch", "--dry-run"]).unwrap_err();
assert!(error.to_string().contains("watch mode"));
let error =
parse(&["bat-official-sync", "--watch", "--interval-seconds", "0"]).unwrap_err();
assert!(error.to_string().contains("greater than zero"));
}
#[test]
fn parses_duration_units() {
assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
assert_eq!(parse_duration("60m").unwrap(), Duration::from_secs(3600));
assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90));
assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250));
assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600));
}
#[test]
fn quiet_up_to_date_suppresses_only_clean_reports() {
assert!(!should_print_status(OfficialUpdateStatus::UpToDate, true));
assert!(should_print_status(OfficialUpdateStatus::Downloaded, true));
assert!(should_print_status(
OfficialUpdateStatus::WouldDownload,
true
));
assert!(should_print_status(OfficialUpdateStatus::UpToDate, false));
}
#[test]
fn status_string_is_stable() {
assert_eq!(OfficialUpdateStatus::UpToDate.as_str(), "up_to_date");
assert_eq!(
OfficialUpdateStatus::WouldDownload.as_str(),
"would_download"
);
assert_eq!(OfficialUpdateStatus::Downloaded.as_str(), "downloaded");
}
}
+4
View File
@@ -276,12 +276,16 @@ mod tests {
hash: "synthetic-manifest-hash".to_string(),
size: 99,
resource_type: ResourceType::AssetBundle,
address: None,
dependencies: Vec::new(),
},
ResourceEntry {
path: "synthetic/catalog.json".to_string(),
hash: "synthetic-catalog-hash".to_string(),
size: 1,
resource_type: ResourceType::Manifest,
address: None,
dependencies: Vec::new(),
},
],
metadata: ManifestMetadata {
+14 -3
View File
@@ -12,17 +12,20 @@
pub mod cas;
pub mod import;
pub mod official_game_main_config;
pub mod official_download;
pub mod official_game_main_config;
pub mod official_launcher;
pub mod official_pull;
pub mod official_sync;
pub mod official_update;
pub mod resources;
pub use cas::FileSystemCasRepository;
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
pub use official_download::{
OfficialResourcePullItem, OfficialResourcePullReport, OfficialResourcePullService,
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
OfficialLocalManifestAuditStatus, OfficialResourcePullItem, OfficialResourcePullReport,
OfficialResourcePullService,
};
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
pub use official_launcher::{
@@ -39,7 +42,15 @@ pub use official_sync::{
build_official_sync_plan, changed_endpoint_urls, classify_sync_decision,
default_official_platforms, OfficialSyncDecision, OfficialSyncPlan,
};
pub use resources::InMemoryResourceRepository;
pub use official_update::{
cached_game_main_config_for_metadata, diff_extended_snapshot, read_bootstrap_cache,
read_snapshot, write_bootstrap_cache, write_snapshot, ExtendedSnapshotDelta,
GameMainConfigSnapshot, LauncherMetadataSnapshot, OfficialBootstrapCache,
OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialServerInfoSource,
OfficialUpdateConfig, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
OfficialUpdateStatus, ResolvedBootstrap,
};
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
/// Infrastructure 版本号
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,13 @@
//! Official launcher package bootstrap for `GameMainConfig`.
//!
//! This is an explicit metadata discovery helper. It downloads official HTTP
//! artifacts into a temporary directory, extracts only the data needed to read
//! `GameMainConfig`, and does not install or execute the official launcher.
use crate::official_launcher::OfficialLauncherBootstrapService;
use crate::official_launcher::launcher_package_url;
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
use crate::official_launcher::OfficialLauncherBootstrapService;
use crate::official_launcher::{YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig};
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -72,14 +76,15 @@ impl OfficialGameMainConfigBootstrapService {
.filter(|value| !value.is_empty())
.ok_or_else(|| "Official launcher remote manifest has no source".to_string())?;
let cdn_config = self.launcher.fetch_cdn_config()?;
let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, &manifest_source)?;
let package_path = select_game_package_path(&game_config, &manifest_source)?;
let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, package_path)?;
let temp_dir = TempDir::new()
.map_err(|error| format!("Failed to create temporary directory: {error}"))?;
let archive_path = temp_dir.path().join("official-game.zip");
self.download_file_with_fallback(
&game_zip_url,
&cdn_config.back_up_cdn,
&manifest_source,
package_path,
&archive_path,
)?;
let extract_root = temp_dir.path().join("extract");
@@ -104,7 +109,8 @@ impl OfficialGameMainConfigBootstrapService {
/// Fetches the latest official game ZIP, extracts `resources.assets`, and
/// decrypts `GameMainConfig`.
pub fn fetch_game_main_config(&self) -> Result<YostarJpGameMainConfig, String> {
self.fetch_bootstrap().map(|bootstrap| bootstrap.game_main_config)
self.fetch_bootstrap()
.map(|bootstrap| bootstrap.game_main_config)
}
fn download_file(&self, url: &str, destination: &Path) -> Result<(), String> {
@@ -180,6 +186,36 @@ impl OfficialGameMainConfigBootstrapService {
}
}
fn select_game_package_path<'a>(
game_config: &'a YostarJpLauncherGameConfig,
manifest_source: &'a str,
) -> Result<&'a str, String> {
if is_launcher_archive_path(manifest_source) {
return Ok(manifest_source);
}
if is_launcher_archive_path(&game_config.game_latest_file_path) {
return Ok(&game_config.game_latest_file_path);
}
Err(format!(
"Official launcher metadata has no usable game archive path: source={manifest_source}, game_latest_file_path={}",
game_config.game_latest_file_path
))
}
fn is_launcher_archive_path(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
&& !path.starts_with('\\')
&& path.ends_with(".zip")
&& !path.contains('?')
&& !path.contains('#')
&& !path.split('/').any(|segment| {
segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\')
})
}
fn find_resources_assets(root: &Path) -> Option<PathBuf> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
File diff suppressed because it is too large Load Diff
+412 -1
View File
@@ -1,9 +1,13 @@
//! 内存资源仓储实现。
use async_trait::async_trait;
use bat_core::domain::Resource;
use bat_core::domain::{Resource, ResourceEntry, ResourceType};
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult};
use sqlx::{QueryBuilder, Sqlite, SqlitePool};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use tokio::sync::RwLock;
/// 以内存 `HashMap` 保存资源索引的仓储实现。
@@ -83,6 +87,359 @@ impl ResourceRepository for InMemoryResourceRepository {
}
}
/// SQLite 资源仓储实现。
#[derive(Debug, Clone)]
pub struct SqliteResourceRepository {
pool: SqlitePool,
}
impl SqliteResourceRepository {
/// 打开或创建 SQLite 资源仓储。
pub async fn new(path: impl AsRef<Path>) -> bat_core::Result<Self> {
if let Some(parent) = path.as_ref().parent() {
tokio::fs::create_dir_all(parent).await?;
}
let options =
SqliteConnectOptions::from_str(&format!("sqlite://{}", path.as_ref().display()))
.map_err(|error| bat_core::Error::Other(error.into()))?
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
let repository = Self { pool };
repository.init_schema().await?;
Ok(repository)
}
async fn init_schema(&self) -> bat_core::Result<()> {
Self::execute_query(
&self.pool,
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS resources (
id TEXT PRIMARY KEY NOT NULL,
path TEXT NOT NULL,
hash TEXT NOT NULL,
size INTEGER NOT NULL CHECK(size >= 0),
resource_type TEXT NOT NULL,
local_path TEXT NOT NULL,
address TEXT,
dependencies_json TEXT NOT NULL DEFAULT '[]'
)
"#,
),
)
.await?;
Self::execute_query(
&self.pool,
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_resources_hash
ON resources(hash)
"#,
),
)
.await?;
Self::execute_query(
&self.pool,
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_resources_type
ON resources(resource_type)
"#,
),
)
.await?;
Self::execute_query(
&self.pool,
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_resources_path
ON resources(path)
"#,
),
)
.await?;
Ok(())
}
async fn execute_query<'q>(
pool: &SqlitePool,
query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
) -> bat_core::Result<SqliteQueryResult> {
query
.execute(pool)
.await
.map_err(|error| bat_core::Error::Other(error.into()))
}
fn resource_type_to_str(resource_type: ResourceType) -> &'static str {
match resource_type {
ResourceType::AssetBundle => "AssetBundle",
ResourceType::Manifest => "Manifest",
ResourceType::TableBundle => "TableBundle",
ResourceType::Other => "Other",
}
}
fn resource_type_from_str(value: &str) -> bat_core::Result<ResourceType> {
match value {
"AssetBundle" => Ok(ResourceType::AssetBundle),
"Manifest" => Ok(ResourceType::Manifest),
"TableBundle" => Ok(ResourceType::TableBundle),
"Other" => Ok(ResourceType::Other),
other => Err(bat_core::Error::Serialization(format!(
"Unknown resource type: {}",
other
))),
}
}
fn dependencies_to_json(dependencies: &[String]) -> bat_core::Result<String> {
serde_json::to_string(dependencies)
.map_err(|error| bat_core::Error::Serialization(error.to_string()))
}
fn dependencies_from_json(value: &str) -> bat_core::Result<Vec<String>> {
serde_json::from_str(value)
.map_err(|error| bat_core::Error::Serialization(error.to_string()))
}
fn resource_from_row(row: ResourceRow) -> bat_core::Result<Resource> {
let (id, path, hash, size, resource_type, local_path, address, dependencies_json) = row;
Ok(Resource {
id,
local_path: PathBuf::from(local_path),
entry: ResourceEntry {
path,
hash,
size: size as u64,
resource_type: Self::resource_type_from_str(&resource_type)?,
address,
dependencies: Self::dependencies_from_json(&dependencies_json)?,
},
})
}
fn apply_filters<'a>(
builder: &mut QueryBuilder<'a, Sqlite>,
query: &'a ResourceQuery,
) -> bat_core::Result<()> {
let mut has_where = false;
if let Some(resource_type) = query.resource_type {
push_condition_prefix(builder, &mut has_where);
builder.push("resource_type = ");
builder.push_bind(Self::resource_type_to_str(resource_type));
}
if let Some(hash) = &query.hash {
push_condition_prefix(builder, &mut has_where);
builder.push("hash = ");
builder.push_bind(hash);
}
if let Some(pattern) = &query.path_pattern {
push_condition_prefix(builder, &mut has_where);
builder.push("path LIKE ");
builder
.push_bind(glob_to_like(pattern))
.push(" ESCAPE '\\'");
}
Ok(())
}
async fn fetch_resources(
&self,
query: &ResourceQuery,
limit: Option<usize>,
) -> bat_core::Result<Vec<Resource>> {
let mut builder = QueryBuilder::<Sqlite>::new(
"SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json FROM resources",
);
Self::apply_filters(&mut builder, query)?;
builder.push(" ORDER BY id");
if let Some(limit) = limit {
builder.push(" LIMIT ").push_bind(limit as i64);
}
let rows: Vec<ResourceRow> = builder
.build_query_as()
.fetch_all(&self.pool)
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
rows.into_iter().map(Self::resource_from_row).collect()
}
async fn count_resources(&self, query: &ResourceQuery) -> bat_core::Result<u64> {
let mut builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM resources");
Self::apply_filters(&mut builder, query)?;
let count: i64 = builder
.build_query_scalar()
.fetch_one(&self.pool)
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
Ok(count as u64)
}
}
#[async_trait]
impl ResourceRepository for SqliteResourceRepository {
async fn add(&self, resource: Resource) -> bat_core::Result<String> {
let dependencies = Self::dependencies_to_json(&resource.entry.dependencies)?;
Self::execute_query(
&self.pool,
sqlx::query(
r#"
INSERT INTO resources (
id, path, hash, size, resource_type, local_path, address, dependencies_json
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
path = excluded.path,
hash = excluded.hash,
size = excluded.size,
resource_type = excluded.resource_type,
local_path = excluded.local_path,
address = excluded.address,
dependencies_json = excluded.dependencies_json
"#,
)
.bind(resource.id.clone())
.bind(resource.entry.path.clone())
.bind(resource.entry.hash.clone())
.bind(resource.entry.size as i64)
.bind(Self::resource_type_to_str(resource.entry.resource_type))
.bind(resource.local_path.to_string_lossy().to_string())
.bind(resource.entry.address.clone())
.bind(dependencies),
)
.await?;
Ok(resource.id)
}
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
let row: Option<ResourceRow> = sqlx::query_as(
r#"
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json
FROM resources
WHERE id = ?1
"#,
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
row.map(Self::resource_from_row)
.transpose()?
.ok_or_else(|| bat_core::Error::NotFound(id.to_string()))
}
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
let row: Option<ResourceRow> = sqlx::query_as(
r#"
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json
FROM resources
WHERE hash = ?1
ORDER BY id
LIMIT 1
"#,
)
.bind(hash)
.fetch_optional(&self.pool)
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
row.map(Self::resource_from_row)
.transpose()?
.ok_or_else(|| bat_core::Error::NotFound(hash.to_string()))
}
async fn list(&self, query: ResourceQuery) -> bat_core::Result<Vec<Resource>> {
self.fetch_resources(&query, None).await
}
async fn update(&self, resource: Resource) -> bat_core::Result<()> {
let _existing = self.find_by_id(&resource.id).await?;
self.add(resource).await?;
Ok(())
}
async fn delete(&self, id: &str) -> bat_core::Result<()> {
let result = Self::execute_query(
&self.pool,
sqlx::query("DELETE FROM resources WHERE id = ?1").bind(id),
)
.await?;
if result.rows_affected() == 0 {
return Err(bat_core::Error::NotFound(id.to_string()));
}
Ok(())
}
async fn count(&self, query: ResourceQuery) -> bat_core::Result<u64> {
self.count_resources(&query).await
}
}
fn push_condition_prefix<'a>(builder: &mut QueryBuilder<'a, Sqlite>, has_where: &mut bool) {
if *has_where {
builder.push(" AND ");
} else {
builder.push(" WHERE ");
*has_where = true;
}
}
type ResourceRow = (
String,
String,
String,
i64,
String,
String,
Option<String>,
String,
);
fn glob_to_like(pattern: &str) -> String {
let mut escaped = String::new();
let mut chars = pattern.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'*' => {
if matches!(chars.peek(), Some('*')) {
chars.next();
}
escaped.push('%');
}
'?' => escaped.push('_'),
'%' | '_' | '\\' => {
escaped.push('\\');
escaped.push(ch);
}
other => escaped.push(other),
}
}
escaped
}
fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool {
if let Some(resource_type) = query.resource_type {
if resource.entry.resource_type != resource_type {
@@ -142,6 +499,8 @@ mod tests {
hash: hash.to_string(),
size: 7,
resource_type,
address: None,
dependencies: Vec::new(),
},
}
}
@@ -214,4 +573,56 @@ mod tests {
);
assert_eq!(repository.count(ResourceQuery::all()).await.unwrap(), 2);
}
async fn sqlite_repository() -> (tempfile::TempDir, SqliteResourceRepository) {
let temp_dir = tempfile::tempdir().unwrap();
let repository = SqliteResourceRepository::new(temp_dir.path().join("resources.sqlite"))
.await
.unwrap();
(temp_dir, repository)
}
#[tokio::test]
async fn sqlite_repository_persists_and_filters_resources() {
let (_temp_dir, repository) = sqlite_repository().await;
let mut resource = resource(
"resource/sqlite-a",
"assets/model.bundle",
"hash-sqlite-a",
ResourceType::AssetBundle,
);
resource.entry.address = Some("Character_001".to_string());
resource
.entry
.dependencies
.push("assets/shared.bundle".to_string());
repository.add(resource.clone()).await.unwrap();
let by_id = repository.find_by_id(&resource.id).await.unwrap();
assert_eq!(by_id.entry.address.as_deref(), Some("Character_001"));
assert_eq!(
by_id.entry.dependencies,
vec!["assets/shared.bundle".to_string()]
);
assert_eq!(
repository.find_by_hash("hash-sqlite-a").await.unwrap().id,
resource.id
);
let bundles = repository
.list(ResourceQuery::by_type(ResourceType::AssetBundle))
.await
.unwrap();
assert_eq!(bundles.len(), 1);
let count = repository.count(ResourceQuery::all()).await.unwrap();
assert_eq!(count, 1);
repository.delete(&resource.id).await.unwrap();
assert!(matches!(
repository.find_by_id(&resource.id).await,
Err(bat_core::Error::NotFound(_))
));
}
}
@@ -3,8 +3,8 @@ use bat_adapters::official::inventory::{
YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
};
use bat_adapters::official::yostar_jp::{
is_official_yostar_jp_url, verified_official_platforms, PatchPlatform, YostarJpResourceEndpointKind,
YostarJpResourceDiscoveryPlan, YostarJpServerInfo,
is_official_yostar_jp_url, verified_official_platforms, PatchPlatform,
YostarJpResourceDiscoveryPlan, YostarJpResourceEndpointKind, YostarJpServerInfo,
};
use bat_infrastructure::{
build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService,
@@ -40,8 +40,14 @@ fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() {
let harness = TestHarness::new();
let bootstrap = harness.fetch_bootstrap();
assert_eq!(bootstrap.game_config.game_latest_version, TEST_LAUNCHER_LATEST_VERSION);
assert_eq!(bootstrap.game_config.game_latest_file_path, TEST_LAUNCHER_LATEST_FILE_PATH);
assert_eq!(
bootstrap.game_config.game_latest_version,
TEST_LAUNCHER_LATEST_VERSION
);
assert_eq!(
bootstrap.game_config.game_latest_file_path,
TEST_LAUNCHER_LATEST_FILE_PATH
);
assert_eq!(bootstrap.manifest_url, TEST_LAUNCHER_MANIFEST_URL);
assert_eq!(
bootstrap.manifest_source.as_deref(),
@@ -54,7 +60,10 @@ fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() {
Some(TEST_SERVER_INFO_URL)
);
assert_eq!(
bootstrap.game_main_config.default_connection_group.as_deref(),
bootstrap
.game_main_config
.default_connection_group
.as_deref(),
Some(TEST_CONNECTION_GROUP)
);
assert_ne!(
@@ -66,6 +75,28 @@ fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() {
);
}
#[test]
fn fetch_bootstrap_falls_back_to_latest_file_path_when_manifest_source_is_not_zip() {
let harness = TestHarness::new_with_manifest_source("/BlueArchive_JP-1.70.436321-game");
let bootstrap = harness.fetch_bootstrap();
assert_eq!(
bootstrap.manifest_source.as_deref(),
Some("/BlueArchive_JP-1.70.436321-game")
);
assert_eq!(
bootstrap.game_zip_url,
format!(
"https://launcher-pkg-ba-jp.yo-star.com/{}",
TEST_LAUNCHER_LATEST_FILE_PATH
)
);
let curl_log = fs::read_to_string(&harness.curl_log).unwrap();
assert!(curl_log.contains(TEST_LAUNCHER_LATEST_FILE_PATH));
assert!(!curl_log.contains(" /BlueArchive_JP-1.70.436321-game"));
}
#[test]
fn official_pipeline_can_dry_run_and_pull_without_local_client() {
let harness = TestHarness::new();
@@ -100,9 +131,7 @@ fn official_pipeline_can_dry_run_and_pull_without_local_client() {
assert_eq!(plan.platforms, verified_official_platforms());
assert_eq!(all_urls.len(), 19);
assert!(all_urls.iter().all(|url| is_official_yostar_jp_url(url)));
assert!(all_urls
.iter()
.all(|url| !url.contains("bluearchive.cafe")));
assert!(all_urls.iter().all(|url| !url.contains("bluearchive.cafe")));
assert!(all_urls
.iter()
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
@@ -132,6 +161,10 @@ struct TestHarness {
impl TestHarness {
fn new() -> Self {
Self::new_with_manifest_source(TEST_LAUNCHER_MANIFEST_SOURCE)
}
fn new_with_manifest_source(manifest_source: &str) -> Self {
let temp = TempDir::new().unwrap();
let resources_assets = synthetic_resources_assets();
@@ -145,11 +178,14 @@ impl TestHarness {
let curl_script = temp.path().join("fake-curl");
write_executable(
&curl_script,
&official_curl_script(&curl_log, &zip_fixture),
&official_curl_script(&curl_log, &zip_fixture, manifest_source),
);
let unzip_script = temp.path().join("fake-unzip");
write_executable(&unzip_script, &launcher_unzip_script(&resources_assets_path));
write_executable(
&unzip_script,
&launcher_unzip_script(&resources_assets_path),
);
Self {
temp,
@@ -159,7 +195,9 @@ impl TestHarness {
}
}
fn fetch_bootstrap(&self) -> bat_infrastructure::official_game_main_config::OfficialGameMainConfigBootstrap {
fn fetch_bootstrap(
&self,
) -> bat_infrastructure::official_game_main_config::OfficialGameMainConfigBootstrap {
OfficialGameMainConfigBootstrapService::with_commands(
TEST_LAUNCHER_VERSION,
&self.curl_script,
@@ -170,11 +208,17 @@ impl TestHarness {
}
fn fetcher(&self) -> OfficialResourcePullService {
OfficialResourcePullService::with_curl_command(self.temp.path().join("official-pull"), &self.curl_script)
OfficialResourcePullService::with_curl_command(
self.temp.path().join("official-pull"),
&self.curl_script,
)
}
fn pull_service(&self) -> OfficialResourcePullService {
OfficialResourcePullService::with_curl_command(self.temp.path().join("pulled"), &self.curl_script)
OfficialResourcePullService::with_curl_command(
self.temp.path().join("pulled"),
&self.curl_script,
)
}
}
@@ -185,7 +229,7 @@ fn write_executable(path: &Path, content: &str) {
fs::set_permissions(path, permissions).unwrap();
}
fn official_curl_script(curl_log: &Path, zip_fixture: &Path) -> String {
fn official_curl_script(curl_log: &Path, zip_fixture: &Path, manifest_source: &str) -> String {
format!(
r#"#!/usr/bin/env bash
set -euo pipefail
@@ -255,23 +299,23 @@ elif [[ "$url" == "{addressables_root}/Android_PatchPack/catalog_Android.hash" ]
elif [[ "$url" == "{addressables_root}/TableBundles/TableCatalog.bytes" ]]; then
emit_text "ExcelDB.db"
elif [[ "$url" == "{addressables_root}/TableBundles/TableCatalog.hash" ]]; then
emit_text "tablecatalog-hash"
emit_text "{table_catalog_hash}"
elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.bytes" ]]; then
emit_text "FullPatch_000.zip"
elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.hash" ]]; then
emit_text "bundlepackinginfo-win-hash"
emit_text "{windows_bundle_catalog_hash}"
elif [[ "$url" == "{addressables_root}/MediaResources-Windows/Catalog/MediaCatalog.bytes" ]]; then
emit_text "JP_Airi_Win.zip"
elif [[ "$url" == "{addressables_root}/MediaResources-Windows/Catalog/MediaCatalog.hash" ]]; then
emit_text "mediacatalog-win-hash"
emit_text "{windows_media_catalog_hash}"
elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.bytes" ]]; then
emit_text "FullPatch_001.zip"
elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.hash" ]]; then
emit_text "bundlepackinginfo-android-hash"
emit_text "{android_bundle_catalog_hash}"
elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.bytes" ]]; then
emit_text "JP_Airi_Android.zip"
elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.hash" ]]; then
emit_text "mediacatalog-android-hash"
emit_text "{android_media_catalog_hash}"
elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then
filename="${{url##*/}}"
if [[ "$filename" == *.zip ]]; then
@@ -292,10 +336,15 @@ fi
launcher_latest_file_path = TEST_LAUNCHER_LATEST_FILE_PATH,
launcher_game_config_json_url = TEST_LAUNCHER_GAME_CONFIG_JSON_URL,
launcher_manifest_url = TEST_LAUNCHER_MANIFEST_URL,
launcher_manifest_source = TEST_LAUNCHER_MANIFEST_SOURCE,
launcher_manifest_source = manifest_source,
server_info_url = TEST_SERVER_INFO_URL,
connection_group = TEST_CONNECTION_GROUP,
addressables_root = TEST_ADDRESSABLES_ROOT
addressables_root = TEST_ADDRESSABLES_ROOT,
table_catalog_hash = xxhash32(b"ExcelDB.db"),
windows_bundle_catalog_hash = xxhash32(b"FullPatch_000.zip"),
windows_media_catalog_hash = xxhash32(b"JP_Airi_Win.zip"),
android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"),
android_media_catalog_hash = xxhash32(b"JP_Airi_Android.zip"),
)
}