//! 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, /// Selected language. #[serde(default)] pub language: Option, /// Default connection group. #[serde(default)] pub default_connection_group: Option, /// Server-info JSON URL. #[serde(default)] pub server_info_data_url: Option, } impl YostarJpGameMainConfig { /// Reads and decrypts `GameMainConfig` from a Unity serialized file. pub fn from_resources_assets(path: impl AsRef) -> Result { 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 { let serialized = UnitySerializedFile::from_slice(bytes)?; Self::from_serialized_file(&serialized) } fn from_serialized_file(serialized: &UnitySerializedFile) -> Result { 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 { 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 { let seed = xxhash32(name.as_bytes()); let mut mt = MersenneTwister::new(seed); mt.next_bytes(8) } fn convert(value: &str, key: &[u8]) -> Result { 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 { 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::>(); 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 { 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 { let key = create_key("GameMainConfig"); let mut utf16 = json .encode_utf16() .flat_map(u16::to_le_bytes) .collect::>(); xor_bytes(&mut utf16, &key); utf16 } fn synthetic_serialized_file(bytes: &[u8]) -> Vec { fn push_u32_be(data: &mut Vec, value: u32) { data.extend_from_slice(&value.to_be_bytes()); } fn push_u64_be(data: &mut Vec, value: u64) { data.extend_from_slice(&value.to_be_bytes()); } fn push_u32_le(data: &mut Vec, value: u32) { data.extend_from_slice(&value.to_le_bytes()); } fn push_i32_le(data: &mut Vec, value: i32) { data.extend_from_slice(&value.to_le_bytes()); } fn push_i64_le(data: &mut Vec, value: i64) { data.extend_from_slice(&value.to_le_bytes()); } fn push_u64_le(data: &mut Vec, value: u64) { data.extend_from_slice(&value.to_le_bytes()); } fn push_i16_le(data: &mut Vec, value: i16) { data.extend_from_slice(&value.to_le_bytes()); } fn align(data: &mut Vec, 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()); } }