diff --git a/Documentation/command-archive.md b/Documentation/command-archive.md index a563926..e4cd05c 100644 --- a/Documentation/command-archive.md +++ b/Documentation/command-archive.md @@ -6,25 +6,112 @@ The `archive` command provides utilities for working with Unity Archives (AssetB | Sub-Command | Description | |-------------|-------------| +| [`info`](#info) | Display a high-level summary | +| [`header`](#header) | Display archive header information | +| [`blocks`](#blocks) | Display the data block list | | [`list`](#list) | List contents of an archive | | [`extract`](#extract) | Extract contents of an archive | --- +## info + +Displays a high-level summary of a Unity Archive file, including compression ratio, file counts, and data sizes. + +### Quick Reference + +``` +UnityDataTool archive info [options] +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `` | Path to the archive file | *(required)* | +| `-f, --format ` | Output format | `Text` | + +### Example + +```bash +UnityDataTool archive info scenes.bundle +UnityDataTool archive info scenes.bundle -f Json +``` + +--- + +## header + +Displays the header information of a Unity Archive file, including format version, Unity version, file size, metadata compression, and archive flags. + +Very old versions of the Unity Archive format are not supported. But the files created by all currently supported Unity versions should be compatible (and it was tested with files as old as Unity 2017). + +### Quick Reference + +``` +UnityDataTool archive header [options] +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `` | Path to the archive file | *(required)* | +| `-f, --format ` | Output format | `Text` | + +### Example + +```bash +UnityDataTool archive header scenes.bundle +UnityDataTool archive header scenes.bundle -f Json +``` + +--- + +## blocks + +Displays the data block list of a Unity Archive file, showing the size, compression type, and file offset of each block. + +Very old versions of the Unity Archive format are not supported. + +### Quick Reference + +``` +UnityDataTool archive blocks [options] +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `` | Path to the archive file | *(required)* | +| `-f, --format ` | Output format | `Text` | + +### Example + +```bash +UnityDataTool archive blocks scenes.bundle +UnityDataTool archive blocks scenes.bundle -f Json +``` + +--- + ## list -Lists the SerializedFiles contained within an archive. +Lists the contents of an archive, including the offset, size, and flags of each file. + +Very old versions of the Unity Archive format are not supported. ### Quick Reference ``` -UnityDataTool archive list +UnityDataTool archive list [options] ``` +| Option | Description | Default | +|--------|-------------|---------| +| `` | Path to the archive file | *(required)* | +| `-f, --format ` | Output format | `Text` | + ### Example ```bash UnityDataTool archive list scenes.bundle +UnityDataTool archive list scenes.bundle -f Json ``` --- @@ -43,11 +130,13 @@ UnityDataTool archive extract [options] |--------|-------------|---------| | `` | Path to the archive file | *(required)* | | `-o, --output-path ` | Output directory | `archive` | +| `--filter ` | Case-insensitive substring filter on file paths inside the archive | *(none — extract all)* | ### Example ```bash UnityDataTool archive extract scenes.bundle -o contents +UnityDataTool archive extract scenes.bundle --filter sharedAssets ``` **Output files:** @@ -58,7 +147,7 @@ contents/BuildPlayer-Scene2.sharedAssets contents/BuildPlayer-Scene2 ``` -> **Note:** The extracted files are binary SerializedFiles, not text. Use the [`dump`](command-dump.md) command to convert them to readable text format. +> **Note:** The extracted files are in binary formats, not text. If they are SerializedFiles then use the [`dump`](command-dump.md) command to convert them to readable text format. See also the [`serialized-file`](command-serialized-file.md) command. --- diff --git a/TestCommon/Data/PlayerDataCompressed/README.md b/TestCommon/Data/PlayerDataCompressed/README.md new file mode 100644 index 0000000..ebc00d0 --- /dev/null +++ b/TestCommon/Data/PlayerDataCompressed/README.md @@ -0,0 +1,5 @@ +This is an example of the format used for Player Data when compression is enabled. + +It is a Unity Archive and can be examined with the "archive" command. It was created with Unity 2021.3.20f1. +This was built without TypeTrees enabled, so the analyze command is not able to extract information. + diff --git a/TestCommon/Data/PlayerDataCompressed/data.unity3d b/TestCommon/Data/PlayerDataCompressed/data.unity3d new file mode 100644 index 0000000..304ba8c Binary files /dev/null and b/TestCommon/Data/PlayerDataCompressed/data.unity3d differ diff --git a/UnityBinaryFormat/ArchiveDetector.cs b/UnityBinaryFormat/ArchiveDetector.cs index 2658362..9deb80f 100644 --- a/UnityBinaryFormat/ArchiveDetector.cs +++ b/UnityBinaryFormat/ArchiveDetector.cs @@ -1,10 +1,115 @@ using System; using System.IO; +using K4os.Compression.LZ4; namespace UnityDataTools.BinaryFormat; /// -/// Utility for detecting Unity Archive (AssetBundle) files by reading their signature. +/// Parsed header information from a Unity Archive file. +/// +/// A Unity Archive consists of three sections: +/// - Header: A small uncompressed header with version info, sizes, and flags. +/// - Metadata: An index section containing the Block List (sizes and compression of each +/// data block) and the Directory (paths, sizes, and flags of files inside the archive). +/// This section may be compressed; the header's compression bits and size fields describe +/// its on-disk vs uncompressed size. +/// - Data: One or more blocks of file content. Each block has its own compression type +/// recorded in its per-block flags. The metadata section is required to interpret the data. +/// A single file can span multiple blocks, and a single block can contain data for multiple files. +/// The blocks account for every byte of the data (there are no offsets stored - no overlapping or +/// gaps can be expressed). However the files could have padding between them. +/// +/// The metadata can appear directly after the header (default layout) or at the end of the +/// file after the data (indicated by the BlocksInfoAtTheEnd flag). +/// +public class ArchiveHeaderInfo +{ + public string Signature { get; set; } + public uint Version { get; set; } + + /// + /// Unused legacy field (formerly "UnityWebBundleVersion"). Always "5.x.x". + /// + public string Unused { get; set; } + + public string UnityVersion { get; set; } + public ulong Size { get; set; } + public uint CompressedMetadataSize { get; set; } + public uint UncompressedMetadataSize { get; set; } + public uint Flags { get; set; } + + /// + /// Compression type used for the metadata section (bits 0-5 of Flags). + /// + public int MetadataCompressionType => (int)(Flags & 0x3F); + + /// + /// Archive flag bits (bits 6+ of Flags), with compression bits masked out. + /// + public uint ArchiveFlagBits => Flags & ~0x3Fu; +} + +public class ArchiveStorageBlock +{ + public uint UncompressedSize { get; set; } + public uint CompressedSize { get; set; } + public ushort Flags { get; set; } + public int CompressionType => Flags & 0x3F; + public bool IsStreamed => (Flags & 0x40) != 0; + + /// + /// Offset of this block from the start of the archive file. + /// Calculated after parsing — not stored in the serialized data. + /// + public long FileOffset { get; set; } + + /// + /// Offset of this block's uncompressed data relative to the start of the + /// full uncompressed data (all blocks concatenated). + /// Calculated after parsing — not stored in the serialized data. + /// + public long DataOffset { get; set; } +} + +public class ArchiveBlocksInfo +{ + public byte[] UncompressedDataHash { get; set; } // Unused + + // Archives with no compression or LZMA will have a single block, + // except when the data exceeds 4GB (because the size fields in ArchiveStorageBlock are 32-bit). + public ArchiveStorageBlock[] Blocks { get; set; } +} + +public class ArchiveDirectoryNode +{ + /// + /// Offset within the uncompressed data (all blocks concatenated). + /// + public ulong DataOffset { get; set; } + public ulong Size { get; set; } + public uint Flags { get; set; } + + /// + /// Path of the file within the archive, using '/' as a separator. + /// Although Flags has a Directory flag, in practice nodes are only created for files, + /// and directories are implied by the paths. + /// + public string Path { get; set; } +} + +public class ArchiveDirectoryInfo +{ + public ArchiveDirectoryNode[] Nodes { get; set; } +} + +public class ArchiveMetadata +{ + public ArchiveBlocksInfo BlocksInfo { get; set; } + public ArchiveDirectoryInfo DirectoryInfo { get; set; } +} + +/// +/// Utility for detecting and parsing Unity Archive (AssetBundle) file headers. /// public static class ArchiveDetector { @@ -60,4 +165,340 @@ public static bool IsUnityArchive(string filePath) return false; } } + + /// + /// Reads a null-terminated signature string, with a length limit to avoid reading + /// deep into non-archive files that don't contain an early null byte. + /// + /// Note: this is used for a very similar purpose to IsUnityArchive(). But IsUnityArchive() is + /// optimized to quickly check a file whereas this one is used when we are actually parsing + /// the file. The two could potentially be merged. + static string ReadSignature(BinaryReader reader) + { + const int maxLength = 20; // Longest valid signature is "UnityArchive" (12 chars) + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < maxLength; i++) + { + byte b = reader.ReadByte(); // Throws EndOfStreamException on EOF + if (b == 0) + return sb.ToString(); + sb.Append((char)b); + } + // No null terminator found within the limit — not a valid archive signature. + return sb.ToString(); + } + + /// + /// Attempts to read and parse the header of a Unity Archive file. + /// Only the "UnityFS" format is supported. Other archive signatures will produce + /// an error message identifying the unsupported signature. + /// + public static bool TryReadArchiveHeader(string filePath, out ArchiveHeaderInfo info, out string errorMessage) + { + info = null; + errorMessage = null; + + if (!File.Exists(filePath)) + { + errorMessage = $"File not found: \"{filePath}\"."; + return false; + } + + try + { + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new BinaryReader(stream); + + string signature; + try + { + signature = ReadSignature(reader); + } + catch (EndOfStreamException) + { + errorMessage = "File is not a Unity Archive."; + return false; + } + + if (signature != "UnityFS") + { + // Check if it's a recognized but unsupported legacy signature. + if (signature == "UnityWeb" || signature == "UnityRaw" || signature == "UnityArchive") + errorMessage = $"Unsupported archive signature: \"{signature}\". Only \"UnityFS\" is supported."; + else + errorMessage = "File is not a Unity Archive."; + return false; + } + + // All numeric fields are big-endian (swap = true). + var version = BinaryFileHelper.ReadUInt32(reader, true); + var unused = BinaryFileHelper.ReadNullTermString(reader); + var unityVersion = BinaryFileHelper.ReadNullTermString(reader); + var size = BinaryFileHelper.ReadUInt64(reader, true); + var compressedMetadataSize = BinaryFileHelper.ReadUInt32(reader, true); + var uncompressedMetadataSize = BinaryFileHelper.ReadUInt32(reader, true); + var flags = BinaryFileHelper.ReadUInt32(reader, true); + + if (compressedMetadataSize > uncompressedMetadataSize) + throw new InvalidDataException("Compressed metadata size exceeds uncompressed size. The file may be corrupt."); + + if (size == 0) + throw new InvalidDataException("Archive size is zero. The file may be corrupt."); + + info = new ArchiveHeaderInfo + { + Signature = signature, + Version = version, + Unused = unused, + UnityVersion = unityVersion, + Size = size, + CompressedMetadataSize = compressedMetadataSize, + UncompressedMetadataSize = uncompressedMetadataSize, + Flags = flags, + }; + + return true; + } + catch (Exception ex) when (ex is EndOfStreamException || ex is InvalidDataException) + { + errorMessage = $"Error reading archive header: {ex.Message}"; + return false; + } + } + + /// + /// Reads and parses the metadata section (BlocksInfo and DirectoryInfo) from a Unity Archive. + /// The header must have been successfully read first via TryReadArchiveHeader. + /// Only the combined BlocksInfo+DirectoryInfo layout is supported. + /// + public static bool TryReadArchiveMetadata(string filePath, ArchiveHeaderInfo header, out ArchiveMetadata metadata, out string errorMessage) + { + metadata = null; + errorMessage = null; + + const uint flagBlocksAndDirectoryInfoCombined = 0x40; + const uint flagBlocksInfoAtTheEnd = 0x80; + + if ((header.ArchiveFlagBits & flagBlocksAndDirectoryInfoCombined) == 0) + { + errorMessage = "This archive does not use the combined BlocksInfo+DirectoryInfo layout. Only the combined layout is supported."; + return false; + } + + try + { + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + + // Calculate where the metadata section starts. + long metadataOffset; + if ((header.ArchiveFlagBits & flagBlocksInfoAtTheEnd) != 0) + metadataOffset = (long)(header.Size - header.CompressedMetadataSize); + else + metadataOffset = GetHeaderSize(header); + + stream.Seek(metadataOffset, SeekOrigin.Begin); + + // Read the metadata bytes (which may be compressed) + var compressedData = new byte[header.CompressedMetadataSize]; + int bytesRead = stream.Read(compressedData, 0, compressedData.Length); + if (bytesRead != compressedData.Length) + throw new InvalidDataException("Could not read the full metadata section from the file."); + + // Decompress if needed. + byte[] uncompressedData; + if (header.MetadataCompressionType == 0) + { + uncompressedData = compressedData; + } + else if (header.MetadataCompressionType == 2 || header.MetadataCompressionType == 3) + { + // LZ4 and LZ4HC use the same decompression algorithm. + uncompressedData = new byte[header.UncompressedMetadataSize]; + int decoded = LZ4Codec.Decode(compressedData, 0, compressedData.Length, + uncompressedData, 0, uncompressedData.Length); + if (decoded != header.UncompressedMetadataSize) + throw new InvalidDataException($"LZ4 decompression produced {decoded} bytes, expected {header.UncompressedMetadataSize}."); + } + else if (header.MetadataCompressionType == 1) + { + errorMessage = "LZMA compression for archive metadata is not supported."; + return false; + } + else + { + errorMessage = $"Unknown metadata compression type: {header.MetadataCompressionType}."; + return false; + } + + // Parse BlocksInfo and DirectoryInfo from the uncompressed buffer. + using var memStream = new MemoryStream(uncompressedData); + using var reader = new BinaryReader(memStream); + + var blocksInfo = ParseBlocksInfo(reader); + var directoryInfo = ParseDirectoryInfo(reader); + + // Populate calculated offsets on each block. + long fileOffset = GetDataOffset(header); + long dataOffset = 0; + foreach (var block in blocksInfo.Blocks) + { + block.FileOffset = fileOffset; + block.DataOffset = dataOffset; + fileOffset += block.CompressedSize; + dataOffset += block.UncompressedSize; + } + + ValidateMetadata(blocksInfo, directoryInfo); + + metadata = new ArchiveMetadata + { + BlocksInfo = blocksInfo, + DirectoryInfo = directoryInfo, + }; + + return true; + } + catch (Exception ex) when (ex is EndOfStreamException || ex is InvalidDataException) + { + errorMessage = $"Error reading archive metadata: {ex.Message}"; + return false; + } + } + + /// + /// Calculates the data section offset from the start of the archive file. + /// This is the byte position where the first data block begins. + /// + public static long GetDataOffset(ArchiveHeaderInfo header) + { + const uint flagBlocksInfoAtTheEnd = 0x80; + const uint flagBlockInfoNeedPaddingAtStart = 0x200; + + long offset = GetHeaderSize(header); + + if ((header.ArchiveFlagBits & flagBlocksInfoAtTheEnd) == 0) + { + if ((header.ArchiveFlagBits & flagBlockInfoNeedPaddingAtStart) != 0) + offset += AlignTo16(header.CompressedMetadataSize); + else + offset += header.CompressedMetadataSize; + } + + return offset; + } + + /// + /// Validates consistency between BlocksInfo and DirectoryInfo. + /// + /// Directory nodes represent files laid out sequentially in the uncompressed data + /// (all blocks concatenated). Nodes must be in non-decreasing offset order and must + /// not overlap, though padding between them is permitted. Every file byte must be + /// covered by block data — the total uncompressed block size must reach at least + /// the end of the last file. + /// + static void ValidateMetadata(ArchiveBlocksInfo blocksInfo, ArchiveDirectoryInfo directoryInfo) + { + var nodes = directoryInfo.Nodes; + var blocks = blocksInfo.Blocks; + + if (nodes.Length == 0 || blocks.Length == 0) + return; + + // Verify directory nodes are in order and non-overlapping. + for (int i = 1; i < nodes.Length; i++) + { + ulong prevEnd = nodes[i - 1].DataOffset + nodes[i - 1].Size; + if (nodes[i].DataOffset < prevEnd) + throw new InvalidDataException( + $"Directory node \"{nodes[i].Path}\" at data offset {nodes[i].DataOffset} overlaps with " + + $"previous node \"{nodes[i - 1].Path}\" which ends at {prevEnd}. The file may be corrupt."); + } + + // Verify that the blocks cover all file data. The last block's end must reach + // at least the end of the last file. (It may exceed it due to padding.) + var lastBlock = blocks[blocks.Length - 1]; + long blocksEnd = lastBlock.DataOffset + lastBlock.UncompressedSize; + + var lastNode = nodes[nodes.Length - 1]; + ulong filesEnd = lastNode.DataOffset + lastNode.Size; + + if ((ulong)blocksEnd < filesEnd) + throw new InvalidDataException( + $"Block data ends at offset {blocksEnd} but directory entries extend to {filesEnd}. " + + $"The file may be corrupt."); + } + + static int GetHeaderSize(ArchiveHeaderInfo header) + { + const uint flagOldWebPluginCompatibility = 0x100; + + int size; + if ((header.ArchiveFlagBits & flagOldWebPluginCompatibility) != 0) + size = 10; // Legacy web plugin signature portion + else + size = header.Signature.Length + 1; + + size += 4; // version + size += header.Unused.Length + 1; + size += header.UnityVersion.Length + 1; + size += 8; // size (UInt64) + size += 4; // compressedMetadataSize + size += 4; // uncompressedMetadataSize + size += 4; // flags + + if (header.Version >= 7) + size = (int)AlignTo16((uint)size); + + return size; + } + + static long AlignTo16(uint value) + { + return (value + 15) & ~15L; + } + + static ArchiveBlocksInfo ParseBlocksInfo(BinaryReader reader) + { + var hash = reader.ReadBytes(16); + var blockCount = BinaryFileHelper.ReadUInt32(reader, true); + + var blocks = new ArchiveStorageBlock[blockCount]; + for (int i = 0; i < blockCount; i++) + { + blocks[i] = new ArchiveStorageBlock + { + UncompressedSize = BinaryFileHelper.ReadUInt32(reader, true), + CompressedSize = BinaryFileHelper.ReadUInt32(reader, true), + Flags = BinaryFileHelper.ReadUInt16(reader, true), + }; + } + + return new ArchiveBlocksInfo + { + UncompressedDataHash = hash, + Blocks = blocks, + }; + } + + static ArchiveDirectoryInfo ParseDirectoryInfo(BinaryReader reader) + { + var nodeCount = BinaryFileHelper.ReadUInt32(reader, true); + + var nodes = new ArchiveDirectoryNode[nodeCount]; + for (int i = 0; i < nodeCount; i++) + { + nodes[i] = new ArchiveDirectoryNode + { + DataOffset = BinaryFileHelper.ReadUInt64(reader, true), + Size = BinaryFileHelper.ReadUInt64(reader, true), + Flags = BinaryFileHelper.ReadUInt32(reader, true), + Path = BinaryFileHelper.ReadNullTermString(reader), + }; + } + + return new ArchiveDirectoryInfo + { + Nodes = nodes, + }; + } } diff --git a/UnityBinaryFormat/BinaryFileHelper.cs b/UnityBinaryFormat/BinaryFileHelper.cs index 3349471..117b4bd 100644 --- a/UnityBinaryFormat/BinaryFileHelper.cs +++ b/UnityBinaryFormat/BinaryFileHelper.cs @@ -61,6 +61,14 @@ public static short ReadInt16(BinaryReader reader, bool swap) return (short)raw; } + public static ushort ReadUInt16(BinaryReader reader, bool swap) + { + ushort raw = reader.ReadUInt16(); + if (swap) + raw = (ushort)((raw << 8) | (raw >> 8)); + return raw; + } + public static uint ReadUInt32(BinaryReader reader, bool swap) { uint raw = reader.ReadUInt32(); diff --git a/UnityBinaryFormat/UnityBinaryFormat.csproj b/UnityBinaryFormat/UnityBinaryFormat.csproj index bc0f6f2..d144acc 100644 --- a/UnityBinaryFormat/UnityBinaryFormat.csproj +++ b/UnityBinaryFormat/UnityBinaryFormat.csproj @@ -14,6 +14,10 @@ AnyCPU + + + + diff --git a/UnityDataTool.Tests/ArchiveTests.cs b/UnityDataTool.Tests/ArchiveTests.cs new file mode 100644 index 0000000..91a9142 --- /dev/null +++ b/UnityDataTool.Tests/ArchiveTests.cs @@ -0,0 +1,355 @@ +using System; +using System.Text.Json; +using Microsoft.Data.Sqlite; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using UnityDataTools.FileSystem; + +namespace UnityDataTools.UnityDataTool.Tests; + +#pragma warning disable NUnit2005, NUnit2006 + +public class ArchiveTests +{ + private string m_TestOutputFolder; + private string m_TestDataFolder; + private string m_ArchivePath; + + [OneTimeSetUp] + public void OneTimeSetup() + { + m_TestOutputFolder = Path.Combine(TestContext.CurrentContext.TestDirectory, "test_folder"); + m_TestDataFolder = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data"); + m_ArchivePath = Path.Combine(m_TestDataFolder, "AssetBundles", "2023.1.0a16", "scenes"); + Directory.CreateDirectory(m_TestOutputFolder); + Directory.SetCurrentDirectory(m_TestOutputFolder); + } + + [TearDown] + public void Teardown() + { + SqliteConnection.ClearAllPools(); + + var testDir = new DirectoryInfo(m_TestOutputFolder); + testDir.EnumerateFiles() + .ToList().ForEach(f => f.Delete()); + testDir.EnumerateDirectories() + .ToList().ForEach(d => d.Delete(true)); + } + + [Test] + public async Task ArchiveList_TextFormat() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + + Assert.AreEqual(0, await Program.Main(new string[] { "archive", "list", m_ArchivePath })); + + var actualOutput = sw.ToString().Replace("\r\n", "\n"); + + var expectedOutput = +@"BuildPlayer-SampleScene.sharedAssets + Data Offset: 0 + Size: 90732 + Flags: SerializedFile + +BuildPlayer-SampleScene + Data Offset: 90732 + Size: 153352 + Flags: SerializedFile + +BuildPlayer-OtherScene.sharedAssets + Data Offset: 244084 + Size: 136744 + Flags: SerializedFile + +BuildPlayer-OtherScene + Data Offset: 380828 + Size: 158340 + Flags: SerializedFile + +"; + + Assert.AreEqual(expectedOutput, actualOutput); + } + finally + { + Console.SetOut(currentOut); + } + } + + [Test] + public async Task ArchiveList_JsonFormat() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + + Assert.AreEqual(0, await Program.Main(new string[] { "archive", "list", m_ArchivePath, "-f", "Json" })); + + var output = sw.ToString(); + var jsonArray = JsonDocument.Parse(output).RootElement; + Assert.AreEqual(JsonValueKind.Array, jsonArray.ValueKind); + Assert.AreEqual(4, jsonArray.GetArrayLength()); + + foreach (var element in jsonArray.EnumerateArray()) + { + Assert.IsTrue(element.TryGetProperty("path", out _)); + Assert.IsTrue(element.TryGetProperty("dataOffset", out _)); + Assert.IsTrue(element.TryGetProperty("size", out _)); + Assert.IsTrue(element.TryGetProperty("flags", out _)); + Assert.AreEqual("SerializedFile", element.GetProperty("flags").GetString()); + } + + Assert.AreEqual("BuildPlayer-SampleScene.sharedAssets", jsonArray[0].GetProperty("path").GetString()); + Assert.AreEqual(0, jsonArray[0].GetProperty("dataOffset").GetUInt64()); + Assert.AreEqual(90732, jsonArray[0].GetProperty("size").GetInt64()); + } + finally + { + Console.SetOut(currentOut); + } + } + + [Test] + public async Task ArchiveHeader_TextFormat() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + + Assert.AreEqual(0, await Program.Main(new string[] { "archive", "header", m_ArchivePath })); + + var output = sw.ToString(); + Assert.That(output, Does.Contain("UnityFS")); + Assert.That(output, Does.Contain("2023.1.0a16")); + Assert.That(output, Does.Contain("93,075")); + Assert.That(output, Does.Contain("Lz4HC")); + Assert.That(output, Does.Contain("BlocksAndDirectoryInfoCombined")); + Assert.That(output, Does.Contain("BlockInfoNeedPaddingAtStart")); + } + finally + { + Console.SetOut(currentOut); + } + } + + [Test] + public async Task ArchiveHeader_JsonFormat() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + + Assert.AreEqual(0, await Program.Main(new string[] { "archive", "header", m_ArchivePath, "-f", "Json" })); + + var output = sw.ToString(); + var json = JsonDocument.Parse(output).RootElement; + Assert.AreEqual(JsonValueKind.Object, json.ValueKind); + + Assert.AreEqual("UnityFS", json.GetProperty("signature").GetString()); + Assert.AreEqual(8u, json.GetProperty("version").GetUInt32()); + Assert.AreEqual("2023.1.0a16", json.GetProperty("unityVersion").GetString()); + Assert.AreEqual(93075u, json.GetProperty("fileSize").GetUInt64()); + Assert.AreEqual(118u, json.GetProperty("compressedMetadataSize").GetUInt32()); + Assert.AreEqual(234u, json.GetProperty("uncompressedMetadataSize").GetUInt32()); + Assert.AreEqual("Lz4HC", json.GetProperty("metadataCompression").GetString()); + + var flags = json.GetProperty("flags"); + Assert.AreEqual(JsonValueKind.Array, flags.ValueKind); + Assert.AreEqual(2, flags.GetArrayLength()); + Assert.AreEqual("BlocksAndDirectoryInfoCombined", flags[0].GetString()); + Assert.AreEqual("BlockInfoNeedPaddingAtStart", flags[1].GetString()); + } + finally + { + Console.SetOut(currentOut); + } + } + + [Test] + public async Task ArchiveBlocks_TextFormat() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + + Assert.AreEqual(0, await Program.Main(new string[] { "archive", "blocks", m_ArchivePath })); + + var output = sw.ToString(); + Assert.That(output, Does.Contain("Blocks: 1")); + Assert.That(output, Does.Contain("#0")); + Assert.That(output, Does.Contain("FileOffset: 192")); + Assert.That(output, Does.Contain("DataOffset: 0")); + Assert.That(output, Does.Contain("Uncompressed: 539,168")); + Assert.That(output, Does.Contain("Compressed: 92,883")); + Assert.That(output, Does.Contain("Compression: Lzma")); + } + finally + { + Console.SetOut(currentOut); + } + } + + [Test] + public async Task ArchiveBlocks_JsonFormat() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + + Assert.AreEqual(0, await Program.Main(new string[] { "archive", "blocks", m_ArchivePath, "-f", "Json" })); + + var output = sw.ToString(); + var json = JsonDocument.Parse(output).RootElement; + Assert.AreEqual(JsonValueKind.Object, json.ValueKind); + + var blocks = json.GetProperty("blocks"); + Assert.AreEqual(JsonValueKind.Array, blocks.ValueKind); + Assert.AreEqual(1, blocks.GetArrayLength()); + + var block = blocks[0]; + Assert.AreEqual(0, block.GetProperty("index").GetInt32()); + Assert.AreEqual(192, block.GetProperty("fileOffset").GetInt64()); + Assert.AreEqual(0, block.GetProperty("dataOffset").GetInt64()); + Assert.AreEqual(539168u, block.GetProperty("uncompressedSize").GetUInt32()); + Assert.AreEqual(92883u, block.GetProperty("compressedSize").GetUInt32()); + Assert.AreEqual("Lzma", block.GetProperty("compression").GetString()); + Assert.AreEqual(true, block.GetProperty("isStreamed").GetBoolean()); + } + finally + { + Console.SetOut(currentOut); + } + } + + [Test] + public async Task ArchiveInfo_TextFormat() + { + var infoPath = Path.Combine(m_TestDataFolder, "PlayerDataCompressed", "data.unity3d"); + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + + Assert.AreEqual(0, await Program.Main(new string[] { "archive", "info", infoPath })); + + var output = sw.ToString(); + Assert.That(output, Does.Contain("2021.3.20f1")); + Assert.That(output, Does.Contain("459,654")); + Assert.That(output, Does.Contain("459,382")); + Assert.That(output, Does.Contain("963,117")); + Assert.That(output, Does.Contain("2.10x")); + Assert.That(output, Does.Contain("Lz4")); + Assert.That(output, Does.Contain("Block Count")); + Assert.That(output, Does.Contain("8")); + Assert.That(output, Does.Contain("File Count")); + Assert.That(output, Does.Contain("Serialized File Count")); + } + finally + { + Console.SetOut(currentOut); + } + } + + [Test] + public async Task ArchiveInfo_JsonFormat() + { + var infoPath = Path.Combine(m_TestDataFolder, "PlayerDataCompressed", "data.unity3d"); + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + + Assert.AreEqual(0, await Program.Main(new string[] { "archive", "info", infoPath, "-f", "Json" })); + + var output = sw.ToString(); + var json = JsonDocument.Parse(output).RootElement; + Assert.AreEqual(JsonValueKind.Object, json.ValueKind); + + Assert.AreEqual("2021.3.20f1", json.GetProperty("unityVersion").GetString()); + Assert.AreEqual(459654u, json.GetProperty("fileSize").GetUInt64()); + Assert.AreEqual(459382, json.GetProperty("dataSize").GetInt64()); + Assert.AreEqual(963117, json.GetProperty("uncompressedDataSize").GetInt64()); + Assert.AreEqual(2.1, json.GetProperty("compressionRatio").GetDouble(), 0.01); + Assert.AreEqual("Lz4", json.GetProperty("compression").GetString()); + Assert.AreEqual(8, json.GetProperty("blockCount").GetInt32()); + Assert.AreEqual(5, json.GetProperty("fileCount").GetInt32()); + Assert.AreEqual(5, json.GetProperty("serializedFileCount").GetInt32()); + } + finally + { + Console.SetOut(currentOut); + } + } + + [Test] + public async Task ArchiveExtract_FilesExtractedSuccessfully() + { + Assert.AreEqual(0, await Program.Main(new string[] { "archive", "extract", m_ArchivePath })); + + string[] expectedFiles = + { + "BuildPlayer-SampleScene.sharedAssets", + "BuildPlayer-SampleScene", + "BuildPlayer-OtherScene.sharedAssets", + "BuildPlayer-OtherScene", + }; + + foreach (var file in expectedFiles) + { + Assert.IsTrue(File.Exists(Path.Combine(m_TestOutputFolder, "archive", file)), $"Expected file not found: {file}"); + } + + // Verify extracted file size matches the size reported by the list command. + var extractedFile = new FileInfo(Path.Combine(m_TestOutputFolder, "archive", "BuildPlayer-SampleScene.sharedAssets")); + Assert.AreEqual(90732, extractedFile.Length); + } + + [Test] + public async Task ArchiveExtract_WithFilter_ExtractsOnlyMatchingFiles() + { + // "sampleSCENE" should match BuildPlayer-SampleScene.sharedAssets and BuildPlayer-SampleScene + // (case-insensitive) but not the OtherScene files. + Assert.AreEqual(0, await Program.Main(new string[] { "archive", "extract", m_ArchivePath, "--filter", "sampleSCENE" })); + + string[] expectedFiles = + { + "BuildPlayer-SampleScene.sharedAssets", + "BuildPlayer-SampleScene", + }; + + string[] excludedFiles = + { + "BuildPlayer-OtherScene.sharedAssets", + "BuildPlayer-OtherScene", + }; + + foreach (var file in expectedFiles) + { + Assert.IsTrue(File.Exists(Path.Combine(m_TestOutputFolder, "archive", file)), $"Expected file not found: {file}"); + } + + foreach (var file in excludedFiles) + { + Assert.IsFalse(File.Exists(Path.Combine(m_TestOutputFolder, "archive", file)), $"File should not have been extracted: {file}"); + } + } +} diff --git a/UnityDataTool.Tests/ExpectedData/2019.4.0f1/ExpectedValues.json b/UnityDataTool.Tests/ExpectedData/2019.4.0f1/ExpectedValues.json index 6da0cff..6bac39b 100644 --- a/UnityDataTool.Tests/ExpectedData/2019.4.0f1/ExpectedValues.json +++ b/UnityDataTool.Tests/ExpectedData/2019.4.0f1/ExpectedValues.json @@ -1,10 +1,13 @@ { "$type": "System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib],[System.Object, System.Private.CoreLib]], System.Private.CoreLib", "NodeCount": 3, + "CAB-5d40f7cad7c871cf2ad2af19ac542994-DataOffset": 0, "CAB-5d40f7cad7c871cf2ad2af19ac542994-Size": 1639323, "CAB-5d40f7cad7c871cf2ad2af19ac542994-Flags": 4, + "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-DataOffset": 1639323, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Size": 2833848, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Flags": 0, + "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-DataOffset": 4473171, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Size": 5248, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Flags": 0, "animation_clips_count": 2, diff --git a/UnityDataTool.Tests/ExpectedData/2020.3.0f1/ExpectedValues.json b/UnityDataTool.Tests/ExpectedData/2020.3.0f1/ExpectedValues.json index 3646830..1d9f660 100644 --- a/UnityDataTool.Tests/ExpectedData/2020.3.0f1/ExpectedValues.json +++ b/UnityDataTool.Tests/ExpectedData/2020.3.0f1/ExpectedValues.json @@ -1,10 +1,13 @@ { "$type": "System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib],[System.Object, System.Private.CoreLib]], System.Private.CoreLib", "NodeCount": 3, + "CAB-5d40f7cad7c871cf2ad2af19ac542994-DataOffset": 0, "CAB-5d40f7cad7c871cf2ad2af19ac542994-Size": 1074083, "CAB-5d40f7cad7c871cf2ad2af19ac542994-Flags": 4, + "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-DataOffset": 1074083, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Size": 2833848, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Flags": 0, + "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-DataOffset": 3907931, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Size": 5248, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Flags": 0, "animation_clips_count": 2, diff --git a/UnityDataTool.Tests/ExpectedData/2021.3.0f1/ExpectedValues.json b/UnityDataTool.Tests/ExpectedData/2021.3.0f1/ExpectedValues.json index 2883328..6407195 100644 --- a/UnityDataTool.Tests/ExpectedData/2021.3.0f1/ExpectedValues.json +++ b/UnityDataTool.Tests/ExpectedData/2021.3.0f1/ExpectedValues.json @@ -1,10 +1,13 @@ { "$type": "System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib],[System.Object, System.Private.CoreLib]], System.Private.CoreLib", "NodeCount": 3, + "CAB-5d40f7cad7c871cf2ad2af19ac542994-DataOffset": 0, "CAB-5d40f7cad7c871cf2ad2af19ac542994-Size": 956211, "CAB-5d40f7cad7c871cf2ad2af19ac542994-Flags": 4, + "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-DataOffset": 956211, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Size": 2833848, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Flags": 0, + "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-DataOffset": 3790059, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Size": 5248, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Flags": 0, "animation_clips_count": 2, diff --git a/UnityDataTool.Tests/ExpectedData/2022.1.20f1/ExpectedValues.json b/UnityDataTool.Tests/ExpectedData/2022.1.20f1/ExpectedValues.json index 88589d1..9c873eb 100644 --- a/UnityDataTool.Tests/ExpectedData/2022.1.20f1/ExpectedValues.json +++ b/UnityDataTool.Tests/ExpectedData/2022.1.20f1/ExpectedValues.json @@ -1,10 +1,13 @@ { "$type": "System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib],[System.Object, System.Private.CoreLib]], System.Private.CoreLib", "NodeCount": 3, + "CAB-5d40f7cad7c871cf2ad2af19ac542994-DataOffset": 0, "CAB-5d40f7cad7c871cf2ad2af19ac542994-Size": 960179, "CAB-5d40f7cad7c871cf2ad2af19ac542994-Flags": 4, + "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-DataOffset": 960179, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Size": 2833056, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Flags": 0, + "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-DataOffset": 3793235, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Size": 5248, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Flags": 0, "animation_clips_count": 2, diff --git a/UnityDataTool.Tests/ExpectedData/2023.1.0a16/ExpectedValues.json b/UnityDataTool.Tests/ExpectedData/2023.1.0a16/ExpectedValues.json index 5e2e91e..506fe8c 100644 --- a/UnityDataTool.Tests/ExpectedData/2023.1.0a16/ExpectedValues.json +++ b/UnityDataTool.Tests/ExpectedData/2023.1.0a16/ExpectedValues.json @@ -1,10 +1,13 @@ { "$type": "System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib],[System.Object, System.Private.CoreLib]], System.Private.CoreLib", "NodeCount": 3, + "CAB-5d40f7cad7c871cf2ad2af19ac542994-DataOffset": 0, "CAB-5d40f7cad7c871cf2ad2af19ac542994-Size": 942315, "CAB-5d40f7cad7c871cf2ad2af19ac542994-Flags": 4, + "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-DataOffset": 942315, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Size": 2833872, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Flags": 0, + "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-DataOffset": 3776187, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Size": 5248, "CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Flags": 0, "animation_clips_count": 2, diff --git a/UnityDataTool.Tests/UnityDataToolAssetBundleTests.cs b/UnityDataTool.Tests/UnityDataToolAssetBundleTests.cs index b47ae73..7eaf657 100644 --- a/UnityDataTool.Tests/UnityDataToolAssetBundleTests.cs +++ b/UnityDataTool.Tests/UnityDataToolAssetBundleTests.cs @@ -85,17 +85,21 @@ public async Task ArchiveList_AssetBundle_ListFilesCorrectly() var lines = sw.ToString().Split(sw.NewLine); + // Each entry: path, offset, size, flags, blank = 5 lines Assert.AreEqual("CAB-5d40f7cad7c871cf2ad2af19ac542994", lines[0]); - Assert.AreEqual($" Size: {Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994-Size")}", lines[1]); - Assert.AreEqual($" Flags: {(ArchiveNodeFlags)(long)Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994-Flags")}", lines[2]); - - Assert.AreEqual("CAB-5d40f7cad7c871cf2ad2af19ac542994.resS", lines[4]); - Assert.AreEqual($" Size: {Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Size")}", lines[5]); - Assert.AreEqual($" Flags: {(ArchiveNodeFlags)(long)Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Flags")}", lines[6]); - - Assert.AreEqual("CAB-5d40f7cad7c871cf2ad2af19ac542994.resource", lines[8]); - Assert.AreEqual($" Size: {Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Size")}", lines[9]); - Assert.AreEqual($" Flags: {(ArchiveNodeFlags)(long)Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Flags")}", lines[10]); + Assert.AreEqual($" Data Offset: {Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994-DataOffset")}", lines[1]); + Assert.AreEqual($" Size: {Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994-Size")}", lines[2]); + Assert.AreEqual($" Flags: {(ArchiveNodeFlags)(long)Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994-Flags")}", lines[3]); + + Assert.AreEqual("CAB-5d40f7cad7c871cf2ad2af19ac542994.resS", lines[5]); + Assert.AreEqual($" Data Offset: {Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-DataOffset")}", lines[6]); + Assert.AreEqual($" Size: {Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Size")}", lines[7]); + Assert.AreEqual($" Flags: {(ArchiveNodeFlags)(long)Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994.resS-Flags")}", lines[8]); + + Assert.AreEqual("CAB-5d40f7cad7c871cf2ad2af19ac542994.resource", lines[10]); + Assert.AreEqual($" Data Offset: {Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-DataOffset")}", lines[11]); + Assert.AreEqual($" Size: {Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Size")}", lines[12]); + Assert.AreEqual($" Flags: {(ArchiveNodeFlags)(long)Context.ExpectedData.Get("CAB-5d40f7cad7c871cf2ad2af19ac542994.resource-Flags")}", lines[13]); } finally diff --git a/UnityDataTool.Tests/WebBundleSupportTests.cs b/UnityDataTool.Tests/WebBundleSupportTests.cs index d70a9f5..914f695 100644 --- a/UnityDataTool.Tests/WebBundleSupportTests.cs +++ b/UnityDataTool.Tests/WebBundleSupportTests.cs @@ -1,4 +1,5 @@ using System; +using System.Text.Json; using Microsoft.Data.Sqlite; using System.IO; using System.Linq; @@ -40,14 +41,14 @@ public void Teardown() public void IsWebBundle_True() { var webBundlePath = Path.Combine(m_TestDataFolder, "WebBundles", "HelloWorld.data"); - Assert.IsTrue(Archive.IsWebBundle(webBundlePath)); + Assert.IsTrue(WebBundleHelper.IsWebBundle(webBundlePath)); } [Test] public void IsWebBundle_False() { var nonWebBundlePath = Path.Combine(m_TestDataFolder, "WebBundles", "NotAWebBundle.txt"); - Assert.IsFalse(Archive.IsWebBundle(nonWebBundlePath)); + Assert.IsFalse(WebBundleHelper.IsWebBundle(nonWebBundlePath)); } [Test] @@ -103,6 +104,43 @@ public async Task ArchiveList_WebBundle_ListFilesCorrectly( } } + [Test] + public async Task ArchiveList_WebBundle_JsonFormat( + [Values( + "HelloWorld.data", + "HelloWorld.data.gz", + "HelloWorld.data.br" + )] string bundlePath) + { + var path = Path.Combine(m_TestDataFolder, "WebBundles", bundlePath); + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + + Assert.AreEqual(0, await Program.Main(new string[] { "archive", "list", path, "-f", "Json" })); + + var output = sw.ToString(); + var jsonArray = JsonDocument.Parse(output).RootElement; + Assert.AreEqual(JsonValueKind.Array, jsonArray.ValueKind); + Assert.AreEqual(6, jsonArray.GetArrayLength()); + + foreach (var element in jsonArray.EnumerateArray()) + { + Assert.IsTrue(element.TryGetProperty("path", out _)); + Assert.IsTrue(element.TryGetProperty("size", out _)); + } + + Assert.AreEqual("data.unity3d", jsonArray[0].GetProperty("path").GetString()); + Assert.AreEqual(253044u, jsonArray[0].GetProperty("size").GetUInt32()); + } + finally + { + Console.SetOut(currentOut); + } + } + [Test] public async Task ArchiveExtract_WebBundle_FileExtractedSuccessfully( [Values("", "-o archive", "--output-path archive")] string options, diff --git a/UnityDataTool/Archive.cs b/UnityDataTool/Archive.cs index 755252b..b997379 100644 --- a/UnityDataTool/Archive.cs +++ b/UnityDataTool/Archive.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Text; +using System.Text.Json; using UnityDataTools.BinaryFormat; using UnityDataTools.FileSystem; @@ -11,20 +9,18 @@ namespace UnityDataTools.UnityDataTool; public static class Archive { - private static readonly byte[] WebBundlePrefix = Encoding.UTF8.GetBytes("UnityWebData1.0\0"); - - public static int HandleExtract(FileInfo filename, DirectoryInfo outputFolder) + public static int HandleExtract(FileInfo filename, DirectoryInfo outputFolder, string filter = null) { try { var path = filename.ToString(); - if (IsWebBundle(path)) + if (WebBundleHelper.IsWebBundle(path)) { - ExtractWebBundle(filename, outputFolder); + WebBundleHelper.Extract(filename, outputFolder, filter); } else if (ArchiveDetector.IsUnityArchive(path)) { - ExtractAssetBundle(filename, outputFolder); + ExtractAssetBundle(filename, outputFolder, filter); } else { @@ -43,18 +39,18 @@ err is NotSupportedException return 0; } - public static int HandleList(FileInfo filename) + public static int HandleList(FileInfo filename, OutputFormat format) { try { var path = filename.ToString(); - if (IsWebBundle(path)) + if (WebBundleHelper.IsWebBundle(path)) { - ListWebBundle(filename); + WebBundleHelper.List(filename, format); } else if (ArchiveDetector.IsUnityArchive(path)) { - ListAssetBundle(filename); + ListAssetBundle(filename, format); } else { @@ -74,142 +70,338 @@ err is NotSupportedException return 0; } - - public static bool IsWebBundle(string path) + public static int HandleHeader(FileInfo filename, OutputFormat format) { - return ( - path.EndsWith(".data") - || path.EndsWith(".data.gz") - || path.EndsWith(".data.br") - ); + var path = filename.ToString(); + + if (WebBundleHelper.IsWebBundle(path)) + { + Console.Error.WriteLine("Web bundle files (.data, .data.gz, .data.br) use a different format. The header command is only supported for Unity Archive files."); + return 1; + } + + if (!ArchiveDetector.TryReadArchiveHeader(filename.FullName, out var info, out var errorMessage)) + { + Console.Error.WriteLine(errorMessage); + return 1; + } + + if (format == OutputFormat.Json) + OutputHeaderJson(info); + else + OutputHeaderText(info); + + return 0; } - struct WebBundleFileDescription + public static int HandleBlocks(FileInfo filename, OutputFormat format) { - public uint ByteOffset; - public uint Size; - public string Path; + var path = filename.ToString(); + + if (WebBundleHelper.IsWebBundle(path)) + { + Console.Error.WriteLine("Web bundle files (.data, .data.gz, .data.br) use a different format. The blocks command is only supported for Unity Archive files."); + return 1; + } + + if (!ArchiveDetector.TryReadArchiveHeader(filename.FullName, out var header, out var errorMessage)) + { + Console.Error.WriteLine(errorMessage); + return 1; + } + + if (!ArchiveDetector.TryReadArchiveMetadata(filename.FullName, header, out var metadata, out errorMessage)) + { + Console.Error.WriteLine(errorMessage); + return 1; + } + + if (format == OutputFormat.Json) + OutputBlocksJson(metadata.BlocksInfo); + else + OutputBlocksText(metadata.BlocksInfo); + + return 0; } - static void ExtractWebBundle(FileInfo filename, DirectoryInfo outputFolder) + public static int HandleInfo(FileInfo filename, OutputFormat format) { - Console.WriteLine($"Extracting web bundle: {filename}"); - using var fileStream = File.Open(filename.ToString(), FileMode.Open); - using var stream = GetStream(filename, fileStream); - using var reader = new BinaryReader(stream, Encoding.UTF8); - var fileDescriptions = ParseWebBundleHeader(reader); - foreach (var description in fileDescriptions) + var path = filename.ToString(); + + if (WebBundleHelper.IsWebBundle(path)) + { + Console.Error.WriteLine("Web bundle files (.data, .data.gz, .data.br) use a different format. The info command is only supported for Unity Archive files."); + return 1; + } + + if (!ArchiveDetector.TryReadArchiveHeader(filename.FullName, out var header, out var errorMessage)) + { + Console.Error.WriteLine(errorMessage); + return 1; + } + + if (!ArchiveDetector.TryReadArchiveMetadata(filename.FullName, header, out var metadata, out errorMessage)) + { + Console.Error.WriteLine(errorMessage); + return 1; + } + + var blocks = metadata.BlocksInfo.Blocks; + var nodes = metadata.DirectoryInfo.Nodes; + + long dataSize = 0; + long uncompressedDataSize = 0; + foreach (var block in blocks) + { + dataSize += block.CompressedSize; + uncompressedDataSize += block.UncompressedSize; + } + + // Determine the compression algorithm by finding the first block that uses compression. + // Individual blocks may be stored uncompressed even when compression is enabled, because + // compression is skipped when it provides no size reduction. So the first compressed block + // tells us what algorithm was used for the archive. + string compression = "Uncompressed"; + foreach (var block in blocks) + { + if (block.CompressionType != 0) + { + compression = FormatCompressionType(block.CompressionType); + break; + } + } + + double compressionRatio = dataSize > 0 ? (double)uncompressedDataSize / dataSize : 0; + int fileCount = nodes.Length; + int serializedFileCount = 0; + foreach (var node in nodes) + { + if ((node.Flags & 0x04) != 0) + serializedFileCount++; + } + + if (format == OutputFormat.Json) { - ExtractFileFromWebBundle(description, reader, outputFolder); + var jsonObject = new + { + unityVersion = header.UnityVersion, + fileSize = header.Size, + dataSize = dataSize, + uncompressedDataSize = uncompressedDataSize, + compressionRatio = Math.Round(compressionRatio, 2), + compression = compression, + blockCount = blocks.Length, + fileCount = fileCount, + serializedFileCount = serializedFileCount, + }; + var json = JsonSerializer.Serialize(jsonObject, new JsonSerializerOptions { WriteIndented = true }); + Console.WriteLine(json); } + else + { + Console.WriteLine($"{"Unity Version",-30} {header.UnityVersion}"); + Console.WriteLine($"{"File Size",-30} {header.Size:N0} bytes"); + Console.WriteLine($"{"Data Size",-30} {dataSize:N0} bytes"); + Console.WriteLine($"{"Uncompressed Data Size",-30} {uncompressedDataSize:N0} bytes"); + Console.WriteLine($"{"Compression Ratio",-30} {compressionRatio:F2}x"); + Console.WriteLine($"{"Compression",-30} {compression}"); + Console.WriteLine($"{"Block Count",-30} {blocks.Length}"); + Console.WriteLine($"{"File Count",-30} {fileCount}"); + Console.WriteLine($"{"Serialized File Count",-30} {serializedFileCount}"); + } + + return 0; } - static Stream GetStream(FileInfo filename, FileStream fileStream) + static void OutputHeaderText(ArchiveHeaderInfo info) { - var fileExtension = Path.GetExtension(filename.ToString()); - return fileExtension switch + Console.WriteLine($"{"Signature",-30} {info.Signature}"); + Console.WriteLine($"{"Version",-30} {info.Version}"); + Console.WriteLine($"{"Unity Version",-30} {info.UnityVersion}"); + Console.WriteLine($"{"File Size",-30} {info.Size:N0} bytes"); + Console.WriteLine($"{"Compressed Metadata Size",-30} {info.CompressedMetadataSize:N0}"); + Console.WriteLine($"{"Uncompressed Metadata Size",-30} {info.UncompressedMetadataSize:N0}"); + Console.WriteLine($"{"Metadata Compression",-30} {FormatCompressionType(info.MetadataCompressionType)}"); + Console.WriteLine($"{"Flags",-30} {FormatArchiveFlags(info.ArchiveFlagBits)}"); + } + + static void OutputHeaderJson(ArchiveHeaderInfo info) + { + var jsonObject = new { - ".data" => fileStream, - ".gz" => new GZipStream(fileStream, CompressionMode.Decompress), - ".br" => new BrotliStream(fileStream, CompressionMode.Decompress), - _ => throw new FileFormatException("Incorrect file extension for web bundle"), + signature = info.Signature, + version = info.Version, + unityVersion = info.UnityVersion, + fileSize = info.Size, + compressedMetadataSize = info.CompressedMetadataSize, + uncompressedMetadataSize = info.UncompressedMetadataSize, + metadataCompression = FormatCompressionType(info.MetadataCompressionType), + flags = GetArchiveFlagNames(info.ArchiveFlagBits), }; + + var json = JsonSerializer.Serialize(jsonObject, new JsonSerializerOptions { WriteIndented = true }); + Console.WriteLine(json); } - static List ParseWebBundleHeader(BinaryReader reader) + static string FormatCompressionType(int compressionType) { - var result = new List(); - var prefix = ReadBytes(reader, WebBundlePrefix.Length); - if (!prefix.SequenceEqual(WebBundlePrefix)) + return compressionType switch { - throw new FileFormatException("File is not a valid web bundle."); - } - uint headerSize = ReadUInt32(reader); - // Advance offset past prefix string and header size uint. - var currentByteOffset = WebBundlePrefix.Length + sizeof(uint); - while (currentByteOffset < headerSize) + 0 => "None", + 1 => "Lzma", + 2 => "Lz4", + 3 => "Lz4HC", + _ => compressionType.ToString(), + }; + } + + static readonly (uint bit, string name)[] KnownArchiveFlags = + { + (0x40, "BlocksAndDirectoryInfoCombined"), + (0x80, "BlocksInfoAtTheEnd"), + (0x100, "OldWebPluginCompatibility"), + (0x200, "BlockInfoNeedPaddingAtStart"), + }; + + static string[] GetArchiveFlagNames(uint flagBits) + { + var names = new List(); + uint remaining = flagBits; + + foreach (var (bit, name) in KnownArchiveFlags) { - var fileByteOffset = ReadUInt32(reader); - var fileSize = ReadUInt32(reader); - var filePathLength = ReadUInt32(reader); - var filePath = Encoding.UTF8.GetString(ReadBytes(reader, (int)filePathLength)); - result.Add(new WebBundleFileDescription() + if ((remaining & bit) != 0) { - ByteOffset = fileByteOffset, - Size = fileSize, - Path = filePath, - }); - // Advance byte offset, so we keep track of the position (to know when we're done reading the header). - currentByteOffset += 3 * sizeof(uint) + filePath.Length; + names.Add(name); + remaining &= ~bit; + } } - return result; + + // Report any unrecognized bits by hex value. + if (remaining != 0) + names.Add($"0x{remaining:X}"); + + return names.ToArray(); } - static void ExtractFileFromWebBundle(WebBundleFileDescription description, BinaryReader reader, DirectoryInfo outputFolder) + static string FormatArchiveFlags(uint flagBits) { - // This function assumes `reader` is at the start of the binary data representing the file contents. - Console.WriteLine($"... Extracting {description.Path}"); - var path = Path.Combine(outputFolder.ToString(), description.Path); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - File.WriteAllBytes(path, ReadBytes(reader, (int)description.Size)); + var names = GetArchiveFlagNames(flagBits); + return names.Length > 0 ? string.Join(", ", names) : "None"; } - static uint ReadUInt32(BinaryReader reader) + static void OutputBlocksText(ArchiveBlocksInfo blocksInfo) { - try + Console.WriteLine($"Blocks: {blocksInfo.Blocks.Length}"); + for (int i = 0; i < blocksInfo.Blocks.Length; i++) { - return reader.ReadUInt32(); + var block = blocksInfo.Blocks[i]; + Console.WriteLine($" #{i,-4} FileOffset: {block.FileOffset:N0} DataOffset: {block.DataOffset:N0} Uncompressed: {block.UncompressedSize:N0} Compressed: {block.CompressedSize:N0} Compression: {FormatCompressionType(block.CompressionType)}"); } - catch (EndOfStreamException) + } + + static void OutputBlocksJson(ArchiveBlocksInfo blocksInfo) + { + var jsonBlocks = new object[blocksInfo.Blocks.Length]; + for (int i = 0; i < blocksInfo.Blocks.Length; i++) { - throw new FileFormatException("File data is corrupt."); + var block = blocksInfo.Blocks[i]; + jsonBlocks[i] = new + { + index = i, + fileOffset = block.FileOffset, + dataOffset = block.DataOffset, + uncompressedSize = block.UncompressedSize, + compressedSize = block.CompressedSize, + compression = FormatCompressionType(block.CompressionType), + isStreamed = block.IsStreamed, + }; } + + var jsonObject = new { blocks = jsonBlocks }; + var json = JsonSerializer.Serialize(jsonObject, new JsonSerializerOptions { WriteIndented = true }); + Console.WriteLine(json); } - static byte[] ReadBytes(BinaryReader reader, int count) + static readonly (uint bit, string name)[] KnownNodeFlags = + { + (0x01, "Directory"), // In practice this is not used + (0x02, "Deleted"), // In practice this is not used + (0x04, "SerializedFile"), + }; + + static string FormatNodeFlags(uint flags) { - var result = reader.ReadBytes(count); - if (result.Length != count) + var names = new List(); + uint remaining = flags; + + foreach (var (bit, name) in KnownNodeFlags) { - throw new FileFormatException("File data is corrupt."); + if ((remaining & bit) != 0) + { + names.Add(name); + remaining &= ~bit; + } } - return result; + + if (remaining != 0) + names.Add($"0x{remaining:X}"); + + return names.Count > 0 ? string.Join(", ", names) : "None"; } - static void ExtractAssetBundle(FileInfo filename, DirectoryInfo outputFolder) + static void ExtractAssetBundle(FileInfo filename, DirectoryInfo outputFolder, string filter) { - Console.WriteLine($"Extracting asset bundle: {filename}"); + Console.WriteLine($"Extracting files from archive: {filename}"); using var archive = UnityFileSystem.MountArchive(filename.FullName, "/"); + + int total = archive.Nodes.Count; + int extracted = 0; + foreach (var node in archive.Nodes) { + if (filter != null && !node.Path.Contains(filter, StringComparison.OrdinalIgnoreCase)) + continue; + Console.WriteLine($"... Extracting {node.Path}"); CopyFile("/" + node.Path, Path.Combine(outputFolder.FullName, node.Path)); + extracted++; } + + Console.WriteLine($"Extracted {extracted} out of {total} files."); } - static void ListAssetBundle(FileInfo filename) + static void ListAssetBundle(FileInfo filename, OutputFormat format) { - using var archive = UnityFileSystem.MountArchive(filename.FullName, "/"); - foreach (var node in archive.Nodes) + if (!ArchiveDetector.TryReadArchiveHeader(filename.FullName, out var header, out var errorMessage)) + throw new NotSupportedException(errorMessage); + + if (!ArchiveDetector.TryReadArchiveMetadata(filename.FullName, header, out var metadata, out errorMessage)) + throw new NotSupportedException(errorMessage); + + var nodes = metadata.DirectoryInfo.Nodes; + + if (format == OutputFormat.Json) { - Console.WriteLine($"{node.Path}"); - Console.WriteLine($" Size: {node.Size}"); - Console.WriteLine($" Flags: {node.Flags}"); - Console.WriteLine(); + var jsonArray = new object[nodes.Length]; + for (int i = 0; i < nodes.Length; i++) + { + var node = nodes[i]; + jsonArray[i] = new { path = node.Path, dataOffset = node.DataOffset, size = node.Size, flags = FormatNodeFlags(node.Flags) }; + } + var json = JsonSerializer.Serialize(jsonArray, new JsonSerializerOptions { WriteIndented = true }); + Console.WriteLine(json); } - } - - static void ListWebBundle(FileInfo filename) - { - using var fileStream = File.Open(filename.ToString(), FileMode.Open); - using var stream = GetStream(filename, fileStream); - using var reader = new BinaryReader(stream, Encoding.UTF8); - var fileDescriptions = ParseWebBundleHeader(reader); - foreach (var description in fileDescriptions) + else { - Console.WriteLine($"{description.Path}"); - Console.WriteLine($" Size: {description.Size}"); - Console.WriteLine(); + foreach (var node in nodes) + { + Console.WriteLine($"{node.Path}"); + Console.WriteLine($" Data Offset: {node.DataOffset}"); + Console.WriteLine($" Size: {node.Size}"); + Console.WriteLine($" Flags: {FormatNodeFlags(node.Flags)}"); + Console.WriteLine(); + } } } diff --git a/UnityDataTool/Program.cs b/UnityDataTool/Program.cs index a65fd24..16b3e0e 100644 --- a/UnityDataTool/Program.cs +++ b/UnityDataTool/Program.cs @@ -120,29 +120,68 @@ public static async Task Main(string[] args) var pathArg = new Argument("filename", "The path of the archive file").ExistingOnly(); var oOpt = new Option(aliases: new[] { "--output-path", "-o" }, description: "Output directory of the extracted archive", getDefaultValue: () => new DirectoryInfo("archive")); + var filterOpt = new Option(aliases: new[] { "--filter" }, description: "Case-insensitive substring filter on file paths inside the archive"); + var extractArchiveCommand = new Command("extract", "Extract an AssetBundle or .data file.") { pathArg, oOpt, + filterOpt, }; extractArchiveCommand.SetHandler( - (FileInfo fi, DirectoryInfo o) => Task.FromResult(Archive.HandleExtract(fi, o)), - pathArg, oOpt); + (FileInfo fi, DirectoryInfo o, string filter) => Task.FromResult(Archive.HandleExtract(fi, o, filter)), + pathArg, oOpt, filterOpt); + + var fOpt = new Option(aliases: new[] { "--format", "-f" }, description: "Output format", getDefaultValue: () => OutputFormat.Text); var listArchiveCommand = new Command("list", "List the contents of an AssetBundle or .data file.") { pathArg, + fOpt, }; listArchiveCommand.SetHandler( - (FileInfo fi) => Task.FromResult(Archive.HandleList(fi)), - pathArg); + (FileInfo fi, OutputFormat f) => Task.FromResult(Archive.HandleList(fi, f)), + pathArg, fOpt); + + var headerArchiveCommand = new Command("header", "Display the header of a Unity Archive file.") + { + pathArg, + fOpt, + }; + + headerArchiveCommand.SetHandler( + (FileInfo fi, OutputFormat f) => Task.FromResult(Archive.HandleHeader(fi, f)), + pathArg, fOpt); + + var blocksArchiveCommand = new Command("blocks", "Display the block list of a Unity Archive file.") + { + pathArg, + fOpt, + }; + + blocksArchiveCommand.SetHandler( + (FileInfo fi, OutputFormat f) => Task.FromResult(Archive.HandleBlocks(fi, f)), + pathArg, fOpt); + + var infoArchiveCommand = new Command("info", "Display a high-level summary of a Unity Archive file.") + { + pathArg, + fOpt, + }; + + infoArchiveCommand.SetHandler( + (FileInfo fi, OutputFormat f) => Task.FromResult(Archive.HandleInfo(fi, f)), + pathArg, fOpt); var archiveCommand = new Command("archive", "Inspect or extract the contents of a Unity archive (AssetBundle or web platform .data file).") { extractArchiveCommand, listArchiveCommand, + headerArchiveCommand, + blocksArchiveCommand, + infoArchiveCommand, }; rootCommand.AddCommand(archiveCommand); diff --git a/UnityDataTool/SerializedFileCommands.cs b/UnityDataTool/SerializedFileCommands.cs index 7f68ee5..3dd0dc3 100644 --- a/UnityDataTool/SerializedFileCommands.cs +++ b/UnityDataTool/SerializedFileCommands.cs @@ -278,7 +278,7 @@ private static void OutputMetadataJson(SerializedFileMetadata metadata) Console.WriteLine(json); } - private static object TypeTreeInfoToJson(TypeTreeInfo info) + private static object TypeTreeInfoToJson(BinaryFormat.TypeTreeInfo info) { return new { diff --git a/UnityDataTool/UnityDataTool.csproj b/UnityDataTool/UnityDataTool.csproj index 1173552..227b923 100644 --- a/UnityDataTool/UnityDataTool.csproj +++ b/UnityDataTool/UnityDataTool.csproj @@ -4,10 +4,10 @@ Exe net9.0 latest - 1.3.2 - 1.3.3.0 - 1.3.3.0 - 1.3.3 + 1.3.4 + 1.3.4.0 + 1.3.4.0 + 1.3.4 diff --git a/UnityDataTool/WebBundleHelper.cs b/UnityDataTool/WebBundleHelper.cs new file mode 100644 index 0000000..4403b30 --- /dev/null +++ b/UnityDataTool/WebBundleHelper.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Text.Json; + +namespace UnityDataTools.UnityDataTool; + +public static class WebBundleHelper +{ + private static readonly byte[] WebBundlePrefix = Encoding.UTF8.GetBytes("UnityWebData1.0\0"); + + public static bool IsWebBundle(string path) + { + return ( + path.EndsWith(".data") + || path.EndsWith(".data.gz") + || path.EndsWith(".data.br") + ); + } + + public static void Extract(FileInfo filename, DirectoryInfo outputFolder, string filter = null) + { + Console.WriteLine($"Extracting web bundle: {filename}"); + using var fileStream = File.Open(filename.ToString(), FileMode.Open); + using var stream = GetStream(filename, fileStream); + using var reader = new BinaryReader(stream, Encoding.UTF8); + var fileDescriptions = ParseWebBundleHeader(reader); + + int total = fileDescriptions.Count; + int extracted = 0; + + foreach (var description in fileDescriptions) + { + // Always read the bytes to advance the stream position. + var data = ReadBytes(reader, (int)description.Size); + + if (filter != null && !description.Path.Contains(filter, StringComparison.OrdinalIgnoreCase)) + continue; + + Console.WriteLine($"... Extracting {description.Path}"); + var path = Path.Combine(outputFolder.ToString(), description.Path); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllBytes(path, data); + extracted++; + } + + Console.WriteLine($"Extracted {extracted} out of {total} files."); + } + + public static void List(FileInfo filename, OutputFormat format) + { + using var fileStream = File.Open(filename.ToString(), FileMode.Open); + using var stream = GetStream(filename, fileStream); + using var reader = new BinaryReader(stream, Encoding.UTF8); + var fileDescriptions = ParseWebBundleHeader(reader); + + if (format == OutputFormat.Json) + { + var jsonArray = new object[fileDescriptions.Count]; + for (int i = 0; i < fileDescriptions.Count; i++) + { + var desc = fileDescriptions[i]; + jsonArray[i] = new { path = desc.Path, size = desc.Size }; + } + var json = JsonSerializer.Serialize(jsonArray, new JsonSerializerOptions { WriteIndented = true }); + Console.WriteLine(json); + } + else + { + foreach (var description in fileDescriptions) + { + Console.WriteLine($"{description.Path}"); + Console.WriteLine($" Size: {description.Size}"); + Console.WriteLine(); + } + } + } + + struct FileDescription + { + public uint ByteOffset; + public uint Size; + public string Path; + } + + static Stream GetStream(FileInfo filename, FileStream fileStream) + { + var fileExtension = Path.GetExtension(filename.ToString()); + return fileExtension switch + { + ".data" => fileStream, + ".gz" => new GZipStream(fileStream, CompressionMode.Decompress), + ".br" => new BrotliStream(fileStream, CompressionMode.Decompress), + _ => throw new FileFormatException("Incorrect file extension for web bundle"), + }; + } + + static List ParseWebBundleHeader(BinaryReader reader) + { + var result = new List(); + var prefix = ReadBytes(reader, WebBundlePrefix.Length); + if (!prefix.SequenceEqual(WebBundlePrefix)) + { + throw new FileFormatException("File is not a valid web bundle."); + } + uint headerSize = ReadUInt32(reader); + // Advance offset past prefix string and header size uint. + var currentByteOffset = WebBundlePrefix.Length + sizeof(uint); + while (currentByteOffset < headerSize) + { + var fileByteOffset = ReadUInt32(reader); + var fileSize = ReadUInt32(reader); + var filePathLength = ReadUInt32(reader); + var filePath = Encoding.UTF8.GetString(ReadBytes(reader, (int)filePathLength)); + result.Add(new FileDescription() + { + ByteOffset = fileByteOffset, + Size = fileSize, + Path = filePath, + }); + // Advance byte offset, so we keep track of the position (to know when we're done reading the header). + currentByteOffset += 3 * sizeof(uint) + (int)filePathLength; + } + return result; + } + + static uint ReadUInt32(BinaryReader reader) + { + try + { + return reader.ReadUInt32(); + } + catch (EndOfStreamException) + { + throw new FileFormatException("File data is corrupt."); + } + } + + static byte[] ReadBytes(BinaryReader reader, int count) + { + var result = reader.ReadBytes(count); + if (result.Length != count) + { + throw new FileFormatException("File data is corrupt."); + } + return result; + } +} diff --git a/UnityFileSystem/DllWrapper.cs b/UnityFileSystem/DllWrapper.cs index 820bc1c..2649ade 100644 --- a/UnityFileSystem/DllWrapper.cs +++ b/UnityFileSystem/DllWrapper.cs @@ -146,6 +146,28 @@ public enum TypeTreeMetaFlags AnyChildUsesAlignBytes = 1 << 15, } +public enum TypeTreeCategory +{ + ObjectType = 0, + RefType = 1, +} + +[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] +public struct TypeTreeInfo +{ + public readonly int TypeId; + public readonly int SerializedSize; + public readonly TypeTreeCategory Category; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] + public readonly uint[] Hash; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public readonly string ClassName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public readonly string NamespaceName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public readonly string AssemblyName; +} + public static class DllWrapper { [DllImport("UnityFileSystemApi", @@ -263,4 +285,39 @@ public static extern ReturnCode GetTypeTreeNodeInfo(TypeTreeHandle handle, int n StringBuilder name, int nameLen, out int offset, out int size, [MarshalAs(UnmanagedType.U4)] out TypeTreeFlags flags, [MarshalAs(UnmanagedType.U4)] out TypeTreeMetaFlags metaFlags, out int firstChildNode, out int nextNode); + + [DllImport("UnityFileSystemApi", + CallingConvention = CallingConvention.Cdecl, + EntryPoint = "UFS_GetDllVersion")] + public static extern ReturnCode GetDllVersion(out int version); + + [DllImport("UnityFileSystemApi", + CallingConvention = CallingConvention.Cdecl, + EntryPoint = "UFS_GetUnityVersion")] + public static extern ReturnCode GetUnityVersion(StringBuilder version, int versionLen); + + [DllImport("UnityFileSystemApi", + CallingConvention = CallingConvention.Cdecl, + EntryPoint = "UFS_GetSerializedFileVersion")] + public static extern ReturnCode GetSerializedFileVersion(SerializedFileHandle handle, out int version); + + [DllImport("UnityFileSystemApi", + CallingConvention = CallingConvention.Cdecl, + EntryPoint = "UFS_GetTypeTreeCount")] + public static extern ReturnCode GetTypeTreeCount(SerializedFileHandle handle, out int count); + + [DllImport("UnityFileSystemApi", + CallingConvention = CallingConvention.Cdecl, + EntryPoint = "UFS_GetTypeTreeInfo")] + public static extern ReturnCode GetTypeTreeInfo(SerializedFileHandle handle, int index, out TypeTreeInfo info); + + [DllImport("UnityFileSystemApi", + CallingConvention = CallingConvention.Cdecl, + EntryPoint = "UFS_GetTypeTreeByIndex")] + public static extern ReturnCode GetTypeTreeByIndex(SerializedFileHandle handle, int index, out TypeTreeHandle typeTree); + + [DllImport("UnityFileSystemApi", + CallingConvention = CallingConvention.Cdecl, + EntryPoint = "UFS_RemoveTypeTreeSource")] + public static extern ReturnCode RemoveTypeTreeSource(long handle); } diff --git a/UnityFileSystem/SerializedFile.cs b/UnityFileSystem/SerializedFile.cs index 6791b6f..0612418 100644 --- a/UnityFileSystem/SerializedFile.cs +++ b/UnityFileSystem/SerializedFile.cs @@ -61,6 +61,43 @@ public TypeTreeNode GetRefTypeTypeTreeRoot(string className, string namespaceNam return node; } + public int GetVersion() + { + var r = DllWrapper.GetSerializedFileVersion(m_Handle, out var version); + UnityFileSystem.HandleErrors(r); + return version; + } + + public int GetTypeTreeCount() + { + var r = DllWrapper.GetTypeTreeCount(m_Handle, out var count); + UnityFileSystem.HandleErrors(r); + return count; + } + + public TypeTreeInfo GetTypeTreeInfo(int index) + { + var r = DllWrapper.GetTypeTreeInfo(m_Handle, index, out var info); + UnityFileSystem.HandleErrors(r); + return info; + } + + public TypeTreeNode GetTypeTreeByIndex(int index) + { + var r = DllWrapper.GetTypeTreeByIndex(m_Handle, index, out var typeTreeHandle); + UnityFileSystem.HandleErrors(r); + + if (m_TypeTreeCache.TryGetValue(typeTreeHandle.Handle, out var node)) + { + return node; + } + + node = new TypeTreeNode(typeTreeHandle, 0); + m_TypeTreeCache.Add(typeTreeHandle.Handle, node); + + return node; + } + private List GetExternalReferences() { var r = DllWrapper.GetExternalReferenceCount(m_Handle, out var count); diff --git a/UnityFileSystem/UnityFileSystem.cs b/UnityFileSystem/UnityFileSystem.cs index e86bc01..a623860 100644 --- a/UnityFileSystem/UnityFileSystem.cs +++ b/UnityFileSystem/UnityFileSystem.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Text; namespace UnityDataTools.FileSystem; @@ -51,6 +52,27 @@ public static long AddTypeTreeSourceFromFile(string path) return handle; } + public static void RemoveTypeTreeSource(long handle) + { + var r = DllWrapper.RemoveTypeTreeSource(handle); + HandleErrors(r); + } + + public static int GetDllVersion() + { + var r = DllWrapper.GetDllVersion(out var version); + HandleErrors(r); + return version; + } + + public static string GetUnityVersion() + { + var version = new StringBuilder(256); + var r = DllWrapper.GetUnityVersion(version, version.Capacity); + HandleErrors(r); + return version.ToString(); + } + public static SerializedFile OpenSerializedFile(string path) { var r = DllWrapper.OpenSerializedFile(path, out var handle);