diff --git a/.github/workflows/.build.yml b/.github/workflows/.build.yml index 9f2bdcf..1b38f14 100644 --- a/.github/workflows/.build.yml +++ b/.github/workflows/.build.yml @@ -50,6 +50,22 @@ jobs: -p:UseAppHost=true -o publish/${{ matrix.os }}/${{ matrix.arch }}-${{ env.environment }} + - name: Remove platform-specific and debug files (Windows) + if: matrix.os == 'windows' + run: | + Remove-Item -Path "publish/${{ matrix.os }}/${{ matrix.arch }}-${{ env.environment }}/*.dylib" -Force -ErrorAction SilentlyContinue + Remove-Item -Path "publish/${{ matrix.os }}/${{ matrix.arch }}-${{ env.environment }}/*.so" -Force -ErrorAction SilentlyContinue + Remove-Item -Path "publish/${{ matrix.os }}/${{ matrix.arch }}-${{ env.environment }}/*.pdb" -Force -ErrorAction SilentlyContinue + shell: pwsh + + - name: Remove platform-specific and debug files (macOS) + if: matrix.os == 'macos' + run: | + rm -f publish/${{ matrix.os }}/${{ matrix.arch }}-${{ env.environment }}/UnityFileSystemApi.dll + rm -f publish/${{ matrix.os }}/${{ matrix.arch }}-${{ env.environment }}/*.so + rm -f publish/${{ matrix.os }}/${{ matrix.arch }}-${{ env.environment }}/*.pdb + shell: bash + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/Documentation/command-archive.md b/Documentation/command-archive.md index a563926..90964dc 100644 --- a/Documentation/command-archive.md +++ b/Documentation/command-archive.md @@ -1,30 +1,119 @@ # archive Command -The `archive` command provides utilities for working with Unity Archives (AssetBundles and web platform `.data` files). +The `archive` command provides utilities for working with [Unity Archives](unity-content-format.md#unity-archive) — container files used for AssetBundles and web platform `.data` files. Archives hold one or more files (typically SerializedFiles) and may apply compression to the content. + +To inspect the serialized objects *inside* an archive, use the [`dump`](command-dump.md#archive-support) command, which can open archives directly without extracting first. ## Sub-Commands -| Sub-Command | Description | -|-------------|-------------| -| [`list`](#list) | List contents of an archive | -| [`extract`](#extract) | Extract contents of an archive | +| Sub-Command | Description | Unity Archive | Web `.data` | +|-------------|-------------|:---:|:---:| +| [`info`](#info) | Display a high-level summary | Yes | — | +| [`header`](#header) | Display archive header information | Yes | — | +| [`blocks`](#blocks) | Display the data block list | Yes | — | +| [`list`](#list) | List contents of an archive | Yes | Yes | +| [`extract`](#extract) | Extract contents of an archive | Yes | Yes | + +--- + +## 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 +132,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,16 +149,15 @@ 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. --- -## Comparison: extract vs dump +## Related: inspecting objects inside an archive + +The `archive` command works at the container level — it shows the archive structure and can extract the raw files. To inspect the serialized objects inside those files, use the [`dump`](command-dump.md#archive-support) command, which can open an archive directly and dump all SerializedFiles inside it without extracting first. | Command | Output | Use Case | |---------|--------|----------| -| `archive extract` | Binary SerializedFiles, .resS anything else inside the archive content | When you need all the raw files inside an archive | -| `dump` | text | When you want to inspect object content | - -The `dump` command can directly process archives without extracting first. - +| `archive extract` | Raw binary files (SerializedFiles, .resS, .resource, etc.) | When you need the individual files on disk | +| [`dump`](command-dump.md#archive-support) | Human-readable text representation of serialized objects | When you want to inspect object properties and values inside an archive | diff --git a/Documentation/command-dump.md b/Documentation/command-dump.md index beb0a26..6f1d0d3 100644 --- a/Documentation/command-dump.md +++ b/Documentation/command-dump.md @@ -15,6 +15,7 @@ UnityDataTool dump [options] | `-f, --output-format ` | Output format | `text` | | `-s, --skip-large-arrays` | Skip dumping large arrays | `false` | | `-i, --objectid ` | Only dump object with this ID | All objects | +| `-t, --type ` | Filter by object type (ClassID number or type name) | All objects | | `-d, --typetree-data ` | Load an external TypeTree data file before processing (Unity 6.5+) | — | ## Examples @@ -39,6 +40,29 @@ Skip large arrays for cleaner output: UnityDataTool dump /path/to/file -s ``` +Dump only MonoBehaviour objects by type name: +```bash +UnityDataTool dump /path/to/file -t MonoBehaviour +``` + +Same thing using the numeric ClassID: +```bash +UnityDataTool dump /path/to/file -t 114 +``` + +Dump the AssetBundle manifest object: +```bash +UnityDataTool dump mybundle -t AssetBundle +``` + +--- + +## Filtering by Type + +The `-t` / `--type` option filters output to objects of a specific Unity type. It accepts either a numeric ClassID (e.g. `114`) or a type name (e.g. `MonoBehaviour`). Type name matching is case-insensitive. + +This is particularly useful for inspecting MonoBehaviour data in built AssetBundles. MonoBehaviour and ScriptableObject field values are serialized as binary, and a typical bundle contains many other object types (meshes, textures, materials, etc.). Using `-t MonoBehaviour` dumps only the scripting objects, showing the serialized C# field names, types, and values. + --- ## Archive Support diff --git a/Documentation/command-serialized-file.md b/Documentation/command-serialized-file.md index 4667a30..73d9839 100644 --- a/Documentation/command-serialized-file.md +++ b/Documentation/command-serialized-file.md @@ -1,6 +1,6 @@ # serialized-file Command -The `serialized-file` command (alias: `sf`) provides utilities for quickly inspecting SerializedFile metadata without performing a full analysis. +The `serialized-file` command (alias: `sf`) provides utilities for quickly inspecting [SerializedFile](unity-content-format.md#serializedfile) metadata without performing a full analysis. This exposes information about the Binary SerializedFile format. This format has evolved over time, but all recent versions have * a small header section (exposed by the `header` subcommand) diff --git a/Documentation/textdumper.md b/Documentation/textdumper.md index ffb9563..3ef490c 100644 --- a/Documentation/textdumper.md +++ b/Documentation/textdumper.md @@ -5,11 +5,12 @@ file (AssetBundle or SerializedFile) into human-readable yaml-style text file. ## How to use -The library consists of a single class called [TextDumperTool](../TextDumper/TextDumperTool.cs). It has a method named Dump and takes four parameters: +The library consists of a single class called [TextDumperTool](../TextDumper/TextDumperTool.cs). It has a method named Dump and takes five parameters: * path (string): path of the data file. * outputPath (string): path where the output files will be created. * skipLargeArrays (bool): if true, the content of arrays larger than 1KB won't be dumped. * objectId (long, optional): if specified and not 0, only the object with this signed 64-bit id will be dumped. If 0 (default), all objects are dumped. +* typeFilter (string, optional): if specified, only objects matching this type are dumped. Accepts a numeric ClassID (e.g. 114) or a type name (e.g. MonoBehaviour, case-insensitive). ## How to interpret the output files diff --git a/Documentation/unity-content-format.md b/Documentation/unity-content-format.md index d21f131..105f11a 100644 --- a/Documentation/unity-content-format.md +++ b/Documentation/unity-content-format.md @@ -6,28 +6,31 @@ This section gives an overview of the core Unity file types and how they are use ### SerializedFile -A SerializedFile the name used for Unity's binary file format for serializing objects. It is made up of a file header, -then each Object, serialized one after another. This binary format is also available in the Editor, but typically Editor content uses the Unity YAML format instead. +A SerializedFile is Unity's binary file format for serializing objects. It is made up of a file header, then each object serialized one after another. This binary format is also available in the Editor, but typically Editor content uses the Unity YAML format instead. The SerializedFiles in build output represent the project content, but optimized for the target platform. Unity will combine objects from multiple source assets together into files, exclude certain objects (for example editor-only objects), and potentially split or duplicate objects across multiple output files. This arrangement of objects is called the `build layout`. Because of all this transformation, there is not a one-to-one mapping between the source assets and the SerializedFiles in the build output. +Use the [`serialized-file`](command-serialized-file.md) command to inspect SerializedFile headers, object lists, external references, and type metadata. + ### Unity Archive -An Unity Archive is a container file (similar to a zip file). Unity can `mount` this file, which makes the files inside it visible to Unity's loading system, via the Unity "Virtual File System" (VFS). Unity Archives often apply compression to the content, but it is also possible to create an uncompressed Archive. +A Unity Archive is a container file (similar to a zip file). Unity can `mount` this file, which makes the files inside it visible to Unity's loading system via the Unity "Virtual File System" (VFS). Unity Archives often apply compression to the content, but it is also possible to create an uncompressed Archive. + +Use the [`archive`](command-archive.md) command to inspect archive headers, data block layouts, and file listings, or to extract the contents of an archive. ## AssetBundles [AssetBundles](https://docs.unity3d.com/Manual/AssetBundlesIntro.html) use the Unity Archive file format, with conventions for what to expect inside the archive. The [Addressables](https://docs.unity3d.com/Manual/com.unity.addressables.html) package uses AssetBundles, so its build output is also made up of Unity Archive files. -AssetBundles always contain at least one SerializedFile. In the case of an AssetBundle containing Scenes there will be multiple Serialized Files. AssetBundles can also contain auxiliary files, such as .resS files containing Textures and Meshes, and .resource files containing audio or video. +AssetBundles always contain at least one SerializedFile. In the case of an AssetBundle containing Scenes there will be multiple SerializedFiles. AssetBundles can also contain auxiliary files, such as .resS files containing Textures and Meshes, and .resource files containing audio or video. -UnityDataTools supports opening Archive files, so it is able to analyze AssetBundles. +UnityDataTools supports opening Archive files, so it can analyze AssetBundles. ## Player Builds A player build produces content as well as compiled code (assemblies, executables) and various configuration files. UnityDataTool only concerns itself with the content portion of that output. -The content compromises of the scenes in the Scene List, the contents of Resources folders, content from the Project Preferences (the "GlobalGameManagers") and also all Assets referenced from those root inputs. This translates into SerializedFiles in the build output. +The content comprises the scenes in the Scene List, the contents of Resources folders, content from the Project Preferences (the "GlobalGameManagers"), and all Assets referenced from those root inputs. This translates into SerializedFiles in the build output. The SerializedFiles are named in a predictable way. This is a very quick summary: @@ -62,11 +65,11 @@ For more information about TypeTrees see the following section. ## TypeTrees -The TypeTree is a data structure exposing how objects have been serialized, i.e. the name, type and -size of their properties. It is used by Unity when loading an SerializedFile that was built by a -previous Unity version. When Unity is deserializing an object it needs to check if the current Type -definition exactly matches the Type definition used when the object was serialized. If they do not match -Unity will attempt to match up the properties as best as it can, based on the property names and structure +The TypeTree is a data structure describing how objects have been serialized, i.e. the name, type, and +size of their properties. It is used by Unity when loading a SerializedFile that was built by a +different Unity version. When Unity deserializes an object it checks whether the current type +definition exactly matches the type definition used when the object was serialized. If they do not match, +Unity will attempt to match up the properties as best it can, based on the property names and structure of the data. This process is called a "Safe Binary Read" and is somewhat slower than the regular fast binary read path. TypeTrees are important in the case of AssetBundles, to avoid rebuilding and redistributing all AssetBundles after each minor upgrade of Unity or after doing minor changes to your MonoBehaviour and ScriptableObject serialization. However there can be a noticeable overhead to storing the TypeTrees in each AssetBundle, e.g. the header size of each SerializedFile is bigger. @@ -76,14 +79,14 @@ TypeTrees also make it possible to load an AssetBundle in the Editor, when testi >[!NOTE] >There is a flag available when building AssetBundles that will exclude TypeTrees, see [BuildAssetBundleOptions.DisableWriteTypeTree](https://docs.unity3d.com/6000.2/Documentation/ScriptReference/BuildAssetBundleOptions.DisableWriteTypeTree.html). This has implications for future redistribution of your content, so use this flag with caution. -For Player Data the expectation is that you always rebuild all content together with each new build of the player. -So the Assemblies and serialized objects will all have matching types definitions. That is why, by default, the types are not included. +For Player Data the expectation is that you always rebuild all content together with each new build of the player. +So the assemblies and serialized objects will all have matching type definitions. That is why, by default, TypeTrees are not included. -UnityDataTools relies on TypeTrees in order to understand the content of serialized objects. Using this approach it does -not need to hard code any knowledge about what exact types and properties to expect inside each built-in Unity type -(for example Materials and Transforms). And it can interpret serialized C# classes (e.g. MonoBehaviours, ScriptableObjects -and objects serialized through the SerializeReference attribute). That also means that UnityDataTools cannot understand -Player built content, unless the Player was built with TypeTrees enabled. +UnityDataTools relies on TypeTrees to understand the content of serialized objects. This approach means it does +not need to hard-code knowledge about the types and properties of each built-in Unity type +(for example Materials and Transforms). It can also interpret serialized C# classes (e.g. MonoBehaviours, ScriptableObjects, +and objects serialized through the SerializeReference attribute). This also means that UnityDataTools cannot understand +Player-built content unless the Player was built with TypeTrees enabled. >[!TIP] >The `binary2text` tool supports an optional argument `-typeinfo` to enable dumping out the TypeTrees in a SerializedFile header. That is a useful way to learn more about TypeTrees and to see exactly how Unity data is represented in the binary format. @@ -102,9 +105,9 @@ The output structure and file formats for a Unity Player build are quite platfor On some platforms the content is packaged into platform-specific container files, for example Android builds use .apk and .obb files. So accessing the actual SerializedFiles may involve mounting or extracting the content of those files, and possibly also opening a data.unity3d file inside them. -UnityDataTools directly supports opening the .data container file format used in Player builds that target Web platforms (e.g. WebGL). Specifically the "archive list" and "archive extract" command line option works with that format. Once extracted you can run other UnityDataTool commands on the output. +UnityDataTools directly supports opening the `.data` container file format used in Player builds that target Web platforms (e.g. WebGL). Specifically the [`archive list`](command-archive.md#list) and [`archive extract`](command-archive.md#extract) commands work with that format. Once extracted, you can run other UnityDataTool commands on the output. -Android APK files are not difficult to open and expand using freely available utilities. For example on Windows they can be opened using 7-zip. Once the content is extracted you can run UnityDataTool commands on the output. +Android APK files are not difficult to open and expand using freely available utilities. For example on Windows they can be opened using 7-zip. Once the content is extracted you can run UnityDataTool commands on the output. ## Mapping back to Source Assets @@ -112,7 +115,7 @@ Because Unity rearranges objects in the build into a build layout there is no 1- The UnityDataTool only looks at the output of the build, and has no information available about the source paths. This is expected, because the built output is optimized for speed and size, and there is no need to "leak" a lot of details about the source project in the data that gets shipped with the Player. -However in cases where you want to understand what contributes to the size your build, or to confirm whether certain content is actually included, then you may want to correlate the output back to the source assets in your project. +However in cases where you want to understand what contributes to the size of your build, or to confirm whether certain content is actually included, you may want to correlate the output back to the source assets in your project. Often the source of content can be easily inferred, based on your own knowledge of your project, and the names of objects. For example the name of a Shader should be unique, and typically has a filename that closely matches the Shader name. @@ -120,4 +123,4 @@ You can include a Unity BuildReport file when running `UnityDataTools analyze`. `UnityDataTools analyze` can also import Addressables build layout files, which include source asset information. See [Addressable Build Reports](./addressables-build-reports.md). -For AssetBundles built by [BuildPipeline.BuildAssetBundles()](https://docs.unity3d.com/ScriptReference/BuildPipeline.BuildAssetBundles.html) Unity creates a .manifest file for each AssetBundle that has source information. This is a text-base format. +For AssetBundles built by [BuildPipeline.BuildAssetBundles()](https://docs.unity3d.com/ScriptReference/BuildPipeline.BuildAssetBundles.html), Unity creates a .manifest file for each AssetBundle that has source information. This is a text-based format. 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/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index 8f5da2c..4a8f7a2 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -14,8 +14,11 @@ public class TextDumperTool SerializedFile m_SerializedFile; StreamWriter m_Writer; - public int Dump(string path, string outputPath, bool skipLargeArrays, long objectId = 0) + public int Dump(string path, string outputPath, bool skipLargeArrays, long objectId = 0, string typeFilter = null) { + if (string.IsNullOrWhiteSpace(typeFilter)) + typeFilter = null; + m_SkipLargeArrays = skipLargeArrays; try @@ -38,7 +41,7 @@ public int Dump(string path, string outputPath, bool skipLargeArrays, long objec { using (m_Writer = new StreamWriter(Path.Combine(outputPath, Path.GetFileName(node.Path) + ".txt"), false)) { - OutputSerializedFile("/" + node.Path, objectId); + OutputSerializedFile("/" + node.Path, objectId, typeFilter); } } } @@ -56,7 +59,7 @@ public int Dump(string path, string outputPath, bool skipLargeArrays, long objec { using (m_Writer = new StreamWriter(Path.Combine(outputPath, Path.GetFileName(path) + ".txt"), false)) { - OutputSerializedFile(path, objectId); + OutputSerializedFile(path, objectId, typeFilter); } } catch (SerializedFileOpenException) @@ -378,8 +381,11 @@ bool DumpManagedReferenceData(TypeTreeNode refTypeNode, TypeTreeNode referencedT return true; } - void OutputSerializedFile(string path, long objectId) + void OutputSerializedFile(string path, long objectId, string typeFilter) { + int filterTypeId = 0; + bool filterByTypeId = typeFilter != null && int.TryParse(typeFilter, out filterTypeId); + using (m_Reader = new UnityFileReader(path, 64 * 1024 * 1024)) using (m_SerializedFile = UnityFileSystem.OpenSerializedFile(path)) { @@ -400,6 +406,26 @@ void OutputSerializedFile(string path, long objectId) continue; var root = m_SerializedFile.GetTypeTreeRoot(obj.Id); + + if (typeFilter != null) + { + if (filterByTypeId) + { + if (obj.TypeId != filterTypeId) + continue; + } + else + { + var typeName = TypeIdRegistry.GetTypeName(obj.TypeId); + // GetTypeName returns the id as a string when the type is unknown; + // fall back to the TypeTree root node for script types. + if (typeName == obj.TypeId.ToString()) + typeName = root.Type; + if (!string.Equals(typeName, typeFilter, StringComparison.OrdinalIgnoreCase)) + continue; + } + } + var offset = obj.Offset; m_Writer.Write($"ID: {obj.Id} (ClassID: {obj.TypeId}) "); @@ -408,8 +434,13 @@ void OutputSerializedFile(string path, long objectId) dumpedObject = true; } - if (objectId != 0 && !dumpedObject) - m_Writer.WriteLine($"Object with ID {objectId} not found."); + if ((objectId != 0 || typeFilter != null) && !dumpedObject) + { + if (objectId != 0) + m_Writer.WriteLine($"Object with ID {objectId} not found."); + else + m_Writer.WriteLine($"No objects found matching type \"{typeFilter}\"."); + } } } 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..d5461fe 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($" 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[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.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[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("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 @@ -144,6 +148,47 @@ public async Task DumpText_SkipLargeArrays_TextFileCreatedCorrectly( Assert.AreEqual(expected, content); } + [Test] + public async Task DumpText_TypeFilterByName_OnlyMatchingObjectsDumped() + { + var path = Path.Combine(Context.UnityDataFolder, "assetbundle"); + var outputFile = Path.Combine(m_TestOutputFolder, "CAB-5d40f7cad7c871cf2ad2af19ac542994.txt"); + + Assert.AreEqual(0, await Program.Main(new string[] { "dump", path, "-t", "MonoBehaviour" })); + Assert.IsTrue(File.Exists(outputFile)); + + var content = File.ReadAllText(outputFile); + Assert.That(content, Does.Contain("(ClassID: 114)")); + Assert.That(content, Does.Not.Contain("(ClassID: 1)")); + } + + [Test] + public async Task DumpText_TypeFilterByClassID_OnlyMatchingObjectsDumped() + { + var path = Path.Combine(Context.UnityDataFolder, "assetbundle"); + var outputFile = Path.Combine(m_TestOutputFolder, "CAB-5d40f7cad7c871cf2ad2af19ac542994.txt"); + + Assert.AreEqual(0, await Program.Main(new string[] { "dump", path, "-t", "114" })); + Assert.IsTrue(File.Exists(outputFile)); + + var content = File.ReadAllText(outputFile); + Assert.That(content, Does.Contain("(ClassID: 114)")); + Assert.That(content, Does.Not.Contain("(ClassID: 1)")); + } + + [Test] + public async Task DumpText_TypeFilterNoMatch_ShowsNotFoundMessage() + { + var path = Path.Combine(Context.UnityDataFolder, "assetbundle"); + var outputFile = Path.Combine(m_TestOutputFolder, "CAB-5d40f7cad7c871cf2ad2af19ac542994.txt"); + + Assert.AreEqual(0, await Program.Main(new string[] { "dump", path, "-t", "NonExistentType" })); + Assert.IsTrue(File.Exists(outputFile)); + + var content = File.ReadAllText(outputFile); + Assert.That(content, Does.Contain("No objects found matching type")); + } + [Test] public async Task Analyze_DefaultArgs_DatabaseCorrect() { 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..fd5be86 100644 --- a/UnityDataTool/Program.cs +++ b/UnityDataTool/Program.cs @@ -92,6 +92,7 @@ public static async Task Main(string[] args) var sOpt = new Option(aliases: new[] { "--skip-large-arrays", "-s" }, description: "Do not dump large arrays of basic data types"); var oOpt = new Option(aliases: new[] { "--output-path", "-o" }, description: "Output folder", getDefaultValue: () => new DirectoryInfo(Environment.CurrentDirectory)); var objectIdOpt = new Option(aliases: new[] { "--objectid", "-i" }, () => 0, "Only dump the object with this signed 64-bit id (default: 0, dump all objects)"); + var typeOpt = new Option(aliases: new[] { "--type", "-t" }, description: "Filter by object type (ClassID number or type name)"); var dOpt = new Option(aliases: new[] { "--typetree-data", "-d" }, description: typeTreeDataDescription); @@ -102,16 +103,17 @@ public static async Task Main(string[] args) sOpt, oOpt, objectIdOpt, + typeOpt, dOpt, }; dumpCommand.SetHandler( - (FileInfo fi, DumpFormat f, bool s, DirectoryInfo o, long objectId, FileInfo d) => + (FileInfo fi, DumpFormat f, bool s, DirectoryInfo o, long objectId, string type, FileInfo d) => { var ttResult = LoadTypeTreeDataFile(d); if (ttResult != 0) return Task.FromResult(ttResult); - return Task.FromResult(HandleDump(fi, f, s, o, objectId)); + return Task.FromResult(HandleDump(fi, f, s, o, objectId, type)); }, - pathArg, fOpt, sOpt, oOpt, objectIdOpt, dOpt); + pathArg, fOpt, sOpt, oOpt, objectIdOpt, typeOpt, dOpt); rootCommand.AddCommand(dumpCommand); } @@ -120,29 +122,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); @@ -274,14 +315,14 @@ static int HandleFindReferences(FileInfo databasePath, string outputFile, long? } } - static int HandleDump(FileInfo filename, DumpFormat format, bool skipLargeArrays, DirectoryInfo outputFolder, long objectId = 0) + static int HandleDump(FileInfo filename, DumpFormat format, bool skipLargeArrays, DirectoryInfo outputFolder, long objectId = 0, string typeFilter = null) { switch (format) { case DumpFormat.Text: { var textDumper = new TextDumperTool(); - return textDumper.Dump(filename.FullName, outputFolder.FullName, skipLargeArrays, objectId); + return textDumper.Dump(filename.FullName, outputFolder.FullName, skipLargeArrays, objectId, typeFilter); } } 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);