feat: prepare experiment push package

This commit is contained in:
2026-06-30 00:08:36 +08:00
parent 3c00659691
commit adc1cd5b91
45 changed files with 7858 additions and 65 deletions
+6
View File
@@ -7,13 +7,19 @@ license.workspace = true
[dependencies]
bat-core = { path = "../core" }
bat-adapters = { path = "../adapters" }
bat-cas-engine = { path = "../crates/bat-cas-engine" }
anyhow.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
async-trait.workspace = true
md-5 = "0.10"
hex = "0.4"
tempfile = "3.14"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3.14"
serde_json.workspace = true
@@ -0,0 +1,104 @@
use bat_infrastructure::OfficialLauncherBootstrapService;
use std::env;
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
let args = parse_args()?;
let service = OfficialLauncherBootstrapService::with_curl_command(
args.launcher_version,
args.curl_command.to_string_lossy().to_string(),
);
let game_config = service.fetch_game_config().map_err(anyhow::Error::msg)?;
let cdn_config = service.fetch_cdn_config().map_err(anyhow::Error::msg)?;
println!("game_latest_version={}", game_config.game_latest_version);
println!(
"game_latest_file_path={}",
game_config.game_latest_file_path
);
println!(
"game_lowest_version={}",
game_config
.game_lowest_version
.as_deref()
.unwrap_or("<none>")
);
println!(
"game_start_exe_name={}",
game_config
.game_start_exe_name
.as_deref()
.unwrap_or("<none>")
);
println!("game_start_params={:?}", game_config.game_start_params);
println!("primary_cdn={}", cdn_config.primary_cdn);
println!("backup_cdn={}", cdn_config.back_up_cdn);
if args.manifest {
let manifest_url = service
.fetch_manifest_url(
&game_config.game_latest_version,
&game_config.game_latest_file_path,
)
.map_err(anyhow::Error::msg)?;
let manifest = service
.fetch_remote_manifest(&manifest_url.url)
.map_err(anyhow::Error::msg)?;
println!("manifest_url={}", manifest_url.url);
println!(
"manifest_source={}",
manifest.source.as_deref().unwrap_or("<none>")
);
println!("manifest_file_count={}", manifest.files.len());
}
Ok(())
}
#[derive(Debug)]
struct CliArgs {
launcher_version: String,
curl_command: PathBuf,
manifest: bool,
}
fn parse_args() -> anyhow::Result<CliArgs> {
let raw_args = env::args().collect::<Vec<_>>();
let mut args = raw_args.into_iter();
let binary = args
.next()
.unwrap_or_else(|| "official_launcher_bootstrap".to_string());
let mut parsed = CliArgs {
launcher_version: "1.7.2".to_string(),
curl_command: PathBuf::from("curl"),
manifest: false,
};
while let Some(flag) = args.next() {
match flag.as_str() {
"--launcher-version" => parsed.launcher_version = next_option_value(&mut args, &flag)?,
"--curl" => parsed.curl_command = PathBuf::from(next_option_value(&mut args, &flag)?),
"--manifest" => parsed.manifest = true,
"--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} [--launcher-version 1.7.2] [--curl curl] [--manifest]");
}
@@ -0,0 +1,431 @@
use bat_adapters::official::inventory::{
YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
};
use bat_adapters::official::yostar_jp::{
server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint,
YostarJpResourceEndpointKind, YostarJpServerInfo,
};
use bat_infrastructure::{
build_official_pull_plan_for_platform_inventory,
build_official_pull_plan_from_platform_inventory, default_official_platforms,
OfficialGameMainConfigBootstrapService, OfficialResourcePullService,
};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::PathBuf;
#[derive(Debug)]
struct CliArgs {
server_info_source: Option<ServerInfoSource>,
connection_group: Option<String>,
app_version: Option<String>,
launcher_version: String,
launcher_bootstrap: bool,
platforms: Option<Vec<PatchPlatform>>,
output_root: PathBuf,
curl_command: PathBuf,
dry_run: bool,
}
#[derive(Debug)]
enum ServerInfoSource {
LocalPath(PathBuf),
OfficialFile(String),
}
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 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 {
Some(
OfficialGameMainConfigBootstrapService::with_commands(
&args.launcher_version,
args.curl_command.to_string_lossy().to_string(),
"unzip",
)
.fetch_bootstrap()
.map_err(anyhow::Error::msg)?,
)
} else {
None
};
let app_version = args
.app_version
.clone()
.or_else(|| {
official_bootstrap
.as_ref()
.map(|bootstrap| bootstrap.game_config.game_latest_version.clone())
})
.ok_or_else(|| {
anyhow::anyhow!("missing --app-version and official bootstrap failed")
})?;
let connection_group = args
.connection_group
.clone()
.or_else(|| {
official_bootstrap
.as_ref()
.and_then(|bootstrap| bootstrap.game_main_config.default_connection_group.clone())
})
.ok_or_else(|| {
anyhow::anyhow!(
"missing --connection-group and official GameMainConfig has no default_connection_group"
)
})?;
let fetcher =
OfficialResourcePullService::with_curl_command(&args.output_root, &args.curl_command);
if args.launcher_bootstrap {
let bootstrap = official_bootstrap.as_ref().ok_or_else(|| {
anyhow::anyhow!("launcher bootstrap requested but official bootstrap is unavailable")
})?;
println!(
"launcher_api_game_latest_version={}",
bootstrap.game_config.game_latest_version
);
println!(
"launcher_api_game_latest_file_path={}",
bootstrap.game_config.game_latest_file_path
);
println!(
"launcher_api_game_lowest_version={}",
bootstrap
.game_config
.game_lowest_version
.as_deref()
.unwrap_or("<none>")
);
println!(
"launcher_api_game_start_exe_name={}",
bootstrap
.game_config
.game_start_exe_name
.as_deref()
.unwrap_or("<none>")
);
println!(
"launcher_api_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={}",
bootstrap.manifest_source.as_deref().unwrap_or("<none>")
);
println!(
"launcher_api_manifest_file_count={}",
bootstrap.manifest_file_count
);
println!(
"launcher_api_game_main_config_server_info_url={}",
bootstrap
.game_main_config
.server_info_data_url
.as_deref()
.unwrap_or("<none>")
);
println!(
"launcher_api_game_main_config_default_connection_group={}",
bootstrap
.game_main_config
.default_connection_group
.as_deref()
.unwrap_or("<none>")
);
println!();
}
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")
})?;
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 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)?;
let plan = if args.platforms.is_some() {
build_official_pull_plan_for_platform_inventory(discovery, inventory, platforms)
} else {
build_official_pull_plan_from_platform_inventory(discovery, inventory)
};
let all_urls = plan.all_urls().map_err(anyhow::Error::msg)?;
println!("connection_group={}", plan.discovery.connection_group_name);
println!("app_version={}", plan.discovery.app_version);
println!(
"bundle_version={}",
plan.discovery.bundle_version.as_deref().unwrap_or("<none>")
);
println!("addressables_root={}", plan.discovery.addressables_root);
println!("platforms={:?}", plan.platforms);
println!("discovery_url_count={}", plan.discovery_urls().len());
println!(
"content_url_count={}",
plan.content_urls().map_err(anyhow::Error::msg)?.len()
);
println!("total_url_count={}", all_urls.len());
println!("output_root={}", args.output_root.display());
println!("dry_run={}", args.dry_run);
println!();
for url in &all_urls {
println!("{url}");
}
if args.dry_run {
return Ok(());
}
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());
Ok(())
}
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)
}
}
}
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> {
let raw_args = env::args().collect::<Vec<_>>();
let mut args = raw_args.into_iter();
let binary = args
.next()
.unwrap_or_else(|| "official_pull_plan".to_string());
let mut parsed = CliArgs {
server_info_source: None,
connection_group: None,
app_version: None,
launcher_version: "1.7.2".to_string(),
launcher_bootstrap: false,
platforms: None,
output_root: PathBuf::from("official_pull_output"),
curl_command: PathBuf::from("curl"),
dry_run: false,
};
while let Some(flag) = args.next() {
match flag.as_str() {
"--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)?;
}
"--launcher-bootstrap" => {
parsed.launcher_bootstrap = true;
}
"--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)?);
}
"--curl" => {
parsed.curl_command = PathBuf::from(next_option_value(&mut args, &flag)?);
}
"--dry-run" => {
parsed.dry_run = true;
}
"--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} [--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>] \
[--platforms Windows,Android] [--output official_pull_output] [--curl curl] [--dry-run]"
);
}
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}")),
}
}
+369
View File
@@ -0,0 +1,369 @@
//! 资源导入服务。
use bat_adapters::manifest::GenericManifest;
use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry};
use bat_core::domain::{Resource, ResourceType};
use bat_core::repositories::{CasRepository, ResourceRepository};
use std::path::{Path, PathBuf};
/// 待导入的 AssetBundle 数据。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BundleSource {
/// Bundle 路径或文件名。
pub path: String,
/// Bundle 原始字节。
pub data: Vec<u8>,
}
impl BundleSource {
/// 创建 Bundle 输入。
pub fn new(path: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
Self {
path: path.into(),
data: data.into(),
}
}
}
/// 单个导入结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedResource {
/// 写入 `ResourceRepository` 的资源 ID。
pub id: String,
/// Manifest 中的资源路径。
pub source_path: String,
/// CAS 返回的对象 ID。
pub object_id: String,
/// 写入 CAS 的字节数。
pub bytes: u64,
/// UnityFS 解析摘要。
pub unityfs: UnityFsImportSummary,
}
/// 导入时解析到的 UnityFS 基础结构摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnityFsImportSummary {
/// Bundle 声明的 Unity 版本。
pub unity_version: String,
/// UnityFS block 数量。
pub block_count: usize,
/// UnityFS directory 数量。
pub directory_count: usize,
/// UnityFS directory 路径。
pub directories: Vec<String>,
}
/// Manifest 导入报告。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourceImportReport {
/// 成功导入的资源。
pub imported: Vec<ImportedResource>,
/// 被跳过的非 AssetBundle 资源路径。
pub skipped: Vec<String>,
}
/// 将解析后的资源清单写入 CAS 与资源仓储的应用服务。
pub struct ResourceImportService<'a> {
cas: &'a dyn CasRepository,
resources: &'a dyn ResourceRepository,
unity_adapters: UnityAdapterRegistry,
}
impl<'a> ResourceImportService<'a> {
/// 创建导入服务。
pub fn new(cas: &'a dyn CasRepository, resources: &'a dyn ResourceRepository) -> Self {
Self::with_unity_adapters(cas, resources, UnityAdapterRegistry::with_defaults())
}
/// 使用指定 Unity 适配器注册表创建导入服务。
pub fn with_unity_adapters(
cas: &'a dyn CasRepository,
resources: &'a dyn ResourceRepository,
unity_adapters: UnityAdapterRegistry,
) -> Self {
Self {
cas,
resources,
unity_adapters,
}
}
/// 导入 manifest 中的 AssetBundle 资源。
///
/// 非 AssetBundle 条目会记录到 `skipped`。AssetBundle 条目必须能在
/// `bundles` 中按完整路径或文件名找到对应数据,否则返回错误。
pub async fn import_manifest_bundles(
&self,
manifest: &GenericManifest,
bundles: &[BundleSource],
) -> bat_core::Result<ResourceImportReport> {
let mut report = ResourceImportReport::default();
for entry in &manifest.resources {
if entry.resource_type != ResourceType::AssetBundle {
report.skipped.push(entry.path.clone());
continue;
}
let bundle = find_bundle(bundles, &entry.path).ok_or_else(|| {
bat_core::Error::InvalidArgument(format!(
"Missing bundle data for manifest resource: {}",
entry.path
))
})?;
let unityfs = self.parse_unityfs_summary(&entry.path, bundle).await?;
let object_id = self.cas.store(&bundle.data).await?;
let mut stored_entry = entry.clone();
stored_entry.hash = object_id.clone();
stored_entry.size = bundle.data.len() as u64;
let resource = Resource {
id: resource_id_for_path(&entry.path),
local_path: PathBuf::from(&entry.path),
entry: stored_entry,
};
let id = self.resources.add(resource).await?;
report.imported.push(ImportedResource {
id,
source_path: entry.path.clone(),
object_id,
bytes: bundle.data.len() as u64,
unityfs,
});
}
Ok(report)
}
async fn parse_unityfs_summary(
&self,
manifest_path: &str,
bundle: &BundleSource,
) -> bat_core::Result<UnityFsImportSummary> {
let raw_bundle = RawAssetBundle {
data: bundle.data.clone(),
path: Some(manifest_path.to_string()),
};
let adapter = self
.unity_adapters
.select_adapter(&raw_bundle)
.map_err(|error| {
bat_core::Error::InvalidArgument(format!(
"No Unity adapter for manifest resource {}: {}",
manifest_path, error
))
})?;
let parsed = adapter.parse(&raw_bundle).await.map_err(|error| {
bat_core::Error::InvalidArgument(format!(
"Invalid UnityFS bundle for manifest resource {}: {}",
manifest_path, error
))
})?;
Ok(UnityFsImportSummary {
unity_version: parsed.unity_version,
block_count: parsed.blocks.len(),
directory_count: parsed.directories.len(),
directories: parsed
.directories
.into_iter()
.map(|directory| directory.path)
.collect(),
})
}
}
fn resource_id_for_path(path: &str) -> String {
format!("resource/{}", path)
}
fn find_bundle<'a>(bundles: &'a [BundleSource], manifest_path: &str) -> Option<&'a BundleSource> {
bundles
.iter()
.find(|bundle| bundle.path == manifest_path)
.or_else(|| {
file_name(manifest_path).and_then(|manifest_name| {
bundles
.iter()
.find(|bundle| file_name(&bundle.path) == Some(manifest_name))
})
})
}
fn file_name(path: &str) -> Option<&str> {
Path::new(path).file_name()?.to_str()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FileSystemCasRepository, InMemoryResourceRepository};
use bat_adapters::manifest::{GenericManifest, ManifestFormat, ManifestMetadata};
use bat_core::domain::ResourceEntry;
use bat_core::repositories::cas_repository::CasRepository;
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
use std::collections::HashMap;
use tempfile::TempDir;
fn push_c_string(data: &mut Vec<u8>, value: &str) {
data.extend_from_slice(value.as_bytes());
data.push(0);
}
fn push_u16(data: &mut Vec<u8>, value: u16) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_i32(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn synthetic_minimal_unityfs_bundle() -> Vec<u8> {
let mut blocks_info = Vec::new();
blocks_info.extend_from_slice(&[0; 16]);
push_i32(&mut blocks_info, 1);
push_u32(&mut blocks_info, 4);
push_u32(&mut blocks_info, 4);
push_u16(&mut blocks_info, 0);
push_i32(&mut blocks_info, 1);
push_u64(&mut blocks_info, 0);
push_u64(&mut blocks_info, 4);
push_u32(&mut blocks_info, 0);
push_c_string(&mut blocks_info, "SYNTHETIC-CAB");
let mut data = Vec::new();
push_c_string(&mut data, "UnityFS");
push_u32(&mut data, 8);
push_c_string(&mut data, "5.x.x");
push_c_string(&mut data, "2021.3.56f2");
push_u64(&mut data, 0);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, 0);
align(&mut data, 16);
data.extend_from_slice(&blocks_info);
data.extend_from_slice(b"data");
let total_size = data.len() as u64;
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
data
}
fn synthetic_manifest() -> GenericManifest {
GenericManifest {
format: ManifestFormat::AddressablesCatalog,
resources: vec![
ResourceEntry {
path: "synthetic/minimal.bundle".to_string(),
hash: "synthetic-manifest-hash".to_string(),
size: 99,
resource_type: ResourceType::AssetBundle,
},
ResourceEntry {
path: "synthetic/catalog.json".to_string(),
hash: "synthetic-catalog-hash".to_string(),
size: 1,
resource_type: ResourceType::Manifest,
},
],
metadata: ManifestMetadata {
locator_id: None,
cdn_prefixes: Vec::new(),
extra: HashMap::new(),
},
}
}
#[tokio::test]
async fn imports_synthetic_manifest_bundles_into_cas_and_resource_repository() {
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let report = service
.import_manifest_bundles(
&synthetic_manifest(),
&[BundleSource::new(
"minimal.bundle",
synthetic_minimal_unityfs_bundle(),
)],
)
.await
.unwrap();
assert_eq!(report.imported.len(), 1);
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
assert!(cas.exists(&report.imported[0].object_id).await);
assert_eq!(report.imported[0].unityfs.unity_version, "2021.3.56f2");
assert_eq!(report.imported[0].unityfs.block_count, 1);
assert_eq!(report.imported[0].unityfs.directory_count, 1);
assert_eq!(
report.imported[0].unityfs.directories,
vec!["SYNTHETIC-CAB".to_string()]
);
let indexed = resources.list(ResourceQuery::all()).await.unwrap();
assert_eq!(indexed.len(), 1);
assert_eq!(indexed[0].entry.hash, report.imported[0].object_id);
assert_eq!(
indexed[0].entry.size,
synthetic_minimal_unityfs_bundle().len() as u64
);
}
#[tokio::test]
async fn returns_error_when_bundle_data_is_missing() {
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let error = service
.import_manifest_bundles(&synthetic_manifest(), &[])
.await
.unwrap_err();
assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
}
#[tokio::test]
async fn returns_error_when_bundle_is_not_unityfs() {
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let error = service
.import_manifest_bundles(
&synthetic_manifest(),
&[BundleSource::new(
"minimal.bundle",
b"not-a-unityfs-bundle".to_vec(),
)],
)
.await
.unwrap_err();
assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
assert_eq!(resources.count(ResourceQuery::all()).await.unwrap(), 0);
}
}
+27
View File
@@ -11,8 +11,35 @@
#![warn(clippy::all)]
pub mod cas;
pub mod import;
pub mod official_game_main_config;
pub mod official_download;
pub mod official_launcher;
pub mod official_pull;
pub mod official_sync;
pub mod resources;
pub use cas::FileSystemCasRepository;
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
pub use official_download::{
OfficialResourcePullItem, OfficialResourcePullReport, OfficialResourcePullService,
};
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
pub use official_launcher::{
is_official_launcher_package_url, launcher_api_url, launcher_authorization_header,
OfficialLauncherBootstrapService, YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig,
YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
};
pub use official_pull::{
build_official_pull_plan, build_official_pull_plan_for_platform_inventory,
build_official_pull_plan_for_platforms, build_official_pull_plan_from_platform_inventory,
OfficialResourcePullPlan,
};
pub use official_sync::{
build_official_sync_plan, changed_endpoint_urls, classify_sync_decision,
default_official_platforms, OfficialSyncDecision, OfficialSyncPlan,
};
pub use resources::InMemoryResourceRepository;
/// Infrastructure 版本号
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+396
View File
@@ -0,0 +1,396 @@
//! Official JP resource download execution.
use crate::official_pull::OfficialResourcePullPlan;
use bat_adapters::official::yostar_jp::is_official_yostar_jp_url;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// One downloaded official resource.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialResourcePullItem {
/// Original URL.
pub url: String,
/// Local destination path under the output root.
pub destination: PathBuf,
/// Downloaded byte count.
pub bytes: u64,
}
/// Download report for one executed pull plan.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OfficialResourcePullReport {
/// Downloaded resources in execution order.
pub items: Vec<OfficialResourcePullItem>,
}
impl OfficialResourcePullReport {
/// Returns the total downloaded byte count.
pub fn total_bytes(&self) -> u64 {
self.items.iter().map(|item| item.bytes).sum()
}
}
/// Executes an official pull plan with the system `curl` command.
#[derive(Debug, Clone)]
pub struct OfficialResourcePullService {
output_root: PathBuf,
curl_command: PathBuf,
}
impl OfficialResourcePullService {
/// Creates a pull service that uses `curl` from `PATH`.
pub fn new(output_root: impl Into<PathBuf>) -> Self {
Self::with_curl_command(output_root, "curl")
}
/// Creates a pull service with an explicit `curl` binary path.
pub fn with_curl_command(
output_root: impl Into<PathBuf>,
curl_command: impl Into<PathBuf>,
) -> Self {
Self {
output_root: output_root.into(),
curl_command: curl_command.into(),
}
}
/// Returns the output root used for downloaded files.
pub fn output_root(&self) -> &Path {
&self.output_root
}
/// Executes the plan and downloads every official URL to disk.
pub fn pull(
&self,
plan: &OfficialResourcePullPlan,
) -> Result<OfficialResourcePullReport, String> {
fs::create_dir_all(&self.output_root).map_err(|error| {
format!(
"Failed to create output root {}: {error}",
self.output_root.display()
)
})?;
let mut items = Vec::new();
for url in plan.all_urls()? {
if !is_official_yostar_jp_url(&url) {
return Err(format!("Refusing to download non-official URL: {url}"));
}
let destination = self.destination_for_url(&url)?;
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create destination directory {}: {error}",
parent.display()
)
})?;
}
self.download_one(&url, &destination)?;
let bytes = fs::metadata(&destination)
.map_err(|error| {
format!(
"Downloaded file missing at {}: {error}",
destination.display()
)
})?
.len();
items.push(OfficialResourcePullItem {
url,
destination,
bytes,
});
}
Ok(OfficialResourcePullReport { items })
}
/// Fetches an official URL into memory.
///
/// This is intended for small discovery inputs such as `server-info`,
/// `TableCatalog.bytes`, `BundlePackingInfo.bytes`, and
/// `MediaCatalog.bytes`.
pub fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
if !is_official_yostar_jp_url(url) {
return Err(format!("Refusing to fetch non-official URL: {url}"));
}
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--url")
.arg(url)
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
self.curl_command.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("curl failed for {url}: {}", stderr.trim()));
}
Ok(output.stdout)
}
fn download_one(&self, url: &str, destination: &Path) -> Result<(), String> {
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--output")
.arg(destination)
.arg("--url")
.arg(url)
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
self.curl_command.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"curl failed for {url} -> {}: {}",
destination.display(),
stderr.trim()
));
}
Ok(())
}
fn destination_for_url(&self, url: &str) -> Result<PathBuf, String> {
if !is_official_yostar_jp_url(url) {
return Err(format!("URL is not an official JP host: {url}"));
}
let rest = url
.strip_prefix("https://")
.ok_or_else(|| format!("Official URL must use https: {url}"))?;
let (host, path) = rest
.split_once('/')
.ok_or_else(|| format!("Official URL has no path: {url}"))?;
let mut destination = PathBuf::from(sanitize_segment(host));
for segment in path.split('/') {
if segment.is_empty() {
continue;
}
if segment == "." || segment == ".." {
return Err(format!("Official URL has unsafe path segment: {url}"));
}
let segment = segment.split(['?', '#']).next().unwrap_or(segment);
if segment.is_empty() {
continue;
}
destination.push(sanitize_segment(segment));
}
Ok(self.output_root.join(destination))
}
}
fn sanitize_segment(segment: &str) -> String {
segment
.chars()
.map(|ch| {
if matches!(ch, ':' | '*' | '?' | '"' | '<' | '>' | '|') {
'_'
} else {
ch
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::official_pull::{
build_official_pull_plan, build_official_pull_plan_for_platforms, OfficialResourcePullPlan,
};
use bat_adapters::official::inventory::{
YostarJpDownloadInventory, YostarJpPlatformDownloadInventory,
};
use bat_adapters::official::yostar_jp::{
PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint,
YostarJpResourceEndpointKind,
};
use std::fs;
use tempfile::TempDir;
fn discovery_plan() -> YostarJpResourceDiscoveryPlan {
YostarJpResourceDiscoveryPlan {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: Some("s8tloc7lo3".to_string()),
addressables_root:
"https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(),
endpoints: vec![
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalog,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes".to_string(),
},
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalogHash,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.hash".to_string(),
},
],
}
}
fn inventory() -> YostarJpDownloadInventory {
YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip",
b"ExcelDB.db",
b"JP_Airi.zip",
)
}
fn platform_inventory() -> YostarJpPlatformDownloadInventory {
YostarJpPlatformDownloadInventory::from_shared_inventory(
inventory(),
&[PatchPlatform::Windows],
)
}
fn fake_curl_script() -> &'static str {
r#"#!/bin/sh
set -eu
out=""
url=""
while [ "$#" -gt 0 ]; do
case "$1" in
--output)
out="$2"
shift 2
;;
--url)
url="$2"
shift 2
;;
*)
shift
;;
esac
done
if [ -n "$out" ]; then
mkdir -p "$(dirname "$out")"
printf '%s' "$url" > "$out"
else
printf '%s' "$url"
fi
"#
}
fn write_fake_curl(path: &Path) {
fs::write(path, fake_curl_script()).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).unwrap();
}
}
#[test]
fn downloads_verified_pull_plan_to_disk() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path);
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
let plan = build_official_pull_plan(discovery_plan(), inventory());
let report = service.pull(&plan).unwrap();
assert_eq!(report.items.len(), 7);
assert!(report.total_bytes() > 0);
assert_eq!(report.items[0].url, plan.discovery.endpoints[0].url);
for item in &report.items {
assert!(item.destination.exists());
assert_eq!(fs::read_to_string(&item.destination).unwrap(), item.url);
}
}
#[test]
fn custom_platform_selection_is_supported() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path);
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
let plan = build_official_pull_plan_for_platforms(
discovery_plan(),
inventory(),
&[PatchPlatform::Windows, PatchPlatform::Android],
);
let report = service.pull(&plan).unwrap();
assert_eq!(report.items.len(), 7);
assert!(report
.items
.iter()
.any(|item| item.url.contains("Android_PatchPack")));
}
#[test]
fn fetches_official_seed_bytes_with_curl() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path);
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
let bytes = service
.fetch_bytes(
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes",
)
.unwrap();
assert_eq!(
String::from_utf8(bytes).unwrap(),
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes"
);
}
#[test]
fn rejects_non_official_urls() {
let service = OfficialResourcePullService::new("/tmp/unused");
let plan = OfficialResourcePullPlan {
discovery: YostarJpResourceDiscoveryPlan {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: None,
addressables_root:
"https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(),
endpoints: vec![YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalog,
platform: None,
url: "https://prod-clientpatch.bluearchive.cafe/r93_token/TableBundles/TableCatalog.bytes".to_string(),
}],
},
inventory: platform_inventory(),
platforms: vec![PatchPlatform::Windows],
};
assert!(service.pull(&plan).is_err());
}
}
@@ -0,0 +1,199 @@
//! Official launcher package bootstrap for `GameMainConfig`.
use crate::official_launcher::OfficialLauncherBootstrapService;
use crate::official_launcher::launcher_package_url;
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
use crate::official_launcher::{YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
/// Combined official bootstrap data for the latest client package.
#[derive(Debug, Clone)]
pub struct OfficialGameMainConfigBootstrap {
/// Latest launcher-reported client metadata.
pub game_config: YostarJpLauncherGameConfig,
/// Launcher CDN configuration used to resolve the package host.
pub cdn_config: YostarJpLauncherCdnConfig,
/// Official remote manifest URL used for the package chain.
pub manifest_url: String,
/// Remote manifest `source` path, if present.
pub manifest_source: Option<String>,
/// Official game ZIP URL built from the launcher CDN root.
pub game_zip_url: String,
/// Number of files declared by the remote manifest.
pub manifest_file_count: usize,
/// Decrypted `GameMainConfig`.
pub game_main_config: YostarJpGameMainConfig,
}
/// Loads and decrypts the official `GameMainConfig` by following the official
/// launcher package chain.
#[derive(Debug, Clone)]
pub struct OfficialGameMainConfigBootstrapService {
launcher: OfficialLauncherBootstrapService,
curl_command: PathBuf,
unzip_command: PathBuf,
}
impl OfficialGameMainConfigBootstrapService {
/// Creates a bootstrapper using `curl` and `unzip` from `PATH`.
pub fn new(launcher_version: impl Into<String>) -> Self {
Self::with_commands(launcher_version, "curl", "unzip")
}
/// Creates a bootstrapper with explicit commands.
pub fn with_commands(
launcher_version: impl Into<String>,
curl_command: impl Into<PathBuf>,
unzip_command: impl Into<PathBuf>,
) -> Self {
let curl_command = curl_command.into();
let unzip_command = unzip_command.into();
let launcher = OfficialLauncherBootstrapService::with_curl_command(
launcher_version,
curl_command.to_string_lossy().to_string(),
);
Self {
launcher,
curl_command,
unzip_command,
}
}
/// Fetches the latest official game ZIP by re-querying the official
/// launcher API, extracts `resources.assets`, and decrypts `GameMainConfig`.
pub fn fetch_bootstrap(&self) -> Result<OfficialGameMainConfigBootstrap, String> {
let (game_config, manifest_url, manifest) = self.launcher.fetch_latest_remote_manifest()?;
let manifest_source = manifest
.source
.clone()
.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 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,
&archive_path,
)?;
let extract_root = temp_dir.path().join("extract");
fs::create_dir_all(&extract_root)
.map_err(|error| format!("Failed to create extract root: {error}"))?;
self.extract_archive(&archive_path, &extract_root)?;
let resources_assets = find_resources_assets(&extract_root)
.ok_or_else(|| "resources.assets not found in official launcher package".to_string())?;
let game_main_config = YostarJpGameMainConfig::from_resources_assets(resources_assets)?;
Ok(OfficialGameMainConfigBootstrap {
game_config,
cdn_config,
manifest_url: manifest_url.url,
manifest_source: Some(manifest_source),
game_zip_url,
manifest_file_count: manifest.files.len(),
game_main_config,
})
}
/// 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)
}
fn download_file(&self, url: &str, destination: &Path) -> Result<(), String> {
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--output")
.arg(destination)
.arg("--url")
.arg(url)
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
self.curl_command.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("curl failed for {url}: {}", stderr.trim()));
}
Ok(())
}
fn download_file_with_fallback(
&self,
primary_url: &str,
backup_cdn_root: &str,
relative_path: &str,
destination: &Path,
) -> Result<(), String> {
match self.download_file(primary_url, destination) {
Ok(()) => Ok(()),
Err(primary_error) => {
let backup_url = launcher_package_url(backup_cdn_root, relative_path)?;
self.download_file(&backup_url, destination).map_err(|backup_error| {
format!(
"failed to download official game ZIP from primary ({primary_error}) and backup ({backup_error})"
)
})
}
}
}
fn extract_archive(&self, archive_path: &Path, destination: &Path) -> Result<(), String> {
let output = Command::new(&self.unzip_command)
.arg("-q")
.arg(archive_path)
.arg("-d")
.arg(destination)
.output()
.map_err(|error| {
format!(
"Failed to launch unzip command {}: {error}",
self.unzip_command.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"unzip failed for {}: {}",
archive_path.display(),
stderr.trim()
));
}
Ok(())
}
}
fn find_resources_assets(root: &Path) -> Option<PathBuf> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = fs::read_dir(&dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.file_name().and_then(|name| name.to_str()) == Some("resources.assets") {
return Some(path);
}
}
}
None
}
+513
View File
@@ -0,0 +1,513 @@
//! Official Yostar JP launcher API bootstrap.
//!
//! This models the PC launcher update API used by the official JP launcher.
//! It is separate from Unity resource `server-info`: the launcher API can
//! discover the latest Windows client package and manifest, while resource
//! updates still come from the game client's server-info flow.
use bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG};
use md5::{Digest, Md5};
use serde::Deserialize;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
/// Official JP launcher API root.
pub const YOSTAR_JP_LAUNCHER_API_ROOT: &str = "https://api-launcher-jp.yo-star.com";
/// Salt embedded in the official JP launcher for request signing.
pub const YOSTAR_JP_LAUNCHER_AUTH_SALT: &str = "DE7108E9B2842FD460F4777702727869";
/// Official primary JP PC launcher package CDN host.
pub const YOSTAR_JP_LAUNCHER_PRIMARY_CDN_HOST: &str = "launcher-pkg-ba-jp.yo-star.com";
/// Official backup JP PC launcher package CDN host.
pub const YOSTAR_JP_LAUNCHER_BACKUP_CDN_HOST: &str = "launcher-pkg-ba-jp-bk.yo-star.com";
/// Latest official PC client metadata returned by `/api/launcher/game/config`.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherGameConfig {
/// Latest Windows client version.
pub game_latest_version: String,
/// Latest manifest basis path.
pub game_latest_file_path: String,
/// Lowest allowed installed client version.
#[serde(default)]
pub game_lowest_version: Option<String>,
/// Executable name without `.exe`.
#[serde(default)]
pub game_start_exe_name: Option<String>,
/// Launcher arguments.
#[serde(default)]
pub game_start_params: Vec<String>,
/// Displayed decompressed size.
#[serde(default)]
pub decompression_size: Option<String>,
}
/// Official launcher CDN roots used for Windows client package files.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherCdnConfig {
/// Primary CDN root.
pub primary_cdn: String,
/// Backup CDN root.
pub back_up_cdn: String,
}
/// Official launcher manifest URL response returned by
/// `/api/launcher/game/config/json`.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherManifestUrl {
/// Remote manifest URL on the official launcher package CDN.
pub url: String,
}
/// Remote PC launcher manifest used to compute install/update diffs.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherRemoteManifest {
/// Optional manifest source field.
#[serde(default)]
pub source: Option<String>,
/// Remote package files.
#[serde(default, rename = "file")]
pub files: Vec<YostarJpLauncherManifestFile>,
}
/// Signed official launcher API fetcher using `curl`.
#[derive(Debug, Clone)]
pub struct OfficialLauncherBootstrapService {
curl_command: String,
launcher_version: String,
}
impl OfficialLauncherBootstrapService {
/// Creates a fetcher using `curl` from `PATH`.
pub fn new(launcher_version: impl Into<String>) -> Self {
Self::with_curl_command(launcher_version, "curl")
}
/// Creates a fetcher with an explicit `curl` command.
pub fn with_curl_command(
launcher_version: impl Into<String>,
curl_command: impl Into<String>,
) -> Self {
Self {
curl_command: curl_command.into(),
launcher_version: launcher_version.into(),
}
}
/// Fetches latest official PC game config.
pub fn fetch_game_config(&self) -> Result<YostarJpLauncherGameConfig, String> {
let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?;
let config: YostarJpLauncherGameConfig =
serde_json::from_value(envelope.data).map_err(|error| {
format!("Failed to parse official launcher game config data: {error}")
})?;
if config.game_latest_version.is_empty() || config.game_latest_file_path.is_empty() {
return Err("Official launcher game config is missing latest version or basis".into());
}
Ok(config)
}
/// Fetches official CDN config for PC client package downloads.
pub fn fetch_cdn_config(&self) -> Result<YostarJpLauncherCdnConfig, String> {
let envelope = self.fetch_launcher_envelope("/api/launcher/advanced/game/download/cdn")?;
serde_json::from_value(envelope.data)
.map_err(|error| format!("Failed to parse official launcher CDN config data: {error}"))
}
/// Fetches the official remote manifest URL for a PC client version and
/// basis path.
pub fn fetch_manifest_url(
&self,
version: &str,
file_path: &str,
) -> Result<YostarJpLauncherManifestUrl, String> {
if version.is_empty() || file_path.is_empty() {
return Err("Launcher manifest version and file path must not be empty".into());
}
let path = format!(
"/api/launcher/game/config/json?version={}&file_path={}",
percent_encode_query_value(version),
percent_encode_query_value(file_path)
);
let envelope = self.fetch_launcher_envelope(&path)?;
let manifest_url: YostarJpLauncherManifestUrl = serde_json::from_value(envelope.data)
.map_err(|error| {
format!("Failed to parse official launcher manifest URL data: {error}")
})?;
if !is_official_launcher_package_url(&manifest_url.url) {
return Err(format!(
"Official launcher API returned non-official manifest URL: {}",
manifest_url.url
));
}
Ok(manifest_url)
}
/// Fetches and parses an official remote PC launcher manifest.
pub fn fetch_remote_manifest(
&self,
manifest_url: &str,
) -> Result<YostarJpLauncherRemoteManifest, String> {
if !is_official_launcher_package_url(manifest_url) {
return Err(format!(
"Refusing to fetch non-official launcher manifest URL: {manifest_url}"
));
}
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--url")
.arg(manifest_url)
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
self.curl_command
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"curl failed for launcher manifest {manifest_url}: {}",
stderr.trim()
));
}
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("Failed to parse official launcher manifest: {error}"))?;
if manifest.files.is_empty() {
return Err("Official launcher remote manifest has no files".into());
}
Ok(manifest)
}
/// Fetches latest PC game config, then follows the official manifest URL.
pub fn fetch_latest_remote_manifest(
&self,
) -> Result<
(
YostarJpLauncherGameConfig,
YostarJpLauncherManifestUrl,
YostarJpLauncherRemoteManifest,
),
String,
> {
let game_config = self.fetch_game_config()?;
let manifest_url = self.fetch_manifest_url(
&game_config.game_latest_version,
&game_config.game_latest_file_path,
)?;
let manifest = self.fetch_remote_manifest(&manifest_url.url)?;
Ok((game_config, manifest_url, manifest))
}
/// Fetches a signed official launcher API endpoint and parses the generic
/// response envelope.
pub fn fetch_launcher_envelope(&self, path: &str) -> Result<LauncherEnvelope, String> {
let url = launcher_api_url(path)?;
let authorization = launcher_authorization_header(&self.launcher_version, "", None)?;
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--header")
.arg("Content-Type: application/json;charset=UTF-8")
.arg("--header")
.arg(format!("Authorization: {authorization}"))
.arg("--url")
.arg(&url)
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
self.curl_command
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("curl failed for {url}: {}", stderr.trim()));
}
let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("Failed to parse official launcher API response: {error}"))?;
if envelope.code != 200 {
return Err(format!(
"Official launcher API returned code {}: {}",
envelope.code,
envelope
.message
.as_deref()
.or(envelope.msg.as_deref())
.unwrap_or("<no message>")
));
}
Ok(envelope)
}
}
/// Generic official launcher API response envelope.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct LauncherEnvelope {
/// Numeric response code. Official launcher treats `200` as success.
pub code: i64,
/// Optional message field.
#[serde(default)]
pub message: Option<String>,
/// Optional alternate message field.
#[serde(default)]
pub msg: Option<String>,
/// Response payload.
pub data: serde_json::Value,
}
/// Builds an official launcher API URL from a path.
pub fn launcher_api_url(path: &str) -> Result<String, String> {
if !path.starts_with('/') || path.contains("..") || path.contains('\\') {
return Err(format!("Invalid official launcher API path: {path}"));
}
Ok(format!("{YOSTAR_JP_LAUNCHER_API_ROOT}{path}"))
}
/// Returns true when a URL points to an official JP PC launcher package host.
pub fn is_official_launcher_package_url(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else {
return false;
};
let host = rest.split('/').next().unwrap_or_default();
matches!(
host,
YOSTAR_JP_LAUNCHER_PRIMARY_CDN_HOST | YOSTAR_JP_LAUNCHER_BACKUP_CDN_HOST
)
}
/// Builds an official launcher package URL from an official CDN root and a
/// relative package path.
pub fn launcher_package_url(cdn_root: &str, file_path: &str) -> Result<String, String> {
if !is_official_launcher_package_url(cdn_root) {
return Err(format!("Launcher CDN root is not official: {cdn_root}"));
}
validate_launcher_package_path(file_path)?;
Ok(format!(
"{}/{}",
cdn_root.trim_end_matches('/'),
normalize_relative_path(file_path)
))
}
fn percent_encode_query_value(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn normalize_relative_path(path: &str) -> String {
path.replace('\\', "/")
}
fn validate_launcher_package_path(path: &str) -> Result<(), String> {
if path.is_empty() || path.starts_with('/') || path.starts_with('\\') {
return Err(format!("Invalid launcher package path: {path}"));
}
for segment in path.split('/') {
if segment.is_empty() || segment == "." || segment == ".." {
return Err(format!("Invalid launcher package path: {path}"));
}
if segment.contains('\\') || segment.contains('?') || segment.contains('#') {
return Err(format!("Invalid launcher package path: {path}"));
}
}
Ok(())
}
/// Builds the official launcher `Authorization` header.
///
/// This matches the official launcher logic:
/// `md5(JSON(head) + body + salt)`, where `head` contains `game_tag`,
/// Unix timestamp, and launcher version.
pub fn launcher_authorization_header(
launcher_version: &str,
body: &str,
timestamp: Option<u64>,
) -> Result<String, String> {
if launcher_version.is_empty() {
return Err("Launcher version must not be empty".into());
}
let timestamp = match timestamp {
Some(timestamp) => timestamp,
None => SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("System time is before Unix epoch: {error}"))?
.as_secs(),
};
let head = serde_json::json!({
"game_tag": YOSTAR_JP_GAME_TAG,
"time": timestamp,
"version": launcher_version,
});
let head_json = serde_json::to_string(&head)
.map_err(|error| format!("Failed to serialize launcher auth head: {error}"))?;
let mut hasher = Md5::new();
hasher.update(head_json.as_bytes());
hasher.update(body.as_bytes());
hasher.update(YOSTAR_JP_LAUNCHER_AUTH_SALT.as_bytes());
let sign = hex::encode(hasher.finalize());
serde_json::to_string(&serde_json::json!({
"head": head,
"sign": sign,
}))
.map_err(|error| format!("Failed to serialize launcher auth header: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_official_launcher_api_url() {
assert_eq!(
launcher_api_url("/api/launcher/game/config").unwrap(),
"https://api-launcher-jp.yo-star.com/api/launcher/game/config"
);
assert!(launcher_api_url("api/launcher/game/config").is_err());
assert!(launcher_api_url("/api/../secret").is_err());
}
#[test]
fn identifies_official_launcher_package_urls_only() {
assert!(is_official_launcher_package_url(
"https://launcher-pkg-ba-jp.yo-star.com/prod/manifest.json"
));
assert!(is_official_launcher_package_url(
"https://launcher-pkg-ba-jp-bk.yo-star.com/prod/manifest.json"
));
assert!(!is_official_launcher_package_url(
"https://launcher-pkg-ba-jp.bluearchive.cafe/prod/manifest.json"
));
assert!(!is_official_launcher_package_url(
"http://launcher-pkg-ba-jp.yo-star.com/prod/manifest.json"
));
}
#[test]
fn builds_official_launcher_package_url() {
assert_eq!(
launcher_package_url(
"https://launcher-pkg-ba-jp.yo-star.com",
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip"
)
.unwrap(),
"https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip"
);
assert!(launcher_package_url(
"https://launcher-pkg-ba-jp.bluearchive.cafe",
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip"
)
.is_err());
}
#[test]
fn encodes_launcher_manifest_query_values() {
assert_eq!(
percent_encode_query_value("prod/ZIP_TEMP/BlueArchive_JP_TEMP/game.zip"),
"prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2Fgame.zip"
);
}
#[test]
fn builds_stable_launcher_authorization_header() {
let header = launcher_authorization_header("1.7.2", "", Some(1_700_000_000)).unwrap();
let value: serde_json::Value = serde_json::from_str(&header).unwrap();
assert_eq!(value["head"]["game_tag"], "BlueArchive_JP");
assert_eq!(value["head"]["time"], 1_700_000_000);
assert_eq!(value["head"]["version"], "1.7.2");
assert_eq!(value["sign"], "b457b5bc3d7796d7a37040d4b7990cb9");
}
#[test]
fn parses_launcher_game_config_payload() {
let config: YostarJpLauncherGameConfig = serde_json::from_str(
r#"{
"game_latest_version": "1.70.0",
"game_latest_file_path": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip",
"game_lowest_version": "1.69.0",
"game_start_exe_name": "xldr_BlueArchiveOnline_JP_loader_x64",
"game_start_params": ["BlueArchive.exe"]
}"#,
)
.unwrap();
assert_eq!(config.game_latest_version, "1.70.0");
assert_eq!(
config.game_start_exe_name.as_deref(),
Some("xldr_BlueArchiveOnline_JP_loader_x64")
);
assert_eq!(config.game_start_params, vec!["BlueArchive.exe"]);
}
#[test]
fn parses_launcher_manifest_url_payload() {
let manifest_url: YostarJpLauncherManifestUrl = serde_json::from_str(
r#"{
"url": "https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/manifest.json"
}"#,
)
.unwrap();
assert!(is_official_launcher_package_url(&manifest_url.url));
}
#[test]
fn parses_remote_manifest_payload() {
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_str(
r#"{
"source": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip",
"file": [
{
"path": "/BlueArchive.exe",
"size": "100",
"hash": "123",
"vc": "abc"
}
]
}"#,
)
.unwrap();
assert_eq!(manifest.files.len(), 1);
assert_eq!(manifest.files[0].path, "/BlueArchive.exe");
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Official JP resource pull planning.
use bat_adapters::official::inventory::{
YostarJpDownloadInventory, YostarJpPlatformDownloadInventory,
};
use bat_adapters::official::yostar_jp::{
verified_official_platforms, PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceRoot,
};
use std::collections::HashSet;
/// Full official JP pull plan, covering discovery endpoints and concrete
/// resource downloads.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialResourcePullPlan {
/// Seed URLs and selected root from server-info.
pub discovery: YostarJpResourceDiscoveryPlan,
/// Concrete file inventory extracted from the catalog bytes.
pub inventory: YostarJpPlatformDownloadInventory,
/// Platforms that will be pulled.
pub platforms: Vec<PatchPlatform>,
}
impl OfficialResourcePullPlan {
/// Creates a pull plan for the requested platforms.
pub fn new(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpDownloadInventory,
platforms: &[PatchPlatform],
) -> Self {
Self::new_platform_inventory(
discovery,
YostarJpPlatformDownloadInventory::from_shared_inventory(inventory, platforms),
platforms,
)
}
/// Creates a pull plan for the requested platforms using platform-specific
/// bundle/media catalogs.
pub fn new_platform_inventory(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpPlatformDownloadInventory,
platforms: &[PatchPlatform],
) -> Self {
Self {
discovery,
inventory,
platforms: unique_platforms(platforms),
}
}
/// Creates a pull plan for the verified official JP platforms.
pub fn verified(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpDownloadInventory,
) -> Self {
Self::new(discovery, inventory, &verified_official_platforms())
}
/// Creates a pull plan for the verified official JP platforms using
/// platform-specific bundle/media catalogs.
pub fn verified_platform_inventory(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpPlatformDownloadInventory,
) -> Self {
Self::new_platform_inventory(discovery, inventory, &verified_official_platforms())
}
/// Returns all discovery URLs in server-provided order.
pub fn discovery_urls(&self) -> Vec<String> {
self.discovery
.endpoints
.iter()
.map(|endpoint| endpoint.url.clone())
.collect()
}
/// Returns the validated official resource root used by the pull plan.
pub fn resource_root(&self) -> Result<YostarJpResourceRoot, String> {
YostarJpResourceRoot::from_addressables_root(&self.discovery.addressables_root)
}
/// Returns all concrete download URLs for the selected platforms.
pub fn content_urls(&self) -> Result<Vec<String>, String> {
let root = self.resource_root()?;
self.inventory
.direct_download_urls_for_platforms(&root, &self.platforms)
}
/// Returns discovery URLs followed by concrete content URLs.
pub fn all_urls(&self) -> Result<Vec<String>, String> {
let mut urls = self.discovery_urls();
let mut seen = urls.iter().cloned().collect::<HashSet<_>>();
for url in self.content_urls()? {
if seen.insert(url.clone()) {
urls.push(url);
}
}
Ok(urls)
}
}
/// Builds a pull plan for the verified official JP platforms.
pub fn build_official_pull_plan(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpDownloadInventory,
) -> OfficialResourcePullPlan {
OfficialResourcePullPlan::verified(discovery, inventory)
}
/// Builds a pull plan for the verified official JP platforms using
/// platform-specific bundle/media catalogs.
pub fn build_official_pull_plan_from_platform_inventory(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpPlatformDownloadInventory,
) -> OfficialResourcePullPlan {
OfficialResourcePullPlan::verified_platform_inventory(discovery, inventory)
}
/// Builds a pull plan for the requested platforms.
pub fn build_official_pull_plan_for_platforms(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpDownloadInventory,
platforms: &[PatchPlatform],
) -> OfficialResourcePullPlan {
OfficialResourcePullPlan::new(discovery, inventory, platforms)
}
/// Builds a pull plan for the requested platforms using platform-specific
/// bundle/media catalogs.
pub fn build_official_pull_plan_for_platform_inventory(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpPlatformDownloadInventory,
platforms: &[PatchPlatform],
) -> OfficialResourcePullPlan {
OfficialResourcePullPlan::new_platform_inventory(discovery, inventory, platforms)
}
fn unique_platforms(platforms: &[PatchPlatform]) -> Vec<PatchPlatform> {
platforms
.iter()
.copied()
.fold(Vec::new(), |mut unique, platform| {
if !unique.contains(&platform) {
unique.push(platform);
}
unique
})
}
#[cfg(test)]
mod tests {
use super::*;
use bat_adapters::official::inventory::{
YostarJpDownloadInventory, YostarJpPlatformCatalogInventory,
YostarJpPlatformDownloadInventory,
};
use bat_adapters::official::yostar_jp::{
YostarJpResourceEndpoint, YostarJpResourceEndpointKind,
};
fn discovery_plan() -> YostarJpResourceDiscoveryPlan {
YostarJpResourceDiscoveryPlan {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: Some("s8tloc7lo3".to_string()),
addressables_root:
"https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(),
endpoints: vec![
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalog,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes".to_string(),
},
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalogHash,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.hash".to_string(),
},
],
}
}
fn inventory() -> YostarJpDownloadInventory {
YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip",
b"ExcelDB.db",
b"JP_Airi.zip",
)
}
fn platform_inventory() -> YostarJpPlatformDownloadInventory {
YostarJpPlatformDownloadInventory::from_catalog_bytes(
b"ExcelDB.db",
vec![
YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Windows,
b"FullPatch_000.zip",
b"JP_Airi_Win.zip",
),
YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Android,
b"FullPatch_001.zip",
b"JP_Airi_Android.zip",
),
],
)
}
#[test]
fn builds_verified_pull_plan_with_all_urls() {
let plan = build_official_pull_plan(discovery_plan(), inventory());
assert_eq!(plan.platforms, verified_official_platforms());
assert_eq!(plan.discovery_urls().len(), 2);
let content_urls = plan.content_urls().unwrap();
assert!(content_urls
.iter()
.any(|url| url.contains("/Windows_PatchPack/FullPatch_000.zip")));
assert!(content_urls
.iter()
.any(|url| url.contains("/Android_PatchPack/FullPatch_000.zip")));
assert_eq!(content_urls.len(), 5);
let all_urls = plan.all_urls().unwrap();
assert_eq!(all_urls.len(), 7);
assert!(all_urls[0].ends_with("TableCatalog.bytes"));
assert!(all_urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
assert_eq!(
all_urls
.iter()
.filter(|url| url.ends_with("/MediaResources/JP_Airi.zip"))
.count(),
1
);
}
#[test]
fn deduplicates_requested_platforms() {
let plan = build_official_pull_plan_for_platforms(
discovery_plan(),
inventory(),
&[PatchPlatform::Windows, PatchPlatform::Windows],
);
assert_eq!(plan.platforms, vec![PatchPlatform::Windows]);
}
#[test]
fn verified_pull_plan_is_windows_and_android_only() {
let plan = build_official_pull_plan(discovery_plan(), inventory());
assert_eq!(
plan.platforms,
vec![PatchPlatform::Windows, PatchPlatform::Android]
);
}
#[test]
fn builds_platform_inventory_pull_plan_without_cross_mixing_catalogs() {
let plan = build_official_pull_plan_from_platform_inventory(
discovery_plan(),
platform_inventory(),
);
let all_urls = plan.all_urls().unwrap();
assert_eq!(plan.platforms, verified_official_platforms());
assert_eq!(all_urls.len(), 7);
assert!(all_urls
.iter()
.any(|url| url.ends_with("/Windows_PatchPack/FullPatch_000.zip")));
assert!(all_urls
.iter()
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip")));
assert!(!all_urls
.iter()
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_000.zip")));
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Official JP synchronization planning.
use bat_adapters::official::yostar_jp::{
verified_official_platforms, PatchPlatform, YostarJpResourceDiscoveryPlan,
YostarJpResourceEndpointChange, YostarJpSyncDelta, YostarJpSyncSnapshot,
};
/// High-level official update decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OfficialSyncDecision {
/// No material change was detected.
UpToDate,
/// Official state changed and content should be downloaded and verified.
DownloadAndVerify,
/// Official state changed enough that the published client snapshot must be updated.
DownloadVerifyAndPublish,
}
/// Comparison result used by schedulers and manual actions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialSyncPlan {
/// Observed official snapshot.
pub snapshot: YostarJpSyncSnapshot,
/// Difference from the previous stored snapshot.
pub delta: YostarJpSyncDelta,
/// Whether the sync should be executed now.
pub decision: OfficialSyncDecision,
}
impl OfficialSyncPlan {
/// Returns true when a download should start.
pub fn should_download(&self) -> bool {
matches!(
self.decision,
OfficialSyncDecision::DownloadAndVerify
| OfficialSyncDecision::DownloadVerifyAndPublish
)
}
/// Returns true when the result may be pushed to clients after verification.
pub fn should_publish(&self) -> bool {
matches!(
self.decision,
OfficialSyncDecision::DownloadVerifyAndPublish
)
}
}
/// Builds a sync plan from an observed snapshot and an optional previous snapshot.
pub fn build_official_sync_plan(
snapshot: YostarJpSyncSnapshot,
previous: Option<&YostarJpSyncSnapshot>,
) -> OfficialSyncPlan {
let delta = snapshot.diff(previous);
let decision = classify_sync_decision(&delta);
OfficialSyncPlan {
snapshot,
delta,
decision,
}
}
/// Classifies the sync decision from a snapshot delta.
pub fn classify_sync_decision(delta: &YostarJpSyncDelta) -> OfficialSyncDecision {
if delta.is_initial {
return OfficialSyncDecision::DownloadVerifyAndPublish;
}
if !delta.has_changes() {
return OfficialSyncDecision::UpToDate;
}
if delta.addressables_root_changed
|| delta.root_token_changed
|| !delta.endpoint_changes.is_empty()
{
return OfficialSyncDecision::DownloadVerifyAndPublish;
}
if delta.bundle_version_changed {
return OfficialSyncDecision::DownloadAndVerify;
}
OfficialSyncDecision::UpToDate
}
/// Returns the ordered platform set used by the official JP downloader.
pub fn default_official_platforms() -> Vec<PatchPlatform> {
verified_official_platforms()
}
/// Returns all endpoints that should be fetched before any patch publish.
pub fn stable_discovery_endpoints(plan: &YostarJpResourceDiscoveryPlan) -> Vec<String> {
plan.endpoints
.iter()
.map(|endpoint| endpoint.url.clone())
.collect()
}
/// Returns endpoint URLs that changed compared with a previous sync snapshot.
pub fn changed_endpoint_urls(delta: &YostarJpSyncDelta) -> Vec<String> {
delta
.endpoint_changes
.iter()
.map(|change| match change {
YostarJpResourceEndpointChange::Added(endpoint) => endpoint.url.clone(),
YostarJpResourceEndpointChange::Removed(endpoint) => endpoint.url.clone(),
YostarJpResourceEndpointChange::Updated { current, .. } => current.url.clone(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use bat_adapters::official::yostar_jp::{
YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint, YostarJpResourceEndpointKind,
};
fn snapshot() -> YostarJpSyncSnapshot {
YostarJpSyncSnapshot {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: Some("s8tloc7lo3".to_string()),
addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(),
endpoints: vec![
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalog,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes".to_string(),
},
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::AddressablesCatalog,
platform: Some(PatchPlatform::Windows),
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/Windows_PatchPack/catalog_StandaloneWindows64.zip".to_string(),
},
],
}
}
fn discovery_plan() -> YostarJpResourceDiscoveryPlan {
YostarJpResourceDiscoveryPlan {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: Some("s8tloc7lo3".to_string()),
addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token"
.to_string(),
endpoints: snapshot().endpoints,
}
}
#[test]
fn classifies_initial_snapshot_as_publishable() {
let plan = build_official_sync_plan(snapshot(), None);
assert!(plan.delta.is_initial);
assert_eq!(
plan.decision,
OfficialSyncDecision::DownloadVerifyAndPublish
);
assert!(plan.should_download());
assert!(plan.should_publish());
}
#[test]
fn classifies_identical_snapshot_as_up_to_date() {
let current = snapshot();
let plan = build_official_sync_plan(current.clone(), Some(&current));
assert_eq!(plan.decision, OfficialSyncDecision::UpToDate);
assert!(!plan.should_download());
assert!(!plan.should_publish());
}
#[test]
fn classifies_bundle_version_only_change_as_download_only() {
let current = snapshot();
let previous = YostarJpSyncSnapshot {
bundle_version: Some("older".to_string()),
..current.clone()
};
let delta = current.diff(Some(&previous));
assert!(delta.bundle_version_changed);
assert_eq!(
classify_sync_decision(&delta),
OfficialSyncDecision::DownloadAndVerify
);
}
#[test]
fn exposes_changed_endpoint_urls() {
let mut current = snapshot();
current.endpoints[0].url = "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes?v=2".to_string();
let delta = current.diff(Some(&snapshot()));
let urls = changed_endpoint_urls(&delta);
assert_eq!(urls.len(), 1);
assert!(urls[0].contains("v=2"));
}
#[test]
fn keeps_discovery_endpoint_urls_stable() {
let plan = discovery_plan();
assert_eq!(stable_discovery_endpoints(&plan).len(), 2);
}
}
+217
View File
@@ -0,0 +1,217 @@
//! 内存资源仓储实现。
use async_trait::async_trait;
use bat_core::domain::Resource;
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
use std::collections::HashMap;
use tokio::sync::RwLock;
/// 以内存 `HashMap` 保存资源索引的仓储实现。
#[derive(Debug, Default)]
pub struct InMemoryResourceRepository {
resources: RwLock<HashMap<String, Resource>>,
}
impl InMemoryResourceRepository {
/// 创建空资源仓储。
pub fn new() -> Self {
Self::default()
}
fn sorted_resources(resources: &HashMap<String, Resource>) -> Vec<Resource> {
let mut resources = resources.values().cloned().collect::<Vec<_>>();
resources.sort_by(|left, right| left.id.cmp(&right.id));
resources
}
}
#[async_trait]
impl ResourceRepository for InMemoryResourceRepository {
async fn add(&self, resource: Resource) -> bat_core::Result<String> {
let id = resource.id.clone();
self.resources.write().await.insert(id.clone(), resource);
Ok(id)
}
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
self.resources
.read()
.await
.get(id)
.cloned()
.ok_or_else(|| bat_core::Error::NotFound(id.to_string()))
}
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
let resources = self.resources.read().await;
Self::sorted_resources(&resources)
.into_iter()
.find(|resource| resource.entry.hash == hash)
.ok_or_else(|| bat_core::Error::NotFound(hash.to_string()))
}
async fn list(&self, query: ResourceQuery) -> bat_core::Result<Vec<Resource>> {
let resources = self.resources.read().await;
let resources = Self::sorted_resources(&resources)
.into_iter()
.filter(|resource| query_matches(&query, resource))
.collect();
Ok(resources)
}
async fn update(&self, resource: Resource) -> bat_core::Result<()> {
let mut resources = self.resources.write().await;
if !resources.contains_key(&resource.id) {
return Err(bat_core::Error::NotFound(resource.id));
}
resources.insert(resource.id.clone(), resource);
Ok(())
}
async fn delete(&self, id: &str) -> bat_core::Result<()> {
self.resources
.write()
.await
.remove(id)
.map(|_| ())
.ok_or_else(|| bat_core::Error::NotFound(id.to_string()))
}
async fn count(&self, query: ResourceQuery) -> bat_core::Result<u64> {
Ok(self.list(query).await?.len() as u64)
}
}
fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool {
if let Some(resource_type) = query.resource_type {
if resource.entry.resource_type != resource_type {
return false;
}
}
if let Some(hash) = &query.hash {
if resource.entry.hash != *hash {
return false;
}
}
if let Some(pattern) = &query.path_pattern {
if !wildcard_matches(pattern, &resource.entry.path) {
return false;
}
}
true
}
fn wildcard_matches(pattern: &str, value: &str) -> bool {
wildcard_matches_bytes(pattern.as_bytes(), value.as_bytes())
}
fn wildcard_matches_bytes(pattern: &[u8], value: &[u8]) -> bool {
match (pattern.first(), value.first()) {
(None, None) => true,
(None, Some(_)) => false,
(Some(b'*'), _) => {
wildcard_matches_bytes(&pattern[1..], value)
|| value
.first()
.is_some_and(|_| wildcard_matches_bytes(pattern, &value[1..]))
}
(Some(b'?'), Some(_)) => wildcard_matches_bytes(&pattern[1..], &value[1..]),
(Some(pattern_byte), Some(value_byte)) if pattern_byte == value_byte => {
wildcard_matches_bytes(&pattern[1..], &value[1..])
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use bat_core::domain::{ResourceEntry, ResourceType};
use std::path::PathBuf;
fn resource(id: &str, path: &str, hash: &str, resource_type: ResourceType) -> Resource {
Resource {
id: id.to_string(),
local_path: PathBuf::from(path),
entry: ResourceEntry {
path: path.to_string(),
hash: hash.to_string(),
size: 7,
resource_type,
},
}
}
#[tokio::test]
async fn add_find_update_and_delete_resource() {
let repository = InMemoryResourceRepository::new();
let id = repository
.add(resource(
"resource/synthetic-minimal.bundle",
"synthetic-minimal.bundle",
"hash-a",
ResourceType::AssetBundle,
))
.await
.unwrap();
assert_eq!(id, "resource/synthetic-minimal.bundle");
assert_eq!(
repository.find_by_id(&id).await.unwrap().entry.path,
"synthetic-minimal.bundle"
);
let mut updated = repository.find_by_id(&id).await.unwrap();
updated.entry.size = 12;
repository.update(updated).await.unwrap();
assert_eq!(repository.find_by_id(&id).await.unwrap().entry.size, 12);
repository.delete(&id).await.unwrap();
assert!(matches!(
repository.find_by_id(&id).await,
Err(bat_core::Error::NotFound(_))
));
}
#[tokio::test]
async fn list_filters_by_type_hash_and_path() {
let repository = InMemoryResourceRepository::new();
repository
.add(resource(
"resource/a",
"synthetic-minimal.bundle",
"hash-a",
ResourceType::AssetBundle,
))
.await
.unwrap();
repository
.add(resource(
"resource/b",
"catalog.json",
"hash-b",
ResourceType::Manifest,
))
.await
.unwrap();
let query = ResourceQuery {
resource_type: Some(ResourceType::AssetBundle),
hash: Some("hash-a".to_string()),
path_pattern: Some("synthetic-*.bundle".to_string()),
};
let results = repository.list(query).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "resource/a");
assert_eq!(
repository.find_by_hash("hash-a").await.unwrap().id,
"resource/a"
);
assert_eq!(repository.count(ResourceQuery::all()).await.unwrap(), 2);
}
}
@@ -0,0 +1,24 @@
{
"m_LocatorId": "AddressablesMainContentCatalog",
"m_InternalIdPrefixes": [
"https://synthetic.invalid/bundles/"
],
"m_InternalIds": [
"synthetic/minimal.bundle",
"synthetic/catalog.json"
],
"m_KeyDataString": "synthetic-key-data",
"m_EntryDataString": "synthetic-entry-data",
"m_Entries": [
{
"internal_id": "synthetic/minimal.bundle",
"hash": "synthetic-fixture-entry-hash",
"size": 119
},
{
"internal_id": "synthetic/catalog.json",
"hash": "synthetic-fixture-catalog-hash",
"size": 2
}
]
}
@@ -0,0 +1,20 @@
{
"imported": [
{
"bytes": 143,
"source_path": "synthetic/minimal.bundle",
"unityfs": {
"block_count": 1,
"directories": [
"SYNTHETIC-GOLDEN-CAB"
],
"directory_count": 1,
"unity_version": "2021.3.56f2"
}
}
],
"resource_count": 1,
"skipped": [
"synthetic/catalog.json"
]
}
@@ -0,0 +1,631 @@
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
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,
};
use bat_infrastructure::{
build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService,
OfficialResourcePullService,
};
use std::collections::HashMap;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use tempfile::TempDir;
// Synthetic fixture values for an official-only integration test.
const TEST_LAUNCHER_VERSION: &str = "1.7.2-fixture";
const TEST_LAUNCHER_LATEST_VERSION: &str = "1.70.0-fixture";
const TEST_LAUNCHER_LATEST_FILE_PATH: &str =
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-launcher-latest.zip";
const TEST_LAUNCHER_MANIFEST_URL: &str =
"https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/manifest.json";
const TEST_LAUNCHER_GAME_CONFIG_JSON_URL: &str =
"https://api-launcher-jp.yo-star.com/api/launcher/game/config/json?version=1.70.0-fixture&file_path=prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2FBlueArchive_JP-launcher-latest.zip";
const TEST_LAUNCHER_MANIFEST_SOURCE: &str =
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip";
const TEST_LAUNCHER_GAME_ZIP_URL: &str =
"https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip";
const TEST_SERVER_INFO_URL: &str =
"https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json";
const TEST_CONNECTION_GROUP: &str = "Fixture-Audit";
const TEST_ADDRESSABLES_ROOT: &str =
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55";
#[test]
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.manifest_url, TEST_LAUNCHER_MANIFEST_URL);
assert_eq!(
bootstrap.manifest_source.as_deref(),
Some(TEST_LAUNCHER_MANIFEST_SOURCE)
);
assert_eq!(bootstrap.game_zip_url, TEST_LAUNCHER_GAME_ZIP_URL);
assert_eq!(bootstrap.manifest_file_count, 1);
assert_eq!(
bootstrap.game_main_config.server_info_data_url.as_deref(),
Some(TEST_SERVER_INFO_URL)
);
assert_eq!(
bootstrap.game_main_config.default_connection_group.as_deref(),
Some(TEST_CONNECTION_GROUP)
);
assert_ne!(
bootstrap.game_zip_url,
format!(
"https://launcher-pkg-ba-jp.yo-star.com/{}",
bootstrap.game_config.game_latest_file_path
)
);
}
#[test]
fn official_pipeline_can_dry_run_and_pull_without_local_client() {
let harness = TestHarness::new();
let bootstrap = harness.fetch_bootstrap();
let fetcher = harness.fetcher();
let server_info_url = bootstrap
.game_main_config
.server_info_data_url
.as_ref()
.unwrap();
assert!(is_official_yostar_jp_url(server_info_url));
let server_info_bytes = fetcher.fetch_bytes(server_info_url).unwrap();
let server_info = YostarJpServerInfo::from_slice(&server_info_bytes).unwrap();
let discovery = server_info
.discovery_plan(
bootstrap
.game_main_config
.default_connection_group
.as_deref()
.unwrap(),
&bootstrap.game_config.game_latest_version,
&verified_official_platforms(),
)
.unwrap();
let inventory = fetch_platform_inventory(&fetcher, &discovery);
let plan = build_official_pull_plan_from_platform_inventory(discovery, inventory);
let all_urls = plan.all_urls().unwrap();
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()
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
assert!(all_urls
.iter()
.any(|url| url.ends_with("/Windows_PatchPack/FullPatch_000.zip")));
assert!(all_urls
.iter()
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip")));
let report = harness.pull_service().pull(&plan).unwrap();
assert_eq!(report.items.len(), all_urls.len());
assert!(report.total_bytes() > 0);
assert!(report.items.iter().all(|item| item.destination.exists()));
let curl_log = fs::read_to_string(&harness.curl_log).unwrap();
assert!(curl_log.contains(server_info_url));
assert!(!curl_log.contains("bluearchive.cafe"));
}
struct TestHarness {
temp: TempDir,
curl_script: std::path::PathBuf,
unzip_script: std::path::PathBuf,
curl_log: std::path::PathBuf,
}
impl TestHarness {
fn new() -> Self {
let temp = TempDir::new().unwrap();
let resources_assets = synthetic_resources_assets();
let resources_assets_path = temp.path().join("resources.assets");
fs::write(&resources_assets_path, &resources_assets).unwrap();
let zip_fixture = temp.path().join("downloaded.zip");
fs::write(&zip_fixture, b"dummy zip content").unwrap();
let curl_log = temp.path().join("curl.log");
let curl_script = temp.path().join("fake-curl");
write_executable(
&curl_script,
&official_curl_script(&curl_log, &zip_fixture),
);
let unzip_script = temp.path().join("fake-unzip");
write_executable(&unzip_script, &launcher_unzip_script(&resources_assets_path));
Self {
temp,
curl_script,
unzip_script,
curl_log,
}
}
fn fetch_bootstrap(&self) -> bat_infrastructure::official_game_main_config::OfficialGameMainConfigBootstrap {
OfficialGameMainConfigBootstrapService::with_commands(
TEST_LAUNCHER_VERSION,
&self.curl_script,
&self.unzip_script,
)
.fetch_bootstrap()
.unwrap()
}
fn fetcher(&self) -> OfficialResourcePullService {
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)
}
}
fn write_executable(path: &Path, content: &str) {
fs::write(path, content).unwrap();
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).unwrap();
}
fn official_curl_script(curl_log: &Path, zip_fixture: &Path) -> String {
format!(
r#"#!/usr/bin/env bash
set -euo pipefail
log={}
printf '%s\n' "$*" >> "$log"
output=""
url=""
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
output="$2"
shift 2
;;
--url)
url="$2"
shift 2
;;
*)
shift
;;
esac
done
emit_text() {{
local payload="$1"
if [[ -n "$output" ]]; then
mkdir -p "$(dirname "$output")"
printf '%s' "$payload" > "$output"
else
printf '%s' "$payload"
fi
}}
emit_zip_fixture() {{
mkdir -p "$(dirname "$output")"
cp {} "$output"
}}
if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then
cat <<'JSON'
{{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}}
JSON
elif [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/advanced/game/download/cdn" ]]; then
cat <<'JSON'
{{"code":200,"message":"ok","data":{{"primary_cdn":"https://launcher-pkg-ba-jp.yo-star.com","back_up_cdn":"https://launcher-pkg-ba-jp-bk.yo-star.com"}}}}
JSON
elif [[ "$url" == "{launcher_game_config_json_url}" ]]; then
cat <<'JSON'
{{"code":200,"message":"ok","data":{{"url":"{launcher_manifest_url}"}}}}
JSON
elif [[ "$url" == "{launcher_manifest_url}" ]]; then
cat <<'JSON'
{{"source":"{launcher_manifest_source}","file":[{{"path":"/BlueArchive.exe","size":"100","hash":"123"}}]}}
JSON
elif [[ "$url" == "{server_info_url}" ]]; then
cat <<'JSON'
{{"ConnectionGroups":[{{"Name":"{connection_group}","AddressablesCatalogUrlRoot":"{addressables_root}","BundleVersion":"s8tloc7lo3","IsLivePublished":true}}]}}
JSON
elif [[ "$url" == "{addressables_root}/Windows_PatchPack/catalog_StandaloneWindows64.zip" ]]; then
emit_zip_fixture
elif [[ "$url" == "{addressables_root}/Windows_PatchPack/catalog_StandaloneWindows64.hash" ]]; then
emit_text "catalog-windows-hash"
elif [[ "$url" == "{addressables_root}/Android_PatchPack/catalog_Android.zip" ]]; then
emit_zip_fixture
elif [[ "$url" == "{addressables_root}/Android_PatchPack/catalog_Android.hash" ]]; then
emit_text "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"
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"
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"
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"
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"
elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then
filename="${{url##*/}}"
if [[ "$filename" == *.zip ]]; then
emit_zip_fixture
else
emit_text "$filename"
fi
elif [[ -n "$output" && "$url" == *.zip ]]; then
emit_zip_fixture
else
echo "unexpected curl url: $url" >&2
exit 1
fi
"#,
shell_quote(curl_log),
shell_quote(zip_fixture),
launcher_latest_version = TEST_LAUNCHER_LATEST_VERSION,
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,
server_info_url = TEST_SERVER_INFO_URL,
connection_group = TEST_CONNECTION_GROUP,
addressables_root = TEST_ADDRESSABLES_ROOT
)
}
fn launcher_unzip_script(resources_assets_path: &Path) -> String {
format!(
r#"#!/usr/bin/env bash
set -euo pipefail
dest=""
while [[ $# -gt 0 ]]; do
case "$1" in
-d)
dest="$2"
shift 2
;;
*)
shift
;;
esac
done
mkdir -p "$dest/nested"
cp {} "$dest/nested/resources.assets"
"#,
shell_quote(resources_assets_path)
)
}
fn shell_quote(path: &Path) -> String {
let mut quoted = String::from("'");
for ch in path.to_string_lossy().chars() {
if ch == '\'' {
quoted.push_str("'\\''");
} else {
quoted.push(ch);
}
}
quoted.push('\'');
quoted
}
fn synthetic_resources_assets() -> Vec<u8> {
let json = r#"{
"SkipTutorial": true,
"Language": "ja-JP",
"DefaultConnectionGroup": "Fixture-Audit",
"ServerInfoDataUrl": "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json"
}"#;
let encrypted = encrypt_game_main_config(json);
let file = synthetic_serialized_file(&encrypted);
let parsed = YostarJpGameMainConfig::from_resources_assets_bytes(&file).unwrap();
assert_eq!(parsed.skip_tutorial, Some(true));
assert_eq!(parsed.language.as_deref(), Some("ja-JP"));
file
}
fn fetch_platform_inventory(
fetcher: &OfficialResourcePullService,
discovery: &YostarJpResourceDiscoveryPlan,
) -> YostarJpPlatformDownloadInventory {
let mut table_catalog = None;
let mut bundle_packing_infos = HashMap::<PatchPlatform, Vec<u8>>::new();
let mut media_catalogs = HashMap::<PatchPlatform, Vec<u8>>::new();
for endpoint in &discovery.endpoints {
match endpoint.kind {
YostarJpResourceEndpointKind::TableCatalog => {
table_catalog = Some(fetcher.fetch_bytes(&endpoint.url).unwrap());
}
YostarJpResourceEndpointKind::BundlePackingInfo => {
let platform = endpoint.platform.unwrap();
bundle_packing_infos.insert(platform, fetcher.fetch_bytes(&endpoint.url).unwrap());
}
YostarJpResourceEndpointKind::MediaCatalog => {
let platform = endpoint.platform.unwrap();
media_catalogs.insert(platform, fetcher.fetch_bytes(&endpoint.url).unwrap());
}
_ => {}
}
}
let table_catalog = table_catalog.unwrap();
let platform_catalogs = verified_official_platforms()
.into_iter()
.map(|platform| {
YostarJpPlatformCatalogInventory::from_catalog_bytes(
platform,
bundle_packing_infos.get(&platform).unwrap(),
media_catalogs.get(&platform).unwrap(),
)
})
.collect();
YostarJpPlatformDownloadInventory::from_catalog_bytes(&table_catalog, platform_catalogs)
}
fn encrypt_game_main_config(json: &str) -> Vec<u8> {
let key = create_key("GameMainConfig");
let mut utf16 = json
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>();
xor_bytes(&mut utf16, &key);
utf16
}
fn create_key(name: &str) -> Vec<u8> {
let seed = xxhash32(name.as_bytes());
let mut mt = MersenneTwister::new(seed);
mt.next_bytes(8)
}
fn xor_bytes(bytes: &mut [u8], key: &[u8]) {
if key.is_empty() {
return;
}
for (index, byte) in bytes.iter_mut().enumerate() {
*byte ^= key[index % key.len()];
}
}
fn xxhash32(bytes: &[u8]) -> u32 {
const PRIME1: u32 = 0x9E37_79B1;
const PRIME2: u32 = 0x85EB_CA77;
const PRIME3: u32 = 0xC2B2_AE3D;
const PRIME4: u32 = 0x27D4_EB2F;
const PRIME5: u32 = 0x1656_67B1;
let len = bytes.len();
let mut index = 0usize;
let mut hash = if len >= 16 {
let mut v1 = PRIME1.wrapping_add(PRIME2);
let mut v2 = PRIME2;
let mut v3 = 0;
let mut v4 = 0u32.wrapping_sub(PRIME1);
while index + 16 <= len {
v1 = round(v1, read_u32_le(bytes, index));
v2 = round(v2, read_u32_le(bytes, index + 4));
v3 = round(v3, read_u32_le(bytes, index + 8));
v4 = round(v4, read_u32_le(bytes, index + 12));
index += 16;
}
v1.rotate_left(1)
.wrapping_add(v2.rotate_left(7))
.wrapping_add(v3.rotate_left(12))
.wrapping_add(v4.rotate_left(18))
} else {
PRIME5
}
.wrapping_add(len as u32);
while index + 4 <= len {
hash = hash
.wrapping_add(read_u32_le(bytes, index).wrapping_mul(PRIME3))
.rotate_left(17)
.wrapping_mul(PRIME4);
index += 4;
}
while index < len {
hash = hash
.wrapping_add((bytes[index] as u32).wrapping_mul(PRIME5))
.rotate_left(11)
.wrapping_mul(PRIME1);
index += 1;
}
avalanche(hash)
}
fn round(acc: u32, input: u32) -> u32 {
const PRIME2: u32 = 0x85EB_CA77;
const PRIME1: u32 = 0x9E37_79B1;
acc.wrapping_add(input.wrapping_mul(PRIME2))
.rotate_left(13)
.wrapping_mul(PRIME1)
}
fn avalanche(mut hash: u32) -> u32 {
hash ^= hash >> 15;
hash = hash.wrapping_mul(0x85EB_CA6B);
hash ^= hash >> 13;
hash = hash.wrapping_mul(0xC2B2_AE35);
hash ^= hash >> 16;
hash
}
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
struct MersenneTwister {
mt: [u32; 624],
index: usize,
}
impl MersenneTwister {
fn new(seed: u32) -> Self {
let mut mt = [0u32; 624];
mt[0] = seed;
for i in 1..624 {
mt[i] = 1812433253u32
.wrapping_mul(mt[i - 1] ^ (mt[i - 1] >> 30))
.wrapping_add(i as u32);
}
Self { mt, index: 624 }
}
fn next_u32(&mut self) -> u32 {
if self.index >= 624 {
self.twist();
}
let mut y = self.mt[self.index];
self.index += 1;
y ^= y >> 11;
y ^= (y << 7) & 0x9D2C_5680;
y ^= (y << 15) & 0xEFC6_0000;
y ^= y >> 18;
y
}
fn next_bytes(&mut self, len: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(len);
while out.len() < len {
let bytes = (self.next_u32() >> 1).to_le_bytes();
for byte in bytes {
if out.len() == len {
break;
}
out.push(byte);
}
}
out
}
fn twist(&mut self) {
const UPPER_MASK: u32 = 0x8000_0000;
const LOWER_MASK: u32 = 0x7FFF_FFFF;
const MATRIX_A: u32 = 0x9908_B0DF;
for i in 0..624 {
let x = (self.mt[i] & UPPER_MASK) | (self.mt[(i + 1) % 624] & LOWER_MASK);
let mut x_a = x >> 1;
if x & 1 != 0 {
x_a ^= MATRIX_A;
}
self.mt[i] = self.mt[(i + 397) % 624] ^ x_a;
}
self.index = 0;
}
}
fn synthetic_serialized_file(bytes: &[u8]) -> Vec<u8> {
fn push_u32_be(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64_be(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i64_le(data: &mut Vec<u8>, value: i64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
let mut object_data = Vec::new();
push_u32_le(&mut object_data, 14);
object_data.extend_from_slice(b"GameMainConfig");
align(&mut object_data, 4);
push_u32_le(&mut object_data, bytes.len() as u32);
object_data.extend_from_slice(bytes);
let mut metadata = Vec::new();
metadata.extend_from_slice(b"2021.3.56f2\0");
push_i32_le(&mut metadata, 19);
metadata.push(0);
push_i32_le(&mut metadata, 1);
push_i32_le(&mut metadata, 49);
metadata.push(0);
push_i16_le(&mut metadata, 0);
metadata.extend_from_slice(&[0; 16]);
push_i32_le(&mut metadata, 1);
align(&mut metadata, 4);
push_i64_le(&mut metadata, 1);
push_u64_le(&mut metadata, 0);
push_u32_le(&mut metadata, object_data.len() as u32);
push_i32_le(&mut metadata, 0);
let header_len = 48usize;
let data_offset = header_len + metadata.len();
let file_size = data_offset + object_data.len();
let mut file = Vec::new();
push_u32_be(&mut file, metadata.len() as u32);
push_u32_be(&mut file, file_size as u32);
push_u32_be(&mut file, 22);
push_u32_be(&mut file, 0);
file.push(0);
file.extend_from_slice(&[0, 0, 0]);
push_u32_be(&mut file, metadata.len() as u32);
push_u64_be(&mut file, file_size as u64);
push_u64_be(&mut file, data_offset as u64);
push_u64_be(&mut file, 0);
file.extend_from_slice(&metadata);
file.extend_from_slice(&object_data);
file
}
@@ -0,0 +1,119 @@
use bat_adapters::manifest::ManifestDriverRegistry;
use bat_core::repositories::cas_repository::CasRepository;
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
use bat_infrastructure::{
BundleSource, FileSystemCasRepository, InMemoryResourceRepository, ResourceImportService,
};
use serde_json::json;
use tempfile::TempDir;
fn push_c_string(data: &mut Vec<u8>, value: &str) {
data.extend_from_slice(value.as_bytes());
data.push(0);
}
fn push_u16(data: &mut Vec<u8>, value: u16) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_i32(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn synthetic_minimal_unityfs_bundle() -> Vec<u8> {
let mut blocks_info = Vec::new();
blocks_info.extend_from_slice(&[0; 16]);
push_i32(&mut blocks_info, 1);
push_u32(&mut blocks_info, 4);
push_u32(&mut blocks_info, 4);
push_u16(&mut blocks_info, 0);
push_i32(&mut blocks_info, 1);
push_u64(&mut blocks_info, 0);
push_u64(&mut blocks_info, 4);
push_u32(&mut blocks_info, 0);
push_c_string(&mut blocks_info, "SYNTHETIC-GOLDEN-CAB");
let mut data = Vec::new();
push_c_string(&mut data, "UnityFS");
push_u32(&mut data, 8);
push_c_string(&mut data, "5.x.x");
push_c_string(&mut data, "2021.3.56f2");
push_u64(&mut data, 0);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, 0);
align(&mut data, 16);
data.extend_from_slice(&blocks_info);
data.extend_from_slice(b"data");
let total_size = data.len() as u64;
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
data
}
#[tokio::test]
async fn imports_synthetic_addressables_catalog_bundle_against_golden() {
let catalog = include_bytes!("fixtures/synthetic_phase2/catalog.json");
let manifest = ManifestDriverRegistry::with_defaults()
.parse(catalog)
.await
.unwrap();
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let report = service
.import_manifest_bundles(
&manifest,
&[BundleSource::new(
"minimal.bundle",
synthetic_minimal_unityfs_bundle(),
)],
)
.await
.unwrap();
assert_eq!(report.imported.len(), 1);
assert!(cas.exists(&report.imported[0].object_id).await);
let indexed = resources.list(ResourceQuery::all()).await.unwrap();
let actual = json!({
"imported": report.imported.iter().map(|imported| {
json!({
"bytes": imported.bytes,
"source_path": imported.source_path,
"unityfs": {
"block_count": imported.unityfs.block_count,
"directories": imported.unityfs.directories,
"directory_count": imported.unityfs.directory_count,
"unity_version": imported.unityfs.unity_version,
}
})
}).collect::<Vec<_>>(),
"resource_count": indexed.len(),
"skipped": report.skipped,
});
let expected: serde_json::Value =
serde_json::from_str(include_str!("golden/synthetic_phase2_import.json")).unwrap();
assert_eq!(actual, expected);
assert_eq!(indexed[0].entry.hash, report.imported[0].object_id);
}