mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 00:55:15 +08:00
Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures. Fixes #13 Fixes #12 Fixes #8
634 lines
21 KiB
Rust
634 lines
21 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 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<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 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<YostarJpGameMainConfig, String> {
|
|
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<YostarJpGameMainConfig, String> {
|
|
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<YostarJpGameMainConfig, String> {
|
|
let decrypted = decrypt_inferred_ascii_json(bytes)?;
|
|
let value = serde_json::from_str::<Value>(&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<String, String> {
|
|
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::<Value>(&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<u8> {
|
|
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<u8> {
|
|
(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<String, Value>,
|
|
field_name: &str,
|
|
) -> Result<Option<String>, 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<bool, String> {
|
|
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<u8> {
|
|
value.encode_utf16().flat_map(u16::to_le_bytes).collect()
|
|
}
|
|
|
|
fn derive_repeating_xor_key(cipher: &[u8], plain: &[u8]) -> Option<Vec<u8>> {
|
|
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<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 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<String, String> {
|
|
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<String, String> {
|
|
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::<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");
|
|
encrypt_utf16le_xor(json, &key)
|
|
}
|
|
|
|
fn encrypt_utf16le_xor(value: &str, key: &[u8]) -> Vec<u8> {
|
|
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<String, Value>,
|
|
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<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_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());
|
|
}
|
|
}
|