Files
BlueArchiveToolkit/adapters/src/manifest/addressables.rs
T
nyaKazuha 90307a3243 feat: track official versions and import resources
Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures.

Fixes #13

Fixes #12

Fixes #8
2026-07-15 19:53:10 +08:00

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);
}
}