mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
feat(assetbundle): 完善官方资源解析与双目录发布
This commit is contained in:
+1
-2
@@ -6,6 +6,7 @@ authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bat-assetbundle = { path = "../crates/bat-assetbundle" }
|
||||
bat-core = { path = "../core" }
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -13,8 +14,6 @@ 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]
|
||||
|
||||
@@ -428,74 +428,105 @@ impl AddressablesCatalogDriver {
|
||||
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();
|
||||
};
|
||||
fn compact_entry_resources(json: &Value) -> Result<Vec<ResourceEntry>, String> {
|
||||
let internal_ids = Self::string_array(json, "m_InternalIds")
|
||||
.ok_or_else(|| "compact catalog missing string array m_InternalIds".to_string())?;
|
||||
let provider_ids = Self::string_array(json, "m_ProviderIds")
|
||||
.ok_or_else(|| "compact catalog missing string array m_ProviderIds".to_string())?;
|
||||
let key_bytes = Self::blob_bytes(json, "m_KeyDataString")
|
||||
.ok_or_else(|| "compact catalog missing decodable m_KeyDataString".to_string())?;
|
||||
let entry_records = Self::compact_entry_records(json)
|
||||
.ok_or_else(|| "failed to decode m_EntryDataString compact records".to_string())?;
|
||||
let buckets = Self::compact_buckets(json)
|
||||
.ok_or_else(|| "failed to decode m_BucketDataString compact buckets".to_string())?;
|
||||
|
||||
let keys = Self::compact_keys(&key_bytes, &buckets);
|
||||
if keys.is_empty() {
|
||||
return Vec::new();
|
||||
return Err("compact catalog contains no decodable key buckets".to_string());
|
||||
}
|
||||
|
||||
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();
|
||||
let extra_data = if json.get("m_ExtraDataString").is_some() {
|
||||
Self::blob_bytes(json, "m_ExtraDataString")
|
||||
.ok_or_else(|| "compact catalog has undecodable m_ExtraDataString".to_string())?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
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));
|
||||
let mut resources = Vec::with_capacity(entry_records.len());
|
||||
for (index, record) in entry_records.iter().enumerate() {
|
||||
if record.internal_id < 0 {
|
||||
return Err(format!("compact entry {index} has negative internal_id"));
|
||||
}
|
||||
if record.provider_index < 0 {
|
||||
return Err(format!("compact entry {index} has negative provider_index"));
|
||||
}
|
||||
if record.primary_key_index < 0 {
|
||||
return Err(format!(
|
||||
"compact entry {index} has negative primary_key_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,
|
||||
crc: extra.crc,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
let internal_id = internal_ids
|
||||
.get(record.internal_id as usize)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"compact entry {index} internal_id index {} out of range {}",
|
||||
record.internal_id,
|
||||
internal_ids.len()
|
||||
)
|
||||
})?;
|
||||
provider_ids
|
||||
.get(record.provider_index as usize)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"compact entry {index} provider_index {} out of range {}",
|
||||
record.provider_index,
|
||||
provider_ids.len()
|
||||
)
|
||||
})?;
|
||||
let primary_key = keys
|
||||
.get(record.primary_key_index as usize)
|
||||
.and_then(|key| key.as_ref())
|
||||
.and_then(AddressablesObject::key_string)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"compact entry {index} primary_key_index {} has no decodable key",
|
||||
record.primary_key_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));
|
||||
|
||||
resources.push(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,
|
||||
crc: extra.crc,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
fn string_array(json: &Value, field: &str) -> Option<Vec<String>> {
|
||||
@@ -677,15 +708,30 @@ impl AddressablesCatalogDriver {
|
||||
format!("{prefix}{path}")
|
||||
}
|
||||
|
||||
fn resources(json: &Value) -> Vec<ResourceEntry> {
|
||||
fn has_compact_catalog_fields(json: &Value) -> bool {
|
||||
[
|
||||
"m_ProviderIds",
|
||||
"m_KeyDataString",
|
||||
"m_BucketDataString",
|
||||
"m_EntryDataString",
|
||||
"m_ExtraDataString",
|
||||
"m_resourceTypes",
|
||||
]
|
||||
.iter()
|
||||
.any(|field| json.get(field).is_some())
|
||||
}
|
||||
|
||||
fn resources(json: &Value) -> Result<Vec<ResourceEntry>, String> {
|
||||
let entry_resources = Self::entry_resources(json);
|
||||
if !entry_resources.is_empty() {
|
||||
return entry_resources;
|
||||
return Ok(entry_resources);
|
||||
}
|
||||
|
||||
let compact_resources = Self::compact_entry_resources(json);
|
||||
if !compact_resources.is_empty() {
|
||||
return compact_resources;
|
||||
if Self::has_compact_catalog_fields(json) {
|
||||
let compact_resources = Self::compact_entry_resources(json)?;
|
||||
if !compact_resources.is_empty() {
|
||||
return Ok(compact_resources);
|
||||
}
|
||||
}
|
||||
|
||||
let key_resources = Self::key_data_resources(json);
|
||||
@@ -694,13 +740,13 @@ impl AddressablesCatalogDriver {
|
||||
.map(|count| key_resources.len() >= count)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return key_resources;
|
||||
return Ok(key_resources);
|
||||
}
|
||||
|
||||
Self::internal_id_resources(json)
|
||||
Ok(Self::internal_id_resources(json))
|
||||
}
|
||||
|
||||
fn extra_metadata(json: &Value) -> HashMap<String, String> {
|
||||
fn extra_metadata(json: &Value, resources: &[ResourceEntry]) -> 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");
|
||||
@@ -732,7 +778,7 @@ impl AddressablesCatalogDriver {
|
||||
"m_ExtraDataString",
|
||||
"extra_data_string_len",
|
||||
);
|
||||
Self::insert_dependency_count(&mut extra, json);
|
||||
Self::insert_resource_summary(&mut extra, resources);
|
||||
extra
|
||||
}
|
||||
|
||||
@@ -772,13 +818,56 @@ impl AddressablesCatalogDriver {
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_dependency_count(extra: &mut HashMap<String, String>, json: &Value) {
|
||||
let count = Self::entry_resources(json)
|
||||
.into_iter()
|
||||
fn insert_resource_summary(extra: &mut HashMap<String, String>, resources: &[ResourceEntry]) {
|
||||
extra.insert("resource_count".to_string(), resources.len().to_string());
|
||||
|
||||
let asset_bundle_count = resources
|
||||
.iter()
|
||||
.filter(|entry| entry.resource_type == ResourceType::AssetBundle)
|
||||
.count();
|
||||
if asset_bundle_count > 0 {
|
||||
extra.insert(
|
||||
"asset_bundle_count".to_string(),
|
||||
asset_bundle_count.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let declared_size_count = resources.iter().filter(|entry| entry.size != 0).count();
|
||||
if declared_size_count > 0 {
|
||||
extra.insert(
|
||||
"declared_size_count".to_string(),
|
||||
declared_size_count.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let declared_crc_count = resources
|
||||
.iter()
|
||||
.filter(|entry| entry.declared_crc().is_some())
|
||||
.count();
|
||||
if declared_crc_count > 0 {
|
||||
extra.insert(
|
||||
"declared_crc_count".to_string(),
|
||||
declared_crc_count.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let dependency_count = resources
|
||||
.iter()
|
||||
.map(|entry| entry.dependencies.len())
|
||||
.sum::<usize>();
|
||||
if count > 0 {
|
||||
extra.insert("dependency_count".to_string(), count.to_string());
|
||||
if dependency_count > 0 {
|
||||
extra.insert("dependency_count".to_string(), dependency_count.to_string());
|
||||
}
|
||||
|
||||
let fallback_hash_count = resources
|
||||
.iter()
|
||||
.filter(|entry| entry.hash.starts_with("addressable_"))
|
||||
.count();
|
||||
if fallback_hash_count > 0 {
|
||||
extra.insert(
|
||||
"fallback_hash_count".to_string(),
|
||||
fallback_hash_count.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -886,16 +975,17 @@ impl ManifestDriver for AddressablesCatalogDriver {
|
||||
|
||||
async fn parse(&self, raw_data: &[u8]) -> Result<GenericManifest, String> {
|
||||
let json = Self::parse_json(raw_data)?;
|
||||
let resources = Self::resources(&json)?;
|
||||
|
||||
let metadata = ManifestMetadata {
|
||||
locator_id: Self::locator_id(&json),
|
||||
cdn_prefixes: Self::cdn_prefixes(&json),
|
||||
extra: Self::extra_metadata(&json),
|
||||
extra: Self::extra_metadata(&json, &resources),
|
||||
};
|
||||
|
||||
Ok(GenericManifest {
|
||||
format: ManifestFormat::AddressablesCatalog,
|
||||
resources: Self::resources(&json),
|
||||
resources,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
@@ -904,6 +994,91 @@ impl ManifestDriver for AddressablesCatalogDriver {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
|
||||
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_serialized_string(data: &mut Vec<u8>, value: &str) -> usize {
|
||||
let offset = data.len();
|
||||
data.push(0);
|
||||
push_u32_le(data, value.len() as u32);
|
||||
data.extend_from_slice(value.as_bytes());
|
||||
offset
|
||||
}
|
||||
|
||||
fn serialized_json_object(json_text: &str) -> Vec<u8> {
|
||||
let assembly_name =
|
||||
"Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
let class_name =
|
||||
"UnityEngine.ResourceManagement.ResourceProviders.AssetBundleRequestOptions";
|
||||
let mut json_bytes = Vec::new();
|
||||
for unit in json_text.encode_utf16() {
|
||||
json_bytes.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.push(7);
|
||||
data.push(assembly_name.len() as u8);
|
||||
data.extend_from_slice(assembly_name.as_bytes());
|
||||
data.push(class_name.len() as u8);
|
||||
data.extend_from_slice(class_name.as_bytes());
|
||||
push_u32_le(&mut data, json_bytes.len() as u32);
|
||||
data.extend_from_slice(&json_bytes);
|
||||
data
|
||||
}
|
||||
|
||||
fn compact_catalog_json(extra_json: &str) -> String {
|
||||
let mut key_data = Vec::new();
|
||||
push_u32_le(&mut key_data, 1);
|
||||
let key_offset = push_serialized_string(&mut key_data, "synthetic.bundle");
|
||||
|
||||
let mut bucket_data = Vec::new();
|
||||
push_u32_le(&mut bucket_data, 1);
|
||||
push_i32_le(&mut bucket_data, key_offset as i32);
|
||||
push_i32_le(&mut bucket_data, 1);
|
||||
push_i32_le(&mut bucket_data, 0);
|
||||
|
||||
let mut entry_data = Vec::new();
|
||||
push_u32_le(&mut entry_data, 1);
|
||||
push_i32_le(&mut entry_data, 0); // internal_id
|
||||
push_i32_le(&mut entry_data, 0); // provider_index
|
||||
push_i32_le(&mut entry_data, -1); // dependency_key_index
|
||||
push_i32_le(&mut entry_data, 0); // reserved/unused
|
||||
push_i32_le(&mut entry_data, 0); // data_index
|
||||
push_i32_le(&mut entry_data, 0); // primary_key_index
|
||||
push_i32_le(&mut entry_data, 0); // resource_type_index
|
||||
|
||||
let extra_data = serialized_json_object(extra_json);
|
||||
|
||||
serde_json::json!({
|
||||
"m_LocatorId": "AddressablesMainContentCatalog",
|
||||
"m_InternalIdPrefixes": [],
|
||||
"m_ProviderIds": [
|
||||
"UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider"
|
||||
],
|
||||
"m_InternalIds": [
|
||||
"{PlatformUtils.AddressableLoadPath}\\synthetic.bundle"
|
||||
],
|
||||
"m_resourceTypes": [
|
||||
{
|
||||
"m_AssemblyName": "Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
|
||||
"m_ClassName": "UnityEngine.ResourceManagement.ResourceProviders.IAssetBundleResource"
|
||||
}
|
||||
],
|
||||
"m_KeyDataString": STANDARD.encode(key_data),
|
||||
"m_BucketDataString": STANDARD.encode(bucket_data),
|
||||
"m_EntryDataString": STANDARD.encode(entry_data),
|
||||
"m_ExtraDataString": STANDARD.encode(extra_data)
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_parse_valid_catalog() {
|
||||
@@ -1048,6 +1223,65 @@ mod tests {
|
||||
assert!(error.contains("Invalid JSON at line"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_compact_catalog_extracts_verification_fields() {
|
||||
let driver = AddressablesCatalogDriver::new();
|
||||
let catalog_json = compact_catalog_json(
|
||||
r#"{
|
||||
"m_Hash":"hash-compact",
|
||||
"m_Crc":305419896,
|
||||
"m_BundleName":"synthetic-bundle-name",
|
||||
"m_BundleSize":42
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = driver.parse(catalog_json.as_bytes()).await.unwrap();
|
||||
|
||||
assert_eq!(manifest.resources.len(), 1);
|
||||
let resource = &manifest.resources[0];
|
||||
assert_eq!(resource.path, "synthetic.bundle");
|
||||
assert_eq!(resource.hash, "hash-compact");
|
||||
assert_eq!(resource.size, 42);
|
||||
assert_eq!(resource.crc, Some(0x1234_5678));
|
||||
assert_eq!(resource.resource_type, ResourceType::AssetBundle);
|
||||
assert_eq!(resource.address.as_deref(), Some("synthetic.bundle"));
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("resource_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("asset_bundle_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("declared_size_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("declared_crc_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_compact_catalog_reports_blob_decode_failure() {
|
||||
let driver = AddressablesCatalogDriver::new();
|
||||
let catalog_json = r#"{
|
||||
"m_LocatorId": "AddressablesMainContentCatalog",
|
||||
"m_ProviderIds": ["UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider"],
|
||||
"m_InternalIds": ["synthetic.bundle"],
|
||||
"m_KeyDataString": "not-base64",
|
||||
"m_BucketDataString": "not-base64",
|
||||
"m_EntryDataString": "not-base64",
|
||||
"m_ExtraDataString": "not-base64"
|
||||
}"#;
|
||||
|
||||
let error = driver.parse(catalog_json.as_bytes()).await.unwrap_err();
|
||||
|
||||
assert!(error.contains("compact catalog"), "{error}");
|
||||
assert!(error.contains("m_KeyDataString"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_table_bundle_resource_types() {
|
||||
let driver = AddressablesCatalogDriver::new();
|
||||
|
||||
@@ -38,13 +38,14 @@ pub struct YostarJpGameMainConfig {
|
||||
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)?;
|
||||
let serialized = UnitySerializedFile::from_path(path).map_err(|error| error.to_string())?;
|
||||
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)?;
|
||||
let serialized =
|
||||
UnitySerializedFile::from_slice(bytes).map_err(|error| error.to_string())?;
|
||||
Self::from_serialized_file(&serialized)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ pub mod unity_2021_3;
|
||||
|
||||
pub use adapter::{
|
||||
ParsedAssetBundle, RawAssetBundle, UnityAdapter, UnityFsBlockInfo, UnityFsCompression,
|
||||
UnityFsDirectoryInfo, UnityFsHeader, VersionRange,
|
||||
UnityFsDirectoryInfo, UnityFsFile, UnityFsHeader, UnitySerializedParseError, VersionRange,
|
||||
};
|
||||
pub use registry::UnityAdapterRegistry;
|
||||
pub use serialized_file::{UnitySerializedFile, UnitySerializedTextAsset};
|
||||
pub use serialized_file::{
|
||||
UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset, UnitySerializedType,
|
||||
UnityTypeTreeNode,
|
||||
};
|
||||
pub use unity_2021_3::Unity2021_3Adapter;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Unity Adapter 接口定义
|
||||
|
||||
use async_trait::async_trait;
|
||||
pub use bat_assetbundle::{
|
||||
ParsedAssetBundle, RawAssetBundle, UnityFsBlockInfo, UnityFsCompression, UnityFsDirectoryInfo,
|
||||
UnityFsFile, UnityFsHeader, UnitySerializedParseError,
|
||||
};
|
||||
|
||||
/// Unity 版本范围
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -56,92 +60,6 @@ fn parse_version_components(version: &str) -> Option<(u64, u64, u64)> {
|
||||
Some((major, minor, patch))
|
||||
}
|
||||
|
||||
/// 原始 AssetBundle 数据
|
||||
#[derive(Debug)]
|
||||
pub struct RawAssetBundle {
|
||||
/// 文件数据
|
||||
pub data: Vec<u8>,
|
||||
/// 文件路径(可选)
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// 解析后的 AssetBundle
|
||||
#[derive(Debug)]
|
||||
pub struct ParsedAssetBundle {
|
||||
/// Unity 版本
|
||||
pub unity_version: String,
|
||||
/// 资源列表(简化表示)
|
||||
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 接口
|
||||
///
|
||||
/// 用于解析不同 Unity 版本的 AssetBundle
|
||||
@@ -172,8 +90,7 @@ pub trait UnityAdapter: Send + Sync {
|
||||
/// - 成功:返回解析后的 AssetBundle
|
||||
/// - 失败:返回错误
|
||||
///
|
||||
/// # 注意
|
||||
/// Phase 1 中标记为 TODO,Phase 2 实现
|
||||
/// 当前 UnityFS 容器解析由具体适配器委托给 `bat-assetbundle`。
|
||||
async fn parse(&self, bundle: &RawAssetBundle) -> Result<ParsedAssetBundle, String>;
|
||||
|
||||
/// 序列化 AssetBundle
|
||||
@@ -185,8 +102,7 @@ pub trait UnityAdapter: Send + Sync {
|
||||
/// - 成功:返回序列化后的数据
|
||||
/// - 失败:返回错误
|
||||
///
|
||||
/// # 注意
|
||||
/// Phase 1 中标记为 TODO,Phase 2 实现
|
||||
/// 当前阶段只定义接口;具体序列化能力尚未进入实现范围。
|
||||
async fn serialize(&self, parsed: &ParsedAssetBundle) -> Result<Vec<u8>, String>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,521 +1,9 @@
|
||||
//! Unity serialized file reader.
|
||||
//! Compatibility exports for Unity serialized file parsing.
|
||||
//!
|
||||
//! This module is intentionally narrow: it extracts `TextAsset` payloads from
|
||||
//! Unity serialized files such as `resources.assets` and
|
||||
//! `globalgamemanagers.assets`.
|
||||
//! The implementation lives in `bat-assetbundle`; adapters keep this module so
|
||||
//! existing call sites can continue to import through `bat_adapters::unity`.
|
||||
|
||||
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 (11..14).contains(&version) {
|
||||
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]
|
||||
#[ignore = "requires BAT_REAL_RESOURCES_ASSETS pointing at a local resources.assets"]
|
||||
fn reads_text_asset_from_real_resource_file() {
|
||||
let path = std::env::var("BAT_REAL_RESOURCES_ASSETS")
|
||||
.expect("BAT_REAL_RESOURCES_ASSETS must be set");
|
||||
|
||||
let parsed =
|
||||
UnitySerializedFile::from_path(Path::new(&path)).expect("parse local resources.assets");
|
||||
let asset = parsed
|
||||
.text_asset("GameMainConfig")
|
||||
.expect("GameMainConfig TextAsset present");
|
||||
|
||||
assert_eq!(asset.name, "GameMainConfig");
|
||||
assert!(!asset.bytes.is_empty());
|
||||
}
|
||||
}
|
||||
pub use bat_assetbundle::{
|
||||
UnitySerializedFile, UnitySerializedObject, UnitySerializedTextAsset, UnitySerializedType,
|
||||
UnityTypeTreeNode,
|
||||
};
|
||||
|
||||
@@ -1,358 +1,24 @@
|
||||
//! Unity 2021.3 Adapter
|
||||
//! Unity 2021.3 adapter.
|
||||
//!
|
||||
//! 支持 Unity 2021.3.x 版本的 AssetBundle
|
||||
//! 该层只负责 Unity 版本选择;UnityFS 容器解析由 `bat-assetbundle` 引擎承担。
|
||||
|
||||
use super::adapter::{
|
||||
ParsedAssetBundle, RawAssetBundle, UnityAdapter, UnityFsBlockInfo, UnityFsCompression,
|
||||
UnityFsDirectoryInfo, UnityFsHeader, VersionRange,
|
||||
};
|
||||
use super::adapter::{ParsedAssetBundle, RawAssetBundle, UnityAdapter, VersionRange};
|
||||
use async_trait::async_trait;
|
||||
use std::io::Cursor;
|
||||
use bat_assetbundle::UnityFsParser;
|
||||
|
||||
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;
|
||||
const SERIALIZE_NOT_IMPLEMENTED: &str = "serialize() 尚未实现";
|
||||
|
||||
/// Unity 2021.3 Adapter
|
||||
/// Unity 2021.3 adapter.
|
||||
pub struct Unity2021_3Adapter;
|
||||
|
||||
impl Unity2021_3Adapter {
|
||||
/// 创建新的适配器实例
|
||||
/// Creates an adapter instance.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn unity_version_bytes(data: &[u8]) -> Option<&[u8]> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if &data[0..7] != b"UnityFS" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version_start = data.windows(7).position(|window| window == b"2021.3.")?;
|
||||
let version_bytes = &data[version_start..];
|
||||
let version_end = version_bytes
|
||||
.iter()
|
||||
.position(|&byte| byte == 0 || !byte.is_ascii())?;
|
||||
|
||||
Some(&version_bytes[..version_end])
|
||||
}
|
||||
|
||||
/// 检测 Unity 版本(从文件头)
|
||||
fn detect_unity_version(data: &[u8]) -> Option<String> {
|
||||
let version_bytes = Self::unity_version_bytes(data)?;
|
||||
std::str::from_utf8(version_bytes)
|
||||
.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)?;
|
||||
validate_directory_bounds(&blocks, &directories)?;
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验目录条目落在解压数据区内。
|
||||
///
|
||||
/// UnityFS 的 directory 是解压后(所有 block 的 uncompressed 数据依次拼接而成的)
|
||||
/// 连续数据区上的 `[offset, offset + size)` 切片。解析阶段只按结构读取这些数值,
|
||||
/// 并不保证它们不越界;截断或损坏的 bundle 会给出指向数据区之外的目录条目,
|
||||
/// 若不校验就静默接受,后续按 offset/size 取数据时才会出错或读到错误内容。
|
||||
/// 这里把每个目录条目与「各 block 解压大小之和」比对,越界即报错并带上下文。
|
||||
fn validate_directory_bounds(
|
||||
blocks: &[UnityFsBlockInfo],
|
||||
directories: &[UnityFsDirectoryInfo],
|
||||
) -> Result<(), String> {
|
||||
let mut data_region_size: u64 = 0;
|
||||
for (index, block) in blocks.iter().enumerate() {
|
||||
data_region_size = data_region_size
|
||||
.checked_add(u64::from(block.uncompressed_size))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"UnityFS 解压数据区大小溢出:累加到第 {index} 个 block(uncompressed_size={})时超过 u64",
|
||||
block.uncompressed_size
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
for (index, directory) in directories.iter().enumerate() {
|
||||
let end = directory
|
||||
.offset
|
||||
.checked_add(directory.size)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"UnityFS 目录条目 {} 的 offset({}) + size({}) 溢出 u64",
|
||||
directory.path, directory.offset, directory.size
|
||||
)
|
||||
})?;
|
||||
if end > data_region_size {
|
||||
return Err(format!(
|
||||
"UnityFS 目录条目 {}(第 {index} 项)越界:offset({}) + size({}) = {} 超过解压数据区大小 {}",
|
||||
directory.path, directory.offset, directory.size, end, data_region_size
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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))
|
||||
UnityFsParser::detect_unity_version(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,16 +39,15 @@ impl UnityAdapter for Unity2021_3Adapter {
|
||||
}
|
||||
|
||||
fn can_handle(&self, bundle: &RawAssetBundle) -> bool {
|
||||
// 检测 Unity 版本
|
||||
if let Some(version) = Self::detect_unity_version(&bundle.data) {
|
||||
self.supported_versions().contains(&version)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
Self::detect_unity_version(&bundle.data)
|
||||
.map(|version| self.supported_versions().contains(&version))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn parse(&self, bundle: &RawAssetBundle) -> Result<ParsedAssetBundle, String> {
|
||||
let parsed = Self::parse_unityfs(&bundle.data)?;
|
||||
let parsed = UnityFsParser::new()
|
||||
.parse_asset_bundle(bundle)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !self.supported_versions().contains(&parsed.unity_version) {
|
||||
return Err(format!(
|
||||
"Unsupported Unity version for {}: {}",
|
||||
@@ -394,12 +59,6 @@ impl UnityAdapter for Unity2021_3Adapter {
|
||||
}
|
||||
|
||||
async fn serialize(&self, _parsed: &ParsedAssetBundle) -> Result<Vec<u8>, String> {
|
||||
// TODO: Phase 2 实现
|
||||
// 需要:
|
||||
// 1. 序列化 Asset 对象
|
||||
// 2. 重新构建 TypeTree
|
||||
// 3. 压缩数据块
|
||||
// 4. 写入 UnityFS 文件头
|
||||
Err(SERIALIZE_NOT_IMPLEMENTED.to_string())
|
||||
}
|
||||
}
|
||||
@@ -407,6 +66,9 @@ impl UnityAdapter for Unity2021_3Adapter {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::unity::UnityFsCompression;
|
||||
|
||||
const UNITYFS_ALIGNMENT: usize = 16;
|
||||
|
||||
fn push_c_string(data: &mut Vec<u8>, value: &str) {
|
||||
data.extend_from_slice(value.as_bytes());
|
||||
@@ -436,7 +98,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_minimal_unityfs_bundle() -> Vec<u8> {
|
||||
fn synthetic_minimal_unityfs_bundle(unity_version: &str) -> Vec<u8> {
|
||||
let mut blocks_info = Vec::new();
|
||||
blocks_info.extend_from_slice(&[0; 16]);
|
||||
push_i32(&mut blocks_info, 1);
|
||||
@@ -453,7 +115,7 @@ mod tests {
|
||||
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_c_string(&mut data, unity_version);
|
||||
push_u64(&mut data, 0);
|
||||
push_u32(&mut data, blocks_info.len() as u32);
|
||||
push_u32(&mut data, blocks_info.len() as u32);
|
||||
@@ -463,7 +125,7 @@ mod tests {
|
||||
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();
|
||||
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + unity_version.len() + 1;
|
||||
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
||||
data
|
||||
}
|
||||
@@ -490,7 +152,7 @@ mod tests {
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
|
||||
let bundle = RawAssetBundle {
|
||||
data: synthetic_minimal_unityfs_bundle(),
|
||||
data: synthetic_minimal_unityfs_bundle("2021.3.56f2"),
|
||||
path: Some("synthetic-minimal.bundle".to_string()),
|
||||
};
|
||||
|
||||
@@ -514,7 +176,7 @@ mod tests {
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
|
||||
let bundle = RawAssetBundle {
|
||||
data: synthetic_minimal_unityfs_bundle(),
|
||||
data: synthetic_minimal_unityfs_bundle("2021.3.56f2"),
|
||||
path: Some("synthetic-minimal.bundle".to_string()),
|
||||
};
|
||||
|
||||
@@ -538,85 +200,34 @@ mod tests {
|
||||
path: None,
|
||||
};
|
||||
|
||||
let result = adapter.parse(&bundle).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("signature"));
|
||||
}
|
||||
|
||||
fn block(uncompressed_size: u32) -> UnityFsBlockInfo {
|
||||
UnityFsBlockInfo {
|
||||
uncompressed_size,
|
||||
compressed_size: uncompressed_size,
|
||||
flags: 0,
|
||||
compression: UnityFsCompression::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn directory(offset: u64, size: u64) -> UnityFsDirectoryInfo {
|
||||
UnityFsDirectoryInfo {
|
||||
offset,
|
||||
size,
|
||||
flags: 0,
|
||||
path: "CAB-test".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_within_data_region_is_accepted() {
|
||||
// 两个 block 共 12 字节解压数据区;目录条目正好覆盖尾部,合法。
|
||||
let result =
|
||||
validate_directory_bounds(&[block(8), block(4)], &[directory(0, 8), directory(8, 4)]);
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_past_data_region_is_rejected() {
|
||||
// 解压数据区仅 4 字节,目录声称 [0, 8) 越界,应被拒绝并带上下文。
|
||||
let error = validate_directory_bounds(&[block(4)], &[directory(0, 8)]).unwrap_err();
|
||||
assert!(error.contains("越界"), "{error}");
|
||||
assert!(error.contains("解压数据区大小 4"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_offset_size_overflow_is_rejected() {
|
||||
let error = validate_directory_bounds(&[block(4)], &[directory(u64::MAX, 1)]).unwrap_err();
|
||||
assert!(error.contains("溢出"), "{error}");
|
||||
let error = adapter.parse(&bundle).await.unwrap_err();
|
||||
assert!(error.contains("signature"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_rejects_out_of_bounds_directory() {
|
||||
// 构造一个 directory.size 超过 block 解压大小的 bundle,端到端验证被拒。
|
||||
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); // block uncompressed_size = 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, 99); // directory size 99 远超数据区
|
||||
push_u32(&mut blocks_info, 0);
|
||||
push_c_string(&mut blocks_info, "CAB-test");
|
||||
async fn test_parse_rejects_unsupported_unity_version() {
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
|
||||
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 bundle = RawAssetBundle {
|
||||
data: synthetic_minimal_unityfs_bundle("2022.3.1f1"),
|
||||
path: Some("unsupported.bundle".to_string()),
|
||||
};
|
||||
|
||||
let error = adapter.parse(&bundle).await.unwrap_err();
|
||||
assert!(error.contains("Unsupported Unity version"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serialize_returns_explicit_not_implemented_error() {
|
||||
let adapter = Unity2021_3Adapter::new();
|
||||
let bundle = RawAssetBundle {
|
||||
data,
|
||||
path: Some("out-of-bounds.bundle".to_string()),
|
||||
data: synthetic_minimal_unityfs_bundle("2021.3.56f2"),
|
||||
path: Some("synthetic-minimal.bundle".to_string()),
|
||||
};
|
||||
let error = adapter.parse(&bundle).await.unwrap_err();
|
||||
assert!(error.contains("越界"), "{error}");
|
||||
let parsed = adapter.parse(&bundle).await.unwrap();
|
||||
|
||||
let error = adapter.serialize(&parsed).await.unwrap_err();
|
||||
|
||||
assert_eq!(error, SERIALIZE_NOT_IMPLEMENTED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"asset_bundle_count": "3",
|
||||
"declared_size_count": "3",
|
||||
"dependency_count": "1",
|
||||
"internal_id_count": "3",
|
||||
"resource_count": "3",
|
||||
"resource_type_count": "1",
|
||||
"key_object_count": "4",
|
||||
"bucket_record_count": "4",
|
||||
|
||||
@@ -21,4 +21,6 @@ async fn parses_local_real_unityfs_bundle() {
|
||||
assert_eq!(parsed.unity_version, "2021.3.56f2");
|
||||
assert!(!parsed.blocks.is_empty());
|
||||
assert!(!parsed.directories.is_empty());
|
||||
assert_eq!(parsed.files.len(), parsed.directories.len());
|
||||
assert!(parsed.files.iter().all(|file| !file.data.is_empty()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user