fix official auto discovery for current launcher manifest

This commit is contained in:
2026-07-06 23:02:52 +08:00
parent 5e76ea4ae3
commit e0d43fb994
7 changed files with 483 additions and 54 deletions
+276 -11
View File
@@ -8,8 +8,15 @@ 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")]
@@ -50,14 +57,203 @@ impl YostarJpGameMainConfig {
/// 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}"))
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() % 2 != 0 {
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);
@@ -69,9 +265,14 @@ fn convert(value: &str, key: &[u8]) -> Result<String, String> {
return Ok(String::new());
}
let mut bytes = STANDARD
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)
}
@@ -248,14 +449,31 @@ mod tests {
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<_>>();
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());
@@ -329,7 +547,7 @@ mod tests {
}
#[test]
fn decodes_synthetic_game_main_config() {
fn decodes_synthetic_legacy_game_main_config() {
let json = r#"{
"SkipTutorial": true,
"Language": "ja-JP",
@@ -353,6 +571,53 @@ mod tests {
}
#[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",