mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:15:14 +08:00
feat: prepare experiment push package
This commit is contained in:
@@ -13,6 +13,10 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
async-trait.workspace = true
|
||||
tokio.workspace = true
|
||||
lz4 = "1.28"
|
||||
lzma-rs = "0.3"
|
||||
base64 = "0.22"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
tempfile = "3.14"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use bat_adapters::official::launcher::YostarJpInstalledGameBootstrap;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
let root = args
|
||||
.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow::anyhow!("usage: yostar_jp_client_bootstrap <official-game-root>"))?;
|
||||
|
||||
let bootstrap =
|
||||
YostarJpInstalledGameBootstrap::from_game_root(&root).map_err(anyhow::Error::msg)?;
|
||||
|
||||
println!("client_root={}", root.display());
|
||||
println!("app_version={}", bootstrap.app_version);
|
||||
println!("executable_name={}", bootstrap.executable_name);
|
||||
println!("executable_params={:?}", bootstrap.executable_params);
|
||||
println!(
|
||||
"manifest_basis={}",
|
||||
bootstrap.basis.as_deref().unwrap_or("<none>")
|
||||
);
|
||||
println!(
|
||||
"manifest_file_count={}",
|
||||
bootstrap
|
||||
.manifest_file_count
|
||||
.map(|count| count.to_string())
|
||||
.unwrap_or_else(|| "<none>".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use bat_adapters::official::yostar_jp::{
|
||||
PatchPlatform, YostarJpResourceEndpoint, YostarJpResourceEndpointKind, YostarJpServerInfo,
|
||||
};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args = env::args().collect::<Vec<_>>();
|
||||
if args.len() < 2 {
|
||||
print_usage(&args[0]);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
let server_info_path = &args[1];
|
||||
let connection_group = args.get(2).map(String::as_str).unwrap_or("Prod-Audit");
|
||||
let app_version = args.get(3).map(String::as_str).unwrap_or("1.70.0");
|
||||
let platforms = args
|
||||
.get(4)
|
||||
.map(|value| parse_platforms(value))
|
||||
.transpose()
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.unwrap_or_else(|| vec![PatchPlatform::Windows, PatchPlatform::Android]);
|
||||
|
||||
let data = fs::read(server_info_path)?;
|
||||
let server_info = YostarJpServerInfo::from_slice(&data)?;
|
||||
let plan = server_info
|
||||
.discovery_plan(connection_group, app_version, &platforms)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
println!("connection_group={}", plan.connection_group_name);
|
||||
println!("app_version={}", plan.app_version);
|
||||
println!(
|
||||
"bundle_version={}",
|
||||
plan.bundle_version.as_deref().unwrap_or("<none>")
|
||||
);
|
||||
println!("addressables_root={}", plan.addressables_root);
|
||||
println!();
|
||||
|
||||
for endpoint in &plan.endpoints {
|
||||
println!(
|
||||
"{}\t{}\t{}",
|
||||
endpoint_kind_label(endpoint.kind),
|
||||
platform_label(endpoint),
|
||||
endpoint.url
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_usage(binary: &str) {
|
||||
eprintln!(
|
||||
"usage: {binary} <server-info.json> [connection-group] [app-version] [platforms]\n\
|
||||
platforms: comma-separated Windows,Android"
|
||||
);
|
||||
}
|
||||
|
||||
fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(parse_platform)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"windows" | "win" => Ok(PatchPlatform::Windows),
|
||||
"android" => Ok(PatchPlatform::Android),
|
||||
_ => Err(format!("unsupported platform: {value}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_kind_label(kind: YostarJpResourceEndpointKind) -> &'static str {
|
||||
match kind {
|
||||
YostarJpResourceEndpointKind::TableCatalog => "table_catalog",
|
||||
YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash",
|
||||
YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog",
|
||||
YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash",
|
||||
YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info",
|
||||
YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash",
|
||||
YostarJpResourceEndpointKind::MediaCatalog => "media_catalog",
|
||||
YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash",
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_label(endpoint: &YostarJpResourceEndpoint) -> &'static str {
|
||||
endpoint
|
||||
.platform
|
||||
.map(PatchPlatform::as_str)
|
||||
.unwrap_or("shared")
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use bat_adapters::official::inventory::YostarJpDownloadInventory;
|
||||
use bat_adapters::official::yostar_jp::{
|
||||
verified_official_platforms, PatchPlatform, YostarJpResourceRoot,
|
||||
};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args = env::args().collect::<Vec<_>>();
|
||||
if args.len() < 5 {
|
||||
print_usage(&args[0]);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
let root_token = &args[1];
|
||||
let bundle_packing_info_path = &args[2];
|
||||
let table_catalog_path = &args[3];
|
||||
let media_catalog_path = &args[4];
|
||||
let platforms = args
|
||||
.get(5)
|
||||
.map(|value| parse_platforms(value))
|
||||
.transpose()
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.unwrap_or_else(verified_official_platforms);
|
||||
|
||||
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
||||
&fs::read(bundle_packing_info_path)?,
|
||||
&fs::read(table_catalog_path)?,
|
||||
&fs::read(media_catalog_path)?,
|
||||
);
|
||||
let root = YostarJpResourceRoot::from_root_token(root_token).map_err(anyhow::Error::msg)?;
|
||||
|
||||
println!(
|
||||
"bundle_patch_pack_count={}",
|
||||
inventory.bundle_patch_pack_names.len()
|
||||
);
|
||||
println!("table_file_count={}", inventory.table_file_names.len());
|
||||
println!("media_file_count={}", inventory.media_file_names.len());
|
||||
println!();
|
||||
|
||||
for url in inventory
|
||||
.direct_download_urls_for_platforms(&root, &platforms)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
{
|
||||
println!("{url}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_usage(binary: &str) {
|
||||
eprintln!(
|
||||
"usage: {binary} <root-token> <BundlePackingInfo.bytes> <TableCatalog.bytes> <MediaCatalog.bytes> [platforms]"
|
||||
);
|
||||
}
|
||||
|
||||
fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(parse_platform)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"windows" | "win" => Ok(PatchPlatform::Windows),
|
||||
"android" => Ok(PatchPlatform::Android),
|
||||
_ => Err(format!("unsupported platform: {value}")),
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod manifest;
|
||||
pub mod official;
|
||||
pub mod unity;
|
||||
|
||||
pub use error::{AdapterError, Result};
|
||||
|
||||
@@ -52,9 +52,52 @@ impl AddressablesCatalogDriver {
|
||||
}
|
||||
}
|
||||
|
||||
fn resources(json: &Value) -> Vec<ResourceEntry> {
|
||||
let resources = json
|
||||
.get("m_InternalIds")
|
||||
fn resource_from_entry(value: &Value, index: usize) -> Option<ResourceEntry> {
|
||||
let path = value
|
||||
.get("internal_id")
|
||||
.or_else(|| value.get("InternalId"))
|
||||
.or_else(|| value.get("path"))
|
||||
.or_else(|| value.get("Path"))
|
||||
.and_then(|value| value.as_str())?;
|
||||
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hash = value
|
||||
.get("hash")
|
||||
.or_else(|| value.get("Hash"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("addressable_{}", index));
|
||||
|
||||
let size = value
|
||||
.get("size")
|
||||
.or_else(|| value.get("Size"))
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(ResourceEntry {
|
||||
path: path.to_string(),
|
||||
hash,
|
||||
size,
|
||||
resource_type: Self::resource_type_for_path(path),
|
||||
})
|
||||
}
|
||||
|
||||
fn entry_resources(json: &Value) -> Vec<ResourceEntry> {
|
||||
json.get("m_Entries")
|
||||
.or_else(|| json.get("entries"))
|
||||
.and_then(|value| value.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter_map(|(index, value)| Self::resource_from_entry(value, index))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn internal_id_resources(json: &Value) -> Vec<ResourceEntry> {
|
||||
json.get("m_InternalIds")
|
||||
.and_then(|value| value.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -72,8 +115,55 @@ impl AddressablesCatalogDriver {
|
||||
resource_type: Self::resource_type_for_path(path),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
resources
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resources(json: &Value) -> Vec<ResourceEntry> {
|
||||
let entry_resources = Self::entry_resources(json);
|
||||
if !entry_resources.is_empty() {
|
||||
return entry_resources;
|
||||
}
|
||||
|
||||
Self::internal_id_resources(json)
|
||||
}
|
||||
|
||||
fn extra_metadata(json: &Value) -> HashMap<String, String> {
|
||||
let mut extra = HashMap::new();
|
||||
Self::insert_array_len(&mut extra, json, "m_InternalIds", "internal_id_count");
|
||||
Self::insert_array_len(&mut extra, json, "m_Entries", "entry_count");
|
||||
Self::insert_string_len(&mut extra, json, "m_KeyDataString", "key_data_string_len");
|
||||
Self::insert_string_len(
|
||||
&mut extra,
|
||||
json,
|
||||
"m_EntryDataString",
|
||||
"entry_data_string_len",
|
||||
);
|
||||
extra
|
||||
}
|
||||
|
||||
fn insert_array_len(extra: &mut HashMap<String, String>, json: &Value, field: &str, key: &str) {
|
||||
if let Some(len) = json
|
||||
.get(field)
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|values| values.len())
|
||||
{
|
||||
extra.insert(key.to_string(), len.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_string_len(
|
||||
extra: &mut HashMap<String, String>,
|
||||
json: &Value,
|
||||
field: &str,
|
||||
key: &str,
|
||||
) {
|
||||
if let Some(len) = json
|
||||
.get(field)
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.len())
|
||||
{
|
||||
extra.insert(key.to_string(), len.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,14 +215,10 @@ impl ManifestDriver for AddressablesCatalogDriver {
|
||||
async fn parse(&self, raw_data: &[u8]) -> Result<GenericManifest, String> {
|
||||
let json = Self::parse_json(raw_data)?;
|
||||
|
||||
// TODO: 解析 m_KeyDataString、m_EntryDataString 等压缩字段
|
||||
// 这些字段使用了自定义压缩格式,需要实现解压缩算法
|
||||
// 参考:Unity Addressables 源代码
|
||||
|
||||
let metadata = ManifestMetadata {
|
||||
locator_id: Self::locator_id(&json),
|
||||
cdn_prefixes: Self::cdn_prefixes(&json),
|
||||
extra: HashMap::new(),
|
||||
extra: Self::extra_metadata(&json),
|
||||
};
|
||||
|
||||
Ok(GenericManifest {
|
||||
@@ -178,9 +264,9 @@ mod tests {
|
||||
"m_LocatorId": "AddressablesMainContentCatalog",
|
||||
"m_InternalIdPrefixes": [],
|
||||
"m_InternalIds": [
|
||||
"academy.bundle",
|
||||
"character.bundle",
|
||||
"catalog.json"
|
||||
"synthetic/minimal-a.bundle",
|
||||
"synthetic/minimal-b.bundle",
|
||||
"synthetic/catalog.json"
|
||||
]
|
||||
}"#;
|
||||
|
||||
@@ -195,4 +281,61 @@ mod tests {
|
||||
ResourceType::AssetBundle
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_entry_catalog_resources_and_metadata() {
|
||||
let driver = AddressablesCatalogDriver::new();
|
||||
|
||||
let catalog_json = r#"{
|
||||
"m_LocatorId": "AddressablesMainContentCatalog",
|
||||
"m_InternalIdPrefixes": ["https://synthetic.invalid/bundles/"],
|
||||
"m_InternalIds": ["synthetic/fallback.bundle"],
|
||||
"m_KeyDataString": "key-data",
|
||||
"m_EntryDataString": "entry-data",
|
||||
"m_Entries": [
|
||||
{
|
||||
"internal_id": "synthetic/minimal.bundle",
|
||||
"hash": "synthetic-entry-hash",
|
||||
"size": 119
|
||||
},
|
||||
{
|
||||
"internal_id": "synthetic/catalog.json",
|
||||
"hash": "synthetic-catalog-hash",
|
||||
"size": 2
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let manifest = driver.parse(catalog_json.as_bytes()).await.unwrap();
|
||||
|
||||
assert_eq!(manifest.resources.len(), 2);
|
||||
assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle");
|
||||
assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash");
|
||||
assert_eq!(manifest.resources[0].size, 119);
|
||||
assert_eq!(
|
||||
manifest.resources[0].resource_type,
|
||||
ResourceType::AssetBundle
|
||||
);
|
||||
assert_eq!(manifest.resources[1].resource_type, ResourceType::Manifest);
|
||||
assert_eq!(
|
||||
manifest.metadata.cdn_prefixes,
|
||||
vec!["https://synthetic.invalid/bundles/".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("internal_id_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("entry_count"),
|
||||
Some(&"2".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("key_data_string_len"),
|
||||
Some(&"8".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("entry_data_string_len"),
|
||||
Some(&"10".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ mod tests {
|
||||
fn test_manifest_metadata() {
|
||||
let metadata = ManifestMetadata {
|
||||
locator_id: Some("test".to_string()),
|
||||
cdn_prefixes: vec!["https://cdn.example.com".to_string()],
|
||||
cdn_prefixes: vec!["https://synthetic.invalid".to_string()],
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Official JP download inventory extraction.
|
||||
|
||||
use super::yostar_jp::{verified_official_platforms, PatchPlatform, YostarJpResourceRoot};
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
/// Download inventory extracted from the official JP catalog bytes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct YostarJpDownloadInventory {
|
||||
/// Patch-pack zip names from `BundlePackingInfo.bytes`.
|
||||
pub bundle_patch_pack_names: Vec<String>,
|
||||
/// Table file names from `TableCatalog.bytes`.
|
||||
pub table_file_names: Vec<String>,
|
||||
/// Media file names from `MediaCatalog.bytes`.
|
||||
pub media_file_names: Vec<String>,
|
||||
}
|
||||
|
||||
/// Platform-specific catalog inventory extracted from official JP bytes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct YostarJpPlatformCatalogInventory {
|
||||
/// Platform this inventory belongs to.
|
||||
pub platform: PatchPlatform,
|
||||
/// Patch-pack zip names from this platform's `BundlePackingInfo.bytes`.
|
||||
pub bundle_patch_pack_names: Vec<String>,
|
||||
/// Media file names from this platform's `MediaCatalog.bytes`.
|
||||
pub media_file_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl YostarJpPlatformCatalogInventory {
|
||||
/// Extracts one platform inventory from official JP catalog bytes.
|
||||
pub fn from_catalog_bytes(
|
||||
platform: PatchPlatform,
|
||||
bundle_packing_info: &[u8],
|
||||
media_catalog: &[u8],
|
||||
) -> Self {
|
||||
Self {
|
||||
platform,
|
||||
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
|
||||
media_file_names: extract_file_names(
|
||||
media_catalog,
|
||||
&["zip", "mp4", "png", "ogg", "wav"],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns this platform's official bundle patch-pack URLs.
|
||||
pub fn bundle_patch_pack_urls(
|
||||
&self,
|
||||
root: &YostarJpResourceRoot,
|
||||
) -> Result<Vec<String>, String> {
|
||||
self.bundle_patch_pack_names
|
||||
.iter()
|
||||
.map(|name| root.bundle_patch_pack(self.platform, name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns this platform's official media URLs.
|
||||
pub fn media_file_urls(&self, root: &YostarJpResourceRoot) -> Result<Vec<String>, String> {
|
||||
self.media_file_names
|
||||
.iter()
|
||||
.map(|name| root.media_file(self.platform, name))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-platform download inventory with shared table files and platform
|
||||
/// specific patch-pack/media catalogs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct YostarJpPlatformDownloadInventory {
|
||||
/// Table file names from the shared `TableCatalog.bytes`.
|
||||
pub table_file_names: Vec<String>,
|
||||
/// Per-platform bundle/media inventories.
|
||||
pub platform_catalogs: Vec<YostarJpPlatformCatalogInventory>,
|
||||
}
|
||||
|
||||
impl YostarJpPlatformDownloadInventory {
|
||||
/// Extracts a multi-platform inventory from official JP catalog bytes.
|
||||
pub fn from_catalog_bytes(
|
||||
table_catalog: &[u8],
|
||||
platform_catalogs: Vec<YostarJpPlatformCatalogInventory>,
|
||||
) -> Self {
|
||||
Self {
|
||||
table_file_names: extract_file_names(table_catalog, &["db", "zip"]),
|
||||
platform_catalogs: merge_platform_catalogs(platform_catalogs),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a platform inventory by reusing a legacy shared inventory for
|
||||
/// each requested platform.
|
||||
pub fn from_shared_inventory(
|
||||
inventory: YostarJpDownloadInventory,
|
||||
platforms: &[PatchPlatform],
|
||||
) -> Self {
|
||||
let platform_catalogs = unique_platforms(platforms)
|
||||
.into_iter()
|
||||
.map(|platform| YostarJpPlatformCatalogInventory {
|
||||
platform,
|
||||
bundle_patch_pack_names: inventory.bundle_patch_pack_names.clone(),
|
||||
media_file_names: inventory.media_file_names.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
table_file_names: inventory.table_file_names,
|
||||
platform_catalogs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the official table file URLs.
|
||||
pub fn table_file_urls(&self, root: &YostarJpResourceRoot) -> Result<Vec<String>, String> {
|
||||
self.table_file_names
|
||||
.iter()
|
||||
.map(|name| root.table_bundle(name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the complete direct-download URL set for multiple platforms.
|
||||
///
|
||||
/// Table bundles are emitted once. Patch-pack and media files are emitted
|
||||
/// from the matching platform catalog only, which avoids creating invalid
|
||||
/// cross-platform URL combinations.
|
||||
pub fn direct_download_urls_for_platforms(
|
||||
&self,
|
||||
root: &YostarJpResourceRoot,
|
||||
platforms: &[PatchPlatform],
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut urls = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
append_unique_urls(&mut urls, &mut seen, self.table_file_urls(root)?);
|
||||
|
||||
for platform in unique_platforms(platforms) {
|
||||
let catalog = self.platform_catalog(platform).ok_or_else(|| {
|
||||
format!(
|
||||
"Missing official catalog inventory for platform: {}",
|
||||
platform.as_str()
|
||||
)
|
||||
})?;
|
||||
|
||||
append_unique_urls(&mut urls, &mut seen, catalog.bundle_patch_pack_urls(root)?);
|
||||
append_unique_urls(&mut urls, &mut seen, catalog.media_file_urls(root)?);
|
||||
}
|
||||
|
||||
Ok(urls)
|
||||
}
|
||||
|
||||
/// Returns the complete direct-download URL set for all verified official
|
||||
/// JP platforms.
|
||||
pub fn direct_download_urls_for_verified_platforms(
|
||||
&self,
|
||||
root: &YostarJpResourceRoot,
|
||||
) -> Result<Vec<String>, String> {
|
||||
self.direct_download_urls_for_platforms(root, &verified_official_platforms())
|
||||
}
|
||||
|
||||
fn platform_catalog(
|
||||
&self,
|
||||
platform: PatchPlatform,
|
||||
) -> Option<&YostarJpPlatformCatalogInventory> {
|
||||
self.platform_catalogs
|
||||
.iter()
|
||||
.find(|catalog| catalog.platform == platform)
|
||||
}
|
||||
}
|
||||
|
||||
impl YostarJpDownloadInventory {
|
||||
/// Extracts an inventory from the three official JP catalog bytes.
|
||||
pub fn from_catalog_bytes(
|
||||
bundle_packing_info: &[u8],
|
||||
table_catalog: &[u8],
|
||||
media_catalog: &[u8],
|
||||
) -> Self {
|
||||
Self {
|
||||
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
|
||||
table_file_names: extract_file_names(table_catalog, &["db", "zip"]),
|
||||
media_file_names: extract_file_names(
|
||||
media_catalog,
|
||||
&["zip", "mp4", "png", "ogg", "wav"],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the official bundle patch-pack URLs for a platform.
|
||||
pub fn bundle_patch_pack_urls(
|
||||
&self,
|
||||
root: &YostarJpResourceRoot,
|
||||
platform: PatchPlatform,
|
||||
) -> Result<Vec<String>, String> {
|
||||
self.bundle_patch_pack_names
|
||||
.iter()
|
||||
.map(|name| root.bundle_patch_pack(platform, name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the official table file URLs.
|
||||
pub fn table_file_urls(&self, root: &YostarJpResourceRoot) -> Result<Vec<String>, String> {
|
||||
self.table_file_names
|
||||
.iter()
|
||||
.map(|name| root.table_bundle(name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the official media file URLs for a platform.
|
||||
pub fn media_file_urls(
|
||||
&self,
|
||||
root: &YostarJpResourceRoot,
|
||||
platform: PatchPlatform,
|
||||
) -> Result<Vec<String>, String> {
|
||||
self.media_file_names
|
||||
.iter()
|
||||
.map(|name| root.media_file(platform, name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the complete direct-download URL set for a platform.
|
||||
pub fn direct_download_urls(
|
||||
&self,
|
||||
root: &YostarJpResourceRoot,
|
||||
platform: PatchPlatform,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut urls = self.bundle_patch_pack_urls(root, platform)?;
|
||||
urls.extend(self.table_file_urls(root)?);
|
||||
urls.extend(self.media_file_urls(root, platform)?);
|
||||
Ok(urls)
|
||||
}
|
||||
|
||||
/// Returns the complete direct-download URL set for multiple platforms.
|
||||
///
|
||||
/// Table bundles are emitted once. Platform-specific URLs are emitted in
|
||||
/// platform order and deduplicated by URL so shared Android media paths
|
||||
/// only appear once.
|
||||
pub fn direct_download_urls_for_platforms(
|
||||
&self,
|
||||
root: &YostarJpResourceRoot,
|
||||
platforms: &[PatchPlatform],
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut urls = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
append_unique_urls(&mut urls, &mut seen, self.table_file_urls(root)?);
|
||||
|
||||
for platform in unique_platforms(platforms) {
|
||||
append_unique_urls(
|
||||
&mut urls,
|
||||
&mut seen,
|
||||
self.bundle_patch_pack_urls(root, platform)?,
|
||||
);
|
||||
append_unique_urls(&mut urls, &mut seen, self.media_file_urls(root, platform)?);
|
||||
}
|
||||
|
||||
Ok(urls)
|
||||
}
|
||||
|
||||
/// Returns the complete direct-download URL set for all verified official
|
||||
/// JP platforms.
|
||||
pub fn direct_download_urls_for_verified_platforms(
|
||||
&self,
|
||||
root: &YostarJpResourceRoot,
|
||||
) -> Result<Vec<String>, String> {
|
||||
self.direct_download_urls_for_platforms(root, &verified_official_platforms())
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_platform_catalogs(
|
||||
catalogs: Vec<YostarJpPlatformCatalogInventory>,
|
||||
) -> Vec<YostarJpPlatformCatalogInventory> {
|
||||
let mut merged = Vec::<YostarJpPlatformCatalogInventory>::new();
|
||||
|
||||
for catalog in catalogs {
|
||||
if let Some(existing) = merged
|
||||
.iter_mut()
|
||||
.find(|existing| existing.platform == catalog.platform)
|
||||
{
|
||||
merge_names(
|
||||
&mut existing.bundle_patch_pack_names,
|
||||
catalog.bundle_patch_pack_names,
|
||||
);
|
||||
merge_names(&mut existing.media_file_names, catalog.media_file_names);
|
||||
} else {
|
||||
merged.push(catalog);
|
||||
}
|
||||
}
|
||||
|
||||
merged.sort_by_key(|catalog| catalog.platform);
|
||||
merged
|
||||
}
|
||||
|
||||
fn merge_names(existing: &mut Vec<String>, names: Vec<String>) {
|
||||
let mut merged = existing.iter().cloned().collect::<BTreeSet<_>>();
|
||||
merged.extend(names);
|
||||
*existing = merged.into_iter().collect();
|
||||
}
|
||||
|
||||
fn extract_full_patch_pack_names(data: &[u8]) -> Vec<String> {
|
||||
extract_file_names(data, &["zip"]) // only .zip names survive here
|
||||
.into_iter()
|
||||
.filter(|name| is_full_patch_pack_name(name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_file_names(data: &[u8], extensions: &[&str]) -> Vec<String> {
|
||||
let mut names = BTreeSet::new();
|
||||
|
||||
for string in extract_printable_strings(data, 4) {
|
||||
for name in candidate_file_names(&string, extensions) {
|
||||
names.insert(name);
|
||||
}
|
||||
}
|
||||
|
||||
names.into_iter().collect()
|
||||
}
|
||||
|
||||
fn extract_printable_strings(data: &[u8], min_len: usize) -> Vec<String> {
|
||||
let mut strings = Vec::new();
|
||||
let mut current = Vec::new();
|
||||
|
||||
for &byte in data {
|
||||
if byte.is_ascii_graphic() || byte == b' ' {
|
||||
current.push(byte);
|
||||
} else if current.len() >= min_len {
|
||||
strings.push(String::from_utf8_lossy(¤t).into_owned());
|
||||
current.clear();
|
||||
} else {
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if current.len() >= min_len {
|
||||
strings.push(String::from_utf8_lossy(¤t).into_owned());
|
||||
}
|
||||
|
||||
strings
|
||||
}
|
||||
|
||||
fn candidate_file_names(value: &str, extensions: &[&str]) -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
let bytes = value.as_bytes();
|
||||
|
||||
for extension in extensions {
|
||||
let suffix = format!(".{extension}");
|
||||
let mut search_from = 0;
|
||||
|
||||
while let Some(relative_index) = value[search_from..].find(&suffix) {
|
||||
let extension_start = search_from + relative_index;
|
||||
let start = filename_start(bytes, extension_start);
|
||||
let end = extension_start + suffix.len();
|
||||
let candidate = &value[start..end];
|
||||
let candidate = candidate.rsplit(['/', '\\']).next().unwrap_or(candidate);
|
||||
|
||||
if is_plausible_file_name(candidate) {
|
||||
names.push(candidate.to_string());
|
||||
}
|
||||
|
||||
search_from = end;
|
||||
}
|
||||
}
|
||||
|
||||
names
|
||||
}
|
||||
|
||||
fn filename_start(bytes: &[u8], mut index: usize) -> usize {
|
||||
while index > 0 {
|
||||
let byte = bytes[index - 1];
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/' | b'\\') {
|
||||
index -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
index
|
||||
}
|
||||
|
||||
fn is_full_patch_pack_name(name: &str) -> bool {
|
||||
let bytes = name.as_bytes();
|
||||
if bytes.len() != 17 || !name.starts_with("FullPatch_") || !name.ends_with(".zip") {
|
||||
return false;
|
||||
}
|
||||
|
||||
bytes[10..13].iter().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn is_plausible_file_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& !name.contains("..")
|
||||
&& !name.contains(':')
|
||||
&& !name.contains('=')
|
||||
&& name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
||||
}
|
||||
|
||||
fn unique_platforms(platforms: &[PatchPlatform]) -> Vec<PatchPlatform> {
|
||||
platforms
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(Vec::new(), |mut unique, platform| {
|
||||
if !unique.contains(&platform) {
|
||||
unique.push(platform);
|
||||
}
|
||||
unique
|
||||
})
|
||||
}
|
||||
|
||||
fn append_unique_urls(urls: &mut Vec<String>, seen: &mut HashSet<String>, next_urls: Vec<String>) {
|
||||
for url in next_urls {
|
||||
if seen.insert(url.clone()) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::official::yostar_jp::YostarJpResourceRoot;
|
||||
|
||||
const ROOT: &str = "r93_dctuo3tcd029wwxnvb55";
|
||||
|
||||
#[test]
|
||||
fn extracts_download_names_from_synthetic_bytes() {
|
||||
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
||||
b"prefix FullPatch_000.zip noise FullPatch_114.zip suffix",
|
||||
b"GameData\\Table\\ExcelDB.db\0rawdata/table/excel/ignored.bytes\0Battle.zip8",
|
||||
b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0JP_Akane.zip",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
inventory.bundle_patch_pack_names,
|
||||
vec![
|
||||
"FullPatch_000.zip".to_string(),
|
||||
"FullPatch_114.zip".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
inventory.table_file_names,
|
||||
vec!["Battle.zip".to_string(), "ExcelDB.db".to_string(),]
|
||||
);
|
||||
assert_eq!(
|
||||
inventory.media_file_names,
|
||||
vec!["JP_Airi.zip".to_string(), "JP_Akane.zip".to_string(),]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_direct_download_urls() {
|
||||
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
||||
b"FullPatch_000.zip FullPatch_001.zip",
|
||||
b"ExcelDB.db Battle.zip",
|
||||
b"JP_Airi.zip JP_Akane.zip",
|
||||
);
|
||||
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
|
||||
|
||||
let urls = inventory
|
||||
.direct_download_urls(&root, PatchPlatform::Windows)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(urls[0], "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/Windows_PatchPack/FullPatch_000.zip");
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_direct_download_urls_for_verified_platforms() {
|
||||
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
||||
b"FullPatch_000.zip",
|
||||
b"ExcelDB.db",
|
||||
b"JP_Airi.zip",
|
||||
);
|
||||
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
|
||||
|
||||
let urls = inventory
|
||||
.direct_download_urls_for_verified_platforms(&root)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(urls.len(), 5);
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.contains("/Windows_PatchPack/FullPatch_000.zip")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.contains("/Android_PatchPack/FullPatch_000.zip")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/MediaResources/JP_Airi.zip")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_platform_specific_download_urls_without_cross_mixing_catalogs() {
|
||||
let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes(
|
||||
b"ExcelDB.db",
|
||||
vec![
|
||||
YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
||||
PatchPlatform::Windows,
|
||||
b"FullPatch_000.zip",
|
||||
b"JP_Airi_Win.zip",
|
||||
),
|
||||
YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
||||
PatchPlatform::Android,
|
||||
b"FullPatch_001.zip",
|
||||
b"JP_Airi_Android.zip",
|
||||
),
|
||||
],
|
||||
);
|
||||
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
|
||||
|
||||
let urls = inventory
|
||||
.direct_download_urls_for_verified_platforms(&root)
|
||||
.unwrap();
|
||||
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/Windows_PatchPack/FullPatch_000.zip")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi_Win.zip")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/MediaResources/JP_Airi_Android.zip")));
|
||||
assert!(!urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_000.zip")));
|
||||
assert!(!urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/Windows_PatchPack/FullPatch_001.zip")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires BAT_REAL_OFFICIAL_BUNDLE_PACKING_INFO, BAT_REAL_OFFICIAL_TABLE_CATALOG, BAT_REAL_OFFICIAL_MEDIA_CATALOG"]
|
||||
fn extracts_realistic_counts_from_official_shape() {
|
||||
let bundle_packing_info =
|
||||
std::fs::read(std::env::var("BAT_REAL_OFFICIAL_BUNDLE_PACKING_INFO").unwrap()).unwrap();
|
||||
let table_catalog =
|
||||
std::fs::read(std::env::var("BAT_REAL_OFFICIAL_TABLE_CATALOG").unwrap()).unwrap();
|
||||
let media_catalog =
|
||||
std::fs::read(std::env::var("BAT_REAL_OFFICIAL_MEDIA_CATALOG").unwrap()).unwrap();
|
||||
|
||||
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
||||
&bundle_packing_info,
|
||||
&table_catalog,
|
||||
&media_catalog,
|
||||
);
|
||||
|
||||
assert_eq!(inventory.bundle_patch_pack_names.len(), 142);
|
||||
assert_eq!(inventory.table_file_names.len(), 6351);
|
||||
assert_eq!(inventory.media_file_names.len(), 1887);
|
||||
assert!(inventory
|
||||
.bundle_patch_pack_names
|
||||
.iter()
|
||||
.any(|name| name == "FullPatch_000.zip"));
|
||||
assert!(inventory
|
||||
.table_file_names
|
||||
.iter()
|
||||
.any(|name| name == "ExcelDB.db"));
|
||||
assert!(inventory
|
||||
.media_file_names
|
||||
.iter()
|
||||
.any(|name| name == "JP_Airi.zip"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Official Yostar JP launcher metadata.
|
||||
//!
|
||||
//! The PC launcher has its own update chain for the Windows game client. This
|
||||
//! module parses only the local launcher files written by the official launcher
|
||||
//! (`manifest.json` and `game-launcher-config.json`). It does not infer Unity
|
||||
//! resource server-info from mirror URLs.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Official JP launcher game id.
|
||||
pub const YOSTAR_JP_GAME_TAG: &str = "BlueArchive_JP";
|
||||
|
||||
/// Local game manifest file name.
|
||||
pub const LAUNCHER_MANIFEST_FILE: &str = "manifest.json";
|
||||
|
||||
/// Local game launcher config file name.
|
||||
pub const LAUNCHER_CONFIG_FILE: &str = "game-launcher-config.json";
|
||||
|
||||
/// Local `game-launcher-config.json`.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct YostarJpLauncherConfig {
|
||||
/// Game tag, expected to be `BlueArchive_JP`.
|
||||
pub tag: String,
|
||||
/// Executable name without `.exe`.
|
||||
pub name: String,
|
||||
/// Arguments passed by the launcher.
|
||||
#[serde(default)]
|
||||
pub params: Vec<String>,
|
||||
/// Installed Windows game client version.
|
||||
pub version: String,
|
||||
/// Launcher integrity hash.
|
||||
#[serde(default)]
|
||||
pub vc: Option<String>,
|
||||
}
|
||||
|
||||
impl YostarJpLauncherConfig {
|
||||
/// Parses a launcher config JSON document.
|
||||
pub fn from_slice(data: &[u8]) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_slice(data)
|
||||
}
|
||||
|
||||
/// Reads `game-launcher-config.json` from an installed game root.
|
||||
pub fn from_game_root(root: impl AsRef<Path>) -> Result<Self, String> {
|
||||
let path = root.as_ref().join(LAUNCHER_CONFIG_FILE);
|
||||
let bytes = fs::read(&path)
|
||||
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
|
||||
let config = Self::from_slice(&bytes)
|
||||
.map_err(|error| format!("Failed to parse {}: {error}", path.display()))?;
|
||||
config.validate_official_jp()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Validates that this config belongs to the official JP game tag.
|
||||
pub fn validate_official_jp(&self) -> Result<(), String> {
|
||||
if self.tag != YOSTAR_JP_GAME_TAG {
|
||||
return Err(format!(
|
||||
"Launcher config tag is not official JP: {}",
|
||||
self.tag
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Local `manifest.json` file written by the official launcher.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct YostarJpLauncherManifest {
|
||||
/// Game tag, expected to be `BlueArchive_JP`.
|
||||
pub name: String,
|
||||
/// Installed Windows game client version.
|
||||
pub version: String,
|
||||
/// Launcher API `game_latest_file_path` value used to fetch the manifest.
|
||||
pub basis: String,
|
||||
/// Manifest integrity hash.
|
||||
#[serde(default)]
|
||||
pub vc: Option<String>,
|
||||
/// File entries in the installed Windows client.
|
||||
#[serde(default)]
|
||||
pub files: Vec<YostarJpLauncherManifestFile>,
|
||||
}
|
||||
|
||||
impl YostarJpLauncherManifest {
|
||||
/// Parses a launcher manifest JSON document.
|
||||
pub fn from_slice(data: &[u8]) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_slice(data)
|
||||
}
|
||||
|
||||
/// Reads `manifest.json` from an installed game root.
|
||||
pub fn from_game_root(root: impl AsRef<Path>) -> Result<Self, String> {
|
||||
let path = root.as_ref().join(LAUNCHER_MANIFEST_FILE);
|
||||
let bytes = fs::read(&path)
|
||||
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
|
||||
let manifest = Self::from_slice(&bytes)
|
||||
.map_err(|error| format!("Failed to parse {}: {error}", path.display()))?;
|
||||
manifest.validate_official_jp()?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Validates that this manifest belongs to the official JP game tag.
|
||||
pub fn validate_official_jp(&self) -> Result<(), String> {
|
||||
if self.name != YOSTAR_JP_GAME_TAG {
|
||||
return Err(format!(
|
||||
"Launcher manifest name is not official JP: {}",
|
||||
self.name
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One launcher manifest file entry.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct YostarJpLauncherManifestFile {
|
||||
/// Path relative to the game root. Official manifests usually prefix this
|
||||
/// with `/`.
|
||||
pub path: String,
|
||||
/// File size as a decimal string.
|
||||
pub size: String,
|
||||
/// CRC64 hash as a decimal string.
|
||||
pub hash: String,
|
||||
/// Per-file integrity hash.
|
||||
#[serde(default)]
|
||||
pub vc: Option<String>,
|
||||
}
|
||||
|
||||
/// Local installed official JP game bootstrap data.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct YostarJpInstalledGameBootstrap {
|
||||
/// Installed client version from `game-launcher-config.json`.
|
||||
pub app_version: String,
|
||||
/// Executable name without `.exe`.
|
||||
pub executable_name: String,
|
||||
/// Launcher arguments.
|
||||
pub executable_params: Vec<String>,
|
||||
/// Optional launcher manifest basis, when `manifest.json` exists.
|
||||
pub basis: Option<String>,
|
||||
/// Optional installed manifest file count.
|
||||
pub manifest_file_count: Option<usize>,
|
||||
}
|
||||
|
||||
impl YostarJpInstalledGameBootstrap {
|
||||
/// Reads all available launcher metadata from an installed official JP game
|
||||
/// root.
|
||||
pub fn from_game_root(root: impl AsRef<Path>) -> Result<Self, String> {
|
||||
let root = root.as_ref();
|
||||
let config = YostarJpLauncherConfig::from_game_root(root)?;
|
||||
let manifest = YostarJpLauncherManifest::from_game_root(root).ok();
|
||||
|
||||
if let Some(manifest) = &manifest {
|
||||
if manifest.version != config.version {
|
||||
return Err(format!(
|
||||
"Launcher config version {} does not match manifest version {}",
|
||||
config.version, manifest.version
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
app_version: config.version,
|
||||
executable_name: config.name,
|
||||
executable_params: config.params,
|
||||
basis: manifest.as_ref().map(|manifest| manifest.basis.clone()),
|
||||
manifest_file_count: manifest.as_ref().map(|manifest| manifest.files.len()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn parses_official_launcher_config() {
|
||||
let config = YostarJpLauncherConfig::from_slice(
|
||||
br#"{
|
||||
"tag": "BlueArchive_JP",
|
||||
"name": "xldr_BlueArchiveOnline_JP_loader_x64",
|
||||
"params": ["BlueArchive.exe"],
|
||||
"version": "1.70.0",
|
||||
"vc": "hash"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.version, "1.70.0");
|
||||
assert_eq!(config.params, vec!["BlueArchive.exe"]);
|
||||
config.validate_official_jp().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_installed_game_bootstrap_from_local_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
dir.path().join(LAUNCHER_CONFIG_FILE),
|
||||
r#"{
|
||||
"tag": "BlueArchive_JP",
|
||||
"name": "xldr_BlueArchiveOnline_JP_loader_x64",
|
||||
"params": ["BlueArchive.exe"],
|
||||
"version": "1.70.0"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join(LAUNCHER_MANIFEST_FILE),
|
||||
r#"{
|
||||
"name": "BlueArchive_JP",
|
||||
"version": "1.70.0",
|
||||
"basis": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip",
|
||||
"files": [{"path": "/BlueArchive.exe", "size": "1", "hash": "2"}]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let bootstrap = YostarJpInstalledGameBootstrap::from_game_root(dir.path()).unwrap();
|
||||
|
||||
assert_eq!(bootstrap.app_version, "1.70.0");
|
||||
assert_eq!(
|
||||
bootstrap.basis.as_deref(),
|
||||
Some("prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip")
|
||||
);
|
||||
assert_eq!(bootstrap.manifest_file_count, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_jp_launcher_config() {
|
||||
let config = YostarJpLauncherConfig::from_slice(
|
||||
br#"{
|
||||
"tag": "BlueArchive_CN",
|
||||
"name": "BlueArchive",
|
||||
"version": "1.70.0"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(config.validate_official_jp().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Official Blue Archive service adapters.
|
||||
//!
|
||||
//! This module only models URL rules observed from the official Yostar JP
|
||||
//! client endpoints. Mirror-specific layers such as `bluearchive.cafe` or
|
||||
//! `text=jp/voice=jp/media=jp` are intentionally excluded.
|
||||
|
||||
pub mod inventory;
|
||||
pub mod game_main_config;
|
||||
pub mod launcher;
|
||||
pub mod yostar_jp;
|
||||
|
||||
pub use inventory::{
|
||||
YostarJpDownloadInventory, YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
|
||||
};
|
||||
pub use game_main_config::YostarJpGameMainConfig;
|
||||
pub use launcher::{
|
||||
YostarJpInstalledGameBootstrap, YostarJpLauncherConfig, YostarJpLauncherManifest,
|
||||
};
|
||||
pub use yostar_jp::{verified_official_platforms, PatchPlatform, YostarJpResourceRoot};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,13 @@
|
||||
|
||||
pub mod adapter;
|
||||
pub mod registry;
|
||||
pub mod serialized_file;
|
||||
pub mod unity_2021_3;
|
||||
|
||||
pub use adapter::{ParsedAssetBundle, RawAssetBundle, UnityAdapter, VersionRange};
|
||||
pub use adapter::{
|
||||
ParsedAssetBundle, RawAssetBundle, UnityAdapter, UnityFsBlockInfo, UnityFsCompression,
|
||||
UnityFsDirectoryInfo, UnityFsHeader, VersionRange,
|
||||
};
|
||||
pub use registry::UnityAdapterRegistry;
|
||||
pub use serialized_file::{UnitySerializedFile, UnitySerializedTextAsset};
|
||||
pub use unity_2021_3::Unity2021_3Adapter;
|
||||
|
||||
@@ -44,6 +44,72 @@ pub struct ParsedAssetBundle {
|
||||
pub assets: Vec<String>,
|
||||
/// 原始数据(保留用于序列化)
|
||||
pub raw_data: Vec<u8>,
|
||||
/// UnityFS 文件头信息。
|
||||
pub unityfs_header: Option<UnityFsHeader>,
|
||||
/// UnityFS 压缩块信息。
|
||||
pub blocks: Vec<UnityFsBlockInfo>,
|
||||
/// UnityFS 目录信息。
|
||||
pub directories: Vec<UnityFsDirectoryInfo>,
|
||||
}
|
||||
|
||||
/// UnityFS 文件头。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnityFsHeader {
|
||||
/// UnityFS 格式版本。
|
||||
pub format_version: u32,
|
||||
/// Bundle 目标版本字符串,例如 `5.x.x`。
|
||||
pub target_version: String,
|
||||
/// Unity 编辑器版本字符串。
|
||||
pub unity_version: String,
|
||||
/// 文件总大小。
|
||||
pub total_size: u64,
|
||||
/// 压缩后的 block info 大小。
|
||||
pub compressed_blocks_info_size: u32,
|
||||
/// 解压后的 block info 大小。
|
||||
pub uncompressed_blocks_info_size: u32,
|
||||
/// UnityFS flags 原始值。
|
||||
pub flags: u32,
|
||||
}
|
||||
|
||||
/// UnityFS 块压缩类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnityFsCompression {
|
||||
/// 未压缩。
|
||||
None,
|
||||
/// LZMA 压缩。
|
||||
Lzma,
|
||||
/// LZ4 压缩。
|
||||
Lz4,
|
||||
/// LZ4HC 压缩。
|
||||
Lz4Hc,
|
||||
/// 当前版本未识别的压缩类型。
|
||||
Unknown(u16),
|
||||
}
|
||||
|
||||
/// UnityFS 压缩块信息。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnityFsBlockInfo {
|
||||
/// 解压后大小。
|
||||
pub uncompressed_size: u32,
|
||||
/// 压缩后大小。
|
||||
pub compressed_size: u32,
|
||||
/// 块 flags 原始值。
|
||||
pub flags: u16,
|
||||
/// 解析出的压缩类型。
|
||||
pub compression: UnityFsCompression,
|
||||
}
|
||||
|
||||
/// UnityFS 目录条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnityFsDirectoryInfo {
|
||||
/// 条目在数据区中的偏移。
|
||||
pub offset: u64,
|
||||
/// 条目大小。
|
||||
pub size: u64,
|
||||
/// 条目 flags 原始值。
|
||||
pub flags: u32,
|
||||
/// 条目路径。
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Unity Adapter 接口
|
||||
@@ -113,10 +179,10 @@ mod tests {
|
||||
fn test_raw_assetbundle() {
|
||||
let bundle = RawAssetBundle {
|
||||
data: vec![0x55, 0x6e, 0x69, 0x74, 0x79, 0x46, 0x53], // "UnityFS"
|
||||
path: Some("test.bundle".to_string()),
|
||||
path: Some("synthetic-minimal.bundle".to_string()),
|
||||
};
|
||||
|
||||
assert_eq!(bundle.data.len(), 7);
|
||||
assert_eq!(bundle.path, Some("test.bundle".to_string()));
|
||||
assert_eq!(bundle.path, Some("synthetic-minimal.bundle".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ impl UnityAdapterRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带有默认 Unity 适配器的注册表。
|
||||
pub fn with_defaults() -> Self {
|
||||
let mut registry = Self::new();
|
||||
registry.register(Arc::new(crate::unity::Unity2021_3Adapter::new()));
|
||||
registry
|
||||
}
|
||||
|
||||
/// 注册一个适配器
|
||||
pub fn register(&mut self, adapter: Arc<dyn UnityAdapter>) {
|
||||
self.adapters.push(adapter);
|
||||
@@ -91,7 +98,7 @@ mod tests {
|
||||
|
||||
let bundle = RawAssetBundle {
|
||||
data,
|
||||
path: Some("test.bundle".to_string()),
|
||||
path: Some("synthetic-minimal.bundle".to_string()),
|
||||
};
|
||||
|
||||
let result = registry.select_adapter(&bundle);
|
||||
@@ -111,4 +118,12 @@ mod tests {
|
||||
let result = registry.select_adapter(&bundle);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_defaults_registers_unity_2021_3_adapter() {
|
||||
let registry = UnityAdapterRegistry::with_defaults();
|
||||
|
||||
assert_eq!(registry.count(), 1);
|
||||
assert_eq!(registry.all_adapters()[0].name(), "Unity-2021.3");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Unity serialized file reader.
|
||||
//!
|
||||
//! This module is intentionally narrow: it extracts `TextAsset` payloads from
|
||||
//! Unity serialized files such as `resources.assets` and
|
||||
//! `globalgamemanagers.assets`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// One extracted Unity `TextAsset`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnitySerializedTextAsset {
|
||||
/// Unity path ID of the object.
|
||||
pub path_id: i64,
|
||||
/// Asset name stored in the serialized object.
|
||||
pub name: String,
|
||||
/// Raw bytes stored by the `TextAsset`.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parsed Unity serialized file summary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnitySerializedFile {
|
||||
/// Serialized file format version.
|
||||
pub version: u32,
|
||||
/// Unity editor version stored in the file.
|
||||
pub unity_version: String,
|
||||
/// Target platform value from the file header.
|
||||
pub platform: i32,
|
||||
text_assets: Vec<UnitySerializedTextAsset>,
|
||||
}
|
||||
|
||||
impl UnitySerializedFile {
|
||||
/// Parses a serialized file from raw bytes.
|
||||
pub fn from_slice(data: &[u8]) -> Result<Self, String> {
|
||||
let mut reader = Reader::new(data);
|
||||
|
||||
let _metadata_size = reader.read_u32_be("metadata_size")?;
|
||||
let _file_size = reader.read_u32_be("file_size")?;
|
||||
let version = reader.read_u32_be("version")?;
|
||||
let _data_offset = reader.read_u32_be("data_offset")?;
|
||||
let endian_flag = reader.read_u8("endian_flag")?;
|
||||
reader.read_bytes(3, "reserved")?;
|
||||
|
||||
let (metadata_size, file_size, data_offset) = if version >= 22 {
|
||||
let metadata_size = reader.read_u32_be("metadata_size_2")?;
|
||||
let file_size = reader.read_u64_be("file_size_2")?;
|
||||
let data_offset = reader.read_u64_be("data_offset_2")? as usize;
|
||||
let _unknown = reader.read_u64_be("unknown_2")?;
|
||||
(metadata_size, file_size, data_offset)
|
||||
} else {
|
||||
(_metadata_size, _file_size as u64, _data_offset as usize)
|
||||
};
|
||||
let _ = metadata_size;
|
||||
let _ = file_size;
|
||||
|
||||
let endian = if endian_flag == 0 {
|
||||
Endian::Little
|
||||
} else {
|
||||
Endian::Big
|
||||
};
|
||||
reader.set_endian(endian);
|
||||
|
||||
let unity_version = reader.read_c_string("unity_version")?;
|
||||
let platform = reader.read_i32("platform")?;
|
||||
let enable_type_tree = reader.read_u8("enable_type_tree")?;
|
||||
let type_count = reader.read_i32("type_count")?;
|
||||
if type_count < 0 {
|
||||
return Err(format!("Invalid Unity type count: {}", type_count));
|
||||
}
|
||||
|
||||
let mut class_ids = Vec::with_capacity(type_count as usize);
|
||||
for _ in 0..type_count {
|
||||
class_ids.push(read_serialized_type(
|
||||
&mut reader,
|
||||
version,
|
||||
enable_type_tree,
|
||||
)?);
|
||||
}
|
||||
|
||||
let big_id_enabled = if version >= 11 && version < 14 {
|
||||
reader.read_i32("big_id_enabled")?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let object_count = reader.read_i32("object_count")?;
|
||||
if object_count < 0 {
|
||||
return Err(format!("Invalid Unity object count: {}", object_count));
|
||||
}
|
||||
|
||||
let mut text_assets = Vec::new();
|
||||
for _ in 0..object_count {
|
||||
if version >= 14 {
|
||||
reader.align(4)?;
|
||||
}
|
||||
|
||||
let path_id = if big_id_enabled != 0 {
|
||||
reader.read_i64("path_id")?
|
||||
} else if version < 14 {
|
||||
reader.read_i32("path_id")? as i64
|
||||
} else {
|
||||
reader.read_i64("path_id")?
|
||||
};
|
||||
|
||||
let byte_start = if version >= 22 {
|
||||
reader.read_u64("byte_start")? as usize
|
||||
} else {
|
||||
reader.read_u32("byte_start")? as usize
|
||||
};
|
||||
let byte_size = reader.read_u32("byte_size")? as usize;
|
||||
let type_id = reader.read_i32("type_id")?;
|
||||
if version < 16 {
|
||||
reader.read_u16("class_id")?;
|
||||
}
|
||||
if version < 11 {
|
||||
reader.read_u16("is_destroyed")?;
|
||||
}
|
||||
if (11..17).contains(&version) {
|
||||
reader.read_i16("script_type_index")?;
|
||||
}
|
||||
if version == 15 || version == 16 {
|
||||
reader.read_u8("stripped")?;
|
||||
}
|
||||
|
||||
let class_id = class_ids
|
||||
.get(type_id as usize)
|
||||
.copied()
|
||||
.ok_or_else(|| format!("Invalid Unity type index: {}", type_id))?;
|
||||
if class_id == 49 {
|
||||
let object_start = data_offset
|
||||
.checked_add(byte_start)
|
||||
.ok_or_else(|| "Unity object offset overflow".to_string())?;
|
||||
let object_end = object_start
|
||||
.checked_add(byte_size)
|
||||
.ok_or_else(|| "Unity object size overflow".to_string())?;
|
||||
if object_end > data.len() {
|
||||
return Err(format!(
|
||||
"Unity object exceeds file size: start={}, size={}, file_size={}",
|
||||
object_start,
|
||||
byte_size,
|
||||
data.len()
|
||||
));
|
||||
}
|
||||
|
||||
let asset = parse_text_asset(path_id, &data[object_start..object_end], endian)?;
|
||||
text_assets.push(asset);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
version,
|
||||
unity_version,
|
||||
platform,
|
||||
text_assets,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a serialized file from disk.
|
||||
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, String> {
|
||||
let path = path.as_ref();
|
||||
let bytes = fs::read(path)
|
||||
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
|
||||
Self::from_slice(&bytes)
|
||||
}
|
||||
|
||||
/// Returns all extracted text assets.
|
||||
pub fn text_assets(&self) -> &[UnitySerializedTextAsset] {
|
||||
&self.text_assets
|
||||
}
|
||||
|
||||
/// Returns one extracted text asset by name.
|
||||
pub fn text_asset(&self, name: &str) -> Option<&UnitySerializedTextAsset> {
|
||||
self.text_assets.iter().find(|asset| asset.name == name)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_text_asset(
|
||||
path_id: i64,
|
||||
data: &[u8],
|
||||
endian: Endian,
|
||||
) -> Result<UnitySerializedTextAsset, String> {
|
||||
let mut reader = Reader::new(data);
|
||||
reader.set_endian(endian);
|
||||
let name = reader.read_len_prefixed_string("text_asset_name")?;
|
||||
reader.align(4)?;
|
||||
let bytes_len = reader.read_u32("text_asset_bytes_len")? as usize;
|
||||
let bytes = reader.read_bytes(bytes_len, "text_asset_bytes")?.to_vec();
|
||||
|
||||
Ok(UnitySerializedTextAsset {
|
||||
path_id,
|
||||
name,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_serialized_type(
|
||||
reader: &mut Reader<'_>,
|
||||
version: u32,
|
||||
enable_type_tree: u8,
|
||||
) -> Result<i32, String> {
|
||||
let class_id = reader.read_i32("type_class_id")?;
|
||||
|
||||
if version >= 16 {
|
||||
reader.read_u8("type_is_stripped")?;
|
||||
}
|
||||
if version >= 17 {
|
||||
reader.read_i16("type_script_index")?;
|
||||
}
|
||||
if version >= 13 {
|
||||
if (version < 16 && class_id < 0) || (version >= 16 && class_id == 114) {
|
||||
reader.read_bytes(16, "type_script_id")?;
|
||||
}
|
||||
reader.read_bytes(16, "type_hash")?;
|
||||
}
|
||||
|
||||
if enable_type_tree != 0 {
|
||||
if version >= 12 || version == 10 {
|
||||
let node_count = reader.read_i32("type_tree_node_count")?;
|
||||
if node_count < 0 {
|
||||
return Err(format!("Invalid Unity type tree node count: {}", node_count));
|
||||
}
|
||||
let string_buffer_size = reader.read_i32("type_tree_string_buffer_size")?;
|
||||
if string_buffer_size < 0 {
|
||||
return Err(format!(
|
||||
"Invalid Unity type tree string buffer size: {}",
|
||||
string_buffer_size
|
||||
));
|
||||
}
|
||||
|
||||
let node_size = 2 + 1 + 1 + 4 + 4 + 4 + 4 + 4 + if version >= 19 { 8 } else { 0 };
|
||||
reader.read_bytes(
|
||||
node_count as usize * node_size,
|
||||
"type_tree_nodes",
|
||||
)?;
|
||||
reader.read_bytes(string_buffer_size as usize, "type_tree_strings")?;
|
||||
}
|
||||
|
||||
if version >= 21 {
|
||||
let dependency_count = reader.read_i32("type_tree_dependency_count")?;
|
||||
if dependency_count < 0 {
|
||||
return Err(format!(
|
||||
"Invalid Unity type tree dependency count: {}",
|
||||
dependency_count
|
||||
));
|
||||
}
|
||||
reader.read_bytes(
|
||||
dependency_count as usize * 4,
|
||||
"type_tree_dependencies",
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(class_id)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Endian {
|
||||
Little,
|
||||
Big,
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
data: &'a [u8],
|
||||
offset: usize,
|
||||
endian: Endian,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self {
|
||||
data,
|
||||
offset: 0,
|
||||
endian: Endian::Big,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_endian(&mut self, endian: Endian) {
|
||||
self.endian = endian;
|
||||
}
|
||||
|
||||
fn read_bytes(&mut self, len: usize, field: &str) -> Result<&'a [u8], String> {
|
||||
let end = self
|
||||
.offset
|
||||
.checked_add(len)
|
||||
.ok_or_else(|| format!("{field} length overflow at offset {}", self.offset))?;
|
||||
if end > self.data.len() {
|
||||
return Err(format!(
|
||||
"Unexpected end while reading {field} at offset {}: need {}, have {}",
|
||||
self.offset,
|
||||
len,
|
||||
self.data.len().saturating_sub(self.offset)
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = &self.data[self.offset..end];
|
||||
self.offset = end;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn align(&mut self, alignment: usize) -> Result<(), String> {
|
||||
if alignment == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remainder = self.offset % alignment;
|
||||
if remainder == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let padding = alignment - remainder;
|
||||
self.read_bytes(padding, "alignment padding").map(|_| ())
|
||||
}
|
||||
|
||||
fn read_u8(&mut self, field: &str) -> Result<u8, String> {
|
||||
Ok(self.read_bytes(1, field)?[0])
|
||||
}
|
||||
|
||||
fn read_u16(&mut self, field: &str) -> Result<u16, String> {
|
||||
let bytes = self.read_bytes(2, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => u16::from_le_bytes([bytes[0], bytes[1]]),
|
||||
Endian::Big => u16::from_be_bytes([bytes[0], bytes[1]]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_i16(&mut self, field: &str) -> Result<i16, String> {
|
||||
let bytes = self.read_bytes(2, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => i16::from_le_bytes([bytes[0], bytes[1]]),
|
||||
Endian::Big => i16::from_be_bytes([bytes[0], bytes[1]]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u32(&mut self, field: &str) -> Result<u32, String> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
Endian::Big => u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u32_be(&mut self, field: &str) -> Result<u32, String> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||
}
|
||||
|
||||
fn read_i32(&mut self, field: &str) -> Result<i32, String> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
Endian::Big => i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u64(&mut self, field: &str) -> Result<u64, String> {
|
||||
let bytes = self.read_bytes(8, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => u64::from_le_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]),
|
||||
Endian::Big => u64::from_be_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u64_be(&mut self, field: &str) -> Result<u64, String> {
|
||||
let bytes = self.read_bytes(8, field)?;
|
||||
Ok(u64::from_be_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_i64(&mut self, field: &str) -> Result<i64, String> {
|
||||
let bytes = self.read_bytes(8, field)?;
|
||||
Ok(match self.endian {
|
||||
Endian::Little => i64::from_le_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]),
|
||||
Endian::Big => i64::from_be_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_c_string(&mut self, field: &str) -> Result<String, String> {
|
||||
let remaining = &self.data[self.offset..];
|
||||
let Some(length) = remaining.iter().position(|&byte| byte == 0) else {
|
||||
return Err(format!(
|
||||
"Missing null terminator while reading {field} at offset {}",
|
||||
self.offset
|
||||
));
|
||||
};
|
||||
let bytes = self.read_bytes(length, field)?;
|
||||
self.offset += 1;
|
||||
std::str::from_utf8(bytes)
|
||||
.map(ToOwned::to_owned)
|
||||
.map_err(|error| format!("Invalid UTF-8 in {field}: {error}"))
|
||||
}
|
||||
|
||||
fn read_len_prefixed_string(&mut self, field: &str) -> Result<String, String> {
|
||||
let len = self.read_u32(field)? as usize;
|
||||
let bytes = self.read_bytes(len, field)?;
|
||||
std::str::from_utf8(bytes)
|
||||
.map(ToOwned::to_owned)
|
||||
.map_err(|error| format!("Invalid UTF-8 in {field}: {error}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
|
||||
data.extend_from_slice(&value.to_le_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_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 align(data: &mut Vec<u8>, alignment: usize) {
|
||||
let remainder = data.len() % alignment;
|
||||
if remainder != 0 {
|
||||
data.resize(data.len() + alignment - remainder, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_serialized_file() -> Vec<u8> {
|
||||
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, 5);
|
||||
object_data.extend_from_slice(b"hello");
|
||||
|
||||
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 parses_synthetic_text_asset() {
|
||||
let file = synthetic_serialized_file();
|
||||
let parsed = UnitySerializedFile::from_slice(&file).unwrap();
|
||||
|
||||
assert_eq!(parsed.version, 22);
|
||||
assert_eq!(parsed.unity_version, "2021.3.56f2");
|
||||
assert_eq!(parsed.platform, 19);
|
||||
assert_eq!(parsed.text_assets.len(), 1);
|
||||
|
||||
let asset = parsed.text_asset("GameMainConfig").unwrap();
|
||||
assert_eq!(asset.name, "GameMainConfig");
|
||||
assert_eq!(asset.bytes, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_text_asset_from_real_resource_file_when_available() {
|
||||
let path = Path::new(
|
||||
"/home/wanye/D/BlueArchive/AllResources/YostarGames/BlueArchive_JP/BlueArchive_Data/resources.assets",
|
||||
);
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed = UnitySerializedFile::from_path(path).unwrap();
|
||||
let asset = parsed.text_asset("GameMainConfig").unwrap();
|
||||
|
||||
assert_eq!(asset.name, "GameMainConfig");
|
||||
assert_eq!(asset.bytes.len(), 896);
|
||||
assert_eq!(
|
||||
&asset.bytes[..32],
|
||||
&[
|
||||
0x60, 0x23, 0x0a, 0x06, 0x95, 0x72, 0x83, 0x57, 0x54, 0x23, 0x49, 0x06,
|
||||
0xfc, 0x72, 0xb4, 0x57, 0x57, 0x23, 0x6b, 0x06, 0x98, 0x72, 0xb5, 0x57,
|
||||
0x71, 0x23, 0x1b, 0x06, 0xec, 0x72, 0xf6, 0x57,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,17 @@
|
||||
//!
|
||||
//! 支持 Unity 2021.3.x 版本的 AssetBundle
|
||||
|
||||
use super::adapter::{ParsedAssetBundle, RawAssetBundle, UnityAdapter, VersionRange};
|
||||
use super::adapter::{
|
||||
ParsedAssetBundle, RawAssetBundle, UnityAdapter, UnityFsBlockInfo, UnityFsCompression,
|
||||
UnityFsDirectoryInfo, UnityFsHeader, VersionRange,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::io::Cursor;
|
||||
|
||||
const PARSE_NOT_IMPLEMENTED: &str = "parse() 将在 Phase 2 实现";
|
||||
const SERIALIZE_NOT_IMPLEMENTED: &str = "serialize() 将在 Phase 2 实现";
|
||||
const UNITYFS_COMPRESSION_MASK: u32 = 0x3f;
|
||||
const UNITYFS_BLOCK_INFO_AT_END_FLAG: u32 = 0x80;
|
||||
const UNITYFS_ALIGNMENT: usize = 16;
|
||||
|
||||
/// Unity 2021.3 Adapter
|
||||
pub struct Unity2021_3Adapter;
|
||||
@@ -42,6 +48,267 @@ impl Unity2021_3Adapter {
|
||||
.map(ToOwned::to_owned)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn parse_unityfs(data: &[u8]) -> Result<ParsedAssetBundle, String> {
|
||||
let mut reader = UnityFsReader::new(data);
|
||||
let signature = reader.read_c_string("signature")?;
|
||||
if signature != "UnityFS" {
|
||||
return Err(format!("Unsupported AssetBundle signature: {}", signature));
|
||||
}
|
||||
|
||||
let format_version = reader.read_u32("format_version")?;
|
||||
let target_version = reader.read_c_string("target_version")?;
|
||||
let unity_version = reader.read_c_string("unity_version")?;
|
||||
let total_size = reader.read_u64("total_size")?;
|
||||
let compressed_blocks_info_size = reader.read_u32("compressed_blocks_info_size")?;
|
||||
let uncompressed_blocks_info_size = reader.read_u32("uncompressed_blocks_info_size")?;
|
||||
let flags = reader.read_u32("flags")?;
|
||||
|
||||
let header = UnityFsHeader {
|
||||
format_version,
|
||||
target_version,
|
||||
unity_version,
|
||||
total_size,
|
||||
compressed_blocks_info_size,
|
||||
uncompressed_blocks_info_size,
|
||||
flags,
|
||||
};
|
||||
|
||||
if format_version >= 7 {
|
||||
reader.align(UNITYFS_ALIGNMENT)?;
|
||||
}
|
||||
|
||||
let blocks_info_bytes = read_blocks_info_bytes(data, &mut reader, &header)?;
|
||||
let block_info = decompress_blocks_info(
|
||||
blocks_info_bytes,
|
||||
compressed_blocks_info_size,
|
||||
uncompressed_blocks_info_size,
|
||||
flags,
|
||||
)?;
|
||||
let (blocks, directories) = Self::parse_blocks_info(&block_info)?;
|
||||
|
||||
Ok(ParsedAssetBundle {
|
||||
unity_version: header.unity_version.clone(),
|
||||
assets: directories
|
||||
.iter()
|
||||
.map(|directory| directory.path.clone())
|
||||
.collect(),
|
||||
raw_data: data.to_vec(),
|
||||
unityfs_header: Some(header),
|
||||
blocks,
|
||||
directories,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_blocks_info(
|
||||
data: &[u8],
|
||||
) -> Result<(Vec<UnityFsBlockInfo>, Vec<UnityFsDirectoryInfo>), String> {
|
||||
let mut reader = UnityFsReader::new(data);
|
||||
let _hash = reader.read_bytes(16, "blocks_info_hash")?;
|
||||
let block_count = reader.read_i32("block_count")?;
|
||||
if block_count < 0 {
|
||||
return Err(format!("Invalid UnityFS block count: {}", block_count));
|
||||
}
|
||||
|
||||
let mut blocks = Vec::with_capacity(block_count as usize);
|
||||
for _ in 0..block_count {
|
||||
let uncompressed_size = reader.read_u32("block_uncompressed_size")?;
|
||||
let compressed_size = reader.read_u32("block_compressed_size")?;
|
||||
let flags = reader.read_u16("block_flags")?;
|
||||
blocks.push(UnityFsBlockInfo {
|
||||
uncompressed_size,
|
||||
compressed_size,
|
||||
flags,
|
||||
compression: compression_from_flags(flags),
|
||||
});
|
||||
}
|
||||
|
||||
let directory_count = reader.read_i32("directory_count")?;
|
||||
if directory_count < 0 {
|
||||
return Err(format!(
|
||||
"Invalid UnityFS directory count: {}",
|
||||
directory_count
|
||||
));
|
||||
}
|
||||
|
||||
let mut directories = Vec::with_capacity(directory_count as usize);
|
||||
for _ in 0..directory_count {
|
||||
directories.push(UnityFsDirectoryInfo {
|
||||
offset: reader.read_u64("directory_offset")?,
|
||||
size: reader.read_u64("directory_size")?,
|
||||
flags: reader.read_u32("directory_flags")?,
|
||||
path: reader.read_c_string("directory_path")?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok((blocks, directories))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_blocks_info_bytes<'a>(
|
||||
data: &'a [u8],
|
||||
reader: &mut UnityFsReader<'a>,
|
||||
header: &UnityFsHeader,
|
||||
) -> Result<&'a [u8], String> {
|
||||
let len = header.compressed_blocks_info_size as usize;
|
||||
if blocks_info_at_end(header.flags) {
|
||||
let start = data.len().checked_sub(len).ok_or_else(|| {
|
||||
format!(
|
||||
"UnityFS block info at end underflow: compressed size {}, file size {}",
|
||||
len,
|
||||
data.len()
|
||||
)
|
||||
})?;
|
||||
return Ok(&data[start..]);
|
||||
}
|
||||
|
||||
reader.read_bytes(len, "blocks_info")
|
||||
}
|
||||
|
||||
fn blocks_info_at_end(flags: u32) -> bool {
|
||||
flags & UNITYFS_BLOCK_INFO_AT_END_FLAG != 0
|
||||
}
|
||||
|
||||
fn decompress_blocks_info(
|
||||
data: &[u8],
|
||||
compressed_size: u32,
|
||||
uncompressed_size: u32,
|
||||
flags: u32,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if data.len() != compressed_size as usize {
|
||||
return Err(format!(
|
||||
"UnityFS block info size mismatch: header says {}, read {}",
|
||||
compressed_size,
|
||||
data.len()
|
||||
));
|
||||
}
|
||||
|
||||
let compression = compression_from_flags((flags & UNITYFS_COMPRESSION_MASK) as u16);
|
||||
match compression {
|
||||
UnityFsCompression::None => {
|
||||
if compressed_size != uncompressed_size {
|
||||
return Err(format!(
|
||||
"Uncompressed UnityFS block info size mismatch: compressed {} != uncompressed {}",
|
||||
compressed_size, uncompressed_size
|
||||
));
|
||||
}
|
||||
Ok(data.to_vec())
|
||||
}
|
||||
UnityFsCompression::Lz4 | UnityFsCompression::Lz4Hc => {
|
||||
lz4::block::decompress(data, Some(uncompressed_size as i32))
|
||||
.map_err(|error| format!("Failed to decompress UnityFS LZ4 block info: {}", error))
|
||||
}
|
||||
UnityFsCompression::Lzma => {
|
||||
let mut output = Vec::with_capacity(uncompressed_size as usize);
|
||||
lzma_rs::lzma_decompress(&mut Cursor::new(data), &mut output).map_err(|error| {
|
||||
format!("Failed to decompress UnityFS LZMA block info: {}", error)
|
||||
})?;
|
||||
if output.len() != uncompressed_size as usize {
|
||||
return Err(format!(
|
||||
"UnityFS LZMA block info size mismatch: expected {}, got {}",
|
||||
uncompressed_size,
|
||||
output.len()
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
UnityFsCompression::Unknown(value) => Err(format!(
|
||||
"Unsupported UnityFS block info compression flag: {}",
|
||||
value
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn compression_from_flags(flags: u16) -> UnityFsCompression {
|
||||
match flags & UNITYFS_COMPRESSION_MASK as u16 {
|
||||
0 => UnityFsCompression::None,
|
||||
1 => UnityFsCompression::Lzma,
|
||||
2 => UnityFsCompression::Lz4,
|
||||
3 | 4 => UnityFsCompression::Lz4Hc,
|
||||
value => UnityFsCompression::Unknown(value),
|
||||
}
|
||||
}
|
||||
|
||||
struct UnityFsReader<'a> {
|
||||
data: &'a [u8],
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> UnityFsReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self { data, offset: 0 }
|
||||
}
|
||||
|
||||
fn read_bytes(&mut self, len: usize, field: &str) -> Result<&'a [u8], String> {
|
||||
let end = self
|
||||
.offset
|
||||
.checked_add(len)
|
||||
.ok_or_else(|| format!("{} length overflow at offset {}", field, self.offset))?;
|
||||
if end > self.data.len() {
|
||||
return Err(format!(
|
||||
"Unexpected end while reading {} at offset {}: need {}, have {}",
|
||||
field,
|
||||
self.offset,
|
||||
len,
|
||||
self.data.len().saturating_sub(self.offset)
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = &self.data[self.offset..end];
|
||||
self.offset = end;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn align(&mut self, alignment: usize) -> Result<(), String> {
|
||||
if alignment == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remainder = self.offset % alignment;
|
||||
if remainder == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let padding = alignment - remainder;
|
||||
self.read_bytes(padding, "alignment padding").map(|_| ())
|
||||
}
|
||||
|
||||
fn read_u16(&mut self, field: &str) -> Result<u16, String> {
|
||||
let bytes = self.read_bytes(2, field)?;
|
||||
Ok(u16::from_be_bytes([bytes[0], bytes[1]]))
|
||||
}
|
||||
|
||||
fn read_u32(&mut self, field: &str) -> Result<u32, String> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||
}
|
||||
|
||||
fn read_i32(&mut self, field: &str) -> Result<i32, String> {
|
||||
let bytes = self.read_bytes(4, field)?;
|
||||
Ok(i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||
}
|
||||
|
||||
fn read_u64(&mut self, field: &str) -> Result<u64, String> {
|
||||
let bytes = self.read_bytes(8, field)?;
|
||||
Ok(u64::from_be_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_c_string(&mut self, field: &str) -> Result<String, String> {
|
||||
let remaining = &self.data[self.offset..];
|
||||
let Some(length) = remaining.iter().position(|&byte| byte == 0) else {
|
||||
return Err(format!(
|
||||
"Missing null terminator while reading {} at offset {}",
|
||||
field, self.offset
|
||||
));
|
||||
};
|
||||
let bytes = self.read_bytes(length, field)?;
|
||||
self.offset += 1;
|
||||
std::str::from_utf8(bytes)
|
||||
.map(ToOwned::to_owned)
|
||||
.map_err(|error| format!("Invalid UTF-8 in {}: {}", field, error))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Unity2021_3Adapter {
|
||||
@@ -69,14 +336,16 @@ impl UnityAdapter for Unity2021_3Adapter {
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse(&self, _bundle: &RawAssetBundle) -> Result<ParsedAssetBundle, String> {
|
||||
// TODO: Phase 2 实现
|
||||
// 需要:
|
||||
// 1. 解析 UnityFS 文件头
|
||||
// 2. 解压缩数据块
|
||||
// 3. 解析 TypeTree
|
||||
// 4. 提取 Asset 对象
|
||||
Err(PARSE_NOT_IMPLEMENTED.to_string())
|
||||
async fn parse(&self, bundle: &RawAssetBundle) -> Result<ParsedAssetBundle, String> {
|
||||
let parsed = Self::parse_unityfs(&bundle.data)?;
|
||||
if !self.supported_versions().contains(&parsed.unity_version) {
|
||||
return Err(format!(
|
||||
"Unsupported Unity version for {}: {}",
|
||||
self.name(),
|
||||
parsed.unity_version
|
||||
));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
async fn serialize(&self, _parsed: &ParsedAssetBundle) -> Result<Vec<u8>, String> {
|
||||
@@ -94,6 +363,66 @@ impl UnityAdapter for Unity2021_3Adapter {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn push_c_string(data: &mut Vec<u8>, value: &str) {
|
||||
data.extend_from_slice(value.as_bytes());
|
||||
data.push(0);
|
||||
}
|
||||
|
||||
fn push_u16(data: &mut Vec<u8>, value: u16) {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_u32(data: &mut Vec<u8>, value: u32) {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_i32(data: &mut Vec<u8>, value: i32) {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_u64(data: &mut Vec<u8>, value: u64) {
|
||||
data.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn align(data: &mut Vec<u8>, alignment: usize) {
|
||||
let remainder = data.len() % alignment;
|
||||
if remainder != 0 {
|
||||
data.resize(data.len() + alignment - remainder, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_minimal_unityfs_bundle() -> Vec<u8> {
|
||||
let mut blocks_info = Vec::new();
|
||||
blocks_info.extend_from_slice(&[0; 16]);
|
||||
push_i32(&mut blocks_info, 1);
|
||||
push_u32(&mut blocks_info, 4);
|
||||
push_u32(&mut blocks_info, 4);
|
||||
push_u16(&mut blocks_info, 0);
|
||||
push_i32(&mut blocks_info, 1);
|
||||
push_u64(&mut blocks_info, 0);
|
||||
push_u64(&mut blocks_info, 4);
|
||||
push_u32(&mut blocks_info, 0);
|
||||
push_c_string(&mut blocks_info, "CAB-test");
|
||||
|
||||
let mut data = Vec::new();
|
||||
push_c_string(&mut data, "UnityFS");
|
||||
push_u32(&mut data, 8);
|
||||
push_c_string(&mut data, "5.x.x");
|
||||
push_c_string(&mut data, "2021.3.56f2");
|
||||
push_u64(&mut data, 0);
|
||||
push_u32(&mut data, blocks_info.len() as u32);
|
||||
push_u32(&mut data, blocks_info.len() as u32);
|
||||
push_u32(&mut data, 0);
|
||||
align(&mut data, UNITYFS_ALIGNMENT);
|
||||
data.extend_from_slice(&blocks_info);
|
||||
data.extend_from_slice(b"data");
|
||||
|
||||
let total_size = data.len() as u64;
|
||||
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
|
||||
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapter_name() {
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
@@ -114,15 +443,9 @@ mod tests {
|
||||
fn test_can_handle_valid_bundle() {
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
|
||||
// 模拟 UnityFS 文件头
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(b"UnityFS\0");
|
||||
data.extend_from_slice(&[0, 0, 0, 8]); // format version
|
||||
data.extend_from_slice(b"5.x.x\x002021.3.56f2\0");
|
||||
|
||||
let bundle = RawAssetBundle {
|
||||
data,
|
||||
path: Some("test.bundle".to_string()),
|
||||
data: synthetic_minimal_unityfs_bundle(),
|
||||
path: Some("synthetic-minimal.bundle".to_string()),
|
||||
};
|
||||
|
||||
assert!(adapter.can_handle(&bundle));
|
||||
@@ -141,7 +464,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_not_implemented() {
|
||||
async fn test_parse_unityfs_metadata() {
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
|
||||
let bundle = RawAssetBundle {
|
||||
data: synthetic_minimal_unityfs_bundle(),
|
||||
path: Some("synthetic-minimal.bundle".to_string()),
|
||||
};
|
||||
|
||||
let parsed = adapter.parse(&bundle).await.unwrap();
|
||||
let header = parsed.unityfs_header.unwrap();
|
||||
assert_eq!(parsed.unity_version, "2021.3.56f2");
|
||||
assert_eq!(header.format_version, 8);
|
||||
assert_eq!(parsed.blocks.len(), 1);
|
||||
assert_eq!(parsed.blocks[0].compression, UnityFsCompression::None);
|
||||
assert_eq!(parsed.directories.len(), 1);
|
||||
assert_eq!(parsed.directories[0].path, "CAB-test");
|
||||
assert_eq!(parsed.assets, vec!["CAB-test".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_rejects_invalid_bundle() {
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
|
||||
let bundle = RawAssetBundle {
|
||||
@@ -151,6 +494,6 @@ mod tests {
|
||||
|
||||
let result = adapter.parse(&bundle).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Phase 2"));
|
||||
assert!(result.unwrap_err().contains("signature"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use bat_adapters::manifest::ManifestDriverRegistry;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires BAT_REAL_ADDRESSABLES_CATALOG pointing at a local catalog_Remote.json"]
|
||||
async fn parses_local_real_addressables_catalog() {
|
||||
let path = std::env::var("BAT_REAL_ADDRESSABLES_CATALOG")
|
||||
.expect("BAT_REAL_ADDRESSABLES_CATALOG must be set");
|
||||
let data = std::fs::read(path).expect("read local Addressables catalog");
|
||||
|
||||
let manifest = ManifestDriverRegistry::with_defaults()
|
||||
.parse(&data)
|
||||
.await
|
||||
.expect("parse local Addressables catalog");
|
||||
|
||||
assert_eq!(
|
||||
manifest.metadata.locator_id.as_deref(),
|
||||
Some("AddressablesMainContentCatalog")
|
||||
);
|
||||
assert!(manifest.resources.len() > 1_000);
|
||||
assert!(manifest.metadata.extra.contains_key("internal_id_count"));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use bat_adapters::unity::{RawAssetBundle, Unity2021_3Adapter, UnityAdapter};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires BAT_REAL_UNITYFS_BUNDLE pointing at a local UnityFS bundle"]
|
||||
async fn parses_local_real_unityfs_bundle() {
|
||||
let path = PathBuf::from(
|
||||
std::env::var("BAT_REAL_UNITYFS_BUNDLE").expect("BAT_REAL_UNITYFS_BUNDLE must be set"),
|
||||
);
|
||||
let data = std::fs::read(&path).expect("read local UnityFS bundle");
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
|
||||
let parsed = adapter
|
||||
.parse(&RawAssetBundle {
|
||||
data,
|
||||
path: Some(path.display().to_string()),
|
||||
})
|
||||
.await
|
||||
.expect("parse local UnityFS bundle");
|
||||
|
||||
assert_eq!(parsed.unity_version, "2021.3.56f2");
|
||||
assert!(!parsed.blocks.is_empty());
|
||||
assert!(!parsed.directories.is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user