//! 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 { 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 { let locator = json .get("m_LocatorId") .and_then(|value| value.as_str()) .map(ToOwned::to_owned); locator } fn cdn_prefixes(json: &Value) -> Vec { 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> { 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 { 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 { 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 { Self::read_u32_le(bytes, 0).map(|value| value as usize) } fn blob_count_field(json: &Value, field: &str) -> Option { Self::blob_bytes(json, field).and_then(|bytes| Self::blob_count(&bytes)) } fn internal_id_count(json: &Value) -> Option { 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::>(); 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::>(); 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 { 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 { 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 { 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::>(); if !strings.is_empty() { return strings; } } } Vec::new() } fn dependencies_from_entry(value: &Value) -> Vec { 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 { 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 { 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 { 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 { 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::>(); 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> { json.get(field)? .as_array()? .iter() .map(|value| value.as_str().map(ToOwned::to_owned)) .collect() } fn compact_entry_records(json: &Value) -> Option> { 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> { 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> { 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], ) -> Vec { 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 { 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::() 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 { 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 { 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, 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, 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, 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, json: &Value) { let count = Self::entry_resources(json) .into_iter() .map(|entry| entry.dependencies.len()) .sum::(); 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, }, } impl AddressablesObject { fn key_string(&self) -> Option { 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 { 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, } #[derive(Debug, Clone, Default)] struct AddressablesExtraData { hash: Option, bundle_name: Option, bundle_size: Option, } 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 { 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); } }