mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 00:55:15 +08:00
Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures. Fixes #13 Fixes #12 Fixes #8
1071 lines
35 KiB
Rust
1071 lines
35 KiB
Rust
//! Unity Addressables Catalog Driver
|
|
//!
|
|
//! 解析 Unity Addressables 的 catalog_Remote.json 文件
|
|
|
|
use super::driver::{GenericManifest, ManifestDriver, ManifestFormat, ManifestMetadata};
|
|
use async_trait::async_trait;
|
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
|
use bat_core::domain::{ResourceEntry, ResourceType};
|
|
use serde_json::Value;
|
|
use std::collections::HashMap;
|
|
|
|
const ADDRESSABLES_ENTRY_RECORD_INTS: usize = 7;
|
|
const PLATFORM_LOAD_PATH_PLACEHOLDER: &str = "{PlatformUtils.AddressableLoadPath}";
|
|
|
|
/// Addressables Catalog Driver
|
|
pub struct AddressablesCatalogDriver;
|
|
|
|
impl AddressablesCatalogDriver {
|
|
/// 创建新的 Driver 实例
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
|
|
fn parse_json(raw_data: &[u8]) -> Result<Value, String> {
|
|
let text = std::str::from_utf8(raw_data).map_err(|e| format!("Invalid UTF-8: {}", e))?;
|
|
serde_json::from_str(text).map_err(|e| {
|
|
format!(
|
|
"Invalid JSON at line {}, column {}: {}",
|
|
e.line(),
|
|
e.column(),
|
|
e
|
|
)
|
|
})
|
|
}
|
|
|
|
fn locator_id(json: &Value) -> Option<String> {
|
|
let locator = json
|
|
.get("m_LocatorId")
|
|
.and_then(|value| value.as_str())
|
|
.map(ToOwned::to_owned);
|
|
locator
|
|
}
|
|
|
|
fn cdn_prefixes(json: &Value) -> Vec<String> {
|
|
let prefixes = json
|
|
.get("m_InternalIdPrefixes")
|
|
.and_then(|value| value.as_array())
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|value| value.as_str())
|
|
.map(ToOwned::to_owned)
|
|
.collect();
|
|
prefixes
|
|
}
|
|
|
|
fn blob_bytes(json: &Value, field: &str) -> Option<Vec<u8>> {
|
|
json.get(field)
|
|
.and_then(|value| value.as_str())
|
|
.and_then(|value| STANDARD.decode(value).ok())
|
|
}
|
|
|
|
fn read_u32_le(bytes: &[u8], offset: usize) -> Option<u32> {
|
|
let end = offset.checked_add(4)?;
|
|
let slice = bytes.get(offset..end)?;
|
|
Some(u32::from_le_bytes(slice.try_into().ok()?))
|
|
}
|
|
|
|
fn read_i32_le(bytes: &[u8], offset: usize) -> Option<i32> {
|
|
let end = offset.checked_add(4)?;
|
|
let slice = bytes.get(offset..end)?;
|
|
Some(i32::from_le_bytes(slice.try_into().ok()?))
|
|
}
|
|
|
|
fn blob_count(bytes: &[u8]) -> Option<usize> {
|
|
Self::read_u32_le(bytes, 0).map(|value| value as usize)
|
|
}
|
|
|
|
fn blob_count_field(json: &Value, field: &str) -> Option<usize> {
|
|
Self::blob_bytes(json, field).and_then(|bytes| Self::blob_count(&bytes))
|
|
}
|
|
|
|
fn internal_id_count(json: &Value) -> Option<usize> {
|
|
json.get("m_InternalIds")
|
|
.and_then(|value| value.as_array())
|
|
.map(|values| values.len())
|
|
}
|
|
|
|
fn decode_serialized_string(bytes: &[u8], offset: usize) -> Option<(String, usize)> {
|
|
let (object, next_offset) = Self::read_serialized_object(bytes, offset)?;
|
|
object.into_key_string().map(|value| (value, next_offset))
|
|
}
|
|
|
|
fn read_serialized_object(bytes: &[u8], offset: usize) -> Option<(AddressablesObject, usize)> {
|
|
let object_type = *bytes.get(offset)?;
|
|
let mut cursor = offset.checked_add(1)?;
|
|
|
|
match object_type {
|
|
0 => {
|
|
let len = Self::read_u32_le(bytes, cursor)? as usize;
|
|
cursor = cursor.checked_add(4)?;
|
|
let end = cursor.checked_add(len)?;
|
|
let value = std::str::from_utf8(bytes.get(cursor..end)?)
|
|
.ok()?
|
|
.to_owned();
|
|
Some((AddressablesObject::String(value), end))
|
|
}
|
|
1 => {
|
|
let len = Self::read_u32_le(bytes, cursor)? as usize;
|
|
cursor = cursor.checked_add(4)?;
|
|
let end = cursor.checked_add(len)?;
|
|
let units = bytes
|
|
.get(cursor..end)?
|
|
.chunks_exact(2)
|
|
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
|
|
.collect::<Vec<_>>();
|
|
let value = String::from_utf16(&units).ok()?;
|
|
Some((AddressablesObject::String(value), end))
|
|
}
|
|
2 => {
|
|
let end = cursor.checked_add(2)?;
|
|
let value = u16::from_le_bytes(bytes.get(cursor..end)?.try_into().ok()?);
|
|
Some((AddressablesObject::Unsigned(value as u32), end))
|
|
}
|
|
3 => {
|
|
let end = cursor.checked_add(4)?;
|
|
let value = u32::from_le_bytes(bytes.get(cursor..end)?.try_into().ok()?);
|
|
Some((AddressablesObject::Unsigned(value), end))
|
|
}
|
|
4 => {
|
|
let end = cursor.checked_add(4)?;
|
|
let value = i32::from_le_bytes(bytes.get(cursor..end)?.try_into().ok()?);
|
|
Some((AddressablesObject::Signed(value), end))
|
|
}
|
|
5 => {
|
|
let len = *bytes.get(cursor)? as usize;
|
|
cursor = cursor.checked_add(1)?;
|
|
let end = cursor.checked_add(len)?;
|
|
let value = std::str::from_utf8(bytes.get(cursor..end)?)
|
|
.ok()?
|
|
.to_owned();
|
|
Some((AddressablesObject::Hash128(value), end))
|
|
}
|
|
7 => Self::read_serialized_json_object(bytes, cursor),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn read_serialized_json_object(
|
|
bytes: &[u8],
|
|
mut cursor: usize,
|
|
) -> Option<(AddressablesObject, usize)> {
|
|
let assembly_len = *bytes.get(cursor)? as usize;
|
|
cursor = cursor.checked_add(1)?;
|
|
let assembly_end = cursor.checked_add(assembly_len)?;
|
|
let assembly_name = std::str::from_utf8(bytes.get(cursor..assembly_end)?)
|
|
.ok()?
|
|
.to_owned();
|
|
cursor = assembly_end;
|
|
|
|
let class_len = *bytes.get(cursor)? as usize;
|
|
cursor = cursor.checked_add(1)?;
|
|
let class_end = cursor.checked_add(class_len)?;
|
|
let class_name = std::str::from_utf8(bytes.get(cursor..class_end)?)
|
|
.ok()?
|
|
.to_owned();
|
|
cursor = class_end;
|
|
|
|
let json_len = Self::read_u32_le(bytes, cursor)? as usize;
|
|
cursor = cursor.checked_add(4)?;
|
|
let json_end = cursor.checked_add(json_len)?;
|
|
let units = bytes
|
|
.get(cursor..json_end)?
|
|
.chunks_exact(2)
|
|
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
|
|
.collect::<Vec<_>>();
|
|
let json_text = String::from_utf16(&units).ok()?;
|
|
let json = serde_json::from_str(&json_text).ok();
|
|
|
|
Some((
|
|
AddressablesObject::JsonObject {
|
|
assembly_name,
|
|
class_name,
|
|
json,
|
|
},
|
|
json_end,
|
|
))
|
|
}
|
|
|
|
fn resource_type_for_path(path: &str) -> ResourceType {
|
|
let normalized = path.replace('\\', "/").to_ascii_lowercase();
|
|
if normalized.contains("catalog") || normalized.ends_with(".hash") {
|
|
ResourceType::Manifest
|
|
} else if normalized.contains("tablebundles/") {
|
|
ResourceType::TableBundle
|
|
} else if normalized.contains("mediaresources/")
|
|
|| normalized.contains("mediaresources-")
|
|
|| matches!(
|
|
normalized.rsplit('.').next(),
|
|
Some(
|
|
"mp3" | "mp4" | "ogg" | "wav" | "png" | "jpg" | "jpeg" | "webp" | "acb" | "awb"
|
|
)
|
|
)
|
|
{
|
|
ResourceType::Media
|
|
} else if normalized.ends_with(".bundle") {
|
|
ResourceType::AssetBundle
|
|
} else if normalized.contains("textassets/")
|
|
|| matches!(
|
|
normalized.rsplit('.').next(),
|
|
Some("txt" | "csv" | "xml" | "yaml" | "yml")
|
|
)
|
|
{
|
|
ResourceType::TextAsset
|
|
} else if normalized.ends_with(".bytes") {
|
|
ResourceType::TableBundle
|
|
} else {
|
|
ResourceType::Other
|
|
}
|
|
}
|
|
|
|
fn resource_type_for_compact_entry(
|
|
path: &str,
|
|
resource_type_name: Option<&str>,
|
|
) -> ResourceType {
|
|
if resource_type_name.is_some_and(|name| name.contains("IAssetBundleResource")) {
|
|
return ResourceType::AssetBundle;
|
|
}
|
|
|
|
if resource_type_name.is_some_and(|name| name.contains("ContentCatalogData")) {
|
|
return ResourceType::Manifest;
|
|
}
|
|
|
|
if resource_type_name.is_some_and(|name| name.contains("TextAsset")) {
|
|
return ResourceType::TextAsset;
|
|
}
|
|
|
|
if resource_type_name.is_some_and(|name| {
|
|
name.contains("AudioClip") || name.contains("VideoClip") || name.contains("Texture2D")
|
|
}) {
|
|
return ResourceType::Media;
|
|
}
|
|
|
|
Self::resource_type_for_path(path)
|
|
}
|
|
|
|
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 address = Self::string_field(value, &["address", "Address", "m_Address", "key", "Key"]);
|
|
let hash = value
|
|
.get("hash")
|
|
.or_else(|| value.get("Hash"))
|
|
.or_else(|| value.get("m_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"))
|
|
.or_else(|| value.get("m_Size"))
|
|
.and_then(|value| value.as_u64())
|
|
.unwrap_or_default();
|
|
|
|
let dependencies = Self::dependencies_from_entry(value);
|
|
|
|
Some(ResourceEntry {
|
|
path: path.to_string(),
|
|
hash,
|
|
size,
|
|
resource_type: Self::resource_type_for_path(path),
|
|
address,
|
|
dependencies,
|
|
})
|
|
}
|
|
|
|
fn string_field(value: &Value, fields: &[&str]) -> Option<String> {
|
|
fields.iter().find_map(|field| {
|
|
value
|
|
.get(field)
|
|
.and_then(|value| value.as_str())
|
|
.map(ToOwned::to_owned)
|
|
})
|
|
}
|
|
|
|
fn string_array_field(value: &Value, fields: &[&str]) -> Vec<String> {
|
|
for field in fields {
|
|
if let Some(array) = value.get(field).and_then(|value| value.as_array()) {
|
|
let strings = array
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
Value::String(value) => Some(value.clone()),
|
|
Value::Object(object) => object
|
|
.get("internal_id")
|
|
.or_else(|| object.get("InternalId"))
|
|
.or_else(|| object.get("path"))
|
|
.or_else(|| object.get("Path"))
|
|
.and_then(|value| value.as_str())
|
|
.map(ToOwned::to_owned),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if !strings.is_empty() {
|
|
return strings;
|
|
}
|
|
}
|
|
}
|
|
|
|
Vec::new()
|
|
}
|
|
|
|
fn dependencies_from_entry(value: &Value) -> Vec<String> {
|
|
let dependencies = Self::string_array_field(
|
|
value,
|
|
&[
|
|
"dependencies",
|
|
"Dependencies",
|
|
"m_Dependencies",
|
|
"dependency",
|
|
"Dependency",
|
|
"m_Dependency",
|
|
],
|
|
);
|
|
if !dependencies.is_empty() {
|
|
return dependencies;
|
|
}
|
|
|
|
Self::string_array_field(
|
|
value,
|
|
&[
|
|
"m_Dependencies",
|
|
"asset_dependencies",
|
|
"AssetDependencies",
|
|
"resource_dependencies",
|
|
"ResourceDependencies",
|
|
],
|
|
)
|
|
}
|
|
|
|
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()
|
|
.enumerate()
|
|
.filter_map(|(index, value)| {
|
|
let path = value.as_str()?;
|
|
if path.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(ResourceEntry {
|
|
path: path.to_string(),
|
|
hash: format!("addressable_{}", index),
|
|
size: 0,
|
|
resource_type: Self::resource_type_for_path(path),
|
|
address: None,
|
|
dependencies: Vec::new(),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn key_data_resources(json: &Value) -> Vec<ResourceEntry> {
|
|
let Some(bytes) = Self::blob_bytes(json, "m_KeyDataString") else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let expected_count = Self::blob_count(&bytes).unwrap_or_default();
|
|
if expected_count == 0 {
|
|
return Vec::new();
|
|
}
|
|
|
|
let limit = Self::internal_id_count(json).unwrap_or(expected_count);
|
|
let mut resources = Vec::with_capacity(expected_count.min(limit));
|
|
let mut offset = 4;
|
|
|
|
while offset < bytes.len() && resources.len() < limit {
|
|
let Some((path, next_offset)) = Self::decode_serialized_string(&bytes, offset) else {
|
|
break;
|
|
};
|
|
offset = next_offset;
|
|
if path.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let index = resources.len();
|
|
let resource_type = Self::resource_type_for_path(&path);
|
|
resources.push(ResourceEntry {
|
|
path,
|
|
hash: format!("addressable_{}", index),
|
|
size: 0,
|
|
resource_type,
|
|
address: None,
|
|
dependencies: Vec::new(),
|
|
});
|
|
}
|
|
|
|
resources
|
|
}
|
|
|
|
fn compact_entry_resources(json: &Value) -> Vec<ResourceEntry> {
|
|
let Some(internal_ids) = Self::string_array(json, "m_InternalIds") else {
|
|
return Vec::new();
|
|
};
|
|
let Some(provider_ids) = Self::string_array(json, "m_ProviderIds") else {
|
|
return Vec::new();
|
|
};
|
|
let Some(key_bytes) = Self::blob_bytes(json, "m_KeyDataString") else {
|
|
return Vec::new();
|
|
};
|
|
let Some(entry_records) = Self::compact_entry_records(json) else {
|
|
return Vec::new();
|
|
};
|
|
let Some(buckets) = Self::compact_buckets(json) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let keys = Self::compact_keys(&key_bytes, &buckets);
|
|
if keys.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let internal_id_prefixes = Self::string_array(json, "m_InternalIdPrefixes")
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.collect::<Vec<_>>();
|
|
let extra_data = Self::blob_bytes(json, "m_ExtraDataString").unwrap_or_default();
|
|
|
|
entry_records
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(index, record)| {
|
|
let internal_id = internal_ids.get(record.internal_id as usize)?;
|
|
provider_ids.get(record.provider_index as usize)?;
|
|
let primary_key = keys
|
|
.get(record.primary_key_index as usize)
|
|
.and_then(|key| key.as_ref())
|
|
.and_then(AddressablesObject::key_string)
|
|
.unwrap_or_else(|| format!("addressable_{}", index));
|
|
let path = Self::normalize_internal_id(&internal_id_prefixes, internal_id);
|
|
let extra = Self::extra_data_at(&extra_data, record.data_index);
|
|
let resource_type_name = Self::resource_type_name(json, record.resource_type_index);
|
|
let dependencies =
|
|
Self::compact_dependencies(record, &entry_records, &buckets, &keys);
|
|
let hash = extra
|
|
.hash
|
|
.filter(|value| !value.is_empty())
|
|
.or_else(|| extra.bundle_name.filter(|value| !value.is_empty()))
|
|
.unwrap_or_else(|| format!("addressable_{}", index));
|
|
|
|
Some(ResourceEntry {
|
|
path: path.clone(),
|
|
hash,
|
|
size: extra.bundle_size.unwrap_or_default(),
|
|
resource_type: Self::resource_type_for_compact_entry(
|
|
&path,
|
|
resource_type_name.as_deref(),
|
|
),
|
|
address: if primary_key.is_empty() {
|
|
None
|
|
} else {
|
|
Some(primary_key)
|
|
},
|
|
dependencies,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn string_array(json: &Value, field: &str) -> Option<Vec<String>> {
|
|
json.get(field)?
|
|
.as_array()?
|
|
.iter()
|
|
.map(|value| value.as_str().map(ToOwned::to_owned))
|
|
.collect()
|
|
}
|
|
|
|
fn compact_entry_records(json: &Value) -> Option<Vec<CompactEntryRecord>> {
|
|
let bytes = Self::blob_bytes(json, "m_EntryDataString")?;
|
|
let count = Self::blob_count(&bytes)?;
|
|
let expected_len = 4usize.checked_add(
|
|
count
|
|
.checked_mul(ADDRESSABLES_ENTRY_RECORD_INTS)?
|
|
.checked_mul(4)?,
|
|
)?;
|
|
if bytes.len() < expected_len {
|
|
return None;
|
|
}
|
|
|
|
let mut records = Vec::with_capacity(count);
|
|
let mut offset = 4;
|
|
for _ in 0..count {
|
|
records.push(CompactEntryRecord {
|
|
internal_id: Self::read_i32_le(&bytes, offset)?,
|
|
provider_index: Self::read_i32_le(&bytes, offset + 4)?,
|
|
dependency_key_index: Self::read_i32_le(&bytes, offset + 8)?,
|
|
data_index: Self::read_i32_le(&bytes, offset + 16)?,
|
|
primary_key_index: Self::read_i32_le(&bytes, offset + 20)?,
|
|
resource_type_index: Self::read_i32_le(&bytes, offset + 24)?,
|
|
});
|
|
offset += ADDRESSABLES_ENTRY_RECORD_INTS * 4;
|
|
}
|
|
|
|
Some(records)
|
|
}
|
|
|
|
fn compact_buckets(json: &Value) -> Option<Vec<CompactBucket>> {
|
|
let bytes = Self::blob_bytes(json, "m_BucketDataString")?;
|
|
let count = Self::blob_count(&bytes)?;
|
|
let mut buckets = Vec::with_capacity(count);
|
|
let mut offset = 4;
|
|
|
|
for _ in 0..count {
|
|
let key_offset = Self::read_i32_le(&bytes, offset)?;
|
|
let entry_count = Self::read_i32_le(&bytes, offset + 4)?;
|
|
if key_offset < 0 || entry_count < 0 {
|
|
return None;
|
|
}
|
|
offset += 8;
|
|
|
|
let mut entries = Vec::with_capacity(entry_count as usize);
|
|
for _ in 0..entry_count {
|
|
let entry_index = Self::read_i32_le(&bytes, offset)?;
|
|
if entry_index < 0 {
|
|
return None;
|
|
}
|
|
entries.push(entry_index as usize);
|
|
offset += 4;
|
|
}
|
|
|
|
buckets.push(CompactBucket {
|
|
key_offset: key_offset as usize,
|
|
entries,
|
|
});
|
|
}
|
|
|
|
Some(buckets)
|
|
}
|
|
|
|
fn compact_keys(
|
|
key_bytes: &[u8],
|
|
buckets: &[CompactBucket],
|
|
) -> Vec<Option<AddressablesObject>> {
|
|
buckets
|
|
.iter()
|
|
.map(|bucket| {
|
|
Self::read_serialized_object(key_bytes, bucket.key_offset).map(|(object, _)| object)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn compact_dependencies(
|
|
record: &CompactEntryRecord,
|
|
records: &[CompactEntryRecord],
|
|
buckets: &[CompactBucket],
|
|
keys: &[Option<AddressablesObject>],
|
|
) -> Vec<String> {
|
|
if record.dependency_key_index < 0 {
|
|
return Vec::new();
|
|
}
|
|
|
|
buckets
|
|
.get(record.dependency_key_index as usize)
|
|
.into_iter()
|
|
.flat_map(|bucket| bucket.entries.iter().copied())
|
|
.filter_map(|entry_index| records.get(entry_index))
|
|
.filter_map(|dependency_record| keys.get(dependency_record.primary_key_index as usize))
|
|
.filter_map(|key| key.as_ref())
|
|
.filter_map(AddressablesObject::key_string)
|
|
.collect()
|
|
}
|
|
|
|
fn extra_data_at(extra_data: &[u8], data_index: i32) -> AddressablesExtraData {
|
|
if data_index < 0 {
|
|
return AddressablesExtraData::default();
|
|
}
|
|
|
|
let Some((object, _)) = Self::read_serialized_object(extra_data, data_index as usize)
|
|
else {
|
|
return AddressablesExtraData::default();
|
|
};
|
|
|
|
let AddressablesObject::JsonObject { json, .. } = object else {
|
|
return AddressablesExtraData::default();
|
|
};
|
|
|
|
let Some(json) = json else {
|
|
return AddressablesExtraData::default();
|
|
};
|
|
|
|
AddressablesExtraData {
|
|
hash: json
|
|
.get("m_Hash")
|
|
.and_then(|value| value.as_str())
|
|
.map(ToOwned::to_owned),
|
|
bundle_name: json
|
|
.get("m_BundleName")
|
|
.and_then(|value| value.as_str())
|
|
.map(ToOwned::to_owned),
|
|
bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()),
|
|
}
|
|
}
|
|
|
|
fn resource_type_name(json: &Value, index: i32) -> Option<String> {
|
|
if index < 0 {
|
|
return None;
|
|
}
|
|
|
|
json.get("m_resourceTypes")?
|
|
.as_array()?
|
|
.get(index as usize)?
|
|
.get("m_ClassName")?
|
|
.as_str()
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn normalize_internal_id(prefixes: &[String], internal_id: &str) -> String {
|
|
let expanded = Self::expand_internal_id(prefixes, internal_id);
|
|
let normalized = expanded.replace('\\', "/");
|
|
normalized
|
|
.strip_prefix(PLATFORM_LOAD_PATH_PLACEHOLDER)
|
|
.map(|path| path.trim_start_matches('/').to_string())
|
|
.unwrap_or(normalized)
|
|
}
|
|
|
|
fn expand_internal_id(prefixes: &[String], internal_id: &str) -> String {
|
|
if prefixes.is_empty() {
|
|
return internal_id.to_string();
|
|
}
|
|
|
|
let Some((index, path)) = internal_id.rsplit_once('#') else {
|
|
return internal_id.to_string();
|
|
};
|
|
let Ok(index) = index.parse::<usize>() else {
|
|
return internal_id.to_string();
|
|
};
|
|
let Some(prefix) = prefixes.get(index) else {
|
|
return internal_id.to_string();
|
|
};
|
|
|
|
format!("{prefix}{path}")
|
|
}
|
|
|
|
fn resources(json: &Value) -> Vec<ResourceEntry> {
|
|
let entry_resources = Self::entry_resources(json);
|
|
if !entry_resources.is_empty() {
|
|
return entry_resources;
|
|
}
|
|
|
|
let compact_resources = Self::compact_entry_resources(json);
|
|
if !compact_resources.is_empty() {
|
|
return compact_resources;
|
|
}
|
|
|
|
let key_resources = Self::key_data_resources(json);
|
|
if !key_resources.is_empty()
|
|
&& Self::internal_id_count(json)
|
|
.map(|count| key_resources.len() >= count)
|
|
.unwrap_or(true)
|
|
{
|
|
return key_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_array_len(&mut extra, json, "m_resourceTypes", "resource_type_count");
|
|
Self::insert_blob_count(&mut extra, json, "m_KeyDataString", "key_object_count");
|
|
Self::insert_blob_count(
|
|
&mut extra,
|
|
json,
|
|
"m_BucketDataString",
|
|
"bucket_record_count",
|
|
);
|
|
Self::insert_blob_count(&mut extra, json, "m_EntryDataString", "entry_record_count");
|
|
Self::insert_string_len(&mut extra, json, "m_KeyDataString", "key_data_string_len");
|
|
Self::insert_string_len(
|
|
&mut extra,
|
|
json,
|
|
"m_BucketDataString",
|
|
"bucket_data_string_len",
|
|
);
|
|
Self::insert_string_len(
|
|
&mut extra,
|
|
json,
|
|
"m_EntryDataString",
|
|
"entry_data_string_len",
|
|
);
|
|
Self::insert_string_len(
|
|
&mut extra,
|
|
json,
|
|
"m_ExtraDataString",
|
|
"extra_data_string_len",
|
|
);
|
|
Self::insert_dependency_count(&mut extra, json);
|
|
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());
|
|
}
|
|
}
|
|
|
|
fn insert_blob_count(
|
|
extra: &mut HashMap<String, String>,
|
|
json: &Value,
|
|
field: &str,
|
|
key: &str,
|
|
) {
|
|
if let Some(count) = Self::blob_count_field(json, field) {
|
|
extra.insert(key.to_string(), count.to_string());
|
|
}
|
|
}
|
|
|
|
fn insert_dependency_count(extra: &mut HashMap<String, String>, json: &Value) {
|
|
let count = Self::entry_resources(json)
|
|
.into_iter()
|
|
.map(|entry| entry.dependencies.len())
|
|
.sum::<usize>();
|
|
if count > 0 {
|
|
extra.insert("dependency_count".to_string(), count.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum AddressablesObject {
|
|
String(String),
|
|
Unsigned(u32),
|
|
Signed(i32),
|
|
Hash128(String),
|
|
JsonObject {
|
|
assembly_name: String,
|
|
class_name: String,
|
|
json: Option<Value>,
|
|
},
|
|
}
|
|
|
|
impl AddressablesObject {
|
|
fn key_string(&self) -> Option<String> {
|
|
match self {
|
|
Self::String(value) | Self::Hash128(value) => Some(value.clone()),
|
|
Self::Unsigned(value) => Some(value.to_string()),
|
|
Self::Signed(value) => Some(value.to_string()),
|
|
Self::JsonObject {
|
|
assembly_name,
|
|
class_name,
|
|
..
|
|
} => Some(format!("{assembly_name}:{class_name}")),
|
|
}
|
|
}
|
|
|
|
fn into_key_string(self) -> Option<String> {
|
|
self.key_string()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct CompactEntryRecord {
|
|
internal_id: i32,
|
|
provider_index: i32,
|
|
dependency_key_index: i32,
|
|
data_index: i32,
|
|
primary_key_index: i32,
|
|
resource_type_index: i32,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct CompactBucket {
|
|
key_offset: usize,
|
|
entries: Vec<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
struct AddressablesExtraData {
|
|
hash: Option<String>,
|
|
bundle_name: Option<String>,
|
|
bundle_size: Option<u64>,
|
|
}
|
|
|
|
impl Default for AddressablesCatalogDriver {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ManifestDriver for AddressablesCatalogDriver {
|
|
fn name(&self) -> &str {
|
|
"Unity Addressables Catalog"
|
|
}
|
|
|
|
fn format_version(&self) -> &str {
|
|
"1.0"
|
|
}
|
|
|
|
fn can_parse(&self, raw_data: &[u8]) -> bool {
|
|
// 检测是否是 Addressables Catalog
|
|
// 1. 必须是有效的 JSON
|
|
// 2. 必须包含 m_LocatorId 字段
|
|
// 3. m_LocatorId 值应该是 "AddressablesMainContentCatalog"
|
|
|
|
let text = match std::str::from_utf8(raw_data) {
|
|
Ok(t) => t,
|
|
Err(_) => return false,
|
|
};
|
|
|
|
let json: Value = match serde_json::from_str(text) {
|
|
Ok(j) => j,
|
|
Err(_) => return false,
|
|
};
|
|
|
|
let locator_id = match json.get("m_LocatorId") {
|
|
Some(id) => id,
|
|
None => return false,
|
|
};
|
|
|
|
let id_str = match locator_id.as_str() {
|
|
Some(s) => s,
|
|
None => return false,
|
|
};
|
|
|
|
id_str.contains("AddressablesMainContentCatalog") || id_str.contains("Addressables")
|
|
}
|
|
|
|
async fn parse(&self, raw_data: &[u8]) -> Result<GenericManifest, String> {
|
|
let json = Self::parse_json(raw_data)?;
|
|
|
|
let metadata = ManifestMetadata {
|
|
locator_id: Self::locator_id(&json),
|
|
cdn_prefixes: Self::cdn_prefixes(&json),
|
|
extra: Self::extra_metadata(&json),
|
|
};
|
|
|
|
Ok(GenericManifest {
|
|
format: ManifestFormat::AddressablesCatalog,
|
|
resources: Self::resources(&json),
|
|
metadata,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_can_parse_valid_catalog() {
|
|
let driver = AddressablesCatalogDriver::new();
|
|
|
|
let valid_json = r#"{
|
|
"m_LocatorId": "AddressablesMainContentCatalog",
|
|
"m_InternalIds": []
|
|
}"#;
|
|
|
|
assert!(driver.can_parse(valid_json.as_bytes()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_can_parse_invalid() {
|
|
let driver = AddressablesCatalogDriver::new();
|
|
|
|
let invalid_json = r#"{"some": "other"}"#;
|
|
assert!(!driver.can_parse(invalid_json.as_bytes()));
|
|
|
|
let not_json = b"not json at all";
|
|
assert!(!driver.can_parse(not_json));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parse_simple_catalog() {
|
|
let driver = AddressablesCatalogDriver::new();
|
|
|
|
let catalog_json = r#"{
|
|
"m_LocatorId": "AddressablesMainContentCatalog",
|
|
"m_InternalIdPrefixes": [],
|
|
"m_InternalIds": [
|
|
"synthetic/minimal-a.bundle",
|
|
"synthetic/minimal-b.bundle",
|
|
"synthetic/catalog.json"
|
|
]
|
|
}"#;
|
|
|
|
let result = driver.parse(catalog_json.as_bytes()).await;
|
|
assert!(result.is_ok());
|
|
|
|
let manifest = result.unwrap();
|
|
assert_eq!(manifest.format, ManifestFormat::AddressablesCatalog);
|
|
assert_eq!(manifest.resources.len(), 3);
|
|
assert_eq!(
|
|
manifest.resources[0].resource_type,
|
|
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_resourceTypes": ["AssetBundle", "Manifest"],
|
|
"m_Entries": [
|
|
{
|
|
"internal_id": "synthetic/minimal.bundle",
|
|
"hash": "synthetic-entry-hash",
|
|
"size": 119,
|
|
"address": "Character_001",
|
|
"dependencies": ["synthetic/shared.bundle"]
|
|
},
|
|
{
|
|
"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].address.as_deref(),
|
|
Some("Character_001")
|
|
);
|
|
assert_eq!(
|
|
manifest.resources[0].dependencies,
|
|
vec!["synthetic/shared.bundle".to_string()]
|
|
);
|
|
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())
|
|
);
|
|
assert_eq!(
|
|
manifest.metadata.extra.get("resource_type_count"),
|
|
Some(&"2".to_string())
|
|
);
|
|
assert_eq!(
|
|
manifest.metadata.extra.get("dependency_count"),
|
|
Some(&"1".to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parse_reports_json_location() {
|
|
let driver = AddressablesCatalogDriver::new();
|
|
|
|
let error = driver
|
|
.parse(
|
|
b"{\n \"m_LocatorId\": \"AddressablesMainContentCatalog\",\n \"m_Entries\": [\n",
|
|
)
|
|
.await
|
|
.expect_err("expected parse failure");
|
|
|
|
assert!(error.contains("Invalid JSON at line"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parse_table_bundle_resource_types() {
|
|
let driver = AddressablesCatalogDriver::new();
|
|
|
|
let catalog_json = r#"{
|
|
"m_LocatorId": "AddressablesMainContentCatalog",
|
|
"m_InternalIds": [
|
|
"TableBundles/10031865119468584059_717066257.bytes"
|
|
]
|
|
}"#;
|
|
|
|
let manifest = driver.parse(catalog_json.as_bytes()).await.unwrap();
|
|
|
|
assert_eq!(manifest.resources.len(), 1);
|
|
assert_eq!(
|
|
manifest.resources[0].resource_type,
|
|
ResourceType::TableBundle
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_parse_text_and_media_resource_types() {
|
|
let driver = AddressablesCatalogDriver::new();
|
|
|
|
let catalog_json = r#"{
|
|
"m_LocatorId": "AddressablesMainContentCatalog",
|
|
"m_Entries": [
|
|
{"internal_id": "TextAssets/dialogue.csv"},
|
|
{"internal_id": "MediaResources-Windows/voice/academy.acb"},
|
|
{"internal_id": "MediaResources-Windows/movie/opening.mp4"}
|
|
]
|
|
}"#;
|
|
|
|
let manifest = driver.parse(catalog_json.as_bytes()).await.unwrap();
|
|
|
|
assert_eq!(manifest.resources[0].resource_type, ResourceType::TextAsset);
|
|
assert_eq!(manifest.resources[1].resource_type, ResourceType::Media);
|
|
assert_eq!(manifest.resources[2].resource_type, ResourceType::Media);
|
|
}
|
|
}
|