Files
BlueArchiveToolkit/adapters/src/official/game_main_config.rs
T

371 lines
11 KiB
Rust

//! 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());
}
}