//! 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 serde_json::{Map, Value}; use std::path::Path; const XOR_KEY_LEN: usize = 8; const FIELD_SKIP_TUTORIAL: &str = "SkipTutorial"; const FIELD_LANGUAGE: &str = "Language"; const FIELD_DEFAULT_CONNECTION_GROUP: &str = "DefaultConnectionGroup"; const FIELD_SERVER_INFO_DATA_URL: &str = "ServerInfoDataUrl"; /// 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 legacy_error = match parse_legacy_game_main_config(bytes) { Ok(config) => return Ok(config), Err(error) => error, }; parse_obfuscated_game_main_config(bytes).map_err(|obfuscated_error| { format!( "Failed to parse GameMainConfig as legacy JSON ({legacy_error}) or current obfuscated JSON ({obfuscated_error})" ) }) } } fn parse_legacy_game_main_config(bytes: &[u8]) -> Result { let key = create_key("GameMainConfig"); let decrypted = decrypt_raw_utf16le_xor(bytes, &key)?; parse_game_main_config_json(&decrypted) } fn parse_game_main_config_json(value: &str) -> Result { serde_json::from_str(value) .map_err(|error| format!("Failed to parse decrypted GameMainConfig JSON: {error}")) } fn parse_obfuscated_game_main_config(bytes: &[u8]) -> Result { let decrypted = decrypt_inferred_ascii_json(bytes)?; let value = serde_json::from_str::(&decrypted).map_err(|error| { format!("Failed to parse decrypted obfuscated GameMainConfig JSON: {error}") })?; let object = value .as_object() .ok_or_else(|| "Obfuscated GameMainConfig JSON is not an object".to_string())?; let skip_tutorial = decode_obfuscated_field(object, FIELD_SKIP_TUTORIAL)? .map(|value| parse_bool_field(FIELD_SKIP_TUTORIAL, &value)) .transpose()?; let language = decode_obfuscated_field(object, FIELD_LANGUAGE)?; let default_connection_group = decode_obfuscated_field(object, FIELD_DEFAULT_CONNECTION_GROUP)?; let server_info_data_url = decode_obfuscated_field(object, FIELD_SERVER_INFO_DATA_URL)?; if skip_tutorial.is_none() && language.is_none() && default_connection_group.is_none() && server_info_data_url.is_none() { return Err("Obfuscated GameMainConfig did not contain any known fields".to_string()); } Ok(YostarJpGameMainConfig { skip_tutorial, language, default_connection_group, server_info_data_url, }) } fn decrypt_inferred_ascii_json(bytes: &[u8]) -> Result { if !bytes.len().is_multiple_of(2) { return Err("Obfuscated GameMainConfig byte length is not UTF-16LE aligned".to_string()); } let high_key_1 = infer_constant_key_byte(bytes, 1) .ok_or_else(|| "Could not infer obfuscated GameMainConfig key byte 1".to_string())?; let high_key_3 = infer_constant_key_byte(bytes, 3) .ok_or_else(|| "Could not infer obfuscated GameMainConfig key byte 3".to_string())?; let high_key_5 = infer_constant_key_byte(bytes, 5) .ok_or_else(|| "Could not infer obfuscated GameMainConfig key byte 5".to_string())?; let high_key_7 = infer_constant_key_byte(bytes, 7) .ok_or_else(|| "Could not infer obfuscated GameMainConfig key byte 7".to_string())?; let candidates_0 = infer_ascii_key_candidates(bytes, 0); let candidates_2 = infer_ascii_key_candidates(bytes, 2); let candidates_4 = infer_ascii_key_candidates(bytes, 4); let candidates_6 = infer_ascii_key_candidates(bytes, 6); for key_0 in candidates_0 { for key_2 in &candidates_2 { for key_4 in &candidates_4 { for key_6 in &candidates_6 { let key = [ key_0, high_key_1, *key_2, high_key_3, *key_4, high_key_5, *key_6, high_key_7, ]; let decrypted = decrypt_raw_utf16le_xor(bytes, &key)?; if !decrypted.trim_start().starts_with('{') { continue; } let Ok(value) = serde_json::from_str::(&decrypted) else { continue; }; if looks_like_obfuscated_config_object(&value) { return Ok(decrypted); } } } } } Err("Could not infer obfuscated GameMainConfig UTF-16LE XOR key".to_string()) } fn infer_constant_key_byte(bytes: &[u8], key_index: usize) -> Option { let mut values = bytes .iter() .enumerate() .filter(|(index, _)| index % XOR_KEY_LEN == key_index) .map(|(_, byte)| *byte); let first = values.next()?; values.all(|value| value == first).then_some(first) } fn infer_ascii_key_candidates(bytes: &[u8], key_index: usize) -> Vec { (0u8..=u8::MAX) .filter(|candidate| { bytes .iter() .enumerate() .filter(|(index, _)| index % XOR_KEY_LEN == key_index) .all(|(_, byte)| is_json_ascii_byte(byte ^ candidate)) }) .collect() } fn is_json_ascii_byte(byte: u8) -> bool { matches!(byte, b'\t' | b'\n' | b'\r' | b' '..=b'~') } fn looks_like_obfuscated_config_object(value: &Value) -> bool { value.as_object().is_some_and(|object| { !object.is_empty() && object.iter().all(|(key, value)| { STANDARD.decode(key).is_ok() && value.as_str().is_some_and(is_base64ish) }) }) } fn is_base64ish(value: &str) -> bool { !value.is_empty() && STANDARD.decode(value).is_ok() } fn decode_obfuscated_field( object: &Map, field_name: &str, ) -> Result, String> { let plain_name = utf16le_bytes(field_name); for (encrypted_name, encrypted_value) in object { let Some(encrypted_value) = encrypted_value.as_str() else { continue; }; let Ok(encrypted_name_bytes) = STANDARD.decode(encrypted_name) else { continue; }; let Some(key) = derive_repeating_xor_key(&encrypted_name_bytes, &plain_name) else { continue; }; let decoded_name = decrypt_raw_utf16le_xor(&encrypted_name_bytes, &key)?; if decoded_name != field_name { continue; } let decoded_value = convert(encrypted_value, &key)?; return Ok(Some(decoded_value)); } Ok(None) } fn parse_bool_field(name: &str, value: &str) -> Result { match value.trim().to_ascii_lowercase().as_str() { "true" => Ok(true), "false" => Ok(false), _ => Err(format!( "GameMainConfig field {name} is not a bool: {value:?}" )), } } fn utf16le_bytes(value: &str) -> Vec { value.encode_utf16().flat_map(u16::to_le_bytes).collect() } fn derive_repeating_xor_key(cipher: &[u8], plain: &[u8]) -> Option> { if cipher.len() != plain.len() { return None; } let mut key = vec![None; XOR_KEY_LEN]; for (index, (cipher_byte, plain_byte)) in cipher.iter().zip(plain).enumerate() { let key_index = index % XOR_KEY_LEN; let key_byte = cipher_byte ^ plain_byte; if key[key_index].is_some_and(|existing| existing != key_byte) { return None; } key[key_index] = Some(key_byte); } key.into_iter().collect() } 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 bytes = STANDARD .decode(value) .map_err(|error| format!("Failed to decode GameMainConfig base64 payload: {error}"))?; decrypt_raw_utf16le_xor(&bytes, key) } fn decrypt_raw_utf16le_xor(bytes: &[u8], key: &[u8]) -> Result { let mut bytes = bytes.to_vec(); 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().is_multiple_of(2) { 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"); encrypt_utf16le_xor(json, &key) } fn encrypt_utf16le_xor(value: &str, key: &[u8]) -> Vec { let mut utf16 = utf16le_bytes(value); xor_bytes(&mut utf16, key); utf16 } fn encrypt_base64_utf16le_xor(value: &str, key: &[u8]) -> String { STANDARD.encode(encrypt_utf16le_xor(value, key)) } fn insert_obfuscated_field( object: &mut Map, field_name: &str, field_value: &str, key: &[u8], ) { object.insert( encrypt_base64_utf16le_xor(field_name, key), Value::String(encrypt_base64_utf16le_xor(field_value, key)), ); } 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_legacy_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 decodes_synthetic_obfuscated_game_main_config() { let mut object = Map::new(); insert_obfuscated_field( &mut object, FIELD_SKIP_TUTORIAL, "false", &[0xa3, 0x03, 0xf1, 0x42, 0x9b, 0xc2, 0x97, 0x08], ); insert_obfuscated_field( &mut object, FIELD_LANGUAGE, "Jp", &[0x8c, 0xbe, 0x65, 0x5a, 0xae, 0xef, 0x96, 0x05], ); insert_obfuscated_field( &mut object, FIELD_DEFAULT_CONNECTION_GROUP, "Prod-Audit", &[0xe1, 0x6f, 0xbb, 0x3a, 0xd3, 0x61, 0xf2, 0x12], ); insert_obfuscated_field( &mut object, FIELD_SERVER_INFO_DATA_URL, "https://yostar-serverinfo.bluearchiveyostar.com/r93_current.json", &[0x0c, 0x4e, 0x7d, 0x5c, 0x6e, 0x6a, 0x03, 0x76], ); let outer_json = Value::Object(object).to_string(); let outer_key = [0x1b, 0x23, 0x28, 0x06, 0xad, 0x72, 0xc2, 0x57]; let encrypted = encrypt_utf16le_xor(&outer_json, &outer_key); let file = synthetic_serialized_file(&encrypted); let parsed = YostarJpGameMainConfig::from_resources_assets_bytes(&file).unwrap(); assert_eq!(parsed.skip_tutorial, Some(false)); assert_eq!(parsed.language.as_deref(), Some("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_current.json") ); } #[test] #[ignore] 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()); } }