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
+370
View File
@@ -0,0 +1,370 @@
//! Official JP `GameMainConfig` decoder.
//!
//! This module reads the `GameMainConfig` text asset embedded in official
//! Unity serialized files and decrypts it using the client algorithm observed
//! from the JP build.
use crate::unity::serialized_file::UnitySerializedFile;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use serde::Deserialize;
use std::path::Path;
/// Decrypted official `GameMainConfig` payload.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub struct YostarJpGameMainConfig {
/// Whether tutorial is skipped.
#[serde(default)]
pub skip_tutorial: Option<bool>,
/// Selected language.
#[serde(default)]
pub language: Option<String>,
/// Default connection group.
#[serde(default)]
pub default_connection_group: Option<String>,
/// Server-info JSON URL.
#[serde(default)]
pub server_info_data_url: Option<String>,
}
impl YostarJpGameMainConfig {
/// Reads and decrypts `GameMainConfig` from a Unity serialized file.
pub fn from_resources_assets(path: impl AsRef<Path>) -> Result<Self, String> {
let serialized = UnitySerializedFile::from_path(path)?;
Self::from_serialized_file(&serialized)
}
/// Reads and decrypts `GameMainConfig` from serialized file bytes.
pub fn from_resources_assets_bytes(bytes: &[u8]) -> Result<Self, String> {
let serialized = UnitySerializedFile::from_slice(bytes)?;
Self::from_serialized_file(&serialized)
}
fn from_serialized_file(serialized: &UnitySerializedFile) -> Result<Self, String> {
let asset = serialized
.text_asset("GameMainConfig")
.ok_or_else(|| {
"GameMainConfig text asset not found in Unity serialized file".to_string()
})?;
Self::from_encrypted_bytes(&asset.bytes)
}
/// Decrypts an encrypted `GameMainConfig` payload from raw text-asset bytes.
pub fn from_encrypted_bytes(bytes: &[u8]) -> Result<Self, String> {
let encrypted = STANDARD.encode(bytes);
let key = create_key("GameMainConfig");
let decrypted = convert(&encrypted, &key)?;
serde_json::from_str(&decrypted)
.map_err(|error| format!("Failed to parse decrypted GameMainConfig JSON: {error}"))
}
}
fn create_key(name: &str) -> Vec<u8> {
let seed = xxhash32(name.as_bytes());
let mut mt = MersenneTwister::new(seed);
mt.next_bytes(8)
}
fn convert(value: &str, key: &[u8]) -> Result<String, String> {
if value.is_empty() {
return Ok(String::new());
}
let mut bytes = STANDARD
.decode(value)
.map_err(|error| format!("Failed to decode GameMainConfig base64 payload: {error}"))?;
xor_bytes(&mut bytes, key);
utf16le_to_string(&bytes)
}
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 utf16le_to_string(bytes: &[u8]) -> Result<String, String> {
if bytes.len() % 2 != 0 {
return Err("GameMainConfig decrypted byte length is not UTF-16LE aligned".into());
}
let units = bytes
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect::<Vec<_>>();
String::from_utf16(&units)
.map_err(|error| format!("Failed to decode GameMainConfig UTF-16LE payload: {error}"))
}
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;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::unity::serialized_file::UnitySerializedFile;
use std::path::Path;
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 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
}
#[test]
fn decodes_synthetic_game_main_config() {
let json = r#"{
"SkipTutorial": true,
"Language": "ja-JP",
"DefaultConnectionGroup": "Prod-Audit",
"ServerInfoDataUrl": "https://yostar-serverinfo.bluearchiveyostar.com/r93_x.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"));
assert_eq!(
parsed.default_connection_group.as_deref(),
Some("Prod-Audit")
);
assert_eq!(
parsed.server_info_data_url.as_deref(),
Some("https://yostar-serverinfo.bluearchiveyostar.com/r93_x.json")
);
}
#[test]
fn extracts_game_main_config_bytes_from_serialized_assets() {
let path = Path::new(
"/home/wanye/D/BlueArchive/AllResources/YostarGames/BlueArchive_JP/BlueArchive_Data/resources.assets",
);
if !path.exists() {
return;
}
let file = UnitySerializedFile::from_path(path).unwrap();
let asset = file.text_asset("GameMainConfig").unwrap();
assert!(!asset.bytes.is_empty());
}
}
+574
View File
@@ -0,0 +1,574 @@
//! Official JP download inventory extraction.
use super::yostar_jp::{verified_official_platforms, PatchPlatform, YostarJpResourceRoot};
use std::collections::{BTreeSet, HashSet};
/// Download inventory extracted from the official JP catalog bytes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct YostarJpDownloadInventory {
/// Patch-pack zip names from `BundlePackingInfo.bytes`.
pub bundle_patch_pack_names: Vec<String>,
/// Table file names from `TableCatalog.bytes`.
pub table_file_names: Vec<String>,
/// Media file names from `MediaCatalog.bytes`.
pub media_file_names: Vec<String>,
}
/// Platform-specific catalog inventory extracted from official JP bytes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct YostarJpPlatformCatalogInventory {
/// Platform this inventory belongs to.
pub platform: PatchPlatform,
/// Patch-pack zip names from this platform's `BundlePackingInfo.bytes`.
pub bundle_patch_pack_names: Vec<String>,
/// Media file names from this platform's `MediaCatalog.bytes`.
pub media_file_names: Vec<String>,
}
impl YostarJpPlatformCatalogInventory {
/// Extracts one platform inventory from official JP catalog bytes.
pub fn from_catalog_bytes(
platform: PatchPlatform,
bundle_packing_info: &[u8],
media_catalog: &[u8],
) -> Self {
Self {
platform,
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
media_file_names: extract_file_names(
media_catalog,
&["zip", "mp4", "png", "ogg", "wav"],
),
}
}
/// Returns this platform's official bundle patch-pack URLs.
pub fn bundle_patch_pack_urls(
&self,
root: &YostarJpResourceRoot,
) -> Result<Vec<String>, String> {
self.bundle_patch_pack_names
.iter()
.map(|name| root.bundle_patch_pack(self.platform, name))
.collect()
}
/// Returns this platform's official media URLs.
pub fn media_file_urls(&self, root: &YostarJpResourceRoot) -> Result<Vec<String>, String> {
self.media_file_names
.iter()
.map(|name| root.media_file(self.platform, name))
.collect()
}
}
/// Multi-platform download inventory with shared table files and platform
/// specific patch-pack/media catalogs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct YostarJpPlatformDownloadInventory {
/// Table file names from the shared `TableCatalog.bytes`.
pub table_file_names: Vec<String>,
/// Per-platform bundle/media inventories.
pub platform_catalogs: Vec<YostarJpPlatformCatalogInventory>,
}
impl YostarJpPlatformDownloadInventory {
/// Extracts a multi-platform inventory from official JP catalog bytes.
pub fn from_catalog_bytes(
table_catalog: &[u8],
platform_catalogs: Vec<YostarJpPlatformCatalogInventory>,
) -> Self {
Self {
table_file_names: extract_file_names(table_catalog, &["db", "zip"]),
platform_catalogs: merge_platform_catalogs(platform_catalogs),
}
}
/// Builds a platform inventory by reusing a legacy shared inventory for
/// each requested platform.
pub fn from_shared_inventory(
inventory: YostarJpDownloadInventory,
platforms: &[PatchPlatform],
) -> Self {
let platform_catalogs = unique_platforms(platforms)
.into_iter()
.map(|platform| YostarJpPlatformCatalogInventory {
platform,
bundle_patch_pack_names: inventory.bundle_patch_pack_names.clone(),
media_file_names: inventory.media_file_names.clone(),
})
.collect();
Self {
table_file_names: inventory.table_file_names,
platform_catalogs,
}
}
/// Returns the official table file URLs.
pub fn table_file_urls(&self, root: &YostarJpResourceRoot) -> Result<Vec<String>, String> {
self.table_file_names
.iter()
.map(|name| root.table_bundle(name))
.collect()
}
/// Returns the complete direct-download URL set for multiple platforms.
///
/// Table bundles are emitted once. Patch-pack and media files are emitted
/// from the matching platform catalog only, which avoids creating invalid
/// cross-platform URL combinations.
pub fn direct_download_urls_for_platforms(
&self,
root: &YostarJpResourceRoot,
platforms: &[PatchPlatform],
) -> Result<Vec<String>, String> {
let mut urls = Vec::new();
let mut seen = HashSet::new();
append_unique_urls(&mut urls, &mut seen, self.table_file_urls(root)?);
for platform in unique_platforms(platforms) {
let catalog = self.platform_catalog(platform).ok_or_else(|| {
format!(
"Missing official catalog inventory for platform: {}",
platform.as_str()
)
})?;
append_unique_urls(&mut urls, &mut seen, catalog.bundle_patch_pack_urls(root)?);
append_unique_urls(&mut urls, &mut seen, catalog.media_file_urls(root)?);
}
Ok(urls)
}
/// Returns the complete direct-download URL set for all verified official
/// JP platforms.
pub fn direct_download_urls_for_verified_platforms(
&self,
root: &YostarJpResourceRoot,
) -> Result<Vec<String>, String> {
self.direct_download_urls_for_platforms(root, &verified_official_platforms())
}
fn platform_catalog(
&self,
platform: PatchPlatform,
) -> Option<&YostarJpPlatformCatalogInventory> {
self.platform_catalogs
.iter()
.find(|catalog| catalog.platform == platform)
}
}
impl YostarJpDownloadInventory {
/// Extracts an inventory from the three official JP catalog bytes.
pub fn from_catalog_bytes(
bundle_packing_info: &[u8],
table_catalog: &[u8],
media_catalog: &[u8],
) -> Self {
Self {
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
table_file_names: extract_file_names(table_catalog, &["db", "zip"]),
media_file_names: extract_file_names(
media_catalog,
&["zip", "mp4", "png", "ogg", "wav"],
),
}
}
/// Returns the official bundle patch-pack URLs for a platform.
pub fn bundle_patch_pack_urls(
&self,
root: &YostarJpResourceRoot,
platform: PatchPlatform,
) -> Result<Vec<String>, String> {
self.bundle_patch_pack_names
.iter()
.map(|name| root.bundle_patch_pack(platform, name))
.collect()
}
/// Returns the official table file URLs.
pub fn table_file_urls(&self, root: &YostarJpResourceRoot) -> Result<Vec<String>, String> {
self.table_file_names
.iter()
.map(|name| root.table_bundle(name))
.collect()
}
/// Returns the official media file URLs for a platform.
pub fn media_file_urls(
&self,
root: &YostarJpResourceRoot,
platform: PatchPlatform,
) -> Result<Vec<String>, String> {
self.media_file_names
.iter()
.map(|name| root.media_file(platform, name))
.collect()
}
/// Returns the complete direct-download URL set for a platform.
pub fn direct_download_urls(
&self,
root: &YostarJpResourceRoot,
platform: PatchPlatform,
) -> Result<Vec<String>, String> {
let mut urls = self.bundle_patch_pack_urls(root, platform)?;
urls.extend(self.table_file_urls(root)?);
urls.extend(self.media_file_urls(root, platform)?);
Ok(urls)
}
/// Returns the complete direct-download URL set for multiple platforms.
///
/// Table bundles are emitted once. Platform-specific URLs are emitted in
/// platform order and deduplicated by URL so shared Android media paths
/// only appear once.
pub fn direct_download_urls_for_platforms(
&self,
root: &YostarJpResourceRoot,
platforms: &[PatchPlatform],
) -> Result<Vec<String>, String> {
let mut urls = Vec::new();
let mut seen = HashSet::new();
append_unique_urls(&mut urls, &mut seen, self.table_file_urls(root)?);
for platform in unique_platforms(platforms) {
append_unique_urls(
&mut urls,
&mut seen,
self.bundle_patch_pack_urls(root, platform)?,
);
append_unique_urls(&mut urls, &mut seen, self.media_file_urls(root, platform)?);
}
Ok(urls)
}
/// Returns the complete direct-download URL set for all verified official
/// JP platforms.
pub fn direct_download_urls_for_verified_platforms(
&self,
root: &YostarJpResourceRoot,
) -> Result<Vec<String>, String> {
self.direct_download_urls_for_platforms(root, &verified_official_platforms())
}
}
fn merge_platform_catalogs(
catalogs: Vec<YostarJpPlatformCatalogInventory>,
) -> Vec<YostarJpPlatformCatalogInventory> {
let mut merged = Vec::<YostarJpPlatformCatalogInventory>::new();
for catalog in catalogs {
if let Some(existing) = merged
.iter_mut()
.find(|existing| existing.platform == catalog.platform)
{
merge_names(
&mut existing.bundle_patch_pack_names,
catalog.bundle_patch_pack_names,
);
merge_names(&mut existing.media_file_names, catalog.media_file_names);
} else {
merged.push(catalog);
}
}
merged.sort_by_key(|catalog| catalog.platform);
merged
}
fn merge_names(existing: &mut Vec<String>, names: Vec<String>) {
let mut merged = existing.iter().cloned().collect::<BTreeSet<_>>();
merged.extend(names);
*existing = merged.into_iter().collect();
}
fn extract_full_patch_pack_names(data: &[u8]) -> Vec<String> {
extract_file_names(data, &["zip"]) // only .zip names survive here
.into_iter()
.filter(|name| is_full_patch_pack_name(name))
.collect()
}
fn extract_file_names(data: &[u8], extensions: &[&str]) -> Vec<String> {
let mut names = BTreeSet::new();
for string in extract_printable_strings(data, 4) {
for name in candidate_file_names(&string, extensions) {
names.insert(name);
}
}
names.into_iter().collect()
}
fn extract_printable_strings(data: &[u8], min_len: usize) -> Vec<String> {
let mut strings = Vec::new();
let mut current = Vec::new();
for &byte in data {
if byte.is_ascii_graphic() || byte == b' ' {
current.push(byte);
} else if current.len() >= min_len {
strings.push(String::from_utf8_lossy(&current).into_owned());
current.clear();
} else {
current.clear();
}
}
if current.len() >= min_len {
strings.push(String::from_utf8_lossy(&current).into_owned());
}
strings
}
fn candidate_file_names(value: &str, extensions: &[&str]) -> Vec<String> {
let mut names = Vec::new();
let bytes = value.as_bytes();
for extension in extensions {
let suffix = format!(".{extension}");
let mut search_from = 0;
while let Some(relative_index) = value[search_from..].find(&suffix) {
let extension_start = search_from + relative_index;
let start = filename_start(bytes, extension_start);
let end = extension_start + suffix.len();
let candidate = &value[start..end];
let candidate = candidate.rsplit(['/', '\\']).next().unwrap_or(candidate);
if is_plausible_file_name(candidate) {
names.push(candidate.to_string());
}
search_from = end;
}
}
names
}
fn filename_start(bytes: &[u8], mut index: usize) -> usize {
while index > 0 {
let byte = bytes[index - 1];
if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/' | b'\\') {
index -= 1;
} else {
break;
}
}
index
}
fn is_full_patch_pack_name(name: &str) -> bool {
let bytes = name.as_bytes();
if bytes.len() != 17 || !name.starts_with("FullPatch_") || !name.ends_with(".zip") {
return false;
}
bytes[10..13].iter().all(|byte| byte.is_ascii_digit())
}
fn is_plausible_file_name(name: &str) -> bool {
!name.is_empty()
&& !name.contains('/')
&& !name.contains('\\')
&& !name.contains("..")
&& !name.contains(':')
&& !name.contains('=')
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
}
fn unique_platforms(platforms: &[PatchPlatform]) -> Vec<PatchPlatform> {
platforms
.iter()
.copied()
.fold(Vec::new(), |mut unique, platform| {
if !unique.contains(&platform) {
unique.push(platform);
}
unique
})
}
fn append_unique_urls(urls: &mut Vec<String>, seen: &mut HashSet<String>, next_urls: Vec<String>) {
for url in next_urls {
if seen.insert(url.clone()) {
urls.push(url);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::official::yostar_jp::YostarJpResourceRoot;
const ROOT: &str = "r93_dctuo3tcd029wwxnvb55";
#[test]
fn extracts_download_names_from_synthetic_bytes() {
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
b"prefix FullPatch_000.zip noise FullPatch_114.zip suffix",
b"GameData\\Table\\ExcelDB.db\0rawdata/table/excel/ignored.bytes\0Battle.zip8",
b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0JP_Akane.zip",
);
assert_eq!(
inventory.bundle_patch_pack_names,
vec![
"FullPatch_000.zip".to_string(),
"FullPatch_114.zip".to_string()
]
);
assert_eq!(
inventory.table_file_names,
vec!["Battle.zip".to_string(), "ExcelDB.db".to_string(),]
);
assert_eq!(
inventory.media_file_names,
vec!["JP_Airi.zip".to_string(), "JP_Akane.zip".to_string(),]
);
}
#[test]
fn builds_direct_download_urls() {
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip FullPatch_001.zip",
b"ExcelDB.db Battle.zip",
b"JP_Airi.zip JP_Akane.zip",
);
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
let urls = inventory
.direct_download_urls(&root, PatchPlatform::Windows)
.unwrap();
assert_eq!(urls[0], "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/FullPatch_000.zip");
assert!(urls
.iter()
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
}
#[test]
fn builds_direct_download_urls_for_verified_platforms() {
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip",
b"ExcelDB.db",
b"JP_Airi.zip",
);
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
let urls = inventory
.direct_download_urls_for_verified_platforms(&root)
.unwrap();
assert_eq!(urls.len(), 5);
assert!(urls
.iter()
.any(|url| url.contains("/Windows_PatchPack/FullPatch_000.zip")));
assert!(urls
.iter()
.any(|url| url.contains("/Android_PatchPack/FullPatch_000.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources/JP_Airi.zip")));
}
#[test]
fn builds_platform_specific_download_urls_without_cross_mixing_catalogs() {
let inventory = 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",
),
],
);
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
let urls = inventory
.direct_download_urls_for_verified_platforms(&root)
.unwrap();
assert!(urls
.iter()
.any(|url| url.ends_with("/Windows_PatchPack/FullPatch_000.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi_Win.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources/JP_Airi_Android.zip")));
assert!(!urls
.iter()
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_000.zip")));
assert!(!urls
.iter()
.any(|url| url.ends_with("/Windows_PatchPack/FullPatch_001.zip")));
}
#[test]
#[ignore = "requires BAT_REAL_OFFICIAL_BUNDLE_PACKING_INFO, BAT_REAL_OFFICIAL_TABLE_CATALOG, BAT_REAL_OFFICIAL_MEDIA_CATALOG"]
fn extracts_realistic_counts_from_official_shape() {
let bundle_packing_info =
std::fs::read(std::env::var("BAT_REAL_OFFICIAL_BUNDLE_PACKING_INFO").unwrap()).unwrap();
let table_catalog =
std::fs::read(std::env::var("BAT_REAL_OFFICIAL_TABLE_CATALOG").unwrap()).unwrap();
let media_catalog =
std::fs::read(std::env::var("BAT_REAL_OFFICIAL_MEDIA_CATALOG").unwrap()).unwrap();
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
&bundle_packing_info,
&table_catalog,
&media_catalog,
);
assert_eq!(inventory.bundle_patch_pack_names.len(), 142);
assert_eq!(inventory.table_file_names.len(), 6351);
assert_eq!(inventory.media_file_names.len(), 1887);
assert!(inventory
.bundle_patch_pack_names
.iter()
.any(|name| name == "FullPatch_000.zip"));
assert!(inventory
.table_file_names
.iter()
.any(|name| name == "ExcelDB.db"));
assert!(inventory
.media_file_names
.iter()
.any(|name| name == "JP_Airi.zip"));
}
}
+243
View File
@@ -0,0 +1,243 @@
//! Official Yostar JP launcher metadata.
//!
//! The PC launcher has its own update chain for the Windows game client. This
//! module parses only the local launcher files written by the official launcher
//! (`manifest.json` and `game-launcher-config.json`). It does not infer Unity
//! resource server-info from mirror URLs.
use serde::Deserialize;
use std::fs;
use std::path::Path;
/// Official JP launcher game id.
pub const YOSTAR_JP_GAME_TAG: &str = "BlueArchive_JP";
/// Local game manifest file name.
pub const LAUNCHER_MANIFEST_FILE: &str = "manifest.json";
/// Local game launcher config file name.
pub const LAUNCHER_CONFIG_FILE: &str = "game-launcher-config.json";
/// Local `game-launcher-config.json`.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherConfig {
/// Game tag, expected to be `BlueArchive_JP`.
pub tag: String,
/// Executable name without `.exe`.
pub name: String,
/// Arguments passed by the launcher.
#[serde(default)]
pub params: Vec<String>,
/// Installed Windows game client version.
pub version: String,
/// Launcher integrity hash.
#[serde(default)]
pub vc: Option<String>,
}
impl YostarJpLauncherConfig {
/// Parses a launcher config JSON document.
pub fn from_slice(data: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(data)
}
/// Reads `game-launcher-config.json` from an installed game root.
pub fn from_game_root(root: impl AsRef<Path>) -> Result<Self, String> {
let path = root.as_ref().join(LAUNCHER_CONFIG_FILE);
let bytes = fs::read(&path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
let config = Self::from_slice(&bytes)
.map_err(|error| format!("Failed to parse {}: {error}", path.display()))?;
config.validate_official_jp()?;
Ok(config)
}
/// Validates that this config belongs to the official JP game tag.
pub fn validate_official_jp(&self) -> Result<(), String> {
if self.tag != YOSTAR_JP_GAME_TAG {
return Err(format!(
"Launcher config tag is not official JP: {}",
self.tag
));
}
Ok(())
}
}
/// Local `manifest.json` file written by the official launcher.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherManifest {
/// Game tag, expected to be `BlueArchive_JP`.
pub name: String,
/// Installed Windows game client version.
pub version: String,
/// Launcher API `game_latest_file_path` value used to fetch the manifest.
pub basis: String,
/// Manifest integrity hash.
#[serde(default)]
pub vc: Option<String>,
/// File entries in the installed Windows client.
#[serde(default)]
pub files: Vec<YostarJpLauncherManifestFile>,
}
impl YostarJpLauncherManifest {
/// Parses a launcher manifest JSON document.
pub fn from_slice(data: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(data)
}
/// Reads `manifest.json` from an installed game root.
pub fn from_game_root(root: impl AsRef<Path>) -> Result<Self, String> {
let path = root.as_ref().join(LAUNCHER_MANIFEST_FILE);
let bytes = fs::read(&path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
let manifest = Self::from_slice(&bytes)
.map_err(|error| format!("Failed to parse {}: {error}", path.display()))?;
manifest.validate_official_jp()?;
Ok(manifest)
}
/// Validates that this manifest belongs to the official JP game tag.
pub fn validate_official_jp(&self) -> Result<(), String> {
if self.name != YOSTAR_JP_GAME_TAG {
return Err(format!(
"Launcher manifest name is not official JP: {}",
self.name
));
}
Ok(())
}
}
/// One launcher manifest file entry.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherManifestFile {
/// Path relative to the game root. Official manifests usually prefix this
/// with `/`.
pub path: String,
/// File size as a decimal string.
pub size: String,
/// CRC64 hash as a decimal string.
pub hash: String,
/// Per-file integrity hash.
#[serde(default)]
pub vc: Option<String>,
}
/// Local installed official JP game bootstrap data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct YostarJpInstalledGameBootstrap {
/// Installed client version from `game-launcher-config.json`.
pub app_version: String,
/// Executable name without `.exe`.
pub executable_name: String,
/// Launcher arguments.
pub executable_params: Vec<String>,
/// Optional launcher manifest basis, when `manifest.json` exists.
pub basis: Option<String>,
/// Optional installed manifest file count.
pub manifest_file_count: Option<usize>,
}
impl YostarJpInstalledGameBootstrap {
/// Reads all available launcher metadata from an installed official JP game
/// root.
pub fn from_game_root(root: impl AsRef<Path>) -> Result<Self, String> {
let root = root.as_ref();
let config = YostarJpLauncherConfig::from_game_root(root)?;
let manifest = YostarJpLauncherManifest::from_game_root(root).ok();
if let Some(manifest) = &manifest {
if manifest.version != config.version {
return Err(format!(
"Launcher config version {} does not match manifest version {}",
config.version, manifest.version
));
}
}
Ok(Self {
app_version: config.version,
executable_name: config.name,
executable_params: config.params,
basis: manifest.as_ref().map(|manifest| manifest.basis.clone()),
manifest_file_count: manifest.as_ref().map(|manifest| manifest.files.len()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn parses_official_launcher_config() {
let config = YostarJpLauncherConfig::from_slice(
br#"{
"tag": "BlueArchive_JP",
"name": "xldr_BlueArchiveOnline_JP_loader_x64",
"params": ["BlueArchive.exe"],
"version": "1.70.0",
"vc": "hash"
}"#,
)
.unwrap();
assert_eq!(config.version, "1.70.0");
assert_eq!(config.params, vec!["BlueArchive.exe"]);
config.validate_official_jp().unwrap();
}
#[test]
fn builds_installed_game_bootstrap_from_local_files() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join(LAUNCHER_CONFIG_FILE),
r#"{
"tag": "BlueArchive_JP",
"name": "xldr_BlueArchiveOnline_JP_loader_x64",
"params": ["BlueArchive.exe"],
"version": "1.70.0"
}"#,
)
.unwrap();
fs::write(
dir.path().join(LAUNCHER_MANIFEST_FILE),
r#"{
"name": "BlueArchive_JP",
"version": "1.70.0",
"basis": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip",
"files": [{"path": "/BlueArchive.exe", "size": "1", "hash": "2"}]
}"#,
)
.unwrap();
let bootstrap = YostarJpInstalledGameBootstrap::from_game_root(dir.path()).unwrap();
assert_eq!(bootstrap.app_version, "1.70.0");
assert_eq!(
bootstrap.basis.as_deref(),
Some("prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip")
);
assert_eq!(bootstrap.manifest_file_count, Some(1));
}
#[test]
fn rejects_non_jp_launcher_config() {
let config = YostarJpLauncherConfig::from_slice(
br#"{
"tag": "BlueArchive_CN",
"name": "BlueArchive",
"version": "1.70.0"
}"#,
)
.unwrap();
assert!(config.validate_official_jp().is_err());
}
}
+19
View File
@@ -0,0 +1,19 @@
//! Official Blue Archive service adapters.
//!
//! This module only models URL rules observed from the official Yostar JP
//! client endpoints. Mirror-specific layers such as `bluearchive.cafe` or
//! `text=jp/voice=jp/media=jp` are intentionally excluded.
pub mod inventory;
pub mod game_main_config;
pub mod launcher;
pub mod yostar_jp;
pub use inventory::{
YostarJpDownloadInventory, YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
};
pub use game_main_config::YostarJpGameMainConfig;
pub use launcher::{
YostarJpInstalledGameBootstrap, YostarJpLauncherConfig, YostarJpLauncherManifest,
};
pub use yostar_jp::{verified_official_platforms, PatchPlatform, YostarJpResourceRoot};
File diff suppressed because it is too large Load Diff