feat(assetbundle): 完成已验证结构的重建发布闭环
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-10 00:46:48 +08:00
parent 68c6c91b1e
commit 69b6e36bf0
21 changed files with 1588 additions and 101 deletions
+2 -2
View File
@@ -17,8 +17,8 @@ pub mod types;
pub use error::{AssetBundleError, Result};
pub use parser::{compression_from_flags, Parser, UnityFsParser};
pub use patch::{
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
StringFieldPatch, TextAssetPatch,
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset,
rebuild_unityfs_bundle, FieldPatch, StringFieldPatch, TextAssetPatch,
};
pub use serialized::{
UnityManagedReferenceMetadata, UnityManagedReferenceRecord, UnitySerializedField,
+1
View File
@@ -174,6 +174,7 @@ fn parse_unityfs(data: &[u8]) -> Result<UnityFsBundle> {
compressed_data_size,
uncompressed_data_size,
raw_data: data.to_vec(),
uncompressed_data,
files,
serialized_files,
text_assets,
+808 -28
View File
@@ -5,7 +5,7 @@ use crate::parser::UnityFsParser;
use crate::serialized::{
UnitySerializedField, UnitySerializedReplacementValue, UnitySerializedValue,
};
use crate::types::UnityFsBundle;
use crate::types::{UnityFsBundle, UnityFsCompression};
use md5::{Digest, Md5};
/// One TextAsset replacement inside a serialized UnityFS directory file.
@@ -105,9 +105,9 @@ impl FieldPatch {
/// Patches one TextAsset and rebuilds the UnityFS container.
///
/// The rebuilt bundle uses a single uncompressed data block. This keeps the
/// patch path deterministic and avoids relying on a compressor-specific
/// implementation while preserving all directory file paths and metadata.
/// The rebuilt bundle retains the parsed block count, compression modes,
/// alignment flags and directory metadata while recalculating all variable
/// offsets and sizes.
pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<Vec<u8>> {
let parser = UnityFsParser::new();
let mut bundle = parser.parse_bytes(data)?;
@@ -142,6 +142,13 @@ pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<V
let rebuilt = rebuild_unityfs(&bundle)?;
let verified = parser.parse_bytes(&rebuilt)?;
verify_rebuild_preserves_unmodified_content(
&bundle,
&verified,
&patch.serialized_file_path,
patch.path_id,
None,
)?;
let asset = verified
.text_assets
.iter()
@@ -197,6 +204,13 @@ pub fn patch_unityfs_string_field(data: &[u8], patch: &StringFieldPatch) -> Resu
let rebuilt = rebuild_unityfs(&bundle)?;
let verified = parser.parse_bytes(&rebuilt)?;
verify_rebuild_preserves_unmodified_content(
&bundle,
&verified,
&patch.serialized_file_path,
patch.path_id,
Some(&patch.field_path),
)?;
let serialized = verified
.serialized_files
.iter()
@@ -259,6 +273,13 @@ pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result<Vec<u8>> {
let rebuilt = rebuild_unityfs(&bundle)?;
let verified = parser.parse_bytes(&rebuilt)?;
verify_rebuild_preserves_unmodified_content(
&bundle,
&verified,
&patch.serialized_file_path,
patch.path_id,
Some(&patch.field_path),
)?;
let serialized = verified
.serialized_files
.iter()
@@ -285,7 +306,12 @@ pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result<Vec<u8>> {
Ok(rebuilt)
}
fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
/// Rebuilds a parsed UnityFS bundle while preserving its container shape.
///
/// The directory order, path, flags, header version and block compression
/// modes are retained. Variable-length file changes are reflected in
/// directory offsets and block sizes; no raw offset is patched blindly.
pub fn rebuild_unityfs_bundle(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
if bundle.files.len() != bundle.directories.len() {
return Err(AssetBundleError::Parse(format!(
"UnityFS file/directory count mismatch: files={}, directories={}",
@@ -293,24 +319,123 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
bundle.directories.len()
)));
}
let mut uncompressed_data = Vec::new();
let mut directory_offsets = Vec::with_capacity(bundle.files.len());
for file in &bundle.files {
let offset = u64::try_from(uncompressed_data.len())
.map_err(|_| AssetBundleError::Parse("UnityFS data offset overflow".to_string()))?;
directory_offsets.push(offset);
uncompressed_data.extend_from_slice(&file.data);
let block_uncompressed_size = bundle
.blocks
.iter()
.try_fold(0u64, |total, block| {
total.checked_add(u64::from(block.uncompressed_size))
})
.ok_or_else(|| AssetBundleError::Parse("UnityFS block size overflow".to_string()))?;
let retained_uncompressed_size = u64::try_from(bundle.uncompressed_data.len())
.map_err(|_| AssetBundleError::Parse("UnityFS data size does not fit u64".to_string()))?;
if block_uncompressed_size != retained_uncompressed_size
|| bundle.uncompressed_data_size != retained_uncompressed_size
{
return Err(AssetBundleError::Parse(format!(
"UnityFS block table/data size mismatch: blocks={}, retained={}, declared={}",
block_uncompressed_size, retained_uncompressed_size, bundle.uncompressed_data_size
)));
}
let mut ordered_directories = bundle.directories.iter().enumerate().collect::<Vec<_>>();
ordered_directories.sort_by_key(|(_, directory)| directory.offset);
let mut uncompressed_data = Vec::with_capacity(bundle.uncompressed_data.len());
let mut directory_offsets = vec![0u64; bundle.files.len()];
let mut cursor = 0usize;
for (index, directory) in ordered_directories {
let file = bundle.files.get(index).ok_or_else(|| {
AssetBundleError::Parse(format!(
"UnityFS directory {} has no corresponding file",
directory.path
))
})?;
if file.path != directory.path {
return Err(AssetBundleError::Parse(format!(
"UnityFS file/directory path mismatch: file={:?}, directory={:?}",
file.path, directory.path
)));
}
let start = usize::try_from(directory.offset).map_err(|_| {
AssetBundleError::Parse(format!(
"UnityFS directory {} offset does not fit usize",
directory.path
))
})?;
let end = start
.checked_add(usize::try_from(directory.size).map_err(|_| {
AssetBundleError::Parse(format!(
"UnityFS directory {} size does not fit usize",
directory.path
))
})?)
.ok_or_else(|| {
AssetBundleError::Parse(format!(
"UnityFS directory {} range overflows usize",
directory.path
))
})?;
if start < cursor || end > bundle.uncompressed_data.len() {
return Err(AssetBundleError::Parse(format!(
"UnityFS directory {} overlaps or exceeds the original data region",
directory.path
)));
}
uncompressed_data.extend_from_slice(&bundle.uncompressed_data[cursor..start]);
directory_offsets[index] = u64::try_from(uncompressed_data.len()).map_err(|_| {
AssetBundleError::Parse("UnityFS directory offset overflow".to_string())
})?;
uncompressed_data.extend_from_slice(&file.data);
cursor = end;
}
uncompressed_data.extend_from_slice(&bundle.uncompressed_data[cursor..]);
let block_sizes = repartition_blocks(uncompressed_data.len(), &bundle.blocks)?;
let mut compressed_blocks = Vec::with_capacity(bundle.blocks.len());
for (index, (block, size)) in bundle.blocks.iter().zip(&block_sizes).enumerate() {
let start = block_sizes[..index].iter().sum::<usize>();
let end = start.checked_add(*size).ok_or_else(|| {
AssetBundleError::Parse(format!("UnityFS rebuilt block {index} range overflows"))
})?;
let bytes = uncompressed_data.get(start..end).ok_or_else(|| {
AssetBundleError::Parse(format!(
"UnityFS rebuilt block {index} range {}..{} exceeds data {}",
start,
end,
uncompressed_data.len()
))
})?;
compressed_blocks.push(compress_unityfs_bytes(
bytes,
block.compression,
&format!("UnityFS data block {index}"),
)?);
}
let data_size = u32::try_from(uncompressed_data.len()).map_err(|_| {
AssetBundleError::Parse("UnityFS rebuilt data exceeds u32 size".to_string())
})?;
let mut blocks_info_body = Vec::new();
push_i32_be(&mut blocks_info_body, 1);
push_u32_be(&mut blocks_info_body, data_size);
push_u32_be(&mut blocks_info_body, data_size);
push_u16_be(&mut blocks_info_body, 0);
push_i32_be(
&mut blocks_info_body,
i32::try_from(bundle.blocks.len())
.map_err(|_| AssetBundleError::Parse("UnityFS block count exceeds i32".to_string()))?,
);
for (block, (bytes, uncompressed_size)) in bundle
.blocks
.iter()
.zip(compressed_blocks.iter().zip(block_sizes.iter()))
{
push_u32_be(
&mut blocks_info_body,
u32::try_from(*uncompressed_size).map_err(|_| {
AssetBundleError::Parse("UnityFS rebuilt block exceeds u32 size".to_string())
})?,
);
push_u32_be(
&mut blocks_info_body,
u32::try_from(bytes.len()).map_err(|_| {
AssetBundleError::Parse("UnityFS compressed block exceeds u32 size".to_string())
})?,
);
push_u16_be(&mut blocks_info_body, block.flags);
}
push_i32_be(
&mut blocks_info_body,
i32::try_from(bundle.files.len()).map_err(|_| {
@@ -328,9 +453,14 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
push_c_string(&mut blocks_info_body, &file.path);
}
let digest = Md5::digest(&blocks_info_body);
let mut blocks_info = Vec::with_capacity(16 + blocks_info_body.len());
blocks_info.extend_from_slice(&digest);
blocks_info.extend_from_slice(&blocks_info_body);
let mut blocks_info_uncompressed = Vec::with_capacity(16 + blocks_info_body.len());
blocks_info_uncompressed.extend_from_slice(&digest);
blocks_info_uncompressed.extend_from_slice(&blocks_info_body);
let blocks_info = compress_unityfs_bytes(
&blocks_info_uncompressed,
compression_from_header_flags(bundle.header.flags),
"UnityFS block info",
)?;
let mut output = Vec::new();
push_c_string(&mut output, "UnityFS");
@@ -342,27 +472,395 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
push_u32_be(
&mut output,
u32::try_from(blocks_info.len()).map_err(|_| {
AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string())
AssetBundleError::Parse("UnityFS compressed block info exceeds u32 size".to_string())
})?,
);
push_u32_be(
&mut output,
u32::try_from(blocks_info.len()).map_err(|_| {
u32::try_from(blocks_info_uncompressed.len()).map_err(|_| {
AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string())
})?,
);
push_u32_be(&mut output, 0);
push_u32_be(&mut output, bundle.header.flags);
if bundle.header.format_version >= 7 {
align_vec(&mut output, 16);
}
output.extend_from_slice(&blocks_info);
output.extend_from_slice(&uncompressed_data);
if block_info_at_end(bundle.header.flags) {
if block_data_aligned(bundle.header.flags) {
align_vec(&mut output, 16);
}
for bytes in &compressed_blocks {
output.extend_from_slice(bytes);
}
output.extend_from_slice(&blocks_info);
} else {
output.extend_from_slice(&blocks_info);
if block_data_aligned(bundle.header.flags) {
align_vec(&mut output, 16);
}
for bytes in &compressed_blocks {
output.extend_from_slice(bytes);
}
}
let total_size = u64::try_from(output.len())
.map_err(|_| AssetBundleError::Parse("UnityFS rebuilt size overflow".to_string()))?;
output[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
Ok(output)
}
fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
rebuild_unityfs_bundle(bundle)
}
fn compression_from_header_flags(flags: u32) -> UnityFsCompression {
crate::parser::compression_from_flags((flags & 0x3f) as u16)
}
fn block_info_at_end(flags: u32) -> bool {
flags & 0x80 != 0
}
fn block_data_aligned(flags: u32) -> bool {
flags & 0x200 != 0
}
fn repartition_blocks(
total_size: usize,
blocks: &[crate::types::UnityFsBlockInfo],
) -> Result<Vec<usize>> {
if blocks.is_empty() {
return Err(AssetBundleError::Parse(
"UnityFS bundle has no blocks to rebuild".to_string(),
));
}
let original_total = blocks
.iter()
.try_fold(0u64, |total, block| {
total.checked_add(u64::from(block.uncompressed_size))
})
.ok_or_else(|| AssetBundleError::Parse("UnityFS block size overflow".to_string()))?;
if original_total == 0 {
return Ok(vec![0; blocks.len()]);
}
let total = u64::try_from(total_size)
.map_err(|_| AssetBundleError::Parse("UnityFS data size does not fit u64".to_string()))?;
let mut result = Vec::with_capacity(blocks.len());
let mut previous = 0u64;
let mut cumulative = 0u64;
for block in blocks {
cumulative = cumulative
.checked_add(u64::from(block.uncompressed_size))
.ok_or_else(|| {
AssetBundleError::Parse("UnityFS block boundary overflow".to_string())
})?;
let boundary = total.checked_mul(cumulative).ok_or_else(|| {
AssetBundleError::Parse("UnityFS block boundary overflow".to_string())
})? / original_total;
result.push(usize::try_from(boundary - previous).map_err(|_| {
AssetBundleError::Parse("UnityFS rebuilt block size does not fit usize".to_string())
})?);
previous = boundary;
}
Ok(result)
}
fn compress_unityfs_bytes(
data: &[u8],
compression: UnityFsCompression,
context: &str,
) -> Result<Vec<u8>> {
match compression {
UnityFsCompression::None => Ok(data.to_vec()),
UnityFsCompression::Lz4 => {
lz4::block::compress(data, Some(lz4::block::CompressionMode::FAST(1)), false).map_err(
|error| {
AssetBundleError::Parse(format!("Failed to compress {context} as LZ4: {error}"))
},
)
}
UnityFsCompression::Lz4Hc => lz4::block::compress(
data,
Some(lz4::block::CompressionMode::HIGHCOMPRESSION(9)),
false,
)
.map_err(|error| {
AssetBundleError::Parse(format!("Failed to compress {context} as LZ4HC: {error}"))
}),
UnityFsCompression::Lzma => {
let mut output = Vec::new();
lzma_rs::lzma_compress(&mut std::io::Cursor::new(data), &mut output).map_err(
|error| {
AssetBundleError::Parse(format!(
"Failed to compress {context} as LZMA: {error}"
))
},
)?;
Ok(output)
}
UnityFsCompression::Unknown(value) => Err(AssetBundleError::UnsupportedFormat(format!(
"unsupported {context} compression flag: {value}"
))),
}
}
fn verify_rebuild_preserves_unmodified_content(
original: &UnityFsBundle,
rebuilt_bytes: &UnityFsBundle,
modified_serialized_path: &str,
modified_path_id: i64,
modified_field_path: Option<&str>,
) -> Result<()> {
if original.header.format_version != rebuilt_bytes.header.format_version
|| original.header.target_version != rebuilt_bytes.header.target_version
|| original.header.unity_version != rebuilt_bytes.header.unity_version
|| original.header.flags != rebuilt_bytes.header.flags
{
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed immutable header metadata".to_string(),
));
}
if original.blocks.len() != rebuilt_bytes.blocks.len()
|| original.directories.len() != rebuilt_bytes.directories.len()
{
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed block or directory count".to_string(),
));
}
if original.serialized_files.len() != rebuilt_bytes.serialized_files.len() {
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed serialized-file count".to_string(),
));
}
if original.serialized_parse_errors != rebuilt_bytes.serialized_parse_errors {
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed serialized-file diagnostics".to_string(),
));
}
for (original, rebuilt) in original.blocks.iter().zip(&rebuilt_bytes.blocks) {
if original.flags != rebuilt.flags || original.compression != rebuilt.compression {
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed block compression metadata".to_string(),
));
}
}
for (original, rebuilt) in original.directories.iter().zip(&rebuilt_bytes.directories) {
if original.path != rebuilt.path || original.flags != rebuilt.flags {
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed directory path or flags".to_string(),
));
}
}
for (original_file, rebuilt_file) in original.files.iter().zip(&rebuilt_bytes.files) {
if original_file.path != modified_serialized_path
&& (original_file.path != rebuilt_file.path || original_file.data != rebuilt_file.data)
{
return Err(AssetBundleError::Parse(format!(
"UnityFS rebuild changed an unmodified directory file: {}",
original_file.path
)));
}
}
for original_file in &original.serialized_files {
let rebuilt_file = rebuilt_bytes
.serialized_files
.iter()
.find(|candidate| candidate.source_path == original_file.source_path)
.ok_or_else(|| {
AssetBundleError::Parse(format!(
"UnityFS rebuild lost serialized file {:?}",
original_file.source_path
))
})?;
if original_file.source_path != rebuilt_file.source_path
|| original_file.version != rebuilt_file.version
|| original_file.unity_version != rebuilt_file.unity_version
|| original_file.platform != rebuilt_file.platform
|| original_file.types != rebuilt_file.types
|| original_file.objects.len() != rebuilt_file.objects.len()
{
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed serialized-file metadata".to_string(),
));
}
for (original_object, rebuilt_object) in
original_file.objects.iter().zip(&rebuilt_file.objects)
{
if original_object.path_id != rebuilt_object.path_id
|| original_object.type_index != rebuilt_object.type_index
|| original_object.class_id != rebuilt_object.class_id
{
return Err(AssetBundleError::Parse(format!(
"UnityFS rebuild changed object table entry path_id {}",
original_object.path_id
)));
}
let is_modified_object = original_file.source_path.as_deref()
== Some(modified_serialized_path)
&& original_object.path_id == modified_path_id;
if !is_modified_object && original_object.byte_size != rebuilt_object.byte_size {
return Err(AssetBundleError::Parse(format!(
"UnityFS rebuild changed unmodified object byte size path_id {}",
original_object.path_id
)));
}
if is_modified_object {
if let Some(field_path) = modified_field_path {
let original_fields = original_file.fields_for_object_entry(original_object)?;
let rebuilt_fields = rebuilt_file.fields_for_object_entry(rebuilt_object)?;
verify_unmodified_fields(
&original_fields,
&rebuilt_fields,
field_path,
original_object.path_id,
)?;
} else {
let original_asset = original_file
.text_assets()
.iter()
.find(|asset| asset.path_id == modified_path_id);
let rebuilt_asset = rebuilt_file
.text_assets()
.iter()
.find(|asset| asset.path_id == modified_path_id);
if original_asset.map(|asset| &asset.name)
!= rebuilt_asset.map(|asset| &asset.name)
{
return Err(AssetBundleError::Parse(format!(
"TextAsset path_id {} name changed while rebuilding",
modified_path_id
)));
}
}
} else {
if original_file.object_bytes(original_object)?
!= rebuilt_file.object_bytes(rebuilt_object)?
{
return Err(AssetBundleError::Parse(format!(
"UnityFS rebuild changed unmodified object path_id {}",
original_object.path_id
)));
}
}
}
}
Ok(())
}
fn verify_unmodified_fields(
original: &[UnitySerializedField],
rebuilt: &[UnitySerializedField],
modified_field_path: &str,
path_id: i64,
) -> Result<()> {
if original.len() != rebuilt.len() {
return Err(AssetBundleError::Parse(format!(
"Unity object path_id {path_id} changed field count while rebuilding"
)));
}
for (original, rebuilt) in original.iter().zip(rebuilt) {
if original.path != rebuilt.path
|| original.name != rebuilt.name
|| original.type_name != rebuilt.type_name
|| original.type_tree_node_index != rebuilt.type_tree_node_index
{
return Err(AssetBundleError::Parse(format!(
"Unity object path_id {path_id} changed field metadata at {}",
original.path
)));
}
if !field_path_contains(&original.path, modified_field_path)
&& original.byte_size != rebuilt.byte_size
{
return Err(AssetBundleError::Parse(format!(
"Unity object path_id {path_id} changed unmodified field byte size at {}",
original.path
)));
}
if original.path == modified_field_path {
continue;
}
verify_unmodified_values(
&original.value,
&rebuilt.value,
modified_field_path,
path_id,
)?;
}
Ok(())
}
fn field_path_contains(parent: &str, child: &str) -> bool {
child == parent
|| child
.strip_prefix(parent)
.is_some_and(|suffix| suffix.starts_with('.') || suffix.starts_with('['))
}
fn verify_unmodified_values(
original: &UnitySerializedValue,
rebuilt: &UnitySerializedValue,
modified_field_path: &str,
path_id: i64,
) -> Result<()> {
match (original, rebuilt) {
(UnitySerializedValue::Object(original), UnitySerializedValue::Object(rebuilt))
| (UnitySerializedValue::Array(original), UnitySerializedValue::Array(rebuilt))
| (UnitySerializedValue::Map(original), UnitySerializedValue::Map(rebuilt)) => {
verify_unmodified_fields(original, rebuilt, modified_field_path, path_id)
}
(
UnitySerializedValue::ManagedReference {
type_name: original_type,
metadata: original_metadata,
fields: original_fields,
bytes: original_bytes,
},
UnitySerializedValue::ManagedReference {
type_name: rebuilt_type,
metadata: rebuilt_metadata,
fields: rebuilt_fields,
bytes: rebuilt_bytes,
},
) => {
if original_type != rebuilt_type
|| original_metadata != rebuilt_metadata
|| original_bytes != rebuilt_bytes
{
return Err(AssetBundleError::Parse(format!(
"Unity object path_id {path_id} changed managed-reference metadata"
)));
}
verify_unmodified_fields(
original_fields,
rebuilt_fields,
modified_field_path,
path_id,
)
}
(
UnitySerializedValue::ManagedReferenceRegistry {
references: _,
fields: original_fields,
},
UnitySerializedValue::ManagedReferenceRegistry {
references: _,
fields: rebuilt_fields,
},
) => verify_unmodified_fields(
original_fields,
rebuilt_fields,
modified_field_path,
path_id,
),
(original, rebuilt) if original != rebuilt => Err(AssetBundleError::Parse(format!(
"Unity object path_id {path_id} changed an unmodified field value"
))),
_ => Ok(()),
}
}
fn find_field_value<'a>(
fields: &'a [UnitySerializedField],
field_path: &str,
@@ -489,6 +987,99 @@ mod tests {
data
}
fn synthetic_bundle_with_compressed_blocks(
files: &[(&str, &[u8])],
block_flags: u16,
header_flags: u32,
) -> Vec<u8> {
let mut uncompressed = Vec::new();
let mut directory_offsets = Vec::with_capacity(files.len());
for (_, bytes) in files {
directory_offsets.push(uncompressed.len() as u64);
uncompressed.extend_from_slice(bytes);
}
let split = uncompressed.len() / 2;
let split = if uncompressed.is_empty() {
0
} else {
split.max(1).min(uncompressed.len())
};
let chunks = [&uncompressed[..split], &uncompressed[split..]];
let compressed_chunks = chunks
.iter()
.map(|chunk| match block_flags & 0x3f {
0 => chunk.to_vec(),
1 => {
let mut output = Vec::new();
lzma_rs::lzma_compress(&mut std::io::Cursor::new(chunk), &mut output).unwrap();
output
}
2..=4 => lz4::block::compress(chunk, None, false).unwrap(),
value => panic!("unsupported fixture compression {value}"),
})
.collect::<Vec<_>>();
let mut block_info_body = Vec::new();
push_i32_be(&mut block_info_body, chunks.len() as i32);
for (chunk, compressed) in chunks.iter().zip(&compressed_chunks) {
push_u32_be(&mut block_info_body, chunk.len() as u32);
push_u32_be(&mut block_info_body, compressed.len() as u32);
push_u16_be(&mut block_info_body, block_flags);
}
push_i32_be(&mut block_info_body, files.len() as i32);
for ((path, bytes), offset) in files.iter().zip(directory_offsets) {
push_u64_be(&mut block_info_body, offset);
push_u64_be(&mut block_info_body, bytes.len() as u64);
push_u32_be(&mut block_info_body, 0);
push_c_string(&mut block_info_body, path);
}
let mut block_info = vec![0; 16];
block_info.extend_from_slice(&block_info_body);
let block_info = match header_flags & 0x3f {
0 => block_info,
1 => {
let mut output = Vec::new();
lzma_rs::lzma_compress(&mut std::io::Cursor::new(&block_info), &mut output)
.unwrap();
output
}
2..=4 => lz4::block::compress(&block_info, None, false).unwrap(),
value => panic!("unsupported fixture block-info compression {value}"),
};
let mut data = Vec::new();
push_c_string(&mut data, "UnityFS");
push_u32_be(&mut data, 8);
push_c_string(&mut data, "5.x.x");
push_c_string(&mut data, "2021.3.56f2");
let total_size_offset = data.len();
push_u64_be(&mut data, 0);
push_u32_be(&mut data, block_info.len() as u32);
push_u32_be(&mut data, (16 + block_info_body.len()) as u32);
push_u32_be(&mut data, header_flags);
align_vec(&mut data, 16);
if header_flags & 0x80 != 0 {
if header_flags & 0x200 != 0 {
align_vec(&mut data, 16);
}
for compressed in &compressed_chunks {
data.extend_from_slice(compressed);
}
data.extend_from_slice(&block_info);
} else {
data.extend_from_slice(&block_info);
if header_flags & 0x200 != 0 {
align_vec(&mut data, 16);
}
for compressed in &compressed_chunks {
data.extend_from_slice(compressed);
}
}
let total_size = data.len() as u64;
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
data
}
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}
@@ -549,6 +1140,66 @@ mod tests {
file
}
fn synthetic_serialized_two_text_assets(first: &[u8], second: &[u8]) -> Vec<u8> {
fn text_asset_object(bytes: &[u8]) -> Vec<u8> {
let mut object = Vec::new();
push_u32_le(&mut object, 8);
object.extend_from_slice(b"Scenario");
align_vec(&mut object, 4);
push_u32_le(&mut object, bytes.len() as u32);
object.extend_from_slice(bytes);
object
}
let first_object = text_asset_object(first);
let second_object = text_asset_object(second);
let mut object_data = Vec::new();
let first_offset = object_data.len() as u64;
object_data.extend_from_slice(&first_object);
let first_size = object_data.len() as u64 - first_offset;
let second_offset = object_data.len() as u64;
object_data.extend_from_slice(&second_object);
let second_size = object_data.len() as u64 - second_offset;
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, 2);
align_vec(&mut metadata, 4);
for (path_id, offset, size) in [
(1i64, first_offset, first_size),
(2, second_offset, second_size),
] {
metadata.extend_from_slice(&path_id.to_le_bytes());
push_u64_le(&mut metadata, offset);
push_u32_le(&mut metadata, size 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.extend_from_slice(&[0, 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
}
fn synthetic_serialized_monobehaviour() -> Vec<u8> {
let mut object_data = Vec::new();
push_u32_le(&mut object_data, 5);
@@ -1391,6 +2042,135 @@ mod tests {
assert_eq!(parsed.unity_version, reparsed.unity_version);
}
#[test]
fn rebuild_preserves_compressed_blocks_alignment_and_untouched_files() {
let serialized = synthetic_serialized_text_asset(b"old");
let source = synthetic_bundle_with_compressed_blocks(
&[("CAB-scenario", &serialized), ("CAB-untouched", b"keep")],
2,
2 | 0x200,
);
let parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
assert_eq!(parsed.blocks.len(), 2);
assert_eq!(parsed.blocks[0].compression, UnityFsCompression::Lz4);
assert_eq!(parsed.header.flags, 2 | 0x200);
let patched = patch_unityfs_text_asset(
&source,
&TextAssetPatch {
serialized_file_path: "CAB-scenario".to_string(),
path_id: 1,
expected_name: Some("Scenario".to_string()),
replacement: b"a longer localized payload".to_vec(),
},
)
.unwrap();
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
assert_eq!(reparsed.blocks.len(), 2);
assert_eq!(reparsed.blocks[0].compression, UnityFsCompression::Lz4);
assert_eq!(reparsed.blocks[1].compression, UnityFsCompression::Lz4);
assert_eq!(reparsed.header.flags, parsed.header.flags);
assert_eq!(
reparsed
.files
.iter()
.find(|file| file.path == "CAB-untouched")
.unwrap()
.data,
b"keep"
);
assert_eq!(reparsed.text_assets[0].bytes, b"a longer localized payload");
}
#[test]
fn rebuild_preserves_unmodified_serialized_objects() {
let serialized = synthetic_serialized_two_text_assets(b"old", b"keep-object");
let source = synthetic_bundle_with_path(&serialized, "CAB-scenario");
let patched = patch_unityfs_text_asset(
&source,
&TextAssetPatch {
serialized_file_path: "CAB-scenario".to_string(),
path_id: 1,
expected_name: Some("Scenario".to_string()),
replacement: b"localized".to_vec(),
},
)
.unwrap();
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
assert_eq!(
reparsed
.text_assets
.iter()
.find(|asset| asset.path_id == 1)
.unwrap()
.bytes,
b"localized"
);
assert_eq!(
reparsed
.text_assets
.iter()
.find(|asset| asset.path_id == 2)
.unwrap()
.bytes,
b"keep-object"
);
}
#[test]
fn rebuild_preserves_block_info_at_end_and_lzma_compression() {
let serialized = synthetic_serialized_text_asset(b"old");
let source = synthetic_bundle_with_compressed_blocks(
&[("CAB-scenario", &serialized)],
1,
1 | 0x80 | 0x200,
);
let patched = patch_unityfs_text_asset(
&source,
&TextAssetPatch {
serialized_file_path: "CAB-scenario".to_string(),
path_id: 1,
expected_name: None,
replacement: b"localized".to_vec(),
},
)
.unwrap();
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
assert_eq!(reparsed.header.flags, 1 | 0x80 | 0x200);
assert_eq!(reparsed.blocks[0].compression, UnityFsCompression::Lzma);
assert_eq!(reparsed.blocks[1].compression, UnityFsCompression::Lzma);
assert_eq!(reparsed.text_assets[0].bytes, b"localized");
}
#[test]
fn rebuild_rejects_unknown_compression_without_guessing() {
let source = synthetic_bundle(b"payload");
let mut parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
parsed.blocks[0].compression = UnityFsCompression::Unknown(63);
let error = rebuild_unityfs_bundle(&parsed).unwrap_err();
assert!(matches!(
error,
AssetBundleError::UnsupportedFormat(message)
if message.contains("unsupported UnityFS data block 0 compression flag: 63")
));
}
#[test]
fn rebuild_rejects_inconsistent_uncompressed_block_table() {
let source = synthetic_bundle(b"payload");
let mut parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
parsed.blocks[0].uncompressed_size += 1;
let error = rebuild_unityfs_bundle(&parsed).unwrap_err().to_string();
assert!(error.contains("block table/data size mismatch"), "{error}");
}
#[test]
fn patches_text_asset_and_verifies_reparsed_payload() {
let original_text = "こんにちは".as_bytes();
+28
View File
@@ -1088,6 +1088,34 @@ impl UnitySerializedFile {
}
}
/// Returns the raw payload bytes for one object table entry.
///
/// The returned slice is still owned by the parsed serialized file. It is
/// useful to compare untouched objects across a variable-length rebuild.
pub fn object_bytes(&self, object: &UnitySerializedObject) -> Result<&[u8]> {
let object_start = self
.data_offset
.checked_add(usize::try_from(object.byte_start).map_err(|_| {
AssetBundleError::Parse(format!(
"Unity object path_id {} byte_start does not fit usize",
object.path_id
))
})?)
.ok_or_else(|| AssetBundleError::Parse("Unity object offset overflow".to_string()))?;
let object_end = object_start
.checked_add(object.byte_size as usize)
.ok_or_else(|| AssetBundleError::Parse("Unity object size overflow".to_string()))?;
self.raw_data.get(object_start..object_end).ok_or_else(|| {
AssetBundleError::Parse(format!(
"Unity object path_id {} byte range {}..{} exceeds file size {}",
object.path_id,
object_start,
object_end,
self.raw_data.len()
))
})
}
fn rewrite_object_payload(
&self,
target_index: usize,
+3
View File
@@ -62,6 +62,9 @@ pub struct UnityFsBundle {
pub uncompressed_data_size: u64,
/// Original bytes retained for future extraction/serialization.
pub raw_data: Vec<u8>,
/// Uncompressed data region retained so rebuilds can preserve gaps and
/// trailing bytes that are not represented by directory entries.
pub(crate) uncompressed_data: Vec<u8>,
/// Files extracted from the UnityFS uncompressed data region.
pub files: Vec<UnityFsFile>,
/// Serialized files parsed from UnityFS directory files.