mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 04:35:16 +08:00
fix official auto discovery for current launcher manifest
This commit is contained in:
+1
-1
@@ -240,7 +240,7 @@ cargo run -p bat-infrastructure --bin bat-official-sync -- \
|
|||||||
1. 使用独立输出目录,例如 `/var/lib/bluearchive-toolkit/official`。
|
1. 使用独立输出目录,例如 `/var/lib/bluearchive-toolkit/official`。
|
||||||
2. 不要指向已有游戏客户端目录。
|
2. 不要指向已有游戏客户端目录。
|
||||||
3. 不要指向 `/home/wanye/D/BlueArchive` 这类开发或人工维护资源目录。
|
3. 不要指向 `/home/wanye/D/BlueArchive` 这类开发或人工维护资源目录。
|
||||||
4. `--auto-discover` 可以下载官方 metadata 和临时 game zip 以解析 `GameMainConfig`,但不会安装或启动官方 launcher。
|
4. `--auto-discover` 可以下载官方 metadata,并按官方 manifest 临时获取 `resources.assets` 以解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装或启动官方 launcher。
|
||||||
5. systemd service、容器或 Go 进程可以负责守护 `bat-official-sync --watch`,但定时检查逻辑已经在 Rust 内部。
|
5. systemd service、容器或 Go 进程可以负责守护 `bat-official-sync --watch`,但定时检查逻辑已经在 Rust 内部。
|
||||||
|
|
||||||
详细运行说明见 `docs/guides/official-resource-test-pull.md`。
|
详细运行说明见 `docs/guides/official-resource-test-pull.md`。
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
- Rust 1.75+
|
- Rust 1.75+
|
||||||
- Go 1.22+
|
- Go 1.22+
|
||||||
- `curl`
|
- `curl`
|
||||||
- `unzip`,仅 `--auto-discover` 在 launcher metadata 变化并需要解析 `GameMainConfig` 时使用
|
- `unzip`,仅旧版 launcher manifest 指向整包 ZIP 且 `--auto-discover` 需要从 ZIP 解析 `GameMainConfig` 时使用;当前目录型 manifest 会直接下载 `resources.assets`
|
||||||
|
|
||||||
运行当前主要测试:
|
运行当前主要测试:
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,15 @@ use crate::unity::serialized_file::UnitySerializedFile;
|
|||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
use std::path::Path;
|
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.
|
/// Decrypted official `GameMainConfig` payload.
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
@@ -50,12 +57,201 @@ impl YostarJpGameMainConfig {
|
|||||||
|
|
||||||
/// Decrypts an encrypted `GameMainConfig` payload from raw text-asset bytes.
|
/// Decrypts an encrypted `GameMainConfig` payload from raw text-asset bytes.
|
||||||
pub fn from_encrypted_bytes(bytes: &[u8]) -> Result<Self, String> {
|
pub fn from_encrypted_bytes(bytes: &[u8]) -> Result<Self, String> {
|
||||||
let encrypted = STANDARD.encode(bytes);
|
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 key = create_key("GameMainConfig");
|
||||||
let decrypted = convert(&encrypted, &key)?;
|
let decrypted = decrypt_raw_utf16le_xor(bytes, &key)?;
|
||||||
serde_json::from_str(&decrypted)
|
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}"))
|
.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> {
|
fn create_key(name: &str) -> Vec<u8> {
|
||||||
@@ -69,9 +265,14 @@ fn convert(value: &str, key: &[u8]) -> Result<String, String> {
|
|||||||
return Ok(String::new());
|
return Ok(String::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut bytes = STANDARD
|
let bytes = STANDARD
|
||||||
.decode(value)
|
.decode(value)
|
||||||
.map_err(|error| format!("Failed to decode GameMainConfig base64 payload: {error}"))?;
|
.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);
|
xor_bytes(&mut bytes, key);
|
||||||
utf16le_to_string(&bytes)
|
utf16le_to_string(&bytes)
|
||||||
}
|
}
|
||||||
@@ -248,14 +449,31 @@ mod tests {
|
|||||||
|
|
||||||
fn encrypt_game_main_config(json: &str) -> Vec<u8> {
|
fn encrypt_game_main_config(json: &str) -> Vec<u8> {
|
||||||
let key = create_key("GameMainConfig");
|
let key = create_key("GameMainConfig");
|
||||||
let mut utf16 = json
|
encrypt_utf16le_xor(json, &key)
|
||||||
.encode_utf16()
|
}
|
||||||
.flat_map(u16::to_le_bytes)
|
|
||||||
.collect::<Vec<_>>();
|
fn encrypt_utf16le_xor(value: &str, key: &[u8]) -> Vec<u8> {
|
||||||
|
let mut utf16 = utf16le_bytes(value);
|
||||||
xor_bytes(&mut utf16, &key);
|
xor_bytes(&mut utf16, &key);
|
||||||
utf16
|
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 synthetic_serialized_file(bytes: &[u8]) -> Vec<u8> {
|
||||||
fn push_u32_be(data: &mut Vec<u8>, value: u32) {
|
fn push_u32_be(data: &mut Vec<u8>, value: u32) {
|
||||||
data.extend_from_slice(&value.to_be_bytes());
|
data.extend_from_slice(&value.to_be_bytes());
|
||||||
@@ -329,7 +547,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decodes_synthetic_game_main_config() {
|
fn decodes_synthetic_legacy_game_main_config() {
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
"SkipTutorial": true,
|
"SkipTutorial": true,
|
||||||
"Language": "ja-JP",
|
"Language": "ja-JP",
|
||||||
@@ -353,6 +571,53 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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() {
|
fn extracts_game_main_config_bytes_from_serialized_assets() {
|
||||||
let path = Path::new(
|
let path = Path::new(
|
||||||
"/home/wanye/D/BlueArchive/AllResources/YostarGames/BlueArchive_JP/BlueArchive_Data/resources.assets",
|
"/home/wanye/D/BlueArchive/AllResources/YostarGames/BlueArchive_JP/BlueArchive_Data/resources.assets",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
3. 不要求把生产环境当作客户端安装目录。
|
3. 不要求把生产环境当作客户端安装目录。
|
||||||
4. 可以显式执行 official metadata discovery 自动发现 `server-info` URL、`connection-group` 和 `app-version`。
|
4. 可以显式执行 official metadata discovery 自动发现 `server-info` URL、`connection-group` 和 `app-version`。
|
||||||
5. 也可以通过配置、调度状态或已审计 metadata snapshot 显式提供这些值。
|
5. 也可以通过配置、调度状态或已审计 metadata snapshot 显式提供这些值。
|
||||||
6. `--auto-discover` 只允许通过官方 HTTP metadata 和临时目录解析 `GameMainConfig`;launcher metadata 未变时必须复用缓存,metadata 变化时才重新下载必要官方 game zip。
|
6. `--auto-discover` 只允许通过官方 HTTP metadata 和临时目录解析 `GameMainConfig`;launcher metadata 未变时必须复用缓存,metadata 变化时才按 manifest 重新下载必要 `resources.assets` 或旧版官方 game zip。
|
||||||
|
|
||||||
### 3.1 发现官方资源根
|
### 3.1 发现官方资源根
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
流程是:
|
流程是:
|
||||||
|
|
||||||
1. 显式执行 `--auto-discover` 或读取已审计 `server-info` 输入。
|
1. 显式执行 `--auto-discover` 或读取已审计 `server-info` 输入。
|
||||||
2. `--auto-discover` 先抓官方 launcher metadata;metadata 未变时复用 `official-bootstrap-cache.json` 中的 `GameMainConfig` 摘要,metadata 变化时临时下载官方 game zip 并重新解析。
|
2. `--auto-discover` 先抓官方 launcher metadata;metadata 未变时复用 `official-bootstrap-cache.json` 中的 `GameMainConfig` 摘要,metadata 变化时按 manifest 临时下载 `resources.assets` 或旧版官方 game zip 并重新解析。
|
||||||
3. 生成当前 v2 snapshot,记录 `app_version`、`connection_group`、`bundle_version`、`addressables_root`、endpoint URL、seed `.hash` 内容、`catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。
|
3. 生成当前 v2 snapshot,记录 `app_version`、`connection_group`、`bundle_version`、`addressables_root`、endpoint URL、seed `.hash` 内容、`catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。
|
||||||
4. 读取上一次成功同步写出的 snapshot。
|
4. 读取上一次成功同步写出的 snapshot。
|
||||||
5. 使用 `OfficialSyncPlan` 和扩展 snapshot diff 判断是否需要下载;URL 未变但 `.hash` / marker 内容变化也会触发更新。
|
5. 使用 `OfficialSyncPlan` 和扩展 snapshot diff 判断是否需要下载;URL 未变但 `.hash` / marker 内容变化也会触发更新。
|
||||||
@@ -211,7 +211,7 @@ Linux 生产路径:
|
|||||||
开发/审计辅助路径:
|
开发/审计辅助路径:
|
||||||
|
|
||||||
1. `official_launcher_bootstrap` 获取官方 launcher 信息。
|
1. `official_launcher_bootstrap` 获取官方 launcher 信息。
|
||||||
2. `official_game_main_config` 从官方 ZIP 解出并解密 `GameMainConfig`。
|
2. `official_game_main_config` 按官方 manifest 获取 `resources.assets`(旧 ZIP manifest 则先解出)并解密 `GameMainConfig`。
|
||||||
3. 手动确认最新 PC metadata、`server-info` URL 和默认 connection group。
|
3. 手动确认最新 PC metadata、`server-info` URL 和默认 connection group。
|
||||||
|
|
||||||
自动发现路径和开发/审计辅助路径都只使用官方 HTTP 和临时目录,不安装或启动官方 launcher。
|
自动发现路径和开发/审计辅助路径都只使用官方 HTTP 和临时目录,不安装或启动官方 launcher。
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ Linux 生产运行时链路只走官方日服 HTTP 资源,不安装、不启
|
|||||||
4. 拉取 seed catalog,生成完整官方 pull plan。
|
4. 拉取 seed catalog,生成完整官方 pull plan。
|
||||||
5. dry-run 只输出 URL;非 dry-run 下载全部官方 URL。
|
5. dry-run 只输出 URL;非 dry-run 下载全部官方 URL。
|
||||||
|
|
||||||
`--auto-discover` 会下载官方 metadata 所需的临时包并解析 `GameMainConfig`,但不会安装官方启动器,也不会执行官方启动器进程。`--launcher-bootstrap` 只是旧命名兼容别名,新流程不要再推荐使用。
|
`--auto-discover` 会下载官方 metadata,并按官方 manifest 临时获取 `resources.assets` 解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装官方启动器,也不会执行官方启动器进程。`--launcher-bootstrap` 只是旧命名兼容别名,新流程不要再推荐使用。
|
||||||
|
|
||||||
## 2. 可选 metadata 审计
|
## 2. 可选 metadata 审计
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
|||||||
状态文件默认位于 `--output` 下:
|
状态文件默认位于 `--output` 下:
|
||||||
|
|
||||||
- `official-sync-snapshot.json`:上一次成功同步的 v2 snapshot,包含 app version、connection group、bundle version、addressables root、endpoint URL、官方 seed `.hash` 内容、Addressables `catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。
|
- `official-sync-snapshot.json`:上一次成功同步的 v2 snapshot,包含 app version、connection group、bundle version、addressables root、endpoint URL、官方 seed `.hash` 内容、Addressables `catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。
|
||||||
- `official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 未变时复用缓存;metadata 变化时才通过官方 HTTP 下载必要 game zip 到临时目录解析。
|
- `official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 未变时复用缓存;metadata 变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
|
||||||
- `official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。
|
- `official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。
|
||||||
|
|
||||||
先 dry-run:
|
先 dry-run:
|
||||||
|
|||||||
@@ -6,8 +6,11 @@
|
|||||||
|
|
||||||
use crate::official_launcher::launcher_package_url;
|
use crate::official_launcher::launcher_package_url;
|
||||||
use crate::official_launcher::OfficialLauncherBootstrapService;
|
use crate::official_launcher::OfficialLauncherBootstrapService;
|
||||||
use crate::official_launcher::{YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig};
|
use crate::official_launcher::{
|
||||||
|
YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig, YostarJpLauncherRemoteManifest,
|
||||||
|
};
|
||||||
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
|
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
|
||||||
|
use bat_adapters::official::launcher::YostarJpLauncherManifestFile;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -24,7 +27,11 @@ pub struct OfficialGameMainConfigBootstrap {
|
|||||||
pub manifest_url: String,
|
pub manifest_url: String,
|
||||||
/// Remote manifest `source` path, if present.
|
/// Remote manifest `source` path, if present.
|
||||||
pub manifest_source: Option<String>,
|
pub manifest_source: Option<String>,
|
||||||
/// Official game ZIP URL built from the launcher CDN root.
|
/// Official URL used to obtain `resources.assets`.
|
||||||
|
///
|
||||||
|
/// Older launcher manifests point to a single game ZIP. Current manifests
|
||||||
|
/// may instead point to a directory source plus per-file entries; in that
|
||||||
|
/// case this is the direct `resources.assets` URL.
|
||||||
pub game_zip_url: String,
|
pub game_zip_url: String,
|
||||||
/// Number of files declared by the remote manifest.
|
/// Number of files declared by the remote manifest.
|
||||||
pub manifest_file_count: usize,
|
pub manifest_file_count: usize,
|
||||||
@@ -76,23 +83,15 @@ impl OfficialGameMainConfigBootstrapService {
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.ok_or_else(|| "Official launcher remote manifest has no source".to_string())?;
|
.ok_or_else(|| "Official launcher remote manifest has no source".to_string())?;
|
||||||
let cdn_config = self.launcher.fetch_cdn_config()?;
|
let cdn_config = self.launcher.fetch_cdn_config()?;
|
||||||
let package_path = select_game_package_path(&game_config, &manifest_source)?;
|
|
||||||
let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, package_path)?;
|
|
||||||
let temp_dir = TempDir::new()
|
let temp_dir = TempDir::new()
|
||||||
.map_err(|error| format!("Failed to create temporary directory: {error}"))?;
|
.map_err(|error| format!("Failed to create temporary directory: {error}"))?;
|
||||||
let archive_path = temp_dir.path().join("official-game.zip");
|
let (game_zip_url, resources_assets) = self.fetch_resources_assets(
|
||||||
self.download_file_with_fallback(
|
&game_config,
|
||||||
&game_zip_url,
|
&manifest,
|
||||||
&cdn_config.back_up_cdn,
|
&manifest_source,
|
||||||
package_path,
|
&cdn_config,
|
||||||
&archive_path,
|
temp_dir.path(),
|
||||||
)?;
|
)?;
|
||||||
let extract_root = temp_dir.path().join("extract");
|
|
||||||
fs::create_dir_all(&extract_root)
|
|
||||||
.map_err(|error| format!("Failed to create extract root: {error}"))?;
|
|
||||||
self.extract_archive(&archive_path, &extract_root)?;
|
|
||||||
let resources_assets = find_resources_assets(&extract_root)
|
|
||||||
.ok_or_else(|| "resources.assets not found in official launcher package".to_string())?;
|
|
||||||
let game_main_config = YostarJpGameMainConfig::from_resources_assets(resources_assets)?;
|
let game_main_config = YostarJpGameMainConfig::from_resources_assets(resources_assets)?;
|
||||||
|
|
||||||
Ok(OfficialGameMainConfigBootstrap {
|
Ok(OfficialGameMainConfigBootstrap {
|
||||||
@@ -159,6 +158,50 @@ impl OfficialGameMainConfigBootstrapService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fetch_resources_assets(
|
||||||
|
&self,
|
||||||
|
game_config: &YostarJpLauncherGameConfig,
|
||||||
|
manifest: &YostarJpLauncherRemoteManifest,
|
||||||
|
manifest_source: &str,
|
||||||
|
cdn_config: &YostarJpLauncherCdnConfig,
|
||||||
|
temp_root: &Path,
|
||||||
|
) -> Result<(String, PathBuf), String> {
|
||||||
|
match select_game_main_config_source(game_config, manifest_source, manifest)? {
|
||||||
|
GameMainConfigSource::Archive(package_path) => {
|
||||||
|
let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, package_path)?;
|
||||||
|
let archive_path = temp_root.join("official-game.zip");
|
||||||
|
self.download_file_with_fallback(
|
||||||
|
&game_zip_url,
|
||||||
|
&cdn_config.back_up_cdn,
|
||||||
|
package_path,
|
||||||
|
&archive_path,
|
||||||
|
)?;
|
||||||
|
let extract_root = temp_root.join("extract");
|
||||||
|
fs::create_dir_all(&extract_root)
|
||||||
|
.map_err(|error| format!("Failed to create extract root: {error}"))?;
|
||||||
|
self.extract_archive(&archive_path, &extract_root)?;
|
||||||
|
let resources_assets = find_resources_assets(&extract_root).ok_or_else(|| {
|
||||||
|
"resources.assets not found in official launcher package".to_string()
|
||||||
|
})?;
|
||||||
|
Ok((game_zip_url, resources_assets))
|
||||||
|
}
|
||||||
|
GameMainConfigSource::ManifestFile { source_dir, file } => {
|
||||||
|
let relative_path = launcher_manifest_file_relative_path(source_dir, &file.path)?;
|
||||||
|
let resources_assets_url =
|
||||||
|
launcher_package_url(&cdn_config.primary_cdn, &relative_path)?;
|
||||||
|
let resources_assets = temp_root.join("resources.assets");
|
||||||
|
self.download_file_with_fallback(
|
||||||
|
&resources_assets_url,
|
||||||
|
&cdn_config.back_up_cdn,
|
||||||
|
&relative_path,
|
||||||
|
&resources_assets,
|
||||||
|
)?;
|
||||||
|
verify_manifest_file_size(&resources_assets, file)?;
|
||||||
|
Ok((resources_assets_url, resources_assets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_archive(&self, archive_path: &Path, destination: &Path) -> Result<(), String> {
|
fn extract_archive(&self, archive_path: &Path, destination: &Path) -> Result<(), String> {
|
||||||
let output = Command::new(&self.unzip_command)
|
let output = Command::new(&self.unzip_command)
|
||||||
.arg("-q")
|
.arg("-q")
|
||||||
@@ -186,16 +229,39 @@ impl OfficialGameMainConfigBootstrapService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_game_package_path<'a>(
|
enum GameMainConfigSource<'a> {
|
||||||
|
Archive(&'a str),
|
||||||
|
ManifestFile {
|
||||||
|
source_dir: &'a str,
|
||||||
|
file: &'a YostarJpLauncherManifestFile,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_game_main_config_source<'a>(
|
||||||
game_config: &'a YostarJpLauncherGameConfig,
|
game_config: &'a YostarJpLauncherGameConfig,
|
||||||
manifest_source: &'a str,
|
manifest_source: &'a str,
|
||||||
) -> Result<&'a str, String> {
|
manifest: &'a YostarJpLauncherRemoteManifest,
|
||||||
|
) -> Result<GameMainConfigSource<'a>, String> {
|
||||||
if is_launcher_archive_path(manifest_source) {
|
if is_launcher_archive_path(manifest_source) {
|
||||||
return Ok(manifest_source);
|
return Ok(GameMainConfigSource::Archive(manifest_source));
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_launcher_manifest_directory(manifest_source) {
|
||||||
|
if let Some(file) = manifest_resource_assets_file(manifest) {
|
||||||
|
return Ok(GameMainConfigSource::ManifestFile {
|
||||||
|
source_dir: manifest_source,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(format!(
|
||||||
|
"Official launcher manifest directory source has no BlueArchive_Data/resources.assets entry: {manifest_source}"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_launcher_archive_path(&game_config.game_latest_file_path) {
|
if is_launcher_archive_path(&game_config.game_latest_file_path) {
|
||||||
return Ok(&game_config.game_latest_file_path);
|
return Ok(GameMainConfigSource::Archive(
|
||||||
|
&game_config.game_latest_file_path,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(format!(
|
Err(format!(
|
||||||
@@ -204,6 +270,16 @@ fn select_game_package_path<'a>(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn manifest_resource_assets_file(
|
||||||
|
manifest: &YostarJpLauncherRemoteManifest,
|
||||||
|
) -> Option<&YostarJpLauncherManifestFile> {
|
||||||
|
manifest.files.iter().find(|file| {
|
||||||
|
normalize_manifest_file_path(&file.path)
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|path| path.eq_ignore_ascii_case("BlueArchive_Data/resources.assets"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn is_launcher_archive_path(path: &str) -> bool {
|
fn is_launcher_archive_path(path: &str) -> bool {
|
||||||
!path.is_empty()
|
!path.is_empty()
|
||||||
&& !path.starts_with('/')
|
&& !path.starts_with('/')
|
||||||
@@ -216,6 +292,73 @@ fn is_launcher_archive_path(path: &str) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_launcher_manifest_directory(path: &str) -> bool {
|
||||||
|
normalize_manifest_source_dir(path).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launcher_manifest_file_relative_path(
|
||||||
|
source_dir: &str,
|
||||||
|
file_path: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let source = normalize_manifest_source_dir(source_dir)
|
||||||
|
.ok_or_else(|| format!("Invalid official launcher manifest source: {source_dir}"))?;
|
||||||
|
let file = normalize_manifest_file_path(file_path)
|
||||||
|
.ok_or_else(|| format!("Invalid official launcher manifest file path: {file_path}"))?;
|
||||||
|
Ok(format!("{source}/{file}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_manifest_source_dir(path: &str) -> Option<String> {
|
||||||
|
let path = path.trim().trim_start_matches('/');
|
||||||
|
if path.is_empty() || path.ends_with(".zip") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
normalize_safe_path(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_manifest_file_path(path: &str) -> Option<String> {
|
||||||
|
let path = path.trim().trim_start_matches('/');
|
||||||
|
if path.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
normalize_safe_path(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_safe_path(path: &str) -> Option<String> {
|
||||||
|
if path.contains('\\') || path.contains('?') || path.contains('#') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
for segment in path.split('/') {
|
||||||
|
if segment.is_empty() || segment == "." || segment == ".." {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
segments.push(segment);
|
||||||
|
}
|
||||||
|
Some(segments.join("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_manifest_file_size(
|
||||||
|
path: &Path,
|
||||||
|
file: &YostarJpLauncherManifestFile,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let expected_size = file.size.parse::<u64>().map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"Official launcher manifest has invalid size for {}: {error}",
|
||||||
|
file.path
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let actual_size = fs::metadata(path)
|
||||||
|
.map_err(|error| format!("Failed to stat downloaded {}: {error}", path.display()))?
|
||||||
|
.len();
|
||||||
|
if actual_size != expected_size {
|
||||||
|
return Err(format!(
|
||||||
|
"Downloaded official launcher file size mismatch for {}: expected {}, got {}",
|
||||||
|
file.path, expected_size, actual_size
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn find_resources_assets(root: &Path) -> Option<PathBuf> {
|
fn find_resources_assets(root: &Path) -> Option<PathBuf> {
|
||||||
let mut stack = vec![root.to_path_buf()];
|
let mut stack = vec![root.to_path_buf()];
|
||||||
while let Some(dir) = stack.pop() {
|
while let Some(dir) = stack.pop() {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ const TEST_LAUNCHER_MANIFEST_SOURCE: &str =
|
|||||||
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip";
|
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip";
|
||||||
const TEST_LAUNCHER_GAME_ZIP_URL: &str =
|
const TEST_LAUNCHER_GAME_ZIP_URL: &str =
|
||||||
"https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip";
|
"https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip";
|
||||||
|
const TEST_LAUNCHER_DIRECTORY_SOURCE: &str = "/BlueArchive_JP-1.70.436321-game";
|
||||||
|
const TEST_LAUNCHER_RESOURCES_ASSETS_URL: &str =
|
||||||
|
"https://launcher-pkg-ba-jp.yo-star.com/BlueArchive_JP-1.70.436321-game/BlueArchive_Data/resources.assets";
|
||||||
const TEST_SERVER_INFO_URL: &str =
|
const TEST_SERVER_INFO_URL: &str =
|
||||||
"https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json";
|
"https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json";
|
||||||
const TEST_CONNECTION_GROUP: &str = "Fixture-Audit";
|
const TEST_CONNECTION_GROUP: &str = "Fixture-Audit";
|
||||||
@@ -54,7 +57,7 @@ fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() {
|
|||||||
Some(TEST_LAUNCHER_MANIFEST_SOURCE)
|
Some(TEST_LAUNCHER_MANIFEST_SOURCE)
|
||||||
);
|
);
|
||||||
assert_eq!(bootstrap.game_zip_url, TEST_LAUNCHER_GAME_ZIP_URL);
|
assert_eq!(bootstrap.game_zip_url, TEST_LAUNCHER_GAME_ZIP_URL);
|
||||||
assert_eq!(bootstrap.manifest_file_count, 1);
|
assert_eq!(bootstrap.manifest_file_count, 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bootstrap.game_main_config.server_info_data_url.as_deref(),
|
bootstrap.game_main_config.server_info_data_url.as_deref(),
|
||||||
Some(TEST_SERVER_INFO_URL)
|
Some(TEST_SERVER_INFO_URL)
|
||||||
@@ -76,25 +79,21 @@ fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fetch_bootstrap_falls_back_to_latest_file_path_when_manifest_source_is_not_zip() {
|
fn fetch_bootstrap_uses_directory_manifest_resources_assets_when_source_is_not_zip() {
|
||||||
let harness = TestHarness::new_with_manifest_source("/BlueArchive_JP-1.70.436321-game");
|
let harness = TestHarness::new_with_manifest_source(TEST_LAUNCHER_DIRECTORY_SOURCE);
|
||||||
let bootstrap = harness.fetch_bootstrap();
|
let bootstrap = harness.fetch_bootstrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bootstrap.manifest_source.as_deref(),
|
bootstrap.manifest_source.as_deref(),
|
||||||
Some("/BlueArchive_JP-1.70.436321-game")
|
Some(TEST_LAUNCHER_DIRECTORY_SOURCE)
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
bootstrap.game_zip_url,
|
|
||||||
format!(
|
|
||||||
"https://launcher-pkg-ba-jp.yo-star.com/{}",
|
|
||||||
TEST_LAUNCHER_LATEST_FILE_PATH
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
assert_eq!(bootstrap.game_zip_url, TEST_LAUNCHER_RESOURCES_ASSETS_URL);
|
||||||
|
|
||||||
let curl_log = fs::read_to_string(&harness.curl_log).unwrap();
|
let curl_log = fs::read_to_string(&harness.curl_log).unwrap();
|
||||||
assert!(curl_log.contains(TEST_LAUNCHER_LATEST_FILE_PATH));
|
assert!(curl_log.contains(TEST_LAUNCHER_RESOURCES_ASSETS_URL));
|
||||||
assert!(!curl_log.contains(" /BlueArchive_JP-1.70.436321-game"));
|
assert!(!curl_log.contains(&format!(
|
||||||
|
"https://launcher-pkg-ba-jp.yo-star.com/{TEST_LAUNCHER_LATEST_FILE_PATH}"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -178,7 +177,13 @@ impl TestHarness {
|
|||||||
let curl_script = temp.path().join("fake-curl");
|
let curl_script = temp.path().join("fake-curl");
|
||||||
write_executable(
|
write_executable(
|
||||||
&curl_script,
|
&curl_script,
|
||||||
&official_curl_script(&curl_log, &zip_fixture, manifest_source),
|
&official_curl_script(
|
||||||
|
&curl_log,
|
||||||
|
&zip_fixture,
|
||||||
|
&resources_assets_path,
|
||||||
|
resources_assets.len(),
|
||||||
|
manifest_source,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
let unzip_script = temp.path().join("fake-unzip");
|
let unzip_script = temp.path().join("fake-unzip");
|
||||||
@@ -229,7 +234,13 @@ fn write_executable(path: &Path, content: &str) {
|
|||||||
fs::set_permissions(path, permissions).unwrap();
|
fs::set_permissions(path, permissions).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn official_curl_script(curl_log: &Path, zip_fixture: &Path, manifest_source: &str) -> String {
|
fn official_curl_script(
|
||||||
|
curl_log: &Path,
|
||||||
|
zip_fixture: &Path,
|
||||||
|
resources_assets_fixture: &Path,
|
||||||
|
resources_assets_size: usize,
|
||||||
|
manifest_source: &str,
|
||||||
|
) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#"#!/usr/bin/env bash
|
r#"#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -268,6 +279,11 @@ emit_zip_fixture() {{
|
|||||||
cp {} "$output"
|
cp {} "$output"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
emit_resources_assets_fixture() {{
|
||||||
|
mkdir -p "$(dirname "$output")"
|
||||||
|
cp {} "$output"
|
||||||
|
}}
|
||||||
|
|
||||||
if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then
|
if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then
|
||||||
cat <<'JSON'
|
cat <<'JSON'
|
||||||
{{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}}
|
{{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}}
|
||||||
@@ -282,8 +298,10 @@ elif [[ "$url" == "{launcher_game_config_json_url}" ]]; then
|
|||||||
JSON
|
JSON
|
||||||
elif [[ "$url" == "{launcher_manifest_url}" ]]; then
|
elif [[ "$url" == "{launcher_manifest_url}" ]]; then
|
||||||
cat <<'JSON'
|
cat <<'JSON'
|
||||||
{{"source":"{launcher_manifest_source}","file":[{{"path":"/BlueArchive.exe","size":"100","hash":"123"}}]}}
|
{{"source":"{launcher_manifest_source}","file":[{{"path":"/BlueArchive.exe","size":"100","hash":"123"}},{{"path":"/BlueArchive_Data/resources.assets","size":"{resources_assets_size}","hash":"456"}}]}}
|
||||||
JSON
|
JSON
|
||||||
|
elif [[ "$url" == "{launcher_resources_assets_url}" ]]; then
|
||||||
|
emit_resources_assets_fixture
|
||||||
elif [[ "$url" == "{server_info_url}" ]]; then
|
elif [[ "$url" == "{server_info_url}" ]]; then
|
||||||
cat <<'JSON'
|
cat <<'JSON'
|
||||||
{{"ConnectionGroups":[{{"Name":"{connection_group}","AddressablesCatalogUrlRoot":"{addressables_root}","BundleVersion":"s8tloc7lo3","IsLivePublished":true}}]}}
|
{{"ConnectionGroups":[{{"Name":"{connection_group}","AddressablesCatalogUrlRoot":"{addressables_root}","BundleVersion":"s8tloc7lo3","IsLivePublished":true}}]}}
|
||||||
@@ -332,11 +350,14 @@ fi
|
|||||||
"#,
|
"#,
|
||||||
shell_quote(curl_log),
|
shell_quote(curl_log),
|
||||||
shell_quote(zip_fixture),
|
shell_quote(zip_fixture),
|
||||||
|
shell_quote(resources_assets_fixture),
|
||||||
launcher_latest_version = TEST_LAUNCHER_LATEST_VERSION,
|
launcher_latest_version = TEST_LAUNCHER_LATEST_VERSION,
|
||||||
launcher_latest_file_path = TEST_LAUNCHER_LATEST_FILE_PATH,
|
launcher_latest_file_path = TEST_LAUNCHER_LATEST_FILE_PATH,
|
||||||
launcher_game_config_json_url = TEST_LAUNCHER_GAME_CONFIG_JSON_URL,
|
launcher_game_config_json_url = TEST_LAUNCHER_GAME_CONFIG_JSON_URL,
|
||||||
launcher_manifest_url = TEST_LAUNCHER_MANIFEST_URL,
|
launcher_manifest_url = TEST_LAUNCHER_MANIFEST_URL,
|
||||||
launcher_manifest_source = manifest_source,
|
launcher_manifest_source = manifest_source,
|
||||||
|
launcher_resources_assets_url = TEST_LAUNCHER_RESOURCES_ASSETS_URL,
|
||||||
|
resources_assets_size = resources_assets_size,
|
||||||
server_info_url = TEST_SERVER_INFO_URL,
|
server_info_url = TEST_SERVER_INFO_URL,
|
||||||
connection_group = TEST_CONNECTION_GROUP,
|
connection_group = TEST_CONNECTION_GROUP,
|
||||||
addressables_root = TEST_ADDRESSABLES_ROOT,
|
addressables_root = TEST_ADDRESSABLES_ROOT,
|
||||||
|
|||||||
Reference in New Issue
Block a user