diff --git a/Analyzer.Tests/Analyzer.Tests.csproj b/Analyzer.Tests/Analyzer.Tests.csproj index e1ad7f2..e8dc9fd 100644 --- a/Analyzer.Tests/Analyzer.Tests.csproj +++ b/Analyzer.Tests/Analyzer.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/Analyzer.Tests/FileDetectionTests.cs b/Analyzer.Tests/FileDetectionTests.cs index 3d8c8bf..2a48bf7 100644 --- a/Analyzer.Tests/FileDetectionTests.cs +++ b/Analyzer.Tests/FileDetectionTests.cs @@ -1,7 +1,8 @@ using System; using System.IO; +using System.Linq; using NUnit.Framework; -using UnityDataTools.Analyzer.Util; +using UnityDataTools.BinaryFormat; using UnityDataTools.FileSystem; namespace UnityDataTools.Analyzer.Tests; @@ -168,6 +169,319 @@ public void TryDetectSerializedFile_NonExistentFile_ReturnsFalse() #endregion + #region SerializedFile Metadata Parsing Tests + + [Test] + public void TryParseMetadata_VersionTooOld_ReturnsFalseWithMessage() + { + var headerInfo = new SerializedFileInfo { Version = 18 }; + + bool result = SerializedFileDetector.TryParseMetadata("irrelevant", headerInfo, out var metadata, out var errorMessage); + + Assert.IsFalse(result); + Assert.IsNull(metadata); + Assert.IsNotNull(errorMessage); + Assert.That(errorMessage, Does.Contain("18"), "Error should mention the actual version"); + Assert.That(errorMessage, Does.Contain("19"), "Error should mention the minimum supported version"); + } + + [Test] + public void TryParseMetadata_VersionTooNew_ReturnsFalseWithMessage() + { + var headerInfo = new SerializedFileInfo { Version = 24 }; + + bool result = SerializedFileDetector.TryParseMetadata("irrelevant", headerInfo, out var metadata, out var errorMessage); + + Assert.IsFalse(result); + Assert.IsNull(metadata); + Assert.IsNotNull(errorMessage); + Assert.That(errorMessage, Does.Contain("24"), "Error should mention the actual version"); + Assert.That(errorMessage, Does.Contain("23"), "Error should mention the maximum supported version"); + Assert.That(errorMessage, Does.Contain("UnityDataTool"), "Error should mention UnityDataTool"); + } + + [Test] + public void TryParseMetadata_PlayerDataLevel0_ReturnsExpectedValues() + { + var testFile = Path.Combine(m_TestDataPath, "PlayerData", "2022.1.20f1", "level0"); + + bool headerResult = SerializedFileDetector.TryDetectSerializedFile(testFile, out var headerInfo); + Assert.IsTrue(headerResult, "level0 should be detected as a valid SerializedFile"); + + bool result = SerializedFileDetector.TryParseMetadata(testFile, headerInfo, out var metadata, out var errorMessage); + + Assert.IsTrue(result, $"Metadata parsing should succeed. Error: {errorMessage}"); + Assert.IsNotNull(metadata); + + // Verify exact values from the level0 metadata section. + // This file was built with Unity 2022.1.20f1 for StandaloneOSX (platform 2), + // with TypeTrees enabled. + Assert.That(metadata.UnityVersion, Is.EqualTo("2022.1.20f1"), "Unity version should be 2022.1.20f1"); + Assert.That(metadata.TargetPlatform, Is.EqualTo(2u), "Target platform should be 2 (StandaloneOSX)"); + Assert.IsTrue(metadata.EnableTypeTree, "EnableTypeTree should be true"); + + // --- TypeTree counts --- + Assert.That(metadata.TypeTreeCount, Is.EqualTo(10), "Should have 10 regular type entries"); + Assert.That(metadata.SerializedReferenceTypeTreeCount, Is.EqualTo(0), "Should have 0 SerializeReference type entries"); + Assert.IsNotNull(metadata.TypeTrees, "TypeTrees should be populated"); + Assert.That(metadata.TypeTrees.Length, Is.EqualTo(10)); + + // --- Per-entry invariants for a player scene with no MonoBehaviours --- + // All types are native Unity types: inline TypeTrees, no script IDs, no ref-type fields. + foreach (var entry in metadata.TypeTrees) + { + Assert.IsTrue(entry.InlineTypeTree, + $"InlineTypeTree should be true (persistentTypeID={entry.PersistentTypeID})"); + Assert.IsFalse(entry.TypeTreeStructureHash.IsZero, + $"TypeTreeStructureHash should not be zero (persistentTypeID={entry.PersistentTypeID})"); + Assert.IsTrue(entry.TypeTreeContentHash.IsZero, + $"TypeTreeContentHash should be zero for version < 23 (persistentTypeID={entry.PersistentTypeID})"); + Assert.Greater(entry.TypeTreeSerializedSize, 0u, + $"TypeTreeSerializedSize should be non-zero (persistentTypeID={entry.PersistentTypeID})"); + Assert.Greater(entry.PersistentTypeID, 0, + $"PersistentTypeID should be positive for native types (got {entry.PersistentTypeID})"); + Assert.That(entry.PersistentTypeID, Is.Not.EqualTo(114), + "No MonoBehaviour types expected in this scene"); + Assert.That(entry.ScriptTypeIndex, Is.EqualTo((short)-1), + $"ScriptTypeIndex should be -1 for native types (persistentTypeID={entry.PersistentTypeID})"); + Assert.IsTrue(entry.ScriptID.IsZero, + $"ScriptID should be zero for native types (persistentTypeID={entry.PersistentTypeID})"); + Assert.That(entry.ClassName, Is.EqualTo(string.Empty), + $"ClassName should be empty for non-ref types (persistentTypeID={entry.PersistentTypeID})"); + Assert.That(entry.Namespace, Is.EqualTo(string.Empty), + $"Namespace should be empty for non-ref types (persistentTypeID={entry.PersistentTypeID})"); + Assert.That(entry.AssemblyName, Is.EqualTo(string.Empty), + $"AssemblyName should be empty for non-ref types (persistentTypeID={entry.PersistentTypeID})"); + Assert.That(entry.TypeDependencies.Length, Is.EqualTo(0), + $"TypeDependencies should be empty (persistentTypeID={entry.PersistentTypeID})"); + } + } + + [Test] + public void TryParseMetadata_PlayerNoTypeTreeLevel1_ReturnsExpectedValues() + { + var testFile = Path.Combine(m_TestDataPath, "PlayerNoTypeTree", "level1"); + + bool headerResult = SerializedFileDetector.TryDetectSerializedFile(testFile, out var headerInfo); + Assert.IsTrue(headerResult, "level1 should be detected as a valid SerializedFile"); + + bool result = SerializedFileDetector.TryParseMetadata(testFile, headerInfo, out var metadata, out var errorMessage); + + Assert.IsTrue(result, $"Metadata parsing should succeed. Error: {errorMessage}"); + Assert.IsNotNull(metadata); + + // Verify exact values from the level1 metadata section. + // This file was built with Unity 6000.0.65f1 for Windows Standalone (platform 19), + // with TypeTrees disabled (PlayerNoTypeTree build). + Assert.That(metadata.UnityVersion, Is.EqualTo("6000.0.65f1"), "Unity version should be 6000.0.65f1"); + Assert.That(metadata.TargetPlatform, Is.EqualTo(19u), "Target platform should be 19 (Windows Standalone x64)"); + Assert.IsFalse(metadata.EnableTypeTree, "EnableTypeTree should be false for a no-type-tree build"); + + // Even when TypeTrees are not stored inline, the metadata still records the full list of + // types used in the file along with their oldTypeHash values. The hashes allow the runtime + // to verify type compatibility against its built-in type definitions at load time. + Assert.That(metadata.TypeTreeCount, Is.EqualTo(6), "Should have 6 type entries"); + Assert.IsNotNull(metadata.TypeTrees, "TypeTrees should be populated"); + Assert.That(metadata.TypeTrees.Length, Is.EqualTo(6)); + + foreach (var entry in metadata.TypeTrees) + { + Assert.Greater(entry.PersistentTypeID, 0, + $"PersistentTypeID should be positive (got {entry.PersistentTypeID})"); + Assert.IsFalse(entry.TypeTreeStructureHash.IsZero, + $"TypeTreeStructureHash should not be zero (persistentTypeID={entry.PersistentTypeID})"); + Assert.IsFalse(entry.InlineTypeTree, + $"InlineTypeTree should be false when EnableTypeTree=false (persistentTypeID={entry.PersistentTypeID})"); + Assert.IsTrue(entry.TypeTreeContentHash.IsZero, + $"TypeTreeContentHash should be zero for this version < 23 file (persistentTypeID={entry.PersistentTypeID})"); + } + } + + [Test] + public void TryParseMetadata_V22PrefabWithSerializedReference_ReturnsExpectedTypeTreeData() + { + var testFile = Path.Combine(m_TestDataPath, "AssetBundleTypeTreeVariations", "v22", + "prefab_with_serializedreference.serializedfile"); + + bool headerResult = SerializedFileDetector.TryDetectSerializedFile(testFile, out var headerInfo); + Assert.IsTrue(headerResult, "File should be detected as a valid SerializedFile"); + + bool result = SerializedFileDetector.TryParseMetadata(testFile, headerInfo, out var metadata, out var errorMessage); + Assert.IsTrue(result, $"Metadata parsing should succeed. Error: {errorMessage}"); + Assert.IsNotNull(metadata); + + // --- Initial metadata fields --- + Assert.IsTrue(metadata.EnableTypeTree, "EnableTypeTree should be true"); + + // --- Type counts --- + Assert.That(metadata.TypeTreeCount, Is.EqualTo(4), "Should have 4 regular type entries"); + Assert.That(metadata.SerializedReferenceTypeTreeCount, Is.EqualTo(1), "Should have 1 SerializeReference type entry"); + Assert.IsNotNull(metadata.TypeTrees, "TypeTrees array should be populated"); + Assert.IsNotNull(metadata.SerializedReferenceTypeTrees, "SerializedReferenceTypeTrees array should be populated"); + + // --- Regular type entries: persistentTypeIDs in order --- + int[] expectedTypeIDs = { 142, 4, 1, 114 }; + Assert.That(metadata.TypeTrees.Length, Is.EqualTo(expectedTypeIDs.Length)); + for (int i = 0; i < expectedTypeIDs.Length; i++) + Assert.That(metadata.TypeTrees[i].PersistentTypeID, Is.EqualTo(expectedTypeIDs[i]), + $"TypeTrees[{i}].PersistentTypeID"); + + // --- v22 files do not store TypeTreeContentHash (it is all-zeros) --- + foreach (var entry in metadata.TypeTrees) + Assert.IsTrue(entry.TypeTreeContentHash.IsZero, + $"TypeTreeContentHash should be zero for v22 (persistentTypeID={entry.PersistentTypeID})"); + foreach (var entry in metadata.SerializedReferenceTypeTrees) + Assert.IsTrue(entry.TypeTreeContentHash.IsZero, + "SerializedReferenceTypeTrees TypeTreeContentHash should be zero for v22"); + + // --- All type trees are inline (non-zero size, InlineTypeTree=true) --- + foreach (var entry in metadata.TypeTrees) + { + Assert.IsTrue(entry.InlineTypeTree, + $"InlineTypeTree should be true (persistentTypeID={entry.PersistentTypeID})"); + Assert.Greater(entry.TypeTreeSerializedSize, 0u, + $"TypeTreeSerializedSize should be non-zero (persistentTypeID={entry.PersistentTypeID})"); + } + foreach (var entry in metadata.SerializedReferenceTypeTrees) + { + Assert.IsTrue(entry.InlineTypeTree, "SerializedReferenceTypeTrees[0].InlineTypeTree should be true"); + Assert.Greater(entry.TypeTreeSerializedSize, 0u, + "SerializedReferenceTypeTrees[0].TypeTreeSerializedSize should be non-zero"); + } + + // --- MonoBehaviour (114) has special entries because it refers to a specific C# class --- + // Note: if multiple C# MonoBehaviour-derived types were used in this serialized files then we would have multiple entries. + var monoBehaviour = metadata.TypeTrees.First(t => t.PersistentTypeID == 114); + Assert.IsFalse(monoBehaviour.ScriptID.IsZero, + "MonoBehaviour type entry should carry a non-zero scriptID"); + + Assert.That(monoBehaviour.ScriptTypeIndex, Is.EqualTo(0), + "MonoBehaviour type entry should have a valid ScriptTypeIndex"); // -1 is used for non-script types, so 0 is the first valid index + + Assert.That(monoBehaviour.TypeDependencies.Length, Is.EqualTo(1), + "MonoBehaviour should have TypeDependencies array because to record SerializedReference dependencies"); + + Assert.That(monoBehaviour.TypeDependencies[0], Is.EqualTo(0), + "MonoBehaviour should record dependency on SerializedReference"); + + // --- Script types --- + // ScriptTypeIndex=0 on the MonoBehaviour entry above means it is backed by ScriptTypes[0]. + // The MonoScript lives in external file #1 (the companion monoscriptbundle). + Assert.IsNotNull(metadata.ScriptTypes, "ScriptTypes should be populated"); + Assert.That(metadata.ScriptTypes.Length, Is.EqualTo(1), "Should have 1 script type entry"); + Assert.That(metadata.ScriptTypes[0].FileID, Is.EqualTo(1), + "ScriptTypes[0].FileID should be 1 (first external reference)"); + Assert.That(metadata.ScriptTypes[0].PathID, Is.EqualTo(3225487681952536265L), + "ScriptTypes[0].PathID should match the MonoScript object ID in monoscriptbundle"); + + // --- SerializedReference type entry --- + Assert.That(metadata.SerializedReferenceTypeTrees.Length, Is.EqualTo(1)); + var refType = metadata.SerializedReferenceTypeTrees[0]; + Assert.That(refType.PersistentTypeID, Is.EqualTo(-1)); + Assert.That(refType.ClassName, Is.EqualTo("Data")); + Assert.That(refType.Namespace, Is.EqualTo("MyScripts")); + Assert.That(refType.AssemblyName, Is.EqualTo("Assembly-CSharp")); + } + + [Test] + public void TryParseMetadata_V23ExtractedMonoscriptBundle_ReturnsExpectedTypeTreeData() + { + // This is a v23 (kExtractedTypeTreeSupport) file where the TypeTree blobs have been + // extracted to a shared external store. The metadata records a non-zero TypeTreeContentHash + // as a cache key, but typeTreeSerializedSize == 0 and InlineTypeTree == false for every entry. + var testFile = Path.Combine(m_TestDataPath, "AssetBundleTypeTreeVariations", "v23_extracted", + "monoscriptbundle.serializedfile"); + + bool headerResult = SerializedFileDetector.TryDetectSerializedFile(testFile, out var headerInfo); + Assert.IsTrue(headerResult, "File should be detected as a valid SerializedFile"); + + bool result = SerializedFileDetector.TryParseMetadata(testFile, headerInfo, out var metadata, out var errorMessage); + Assert.IsTrue(result, $"Metadata parsing should succeed. Error: {errorMessage}"); + Assert.IsNotNull(metadata); + + // --- Initial metadata fields --- + Assert.IsTrue(metadata.EnableTypeTree, "EnableTypeTree should be true"); + + // --- Type counts --- + Assert.That(metadata.TypeTreeCount, Is.EqualTo(2), "Should have 2 regular type entries"); + Assert.That(metadata.SerializedReferenceTypeTreeCount, Is.EqualTo(0), "Should have 0 SerializeReference type entries"); + Assert.IsNotNull(metadata.TypeTrees, "TypeTrees array should be populated"); + Assert.That(metadata.TypeTrees.Length, Is.EqualTo(2)); + + // --- All TypeTree blobs are extracted: non-zero content hash, zero size, not inline --- + foreach (var entry in metadata.TypeTrees) + { + Assert.IsFalse(entry.TypeTreeContentHash.IsZero, + $"TypeTreeContentHash should be non-zero for extracted v23 entry (persistentTypeID={entry.PersistentTypeID})"); + Assert.That(entry.TypeTreeSerializedSize, Is.EqualTo(0u), + $"TypeTreeSerializedSize should be 0 for extracted entry (persistentTypeID={entry.PersistentTypeID})"); + Assert.IsFalse(entry.InlineTypeTree, + $"InlineTypeTree should be false for extracted entry (persistentTypeID={entry.PersistentTypeID})"); + } + } + + [Test] + public void TryParseMetadata_V22PrefabWithSerializedReference_ReturnsExpectedObjectList() + { + var testFile = Path.Combine(m_TestDataPath, "AssetBundleTypeTreeVariations", "v22", + "prefab_with_serializedreference.serializedfile"); + + bool headerResult = SerializedFileDetector.TryDetectSerializedFile(testFile, out var headerInfo); + Assert.IsTrue(headerResult, "File should be detected as a valid SerializedFile"); + + bool result = SerializedFileDetector.TryParseMetadata(testFile, headerInfo, out var metadata, out var errorMessage); + Assert.IsTrue(result, $"Metadata parsing should succeed. Error: {errorMessage}"); + Assert.IsNotNull(metadata); + + Assert.IsNotNull(metadata.ObjectList, "ObjectList should be populated"); + Assert.That(metadata.ObjectList.Length, Is.EqualTo(6), "Should have 6 objects"); + + // Verify exact values for each object entry. + // Expected data from the file's object table (fileID, typeID, offset, size): + var expected = new (long Id, int TypeId, long Offset, long Size)[] + { + ( 1L, 142, 5552L, 300L), // AssetBundle + ( 674343093664966924L, 4, 5856L, 68L), // Transform + (4902368549205534988L, 4, 5936L, 80L), // Transform + (5206304541755795724L, 1, 6016L, 51L), // GameObject + (6854740422901983500L, 1, 6080L, 35L), // GameObject + (8430482813342345484L, 114, 6128L, 104L), // MonoBehaviour + }; + + for (int i = 0; i < expected.Length; i++) + { + var obj = metadata.ObjectList[i]; + Assert.That(obj.Id, Is.EqualTo(expected[i].Id), $"ObjectList[{i}].Id"); + Assert.That(obj.TypeId, Is.EqualTo(expected[i].TypeId), $"ObjectList[{i}].TypeId"); + Assert.That(obj.Offset, Is.EqualTo(expected[i].Offset), $"ObjectList[{i}].Offset"); + Assert.That(obj.Size, Is.EqualTo(expected[i].Size), $"ObjectList[{i}].Size"); + } + } + + [Test] + public void TryParseMetadata_V22PrefabWithSerializedReference_ReturnsExpectedExternalReferences() + { + var testFile = Path.Combine(m_TestDataPath, "AssetBundleTypeTreeVariations", "v22", + "prefab_with_serializedreference.serializedfile"); + + bool headerResult = SerializedFileDetector.TryDetectSerializedFile(testFile, out var headerInfo); + Assert.IsTrue(headerResult, "File should be detected as a valid SerializedFile"); + + bool result = SerializedFileDetector.TryParseMetadata(testFile, headerInfo, out var metadata, out var errorMessage); + Assert.IsTrue(result, $"Metadata parsing should succeed. Error: {errorMessage}"); + Assert.IsNotNull(metadata); + + Assert.IsNotNull(metadata.ExternalReferences, "ExternalReferences should be populated"); + Assert.That(metadata.ExternalReferences.Length, Is.EqualTo(1), "Should have 1 external reference"); + + var extRef = metadata.ExternalReferences[0]; + Assert.That(extRef.Path, Is.EqualTo("archive:/CAB-d57a1d89ac0708bf030936c59479c685/CAB-d57a1d89ac0708bf030936c59479c685")); + Assert.That(extRef.Guid, Is.EqualTo("00000000000000000000000000000000")); + Assert.That(extRef.Type, Is.EqualTo(ExternalReferenceType.NonAssetType)); + } + + #endregion + #region YAML SerializedFile Detection Tests [Test] @@ -241,7 +555,7 @@ public void IsUnityArchive_ValidAssetBundle_ReturnsTrue() [Test] public void IsUnityArchive_OldFormatArchive_ReturnsTrue() { - var testFile = Path.Combine(m_TestDataPath, "LegacyFormats", "alienprefab"); + var testFile = Path.Combine(m_TestDataPath, "LegacyFormats", "AssetBundles", "alienprefab"); bool result = ArchiveDetector.IsUnityArchive(testFile); diff --git a/Analyzer/Analyzer.csproj b/Analyzer/Analyzer.csproj index 33b56cb..e78cc92 100644 --- a/Analyzer/Analyzer.csproj +++ b/Analyzer/Analyzer.csproj @@ -23,6 +23,7 @@ + diff --git a/Analyzer/AnalyzerTool.cs b/Analyzer/AnalyzerTool.cs index ba7a5f4..4a8efe6 100644 --- a/Analyzer/AnalyzerTool.cs +++ b/Analyzer/AnalyzerTool.cs @@ -6,6 +6,8 @@ using UnityDataTools.Analyzer.SQLite.Parsers; using UnityDataTools.Analyzer.SQLite.Parsers.Models; using UnityDataTools.Analyzer.SQLite.Writers; +using UnityDataTools.BinaryFormat; +using UnityDataTools.FileSystem; namespace UnityDataTools.Analyzer; @@ -74,8 +76,21 @@ public int Analyze( ReportProgress(Path.GetRelativePath(path, file), i, files.Length); countSuccess++; } + catch (SerializedFileOpenException e) + { + // Expected failure — the file content could not be parsed. + // Don't print a stack trace; it adds no value for this known failure mode. + EraseProgressLine(); + var relativePath = Path.GetRelativePath(path, file); + Console.Error.WriteLine($"Failed to open: {relativePath}"); + var hint = SerializedFileDetector.GetOpenFailureHint(e.FilePath); + if (hint != null) + Console.Error.WriteLine(hint); + countFailures++; + } catch (Exception e) { + // Unexpected failure (SQL error, I/O error, bug, etc.) — print full details. EraseProgressLine(); var relativePath = Path.GetRelativePath(path, file); Console.Error.WriteLine($"Failed to process: {relativePath}"); diff --git a/Analyzer/SQLite/Parsers/SerializedFileParser.cs b/Analyzer/SQLite/Parsers/SerializedFileParser.cs index 2a84e6a..dcb0128 100644 --- a/Analyzer/SQLite/Parsers/SerializedFileParser.cs +++ b/Analyzer/SQLite/Parsers/SerializedFileParser.cs @@ -4,7 +4,7 @@ using Microsoft.Data.Sqlite; using UnityDataTools.Analyzer.SQLite.Handlers; using UnityDataTools.Analyzer.SQLite.Writers; -using UnityDataTools.Analyzer.Util; +using UnityDataTools.BinaryFormat; using UnityDataTools.FileSystem; namespace UnityDataTools.Analyzer.SQLite.Parsers diff --git a/Analyzer/SerializedObjects/BuildReport.cs b/Analyzer/SerializedObjects/BuildReport.cs index 6dd413d..16d0483 100644 --- a/Analyzer/SerializedObjects/BuildReport.cs +++ b/Analyzer/SerializedObjects/BuildReport.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.IO; -using UnityDataTools.Analyzer.Util; +using UnityDataTools.BinaryFormat; using UnityDataTools.FileSystem.TypeTreeReaders; namespace UnityDataTools.Analyzer.SerializedObjects; diff --git a/Analyzer/SerializedObjects/PackedAssets.cs b/Analyzer/SerializedObjects/PackedAssets.cs index be7741a..ab81ed0 100644 --- a/Analyzer/SerializedObjects/PackedAssets.cs +++ b/Analyzer/SerializedObjects/PackedAssets.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using UnityDataTools.Analyzer.Util; +using UnityDataTools.BinaryFormat; using UnityDataTools.FileSystem.TypeTreeReaders; namespace UnityDataTools.Analyzer.SerializedObjects; diff --git a/Analyzer/Util/SerializedFileDetector.cs b/Analyzer/Util/SerializedFileDetector.cs deleted file mode 100644 index 0e02c0a..0000000 --- a/Analyzer/Util/SerializedFileDetector.cs +++ /dev/null @@ -1,330 +0,0 @@ -using System; -using System.IO; - -namespace UnityDataTools.Analyzer.Util; - -/// -/// Information extracted from a Unity SerializedFile header. -/// -public class SerializedFileInfo -{ - public uint Version { get; set; } - public ulong FileSize { get; set; } - public ulong MetadataSize { get; set; } - public ulong DataOffset { get; set; } - public byte Endianness { get; set; } - public bool IsLegacyFormat { get; set; } -} - -/// -/// Utility for detecting Unity SerializedFile format by reading and validating the file header. -/// -/// Unity SerializedFiles have evolved through several format versions: -/// -/// Version < 9: -/// - 20-byte header (SerializedFileHeader32) with 32-bit offsets/sizes -/// - Layout: [header][data][metadata] -/// - Endianness byte stored at END of file, just before metadata -/// -/// Version 9-21: -/// - 20-byte header (SerializedFileHeader32) with 32-bit offsets/sizes -/// - Layout: [header][metadata][data] -/// - Endianness byte at offset 16 in header -/// - Limited to 4GB file sizes -/// -/// Version >= 22 (kLargeFilesSupport): -/// - 48-byte header (SerializedFileHeader) with 64-bit offsets/sizes -/// - Layout: [header][metadata][data] -/// - Endianness byte at offset 40 in header -/// - Supports files larger than 4GB -/// -/// Important: The header itself is always stored in big-endian format on disk, -/// but the m_Endianness byte indicates the endianness of the actual data section. -/// -/// DEPRECATION WARNING: The deprecation process for Version <18 (Unity 5.5 and earlier) has started in Unity 6.5. -/// Initially this will be a warning, but upcoming versions of UnityDataTool and UnityFileSystem can be expected -/// to lose the ability to open and read those files (apart from low level information exposed by the -/// "serialized-file header" command). -/// -public static class SerializedFileDetector -{ - // Version boundaries for format changes - // NOTE: This version is so old that it is extremely unlikely it will work with modern versions of Unity, - // we handle it just for the purpose of trying to report accurate information about the file. - private const uint NewLayoutVersion = 9; // Changed from [header][data][metadata] to [header][metadata][data] - - private const uint LargeFilesSupportVersion = 22; // Changed to 64-bit header - - // Reasonable version range for SerializedFiles - // Unity versions currently use values in the 20s-30s range - private const uint MinVersion = 1; - private const uint MaxVersion = 50; - - // Endianness values (only little-endian is supported in Unity 2023+) - private const byte LittleEndian = 0; - private const byte BigEndian = 1; - - // Header sizes - private const int LegacyHeaderSize = 20; // SerializedFileHeader32 - private const int ModernHeaderSize = 48; // SerializedFileHeader - - /// - /// Attempts to detect if a file is a Unity SerializedFile by reading and validating its header. - /// Returns false immediately if the file doesn't match the expected format. - /// - /// Path to the file to check - /// If successful, contains header information - /// True if file appears to be a valid SerializedFile, false otherwise - public static bool TryDetectSerializedFile(string filePath, out SerializedFileInfo info) - { - info = null; - - if (!File.Exists(filePath)) - return false; - - try - { - using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - long fileLength = stream.Length; - - // Quick rejection: file must be at least large enough for the legacy header - if (fileLength < LegacyHeaderSize) - return false; - - // Read enough bytes to cover a modern header (48 bytes) - // We'll determine which format to parse based on the version field - byte[] headerBytes = new byte[ModernHeaderSize]; - int bytesRead = stream.Read(headerBytes, 0, headerBytes.Length); - - if (bytesRead < LegacyHeaderSize) - return false; - - // ============================================================ - // STEP 1: Read version to determine header format - // ============================================================ - - // The version field is always at offset 8 in both header formats. - // The header itself is always stored in big-endian format on disk. - // On little-endian platforms (Windows, etc.), we need to swap the header fields. - // - // We try both interpretations to determine if swapping is needed: - uint versionLE = BitConverter.ToUInt32(headerBytes, 8); - uint versionBE = SwapUInt32(versionLE); - - // Determine which interpretation gives us a valid version number - uint version; - bool needsSwap; // Whether header fields need byte swapping (expected to be true when running on most modern systems, which are little-endian) - - if (versionLE >= MinVersion && versionLE <= MaxVersion) - { - // Reading as little-endian gives valid version (header is in little-endian format) - version = versionLE; - needsSwap = false; - } - else if (versionBE >= MinVersion && versionBE <= MaxVersion) - { - // Reading as big-endian gives valid version (header is in big-endian format) - version = versionBE; - needsSwap = true; - } - else - { - // Neither interpretation gives a valid version - return false; - } - - // Determine header format based on version - bool isLegacyFormat = version < LargeFilesSupportVersion; - - // ============================================================ - // STEP 2: Read endianness byte - // ============================================================ - // - // The m_Endianness byte indicates the endianness of the DATA section - // (not the header, which is always big-endian on disk). - // Location depends on version: - // - Version < 9: At end of file (before metadata) - we skip reading it for detection - // - Version 9-21: At offset 16 in the 20-byte header - // - Version >= 22: At offset 40 in the 48-byte header - // - // The endianness byte is never swapped (it's a single byte). - - byte endianness; - - if (version < NewLayoutVersion) - { - // Version < 9: Endianness is at the end of the file - // For detection purposes, we infer it from the header byte order - // (though this is technically the header's endianness, not the data's) - endianness = needsSwap ? BigEndian : LittleEndian; - } - else if (isLegacyFormat) - { - // Version 9-21: Endianness is at offset 16 in SerializedFileHeader32 - if (bytesRead >= 17) - { - endianness = headerBytes[16]; - - // Validate endianness value - if (endianness != LittleEndian && endianness != BigEndian) - return false; - } - else - { - return false; // File truncated - } - } - else - { - // Version >= 22: Endianness is at offset 40 in SerializedFileHeader - if (bytesRead >= 41) - { - endianness = headerBytes[40]; - - // Validate endianness value - if (endianness != LittleEndian && endianness != BigEndian) - return false; - } - else - { - return false; // File truncated - } - } - - // ============================================================ - // STEP 3: Parse the appropriate header format - // ============================================================ - - ulong metadataSize, fileSize, dataOffset; - - if (isLegacyFormat) - { - // SerializedFileHeader32 Layout (20 bytes total): - // Offset 0-3: UInt32 m_MetadataSize - // Offset 4-7: UInt32 m_FileSize - // Offset 8-11: UInt32 m_Version - // Offset 12-15: UInt32 m_DataOffset - // Offset 16: UInt8 m_Endianness (only present for version >= 9) - // Offset 17-19: UInt8 m_Reserved[3] - // - // Note: For version < 9, m_Endianness is NOT in the header. - // It's stored at the end of the file, just before metadata begins. - - uint metadataSize32 = ReadUInt32(headerBytes, 0, needsSwap); - uint fileSize32 = ReadUInt32(headerBytes, 4, needsSwap); - uint dataOffset32 = ReadUInt32(headerBytes, 12, needsSwap); - - // Convert to 64-bit for consistency - metadataSize = metadataSize32; - fileSize = fileSize32; - dataOffset = dataOffset32; - - // Special case: Legacy format used UInt32.MaxValue to indicate "unknown" file size - if (fileSize32 == uint.MaxValue) - { - fileSize = ulong.MaxValue; - } - } - else - { - // SerializedFileHeader Layout (48 bytes total): - // Offset 0-7: UInt8[8] m_Legacy (unused, allows struct alignment with SerializedFileHeader32) - // Offset 8-11: UInt32 m_Version - // Offset 12-15: UInt8[4] m_Reserved0 (explicit padding) - // Offset 16-23: UInt64 m_MetadataSize - // Offset 24-31: UInt64 m_FileSize - // Offset 32-39: UInt64 m_DataOffset - // Offset 40: UInt8 m_Endianness - // Offset 41-47: UInt8[7] m_Reserved1 - - metadataSize = ReadUInt64(headerBytes, 16, needsSwap); - fileSize = ReadUInt64(headerBytes, 24, needsSwap); - dataOffset = ReadUInt64(headerBytes, 32, needsSwap); - } - - // ============================================================ - // STEP 4: Validate header consistency - // ============================================================ - - // MetadataSize must not be the sentinel value (indicates corruption) - if (metadataSize == ulong.MaxValue) - return false; - - // DataOffset must be within the file size - if (fileSize != ulong.MaxValue && dataOffset > fileSize) - return false; - - // FileSize should roughly match actual file size - // Allow some tolerance for "stream files" which can have padding - if (fileSize != ulong.MaxValue) - { - // File size should not exceed actual file size by more than 1KB (arbitrary tolerance) - if (fileSize > (ulong)fileLength + 1024) - return false; - } - - // MetadataSize should be reasonable (not larger than the file itself) - if (metadataSize > (ulong)fileLength) - return false; - - // ============================================================ - // STEP 5: Populate and return info - // ============================================================ - - info = new SerializedFileInfo - { - Version = version, - FileSize = fileSize, - MetadataSize = metadataSize, - DataOffset = dataOffset, - Endianness = endianness, - IsLegacyFormat = isLegacyFormat - }; - - return true; - } - catch - { - // Any exception during reading/parsing means this isn't a valid SerializedFile - return false; - } - } - - /// - /// Reads a UInt32 from a byte array at the specified offset, optionally swapping endianness. - /// - private static uint ReadUInt32(byte[] buffer, int offset, bool swap) - { - uint value = BitConverter.ToUInt32(buffer, offset); - return swap ? SwapUInt32(value) : value; - } - - /// - /// Reads a UInt64 from a byte array at the specified offset, optionally swapping endianness. - /// - private static ulong ReadUInt64(byte[] buffer, int offset, bool swap) - { - ulong value = BitConverter.ToUInt64(buffer, offset); - return swap ? SwapUInt64(value) : value; - } - - private static uint SwapUInt32(uint value) - { - return ((value & 0x000000FFU) << 24) | - ((value & 0x0000FF00U) << 8) | - ((value & 0x00FF0000U) >> 8) | - ((value & 0xFF000000U) >> 24); - } - - private static ulong SwapUInt64(ulong value) - { - return ((value & 0x00000000000000FFUL) << 56) | - ((value & 0x000000000000FF00UL) << 40) | - ((value & 0x0000000000FF0000UL) << 24) | - ((value & 0x00000000FF000000UL) << 8) | - ((value & 0x000000FF00000000UL) >> 8) | - ((value & 0x0000FF0000000000UL) >> 24) | - ((value & 0x00FF000000000000UL) >> 40) | - ((value & 0xFF00000000000000UL) >> 56); - } -} diff --git a/Documentation/command-dump.md b/Documentation/command-dump.md index d23b7f2..f8f2165 100644 --- a/Documentation/command-dump.md +++ b/Documentation/command-dump.md @@ -60,6 +60,35 @@ BuildPlayer-Scene2.txt --- +## TypeTree Requirement + +Unity's binary SerializedFile format stores objects as raw binary blobs. TypeTrees are schema metadata embedded in the file that describe the layout of each type — field names, data sizes, and alignment. The `dump` command requires TypeTrees to interpret those blobs and produce readable output. + +**When are TypeTrees absent?** + +TypeTrees are included by default. They are stripped in two common situations: + +- **Player builds** with *Strip Engine Code* or similar size-reduction options enabled. +- **AssetBundles** built with the *Disable Write TypeTree* build option. + +**Error when TypeTrees are missing:** + +Various errors can be caused by missing TypeTrees, including: + +``` +ArgumentException: Invalid object id +``` + +**Tip:** Use `serialized-file metadata` to confirm whether a file has TypeTrees: + +```bash +UnityDataTool serialized-file metadata /path/to/file +``` + +The `TypeTree Definitions` field will show `No` when TypeTrees are absent. + +--- + ## Output Format The output is similar to Unity's `binary2text` tool. Each file begins with external references: diff --git a/Documentation/command-serialized-file.md b/Documentation/command-serialized-file.md index d8d7cc4..4bcfd91 100644 --- a/Documentation/command-serialized-file.md +++ b/Documentation/command-serialized-file.md @@ -21,6 +21,7 @@ The `dump` command can be used to view the serialized objects. | [`externalrefs`](#externalrefs) | List external file references | | [`objectlist`](#objectlist) | List all objects in the file | | [`header`](#header) | Show SerializedFile header information | +| [`metadata`](#metadata) | Show SerializedFile metadata (Unity version, platform, TypeTree summary) | --- @@ -204,6 +205,112 @@ UnityDataTool serialized-file header level0 --format json --- +## metadata + +Shows information from the metadata section of a SerializedFile. This includes the Unity version, target platform, TypeTree storage mode (inline, external, or absent), and counts of the type entries recorded in the file. The JSON output includes additional per-type details; see the notes below. + +Requires SerializedFile version 19 (Unity 2019.1) or newer. Files older than version 19 are not supported by this subcommand. + +### Quick Reference + +``` +UnityDataTool serialized-file metadata [options] +UnityDataTool sf metadata [options] +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `` | Path to the SerializedFile | *(required)* | +| `-f, --format ` | Output format: `Text` or `Json` | `Text` | + +### Example - Text Output + +```bash +UnityDataTool sf metadata level0 +``` + +**Output:** +``` +Unity Version 6000.0.65f1 +Target Platform 19 +TypeTree Definitions No +TypeTree Count 6 +RefType Count 0 +``` + +### Example - JSON Output + +```bash +UnityDataTool serialized-file metadata level0 --format json +``` + +**Output (top-level fields shown; per-type arrays omitted for brevity):** +```json +{ + "unityVersion": "6000.0.65f1", + "targetPlatform": 19, + "enableTypeTree": false, + "typeTreeCount": 6, + "serializedReferenceTypeTreeCount": 0, + "typeTrees": [ ... ], + "serializedReferenceTypeTrees": [ ... ], + "scriptTypes": [ ... ] +} +``` + +Each element of `typeTrees` and `serializedReferenceTypeTrees` contains per-type details including hash values, TypeTree blob size, inline/external flag, and (for SerializeReference types) the C# class identity. Each element of `scriptTypes` contains the file and object ID of the backing MonoScript asset. + +### Metadata Fields + +The text and JSON outputs use different field names and representations for some fields. + +| Text Field | JSON Field | Description | +|------------|------------|-------------| +| **Unity Version** | `unityVersion` | The Unity version string used to build this file (e.g. `"2022.1.20f1"`, `"6000.0.65f1"`). | +| **Target Platform** | `targetPlatform` | Numeric platform identifier. Common values: `2` = OSX Standalone, `9` = iOS, `13` = Android, `19` = Windows Standalone x64. See [BuildTarget](https://docs.unity3d.com/ScriptReference/BuildTarget.html) for details. | +| **TypeTree Definitions** | `enableTypeTree` | Whether TypeTree blobs are stored in this file. The text output derives a descriptive string from the raw boolean and the parsed type entries; the JSON output exposes the raw boolean directly. Text values: `No` — TypeTrees absent (default Player build); `Inline` — all TypeTree blobs are embedded in the file (Editor and TypeTree-enabled builds); `External` — TypeTree blobs were extracted to a separate store (version ≥ 23); `Mixed` — entries disagree (unexpected; indicates a parser or file anomaly); `Unknown` — `enableTypeTree` is true but no type entries were parsed. TypeTrees are required for the `objectlist` and `externalrefs` subcommands to show type names. | +| **TypeTree Count** | `typeTreeCount` | Number of regular (object) type entries recorded in the file. Present even when TypeTree definitions are not stored inline. | +| **RefType Count** | `serializedReferenceTypeTreeCount` | Number of type entries for `[SerializeReference]` types recorded in the file. Always `0` for files with version < 20. | +| *(JSON only)* | `typeTrees` | Array of per-type detail objects for the regular type entries. `null` when parsing failed or was not attempted. See **Per-Type Entry Fields** below. | +| *(JSON only)* | `serializedReferenceTypeTrees` | Array of per-type detail objects for the `[SerializeReference]` type entries. Empty array for files with version < 20. See **Per-Type Entry Fields** below. | +| *(JSON only)* | `scriptTypes` | Array of MonoScript references for the C# types used in this file. Each entry's index corresponds to the `scriptTypeIndex` field of a type entry in `typeTrees`. See **Script Type Entry Fields** below. | + +### Per-Type Entry Fields + +Each element of `typeTrees` and `serializedReferenceTypeTrees` in the JSON output contains the following fields: + +| JSON Field | Description | +|------------|-------------| +| `persistentTypeID` | Unity ClassID (e.g. `114` = MonoBehaviour). `-1` for `[SerializeReference]` entries whose type has no built-in ClassID. | +| `isStrippedType` | `true` for types representing prefab-stripped objects (the `stripped` keyword in YAML). Orthogonal to TypeTree presence. | +| `scriptTypeIndex` | Index into the file's MonoScript reference list. `-1` for native Unity types. | +| `scriptID` | 128-bit hash (MD4 of assembly + namespace + class name) identifying the MonoScript. All-zeros when not applicable. | +| `typeTreeStructureHash` | MD4 hash of the TypeTree structure as originally written; used for compatibility checking at load time. | +| `typeTreeContentHash` | XXH3 hash of the TypeTree blob. All-zeros for files with version < 23. | +| `typeTreeSerializedSize` | Byte size of the TypeTree blob for this entry. `0` when `inlineTypeTree` is false. | +| `inlineTypeTree` | `true` when the TypeTree blob is present inline in the file's metadata. | +| `className` | C# class name; non-empty only for `[SerializeReference]` entries (version ≥ 21). | +| `namespaceName` | C# namespace; non-empty only for `[SerializeReference]` entries (version ≥ 21). | +| `assemblyName` | Assembly name; non-empty only for `[SerializeReference]` entries (version ≥ 21). | +| `typeDependencies` | Array of indices into `serializedReferenceTypeTrees` listing which `[SerializeReference]` types objects of this type may hold. Empty for `[SerializeReference]` entries or files with version < 21. | + +### Script Type Entry Fields + +Each element of `scriptTypes` in the JSON output contains: + +| JSON Field | Description | +|------------|-------------| +| `fileID` | Index into the file's external references list identifying which SerializedFile contains the MonoScript asset. `0` = this file itself; `1`+ = 1-based index into the `externalrefs` list. | +| `pathID` | The object ID (`localIdentifierInFile`) of the MonoScript within the identified file. Corresponds to the `id` field shown by `sf objectlist` on that file. | + +Notes: + +* For SerializedFiles inside AssetBundles the Unity Version is frequently stripped ("0.0.0"). See [BuildAssetBundleOptions.AssetBundleStripUnityVersion](https://docs.unity3d.com/ScriptReference/BuildAssetBundleOptions.AssetBundleStripUnityVersion.html). +* For AssetBundles the version string may take the form "\n". The assetbundle-format-version rarely changes, and is currently 2. +* The Unity Editor will attempt to load SerializedFiles regardless of the Platform. But the Runtime will only load files built with the correct platform value. + +--- + ## Use Cases ### Quick File Inspection @@ -214,6 +321,9 @@ Use `serialized-file` when you need quick information about a SerializedFile wit # Check file format and version UnityDataTool sf header level0 +# Check Unity version, target platform, and TypeTree flag +UnityDataTool sf metadata level0 + # Check what objects are in a file UnityDataTool sf objectlist sharedassets0.assets diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/README.md b/TestCommon/Data/AssetBundleTypeTreeVariations/README.md new file mode 100644 index 0000000..1c0d391 --- /dev/null +++ b/TestCommon/Data/AssetBundleTypeTreeVariations/README.md @@ -0,0 +1,50 @@ +This folder contains variations of the typetree representations in the newest SerializedFile formats. + +* v22 is used in recent versions of Unity +* v23 is introduced in Unity 6.5 + +They are all builds of the same tiny Addressables project. + +# Summary of Content + +## packedassets_assets_all.bundle + +* Main AssetBundle +* It builds a simple prefab that includes a MonoBehavior (an instance of the MyScripts.Data class) +* The MonoBehaviour has a SerializedReference field that stores an instance of a scripting class. + +## MonoScript_monoscripts_dde848dc9848681e340a8b4fa9bd7578.bundle + +* Autogenerated AssetBundle +* Contains the MonoScript that tracks the MonoBehaviour type. + +## AssetBundle.typetreedata + +* An archive file with the typetree info from both bundles. +* Only present in the v23 build when Addressables is built with Extract Typetrees enabled. + +## prefab_with_serializedreference.serializedfile + +* Serialized file that is inside packedassets_assets_all.bundle +* Actual name inside AssetBundle is CAB-394ff12e47c27ee4c30d41d2747acd4b +* It contains 2 GameObjects, 2 Transforms, a MonoBehavior and an AssetBundle (visible using `UnityDataTool sf objectlist prefab_with_serializedreference.serializedfile`) +* The TypeTree array has type info for AssetBundle (type 142), Transform (type 4), GameObject (type 1), MonoBehaviour (type 114) +* Note: the TypeTree for the MonoBehavior is specific to the MyScripts.Data class, e.g. it includes the SerializedReference field MyData, which is not part of other classes derived from MonoBehaviour. The ScriptID hash is used to distinguish the precise type when there are multiple MonoBehaviours referenced in the same file. +* The SerializedReference TypeTree array references C# type Assembly: Assembly-CSharp NameSpace: MyScripts Class: Data + + +## monoscriptbundle.serializedfile + +* Serialized file that is inside MonoScript_monoscripts_dde848dc9848681e340a8b4fa9bd7578.bundle +* Extracted using UnityDataTool archive extract +* Actual name inside AssetBundle is CAB-d57a1d89ac0708bf030936c59479c685 + +# Binary2text tips + +* The `-typeinfo` argument is a way to see the actual contents of the typetrees. +* To use binary2text with the extracted versions of the serialized files you must pass in AssetBundle.typetreedata using the `typetreefile` argument. + +Example: +``` +binary2text.exe prefab_with_serializedreference.serializedfile -typetreefile AssetBundle.typetreedata -typeinfo +``` \ No newline at end of file diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v22/MonoScript_monoscripts_dde848dc9848681e340a8b4fa9bd7578.bundle b/TestCommon/Data/AssetBundleTypeTreeVariations/v22/MonoScript_monoscripts_dde848dc9848681e340a8b4fa9bd7578.bundle new file mode 100644 index 0000000..acc7990 Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v22/MonoScript_monoscripts_dde848dc9848681e340a8b4fa9bd7578.bundle differ diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v22/monoscriptbundle.serializedfile b/TestCommon/Data/AssetBundleTypeTreeVariations/v22/monoscriptbundle.serializedfile new file mode 100644 index 0000000..bb730fe Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v22/monoscriptbundle.serializedfile differ diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v22/packedassets_assets_all.bundle b/TestCommon/Data/AssetBundleTypeTreeVariations/v22/packedassets_assets_all.bundle new file mode 100644 index 0000000..6bfa391 Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v22/packedassets_assets_all.bundle differ diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v22/prefab_with_serializedreference.serializedfile b/TestCommon/Data/AssetBundleTypeTreeVariations/v22/prefab_with_serializedreference.serializedfile new file mode 100644 index 0000000..5c0f217 Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v22/prefab_with_serializedreference.serializedfile differ diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v23_Inline/MonoScript_monoscripts_dde848dc9848681e340a8b4fa9bd7578.bundle b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_Inline/MonoScript_monoscripts_dde848dc9848681e340a8b4fa9bd7578.bundle new file mode 100644 index 0000000..f57de6a Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_Inline/MonoScript_monoscripts_dde848dc9848681e340a8b4fa9bd7578.bundle differ diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v23_Inline/monoscriptbundle.serializedfile b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_Inline/monoscriptbundle.serializedfile new file mode 100644 index 0000000..e66ce68 Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_Inline/monoscriptbundle.serializedfile differ diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v23_Inline/packedassets_assets_all.bundle b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_Inline/packedassets_assets_all.bundle new file mode 100644 index 0000000..b157255 Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_Inline/packedassets_assets_all.bundle differ diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v23_Inline/prefab_with_serializedreference.serializedfile b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_Inline/prefab_with_serializedreference.serializedfile new file mode 100644 index 0000000..620fe4d Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_Inline/prefab_with_serializedreference.serializedfile differ diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/AssetBundle.typetreedata b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/AssetBundle.typetreedata new file mode 100644 index 0000000..9f43178 Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/AssetBundle.typetreedata differ diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/MonoScript_monoscripts_dde848dc9848681e340a8b4fa9bd7578.bundle b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/MonoScript_monoscripts_dde848dc9848681e340a8b4fa9bd7578.bundle new file mode 100644 index 0000000..e1f6474 Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/MonoScript_monoscripts_dde848dc9848681e340a8b4fa9bd7578.bundle differ diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/monoscriptbundle.serializedfile b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/monoscriptbundle.serializedfile new file mode 100644 index 0000000..e08c2b2 Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/monoscriptbundle.serializedfile differ diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/packedassets_assets_all.bundle b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/packedassets_assets_all.bundle new file mode 100644 index 0000000..08b813d Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/packedassets_assets_all.bundle differ diff --git a/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/prefab_with_serializedreference.serializedfile b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/prefab_with_serializedreference.serializedfile new file mode 100644 index 0000000..81bd8f4 Binary files /dev/null and b/TestCommon/Data/AssetBundleTypeTreeVariations/v23_extracted/prefab_with_serializedreference.serializedfile differ diff --git a/TestCommon/Data/LegacyFormats/alienprefab b/TestCommon/Data/LegacyFormats/AssetBundles/alienprefab similarity index 100% rename from TestCommon/Data/LegacyFormats/alienprefab rename to TestCommon/Data/LegacyFormats/AssetBundles/alienprefab diff --git a/TestCommon/Data/LegacyFormats/AssetBundles/v22.scene.bundle b/TestCommon/Data/LegacyFormats/AssetBundles/v22.scene.bundle new file mode 100644 index 0000000..65d6b73 Binary files /dev/null and b/TestCommon/Data/LegacyFormats/AssetBundles/v22.scene.bundle differ diff --git a/TestCommon/Data/LegacyFormats/v22_strippedVersion b/TestCommon/Data/LegacyFormats/v22_strippedVersion new file mode 100644 index 0000000..35d8bba Binary files /dev/null and b/TestCommon/Data/LegacyFormats/v22_strippedVersion differ diff --git a/TextDumper/TextDumper.csproj b/TextDumper/TextDumper.csproj index 1e54dd1..a5624d3 100644 --- a/TextDumper/TextDumper.csproj +++ b/TextDumper/TextDumper.csproj @@ -17,6 +17,7 @@ + diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index 4846c71..8f5da2c 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Text; +using UnityDataTools.BinaryFormat; using UnityDataTools.FileSystem; namespace UnityDataTools.TextDumper; @@ -19,10 +20,15 @@ public int Dump(string path, string outputPath, bool skipLargeArrays, long objec try { - try + if (!File.Exists(path)) { - // Try the input as an unity archive, e.g. an AssetBundle - // In that case we dump each serialized file contained inside it. + Console.WriteLine($"Error: File not found: {path}"); + return 1; + } + + if (ArchiveDetector.IsUnityArchive(path)) + { + // The input is a Unity archive (e.g. AssetBundle); dump each serialized file inside it. using var archive = UnityFileSystem.MountArchive(path, "/"); foreach (var node in archive.Nodes) { @@ -37,20 +43,49 @@ public int Dump(string path, string outputPath, bool skipLargeArrays, long objec } } } - catch (NotSupportedException) + else if (YamlSerializedFileDetector.IsYamlSerializedFile(path)) + { + Console.WriteLine("Error: The file is a YAML-format SerializedFile, which is not supported."); + Console.WriteLine("UnityDataTool only supports binary-format SerializedFiles."); + return 1; + } + else if (SerializedFileDetector.TryDetectSerializedFile(path, out _)) { - // Try as SerializedFile - using (m_Writer = new StreamWriter(Path.Combine(outputPath, Path.GetFileName(path) + ".txt"), false)) + // The input is a binary SerializedFile; dump it directly. + try { - OutputSerializedFile(path, objectId); + using (m_Writer = new StreamWriter(Path.Combine(outputPath, Path.GetFileName(path) + ".txt"), false)) + { + OutputSerializedFile(path, objectId); + } + } + catch (SerializedFileOpenException) + { + var hint = SerializedFileDetector.GetOpenFailureHint(path); + if (hint != null) + { + Console.WriteLine(); + Console.WriteLine(hint); + } + return 1; + } + catch (Exception e) + { + Console.WriteLine($"Error: {e.GetType()}: {e.Message}"); + Console.WriteLine(e.StackTrace); + return 1; } } + else + { + Console.WriteLine("Error: The file does not appear to be a valid Unity SerializedFile or Unity Archive."); + Console.WriteLine($"File: {path}"); + return 1; + } } catch (Exception e) { - Console.WriteLine("Error!"); - Console.Write($"{e.GetType()}: "); - Console.WriteLine(e.Message); + Console.WriteLine($"Error: {e.GetType()}: {e.Message}"); Console.WriteLine(e.StackTrace); return 1; } @@ -58,6 +93,7 @@ public int Dump(string path, string outputPath, bool skipLargeArrays, long objec return 0; } + void RecursiveDump(TypeTreeNode node, ref long offset, int level, int arrayIndex = -1) { bool skipChildren = false; diff --git a/Analyzer/Util/ArchiveDetector.cs b/UnityBinaryFormat/ArchiveDetector.cs similarity index 98% rename from Analyzer/Util/ArchiveDetector.cs rename to UnityBinaryFormat/ArchiveDetector.cs index 7ce6914..2658362 100644 --- a/Analyzer/Util/ArchiveDetector.cs +++ b/UnityBinaryFormat/ArchiveDetector.cs @@ -1,7 +1,7 @@ using System; using System.IO; -namespace UnityDataTools.Analyzer.Util; +namespace UnityDataTools.BinaryFormat; /// /// Utility for detecting Unity Archive (AssetBundle) files by reading their signature. diff --git a/UnityBinaryFormat/BinaryFileHelper.cs b/UnityBinaryFormat/BinaryFileHelper.cs new file mode 100644 index 0000000..3349471 --- /dev/null +++ b/UnityBinaryFormat/BinaryFileHelper.cs @@ -0,0 +1,134 @@ +using System; +using System.IO; +using System.Text; + +namespace UnityDataTools.BinaryFormat; + +/// +/// A 128-bit hash stored as four 32-bit unsigned integers, matching Unity's Hash128 binary layout. +/// +public readonly struct UnityHash128 +{ + public uint Data0 { get; init; } + public uint Data1 { get; init; } + public uint Data2 { get; init; } + public uint Data3 { get; init; } + + public bool IsZero => Data0 == 0 && Data1 == 0 && Data2 == 0 && Data3 == 0; + + public override string ToString() => $"{Data0:x8}{Data1:x8}{Data2:x8}{Data3:x8}"; +} + +/// +/// Helpers for reading primitive types and Unity-specific data from binary streams and byte +/// arrays, with optional endianness swapping. +/// +public static class BinaryFileHelper +{ + // ----------------------------------------------------------------------- + // Stream / BinaryReader helpers + // ----------------------------------------------------------------------- + + /// Advances the stream to the next 4-byte boundary measured from . + public static void AlignTo4(Stream stream, long baseOffset) + { + long rel = stream.Position - baseOffset; + long aligned = (rel + 3) & ~3L; + stream.Position = baseOffset + aligned; + } + + /// Reads a null-terminated ASCII string from the stream. + public static string ReadNullTermString(BinaryReader reader) + { + var sb = new StringBuilder(); + byte b; + while ((b = reader.ReadByte()) != 0) + sb.Append((char)b); + return sb.ToString(); + } + + public static int ReadInt32(BinaryReader reader, bool swap) + { + uint raw = reader.ReadUInt32(); + return (int)(swap ? SwapUInt32(raw) : raw); + } + + public static short ReadInt16(BinaryReader reader, bool swap) + { + ushort raw = reader.ReadUInt16(); + if (swap) + raw = (ushort)((raw << 8) | (raw >> 8)); + return (short)raw; + } + + public static uint ReadUInt32(BinaryReader reader, bool swap) + { + uint raw = reader.ReadUInt32(); + return swap ? SwapUInt32(raw) : raw; + } + + public static ulong ReadUInt64(BinaryReader reader, bool swap) + { + ulong raw = reader.ReadUInt64(); + return swap ? SwapUInt64(raw) : raw; + } + + public static long ReadInt64(BinaryReader reader, bool swap) + { + ulong raw = reader.ReadUInt64(); + return (long)(swap ? SwapUInt64(raw) : raw); + } + + public static UnityHash128 ReadHash128(BinaryReader reader, bool swap) + { + return new UnityHash128 + { + Data0 = ReadUInt32(reader, swap), + Data1 = ReadUInt32(reader, swap), + Data2 = ReadUInt32(reader, swap), + Data3 = ReadUInt32(reader, swap), + }; + } + + // ----------------------------------------------------------------------- + // Byte-array helpers + // ----------------------------------------------------------------------- + + /// Reads a UInt32 from a byte array at the specified offset, optionally swapping endianness. + public static uint ReadUInt32(byte[] buffer, int offset, bool swap) + { + uint value = BitConverter.ToUInt32(buffer, offset); + return swap ? SwapUInt32(value) : value; + } + + /// Reads a UInt64 from a byte array at the specified offset, optionally swapping endianness. + public static ulong ReadUInt64(byte[] buffer, int offset, bool swap) + { + ulong value = BitConverter.ToUInt64(buffer, offset); + return swap ? SwapUInt64(value) : value; + } + + // ----------------------------------------------------------------------- + // Byte-swap utilities + // ----------------------------------------------------------------------- + + public static uint SwapUInt32(uint value) + { + return ((value & 0x000000FFU) << 24) | + ((value & 0x0000FF00U) << 8) | + ((value & 0x00FF0000U) >> 8) | + ((value & 0xFF000000U) >> 24); + } + + public static ulong SwapUInt64(ulong value) + { + return ((value & 0x00000000000000FFUL) << 56) | + ((value & 0x000000000000FF00UL) << 40) | + ((value & 0x0000000000FF0000UL) << 24) | + ((value & 0x00000000FF000000UL) << 8) | + ((value & 0x000000FF00000000UL) >> 8) | + ((value & 0x0000FF0000000000UL) >> 24) | + ((value & 0x00FF000000000000UL) >> 40) | + ((value & 0xFF00000000000000UL) >> 56); + } +} diff --git a/Analyzer/Util/GuidHelper.cs b/UnityBinaryFormat/GuidHelper.cs similarity index 97% rename from Analyzer/Util/GuidHelper.cs rename to UnityBinaryFormat/GuidHelper.cs index f7a6bd0..8bacf69 100644 --- a/Analyzer/Util/GuidHelper.cs +++ b/UnityBinaryFormat/GuidHelper.cs @@ -1,4 +1,4 @@ -namespace UnityDataTools.Analyzer.Util; +namespace UnityDataTools.BinaryFormat; /// /// Helper class for converting Unity GUID data to string format. @@ -42,4 +42,3 @@ private static void FormatUInt32Reversed(uint value, char[] output, int offset) } } } - diff --git a/UnityBinaryFormat/SerializedFileDetector.cs b/UnityBinaryFormat/SerializedFileDetector.cs new file mode 100644 index 0000000..700da24 --- /dev/null +++ b/UnityBinaryFormat/SerializedFileDetector.cs @@ -0,0 +1,846 @@ +using System; +using System.IO; +using ExternalReference = UnityDataTools.FileSystem.ExternalReference; +using ExternalReferenceType = UnityDataTools.FileSystem.ExternalReferenceType; +using ObjectInfo = UnityDataTools.FileSystem.ObjectInfo; + +namespace UnityDataTools.BinaryFormat; + +/// +/// Information extracted from a Unity SerializedFile header. +/// +public class SerializedFileInfo +{ + public uint Version { get; set; } + public ulong FileSize { get; set; } + public ulong MetadataSize { get; set; } + public ulong DataOffset { get; set; } + public byte Endianness { get; set; } + public bool IsLegacyFormat { get; set; } +} + +/// +/// Summary information about a single TypeTree entry within a SerializedFile metadata section. +/// Does not contain the full TypeTree node graph — only the per-entry header fields. +/// +/// Each entry corresponds to one element of either the regular type list (m_Types) or the +/// SerializeReference type list (m_RefTypes, version >= 20). Fields that are not applicable +/// for a given entry use well-defined sentinel values: +/// - UnityHash128 fields use IsZero == true to indicate "not present" +/// - short ScriptTypeIndex uses -1 to indicate "not a script type" +/// - string fields (ClassName, Namespace, AssemblyName) use string.Empty for regular type entries +/// - TypeDependencies uses an empty array for ref type entries or version < 21 +/// +public class TypeTreeInfo +{ + // ----------------------------------------------------------------------- + // Fields present for all versions >= 16 (kRefactoredClassId) + // ----------------------------------------------------------------------- + + /// + /// Unity ClassID for this type (e.g. 114 = MonoBehaviour, 115 = MonoScript). + /// Corresponds to m_PersistentTypeID in the file. For ref type entries this is -1 + /// and the type is identified by the ClassName/Namespace/AssemblyName triple instead. + /// + public int PersistentTypeID { get; set; } + + /// + /// True for types that represent Prefab-stripped objects. In text/YAML files this corresponds to the stripped keyword: + /// e.g. --- !u!123 &111 stripped. + /// + /// This field is not related to the presence of a TypeTree blob in the file. + public bool IsStrippedType { get; set; } + + /// + /// Index into the file's script type list (m_ScriptTypes). + /// -1 (sentinel) means this entry is not backed by a MonoScript (i.e. a native Unity type). + /// + public short ScriptTypeIndex { get; set; } = -1; + + // ----------------------------------------------------------------------- + // Hash fields (version >= 13, kHasTypeTreeHashes) + // ----------------------------------------------------------------------- + + /// + /// MD4 hash of (assembly name + namespace + class name) identifying the script. + /// Written for MonoBehaviour (ClassID 114), unknown script types, and entries where + /// ScriptTypeIndex >= 0. IsZero == true indicates this field is not applicable for + /// this entry (native type with no associated MonoScript). + /// + public UnityHash128 ScriptID { get; set; } + + /// + /// Hash of the TypeTree structure (field names, types, byte sizes, alignment flags), + /// computed via MD4 over the node graph. Used to detect type schema changes between + /// the version stored in the file and the current runtime type, and to deduplicate + /// type entries when writing serialized files. Sometimes referred to as the "OldTypeHash" + /// because it refers to the type at the time it was serialized, which might be older than + /// the current type. + /// Used for compatibility checking at load time. + /// + public UnityHash128 TypeTreeStructureHash { get; set; } + + // ----------------------------------------------------------------------- + // TypeTree inline/extracted data (only when EnableTypeTree = true) + // ----------------------------------------------------------------------- + + /// + /// XXH3 content hash of the TypeTree blob, e.g. hash of the raw binary encoding + /// of the TypeTree definition. Stored explicitly in the metadata for + /// version >= 23 (kExtractedTypeTreeSupport). IsZero == true indicates this field + /// was not present in the metadata (version < 23 or no inline TypeTree). + /// This is used for TypeTree deduplication and caching. + /// + public UnityHash128 TypeTreeContentHash { get; set; } + + /// + /// Actual size in bytes of the TypeTree blob for this entry. + /// 0 when InlineTypeTree is false (stripped, EnableTypeTree=false, or extracted to + /// an external store in version >= 23). For version < 23 where the size is not + /// stored explicitly, this is computed by skipping over the blob during parsing. + /// + public uint TypeTreeSerializedSize { get; set; } + + /// + /// True when the TypeTree blob is present inline in this file's metadata and can be + /// read without an external TypeTree store. False when EnableTypeTree is false, or + /// TypeTreeSerializedSize is 0 (blob extracted to an external store, version >= 23). + /// Note: IsStrippedType is orthogonal and does not affect TypeTree presence. + /// + public bool InlineTypeTree { get; set; } + + // ----------------------------------------------------------------------- + // Ref-type identification (only for entries in SerializedReferenceTypeTrees, + // version >= 20) + // ----------------------------------------------------------------------- + + /// + /// C# class name of the SerializeReference type. + /// string.Empty for regular (non-ref) type entries. + /// + public string ClassName { get; set; } = string.Empty; + + /// + /// C# namespace of the SerializeReference type. + /// string.Empty for regular (non-ref) type entries. + /// + public string Namespace { get; set; } = string.Empty; + + /// + /// Assembly name of the SerializeReference type. + /// string.Empty for regular (non-ref) type entries. + /// + public string AssemblyName { get; set; } = string.Empty; + + // ----------------------------------------------------------------------- + // Non-ref type dependency list (only for entries in TypeTrees, + // version >= 21, kStoresTypeDependencies) + // ----------------------------------------------------------------------- + + /// + /// Indices into the SerializedReferenceTypeTrees array representing the + /// SerializeReference types that objects of this type may reference. + /// Empty array for ref type entries or files with version < 21. + /// + public int[] TypeDependencies { get; set; } = Array.Empty(); +} + +/// +/// A reference to a MonoScript object that backs a C# MonoBehaviour type recorded in this file. +/// Corresponds to one entry in the file's m_ScriptTypes list. +/// +public class ScriptType +{ + /// + /// Index into the file's external references list, identifying which SerializedFile contains + /// the MonoScript object. 0 = this file itself; 1+ = 1-based index into the externals list. + /// + public int FileID { get; set; } + + /// + /// The object ID (localIdentifierInFile) of the MonoScript within the identified file. + /// + public long PathID { get; set; } +} + +/// +/// Information extracted from the beginning of a Unity SerializedFile metadata section. +/// +public class SerializedFileMetadata +{ + public string UnityVersion { get; set; } + public uint TargetPlatform { get; set; } + public bool EnableTypeTree { get; set; } + + /// + /// Number of regular (object) TypeTree entries (m_Types). + /// Populated even when TypeTrees is null. + /// + public int TypeTreeCount { get; set; } + + /// + /// Number of SerializeReference TypeTree entries (m_RefTypes). + /// Always 0 for files with version < 20 (kSupportsRefObject). + /// + public int SerializedReferenceTypeTreeCount { get; set; } + + /// + /// Summary of each regular type entry. Null until the TypeTree section has been parsed. + /// + public TypeTreeInfo[] TypeTrees { get; set; } + + /// + /// Summary of each SerializeReference type entry. + /// Empty array for files with version < 20. + /// + public TypeTreeInfo[] SerializedReferenceTypeTrees { get; set; } + + /// + /// List of MonoScript references for the C# types used in this file. + /// Each entry points to the MonoScript object (in this file or an external file) that backs + /// a C# MonoBehaviour-derived type whose ScriptTypeIndex is the index of that entry here. + /// Null until the metadata section has been parsed. + /// + public ScriptType[] ScriptTypes { get; set; } + + /// + /// List of all objects recorded in the file's object table. + /// Null until the metadata section has been parsed. + /// + public ObjectInfo[] ObjectList { get; set; } + + /// + /// List of external file references recorded in the file's externals table. + /// Null until the metadata section has been parsed. + /// + public ExternalReference[] ExternalReferences { get; set; } +} + +/// +/// Utility for detecting Unity SerializedFile format by reading and validating the file header. +/// +/// Unity SerializedFiles have evolved through several format versions: +/// +/// Version < 9: +/// - 20-byte header (SerializedFileHeader32) with 32-bit offsets/sizes +/// - Layout: [header][data][metadata] +/// - Endianness byte stored at END of file, just before metadata +/// +/// Version 9-21: +/// - 20-byte header (SerializedFileHeader32) with 32-bit offsets/sizes +/// - Layout: [header][metadata][data] +/// - Endianness byte at offset 16 in header +/// - Limited to 4GB file sizes +/// +/// Version >= 22 (kLargeFilesSupport): +/// - 48-byte header (SerializedFileHeader) with 64-bit offsets/sizes +/// - Layout: [header][metadata][data] +/// - Endianness byte at offset 40 in header +/// - Supports files larger than 4GB +/// +/// Important: The header itself is always stored in big-endian format on disk, +/// but the m_Endianness byte indicates the endianness of the actual data section. +/// +/// DEPRECATION WARNING: The deprecation process for Version <18 (Unity 5.5 and earlier) has started in Unity 6.5. +/// Initially this will be a warning, but upcoming versions of UnityDataTool and UnityFileSystem can be expected +/// to lose the ability to open and read those files (apart from low level information exposed by the +/// "serialized-file header" command). +/// +public static class SerializedFileDetector +{ + // Version boundaries for format changes + // NOTE: This version is so old that it is extremely unlikely it will work with modern versions of Unity, + // we handle it just for the purpose of trying to report accurate information about the file. + private const uint NewLayoutVersion = 9; // Changed from [header][data][metadata] to [header][metadata][data] + + private const uint LargeFilesSupportVersion = 22; // Changed to 64-bit header + + // Minimum version for metadata section parsing (kTypeTreeNodeWithTypeFlags = 19, Unity 2019.1). + // Older files have format differences that we do not attempt to support. + private const uint MinMetadataParseVersion = 19; + + // Maximum version for metadata section parsing (kExtractedTypeTreeSupport = 23, Unity 6000.4). + // Files newer than this version may have an unknown format and cannot be parsed safely. + private const uint MaxMetadataParseVersion = 23; + + // Reasonable version range for SerializedFiles + // Unity versions currently use values in the 20s-30s range + private const uint MinVersion = 1; + private const uint MaxVersion = 50; + + // Endianness values (only little-endian is supported in Unity 2023+) + private const byte LittleEndian = 0; + private const byte BigEndian = 1; + + // Header sizes + private const int LegacyHeaderSize = 20; // SerializedFileHeader32 + private const int ModernHeaderSize = 48; // SerializedFileHeader + + // TypeTree section version boundaries + private const uint SupportsRefObjectVersion = 20; // m_RefTypes list (appears after externals) + private const uint StoresTypeDependenciesVersion = 21; // Per-type dependency list added + private const uint ExtractedTypeTreeSupportVersion = 23; // TypeTree blob may be extracted externally + + // Per-type-entry constants + private const int MonoBehaviourClassID = 114; // persistentTypeID for MonoBehaviour + private const int UndefinedPersistentTypeID = -1; // persistentTypeID for types with no known ClassID + private const uint TypeTreeNodeSize = 32; // Bytes per node in the blob (version >= 18) + + /// + /// Attempts to detect if a file is a Unity SerializedFile by reading and validating its header. + /// Returns false immediately if the file doesn't match the expected format. + /// + /// Path to the file to check + /// If successful, contains header information + /// True if file appears to be a valid SerializedFile, false otherwise + public static bool TryDetectSerializedFile(string filePath, out SerializedFileInfo info) + { + info = null; + + if (!File.Exists(filePath)) + return false; + + try + { + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + long fileLength = stream.Length; + + // Quick rejection: file must be at least large enough for the legacy header + if (fileLength < LegacyHeaderSize) + return false; + + // Read enough bytes to cover a modern header (48 bytes) + // We'll determine which format to parse based on the version field + byte[] headerBytes = new byte[ModernHeaderSize]; + int bytesRead = stream.Read(headerBytes, 0, headerBytes.Length); + + if (bytesRead < LegacyHeaderSize) + return false; + + // ============================================================ + // STEP 1: Read version to determine header format + // ============================================================ + + // The version field is always at offset 8 in both header formats. + // The header itself is always stored in big-endian format on disk. + // On little-endian platforms (Windows, etc.), we need to swap the header fields. + // + // We try both interpretations to determine if swapping is needed: + uint versionLE = BitConverter.ToUInt32(headerBytes, 8); + uint versionBE = BinaryFileHelper.SwapUInt32(versionLE); + + // Determine which interpretation gives us a valid version number + uint version; + bool needsSwap; // Whether header fields need byte swapping (expected to be true when running on most modern systems, which are little-endian) + + if (versionLE >= MinVersion && versionLE <= MaxVersion) + { + // Reading as little-endian gives valid version (header is in little-endian format) + version = versionLE; + needsSwap = false; + } + else if (versionBE >= MinVersion && versionBE <= MaxVersion) + { + // Reading as big-endian gives valid version (header is in big-endian format) + version = versionBE; + needsSwap = true; + } + else + { + // Neither interpretation gives a valid version + return false; + } + + // Determine header format based on version + bool isLegacyFormat = version < LargeFilesSupportVersion; + + // ============================================================ + // STEP 2: Read endianness byte + // ============================================================ + // + // The m_Endianness byte indicates the endianness of the DATA section + // (not the header, which is always big-endian on disk). + // Location depends on version: + // - Version < 9: At end of file (before metadata) - we skip reading it for detection + // - Version 9-21: At offset 16 in the 20-byte header + // - Version >= 22: At offset 40 in the 48-byte header + // + // The endianness byte is never swapped (it's a single byte). + + byte endianness; + + if (version < NewLayoutVersion) + { + // Version < 9: Endianness is at the end of the file + // For detection purposes, we infer it from the header byte order + // (though this is technically the header's endianness, not the data's) + endianness = needsSwap ? BigEndian : LittleEndian; + } + else if (isLegacyFormat) + { + // Version 9-21: Endianness is at offset 16 in SerializedFileHeader32 + if (bytesRead >= 17) + { + endianness = headerBytes[16]; + + // Validate endianness value + if (endianness != LittleEndian && endianness != BigEndian) + return false; + } + else + { + return false; // File truncated + } + } + else + { + // Version >= 22: Endianness is at offset 40 in SerializedFileHeader + if (bytesRead >= 41) + { + endianness = headerBytes[40]; + + // Validate endianness value + if (endianness != LittleEndian && endianness != BigEndian) + return false; + } + else + { + return false; // File truncated + } + } + + // ============================================================ + // STEP 3: Parse the appropriate header format + // ============================================================ + + ulong metadataSize, fileSize, dataOffset; + + if (isLegacyFormat) + { + // SerializedFileHeader32 Layout (20 bytes total): + // Offset 0-3: UInt32 m_MetadataSize + // Offset 4-7: UInt32 m_FileSize + // Offset 8-11: UInt32 m_Version + // Offset 12-15: UInt32 m_DataOffset + // Offset 16: UInt8 m_Endianness (only present for version >= 9) + // Offset 17-19: UInt8 m_Reserved[3] + // + // Note: For version < 9, m_Endianness is NOT in the header. + // It's stored at the end of the file, just before metadata begins. + + uint metadataSize32 = BinaryFileHelper.ReadUInt32(headerBytes, 0, needsSwap); + uint fileSize32 = BinaryFileHelper.ReadUInt32(headerBytes, 4, needsSwap); + uint dataOffset32 = BinaryFileHelper.ReadUInt32(headerBytes, 12, needsSwap); + + // Convert to 64-bit for consistency + metadataSize = metadataSize32; + fileSize = fileSize32; + dataOffset = dataOffset32; + + // Special case: Legacy format used UInt32.MaxValue to indicate "unknown" file size + if (fileSize32 == uint.MaxValue) + { + fileSize = ulong.MaxValue; + } + } + else + { + // SerializedFileHeader Layout (48 bytes total): + // Offset 0-7: UInt8[8] m_Legacy (unused, allows struct alignment with SerializedFileHeader32) + // Offset 8-11: UInt32 m_Version + // Offset 12-15: UInt8[4] m_Reserved0 (explicit padding) + // Offset 16-23: UInt64 m_MetadataSize + // Offset 24-31: UInt64 m_FileSize + // Offset 32-39: UInt64 m_DataOffset + // Offset 40: UInt8 m_Endianness + // Offset 41-47: UInt8[7] m_Reserved1 + + metadataSize = BinaryFileHelper.ReadUInt64(headerBytes, 16, needsSwap); + fileSize = BinaryFileHelper.ReadUInt64(headerBytes, 24, needsSwap); + dataOffset = BinaryFileHelper.ReadUInt64(headerBytes, 32, needsSwap); + } + + // ============================================================ + // STEP 4: Validate header consistency + // ============================================================ + + // MetadataSize must not be the sentinel value (indicates corruption) + if (metadataSize == ulong.MaxValue) + return false; + + // DataOffset must be within the file size + if (fileSize != ulong.MaxValue && dataOffset > fileSize) + return false; + + // FileSize should roughly match actual file size + // Allow some tolerance for "stream files" which can have padding + if (fileSize != ulong.MaxValue) + { + // File size should not exceed actual file size by more than 1KB (arbitrary tolerance) + if (fileSize > (ulong)fileLength + 1024) + return false; + } + + // MetadataSize should be reasonable (not larger than the file itself) + if (metadataSize > (ulong)fileLength) + return false; + + // ============================================================ + // STEP 5: Populate and return info + // ============================================================ + + info = new SerializedFileInfo + { + Version = version, + FileSize = fileSize, + MetadataSize = metadataSize, + DataOffset = dataOffset, + Endianness = endianness, + IsLegacyFormat = isLegacyFormat + }; + + return true; + } + catch + { + // Any exception during reading/parsing means this isn't a valid SerializedFile + return false; + } + } + + /// + /// Parses the metadata section from a previously-validated SerializedFile. + /// + /// The metadata starts immediately after the file header: + /// - Legacy format (version 9-21): header is 20 bytes + /// - Modern format (version >= 22): header is 48 bytes + /// + /// The metadata content is written in the endianness indicated by headerInfo.Endianness. + /// All multi-byte integer fields are byte-swapped when that value is BigEndian (1). + /// + /// Path to the SerializedFile (must already have passed TryDetectSerializedFile) + /// Header info from a prior successful TryDetectSerializedFile call + /// On success, the parsed metadata; null on failure + /// On failure, a description of what went wrong; null on success + /// True if at least the initial metadata fields were successfully parsed + public static bool TryParseMetadata(string filePath, SerializedFileInfo headerInfo, out SerializedFileMetadata metadata, out string errorMessage) + { + metadata = null; + errorMessage = null; + + // Only support version >= 19 (Unity 2019.1). Older files have metadata format + // differences we have not implemented. + if (headerInfo.Version < MinMetadataParseVersion) + { + errorMessage = $"Metadata parsing is not supported for SerializedFile version {headerInfo.Version}. " + + $"Version {MinMetadataParseVersion} (Unity 2019.1) or newer is required."; + return false; + } + + // Reject versions beyond the highest known format. Future Unity versions may change the + // metadata layout in ways that would cause incorrect results or a parse failure. + // A newer version of UnityDataTool is required to read these files. + if (headerInfo.Version > MaxMetadataParseVersion) + { + errorMessage = $"SerializedFile version {headerInfo.Version} is not supported. " + + $"UnityDataTool supports up to version {MaxMetadataParseVersion}. " + + $"Please use a newer version of UnityDataTool to read this file."; + return false; + } + + try + { + long metadataOffset = headerInfo.IsLegacyFormat ? LegacyHeaderSize : ModernHeaderSize; + bool swap = headerInfo.Endianness == BigEndian; + + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + stream.Seek(metadataOffset, SeekOrigin.Begin); + using var reader = new BinaryReader(stream, System.Text.Encoding.ASCII, leaveOpen: true); + + // --- Field 1: Unity version string (null-terminated ASCII) --- + string unityVersion = BinaryFileHelper.ReadNullTermString(reader); + + // An empty or unusually long version string indicates a corrupt file. + // Even a stripped version string would be "0.0.0", not empty. + if (unityVersion.Length == 0 || unityVersion.Length > 64) + { + errorMessage = $"Unity version string has unexpected length ({unityVersion.Length})."; + return false; + } + + // --- Field 2: Target platform (uint32) --- + uint targetPlatform = BinaryFileHelper.ReadUInt32(reader, swap); + + // --- Field 3: Enable type tree flag (bool serialized as 1 byte) --- + bool enableTypeTree = reader.ReadByte() != 0; + + metadata = new SerializedFileMetadata + { + UnityVersion = unityVersion, + TargetPlatform = targetPlatform, + EnableTypeTree = enableTypeTree, + }; + + // Parse the rest of the metadata section. Protected by its own try/catch so that any + // failure there still returns a partially-populated metadata struct. + ParseExtendedMetadata(reader, headerInfo, swap, metadataOffset, metadata); + + return true; + } + catch + { + errorMessage = "An unexpected error occurred while parsing the metadata section."; + return false; + } + } + + /// + /// Returns a diagnostic hint explaining why a SerializedFile may have failed to open, + /// or null if no specific diagnosis is available. + /// Currently detects the common case of missing TypeTrees (player builds compiled + /// without type information, which the DLL reports as a generic unknown error). + /// + /// Real filesystem path to the file that failed to open. + public static string GetOpenFailureHint(string path) + { + if (TryDetectSerializedFile(path, out var fileInfo) && + TryParseMetadata(path, fileInfo, out var metadata, out _) && + !metadata.EnableTypeTree) + { + return "Note: This file does not have TypeTrees and can only be opened if all the " + + "types it uses exactly match the types in the build of UnityFileSystemApi being used."; + } + return null; + } + + /// + /// Parses the TypeTree and other arrays that are stored in the metadata, + /// + private static void ParseExtendedMetadata(BinaryReader reader, SerializedFileInfo headerInfo, + bool swap, long metadataOffset, SerializedFileMetadata metadata) + { + try + { + uint version = headerInfo.Version; + bool enableTypeTree = metadata.EnableTypeTree; + Stream stream = reader.BaseStream; + + // --- Regular type list (m_Types) --- + int typeCount = BinaryFileHelper.ReadInt32(reader, swap); + metadata.TypeTreeCount = typeCount; + + var typeTrees = new TypeTreeInfo[typeCount]; + for (int i = 0; i < typeCount; i++) + typeTrees[i] = ReadTypeEntry(reader, version, swap, isRefType: false, enableTypeTree); + metadata.TypeTrees = typeTrees; + + // --- Object list --- + // Per-object layout (version >= 19): + // [4-byte alignment relative to metadata start] + // [int64 fileID] + // [uint32 byteStart] or [uint64 byteStart] (version >= 22) + // [uint32 byteSize] + // [uint32 typeID] + int objectCount = BinaryFileHelper.ReadInt32(reader, swap); + var objectList = new ObjectInfo[objectCount]; + for (int i = 0; i < objectCount; i++) + { + BinaryFileHelper.AlignTo4(stream, metadataOffset); + long fileID = BinaryFileHelper.ReadInt64(reader, swap); + // byteStart is relative to the data section; add DataOffset to get the absolute file offset, + // matching the behaviour of the native DLL which returns the absolute offset in ObjectInfo.Offset. + long byteStart = version >= LargeFilesSupportVersion + ? (long)BinaryFileHelper.ReadUInt64(reader, swap) + : BinaryFileHelper.ReadUInt32(reader, swap); + byteStart += (long)headerInfo.DataOffset; + long byteSize = BinaryFileHelper.ReadUInt32(reader, swap); + // typeIndex is a 0-based index into the m_Types array, not the persistent type ID. + // Resolve it to the persistent type ID to match the behaviour of the native DLL. + int typeIndex = BinaryFileHelper.ReadInt32(reader, swap); + int persistentTypeID = (typeIndex >= 0 && typeIndex < typeTrees.Length) + ? typeTrees[typeIndex].PersistentTypeID + : typeIndex; + objectList[i] = new ObjectInfo(fileID, byteStart, byteSize, persistentTypeID); + } + metadata.ObjectList = objectList; + + // --- Script type list --- + // Each entry points to the MonoScript object that backs a C# MonoBehaviour-derived type. + // Per-entry layout (version >= 14, applies to all our versions): + // [int32 localSerializedFileIndex] (FileID: 0 = this file, 1+ = external ref index) + // [4-byte alignment relative to metadata start] + // [int64 localIdentifierInFile] (PathID: object ID within the identified file) + int scriptTypeCount = BinaryFileHelper.ReadInt32(reader, swap); + var scriptTypes = new ScriptType[scriptTypeCount]; + for (int i = 0; i < scriptTypeCount; i++) + { + int fileID = BinaryFileHelper.ReadInt32(reader, swap); + BinaryFileHelper.AlignTo4(stream, metadataOffset); + long pathID = BinaryFileHelper.ReadInt64(reader, swap); + scriptTypes[i] = new ScriptType { FileID = fileID, PathID = pathID }; + } + metadata.ScriptTypes = scriptTypes; + + // --- External references list --- + // Per-entry layout: + // [null-terminated string tempEmpty] + // [uint32[4] guid] (16 bytes) + // [int32 type] + // [null-terminated string pathName] + int externalsCount = BinaryFileHelper.ReadInt32(reader, swap); + var externalRefs = new ExternalReference[externalsCount]; + for (int i = 0; i < externalsCount; i++) + { + BinaryFileHelper.ReadNullTermString(reader); // tempEmpty (empty in practice) + var guid = BinaryFileHelper.ReadHash128(reader, swap); + int typeInt = BinaryFileHelper.ReadInt32(reader, swap); + string pathName = BinaryFileHelper.ReadNullTermString(reader); + externalRefs[i] = new ExternalReference + { + Path = pathName, + Guid = guid.ToString(), + Type = (ExternalReferenceType)typeInt, + }; + } + metadata.ExternalReferences = externalRefs; + + // m_RefTypes (version >= 20) is not located immediately after m_Types. + // It appears at the end of the metadata section + if (version < SupportsRefObjectVersion) + return; + + // --- SerializeReference type list (m_RefTypes, version >= 20) --- + int refTypeCount = BinaryFileHelper.ReadInt32(reader, swap); + metadata.SerializedReferenceTypeTreeCount = refTypeCount; + + var refTypeTrees = new TypeTreeInfo[refTypeCount]; + for (int i = 0; i < refTypeCount; i++) + refTypeTrees[i] = ReadTypeEntry(reader, version, swap, isRefType: true, enableTypeTree); + metadata.SerializedReferenceTypeTrees = refTypeTrees; + } + catch + { + // Best-effort: leave metadata partially populated with whatever was parsed + // successfully before the failure. + } + } + + /// + /// Reads one type entry from the metadata stream into a . + /// Advances the stream past all fields, including the TypeTree blob when present. + /// + /// Per-entry layout: + /// [int32 persistentTypeID] + /// [uint8 isStrippedType] + /// [int16 scriptTypeIndex] + /// [Hash128 scriptID] (conditional — see below) + /// [Hash128 oldTypeHash] + /// if enableTypeTree: + /// if version >= 23: + /// [Hash128 typeTreeContentHash] + /// [uint32 typeTreeSize] (0 = blob extracted to external store) + /// [TypeTree blob] (present when version < 23 or typeTreeSize > 0) + /// if version >= 21: + /// if isRefType: [string className] [string nameSpace] [string asmName] + /// else: [int32 depCount] [int32 * depCount] + /// + private static TypeTreeInfo ReadTypeEntry(BinaryReader reader, uint version, bool swap, + bool isRefType, bool enableTypeTree) + { + var info = new TypeTreeInfo(); + Stream stream = reader.BaseStream; + + // persistentTypeID: the Unity ClassID. -1 (UndefinedPersistentTypeID) when the + // class has no known built-in ClassID (e.g. an unresolved script type). + info.PersistentTypeID = BinaryFileHelper.ReadInt32(reader, swap); + + // isStrippedType: true when the type definition was stripped from the build. + // Objects of a stripped type cannot be fully deserialized without a matching runtime. + info.IsStrippedType = reader.ReadByte() != 0; + + // scriptTypeIndex: index into the file's MonoScript reference list. -1 = not a script type. + info.ScriptTypeIndex = BinaryFileHelper.ReadInt16(reader, swap); + + // scriptID is a 128-bit hash identifying a MonoScript (MD4 of assembly + namespace + class name). + // It is present for: + // - Types with no known ClassID (persistentTypeID == UndefinedPersistentTypeID) + // - MonoBehaviour types (persistentTypeID == 114) + // - Script-backed types (scriptTypeIndex >= 0) + // + // Historical note: files written before Unity 2018.3.0a1 omitted scriptID when + // scriptTypeIndex >= 0. All files this parser supports are version >= 19 (Unity 2019.1+), + // so that historical case never applies here. + bool hasScriptID = info.PersistentTypeID == UndefinedPersistentTypeID + || info.PersistentTypeID == MonoBehaviourClassID + || info.ScriptTypeIndex >= 0; + if (hasScriptID) + info.ScriptID = BinaryFileHelper.ReadHash128(reader, swap); + + // oldTypeHash: always present. Hash of the TypeTree content as originally written. + info.TypeTreeStructureHash = BinaryFileHelper.ReadHash128(reader, swap); + + if (!enableTypeTree) + return info; + + // --- TypeTree blob --- + + uint typeTreeSize = 0; + if (version >= ExtractedTypeTreeSupportVersion) + { + // Version >= 23: a 20-byte prefix precedes the blob. + // typeTreeContentHash is used as a cache key for the TypeTree store. + // typeTreeSize == 0 means the blob was extracted to an external archive. + info.TypeTreeContentHash = BinaryFileHelper.ReadHash128(reader, swap); + typeTreeSize = BinaryFileHelper.ReadUInt32(reader, swap); + info.TypeTreeSerializedSize = typeTreeSize; + } + + bool blobPresent = version < ExtractedTypeTreeSupportVersion || typeTreeSize > 0; + if (blobPresent) + { + if (version < ExtractedTypeTreeSupportVersion) + { + // Versions 19-22: blob begins directly with [uint32 numberOfNodes][uint32 numberOfChars], + // followed by a flat array of 32-byte nodes and a packed string buffer. + uint numberOfNodes = BinaryFileHelper.ReadUInt32(reader, swap); + uint numberOfChars = BinaryFileHelper.ReadUInt32(reader, swap); + uint dataBytes = numberOfNodes * TypeTreeNodeSize + numberOfChars; + stream.Seek(dataBytes, SeekOrigin.Current); + // Record the total blob size including the 8-byte count header. + info.TypeTreeSerializedSize = 8 + dataBytes; + } + else + { + // Version >= 23 with inline blob: skip exactly typeTreeSize bytes. + // The blob starts with its own 8-byte magic+version prefix, followed by + // node count, char count, node array, and string buffer. + stream.Seek(typeTreeSize, SeekOrigin.Current); + } + info.InlineTypeTree = true; + } + + if (version >= StoresTypeDependenciesVersion) + { + if (isRefType) + { + // SerializeReference entries carry their type identity strings here. + info.ClassName = BinaryFileHelper.ReadNullTermString(reader); + info.Namespace = BinaryFileHelper.ReadNullTermString(reader); + info.AssemblyName = BinaryFileHelper.ReadNullTermString(reader); + } + else + { + // Regular type entries carry indices into the m_RefTypes pool, identifying + // which SerializeReference types objects of this type may hold. + int depCount = BinaryFileHelper.ReadInt32(reader, swap); + var deps = new int[depCount]; + for (int j = 0; j < depCount; j++) + deps[j] = BinaryFileHelper.ReadInt32(reader, swap); + info.TypeDependencies = deps; + } + } + + return info; + } + +} diff --git a/UnityBinaryFormat/UnityBinaryFormat.csproj b/UnityBinaryFormat/UnityBinaryFormat.csproj new file mode 100644 index 0000000..bc0f6f2 --- /dev/null +++ b/UnityBinaryFormat/UnityBinaryFormat.csproj @@ -0,0 +1,21 @@ + + + + Library + net9.0 + latest + + + + AnyCPU + + + + AnyCPU + + + + + + + diff --git a/Analyzer/Util/YamlSerializedFileDetector.cs b/UnityBinaryFormat/YamlSerializedFileDetector.cs similarity index 98% rename from Analyzer/Util/YamlSerializedFileDetector.cs rename to UnityBinaryFormat/YamlSerializedFileDetector.cs index c4bd011..ab26b80 100644 --- a/Analyzer/Util/YamlSerializedFileDetector.cs +++ b/UnityBinaryFormat/YamlSerializedFileDetector.cs @@ -2,7 +2,7 @@ using System.IO; using System.Text; -namespace UnityDataTools.Analyzer.Util; +namespace UnityDataTools.BinaryFormat; /// /// Utility for detecting YAML-format Unity SerializedFiles. diff --git a/UnityDataTool.Tests/SerializedFileCommandTests.cs b/UnityDataTool.Tests/SerializedFileCommandTests.cs index c4ddeb5..b0fda0c 100644 --- a/UnityDataTool.Tests/SerializedFileCommandTests.cs +++ b/UnityDataTool.Tests/SerializedFileCommandTests.cs @@ -293,6 +293,49 @@ public async Task ObjectList_SharedAssets_ContainsExpectedTypes() } } + [Test] + public async Task ObjectList_NoTypeTree_JsonFormat_OutputsExpectedValues() + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "PlayerNoTypeTree", "level0"); + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + + Assert.AreEqual(0, await Program.Main(new string[] { "sf", "objectlist", path, "-f", "json" })); + + var output = sw.ToString(); + var jsonArray = JsonDocument.Parse(output).RootElement; + Assert.AreEqual(7, jsonArray.GetArrayLength()); + + // Spot-check a few entries by index + var first = jsonArray[0]; + Assert.AreEqual(1, first.GetProperty("id").GetInt64()); + Assert.AreEqual(1, first.GetProperty("typeId").GetInt32()); + Assert.AreEqual("GameObject", first.GetProperty("typeName").GetString()); + Assert.AreEqual(576, first.GetProperty("offset").GetInt64()); + Assert.AreEqual(63, first.GetProperty("size").GetInt64()); + + var third = jsonArray[2]; + Assert.AreEqual(3, third.GetProperty("id").GetInt64()); + Assert.AreEqual(104, third.GetProperty("typeId").GetInt32()); + Assert.AreEqual("RenderSettings", third.GetProperty("typeName").GetString()); + Assert.AreEqual(720, third.GetProperty("offset").GetInt64()); + + var last = jsonArray[6]; + Assert.AreEqual(7, last.GetProperty("id").GetInt64()); + Assert.AreEqual(114, last.GetProperty("typeId").GetInt32()); + Assert.AreEqual("MonoBehaviour", last.GetProperty("typeName").GetString()); + Assert.AreEqual(1200, last.GetProperty("offset").GetInt64()); + Assert.AreEqual(44, last.GetProperty("size").GetInt64()); + } + finally + { + Console.SetOut(currentOut); + } + } + #endregion #region Header Tests @@ -412,6 +455,173 @@ public async Task Header_ArchiveFile_ReturnsError() #endregion + #region Metadata Tests + + [Test] + public async Task Metadata_LegacyVersion_ReturnsError() + { + // CAB-c5053efeda8860d7e7b7ce4b4c66705b is a version 17 SerializedFile. + // Version 17 is below the minimum supported version for metadata parsing (19), + // so the command should fail with an appropriate error message. + var cabPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "LegacyFormats", + "CAB-c5053efeda8860d7e7b7ce4b4c66705b"); + + if (!File.Exists(cabPath)) + { + Assert.Ignore("CAB test file not found"); + return; + } + + using var sw = new StringWriter(); + var currentErr = Console.Error; + try + { + Console.SetError(sw); + + var result = await Program.Main(new string[] { "serialized-file", "metadata", cabPath, "-f", "json" }); + + Assert.AreNotEqual(0, result, "Should return error code for version 17 file (too old for metadata parsing)"); + + var errorOutput = sw.ToString(); + StringAssert.Contains("not supported", errorOutput, "Error should mention that the version is not supported"); + StringAssert.Contains("17", errorOutput, "Error should mention the file's version number"); + } + finally + { + Console.SetError(currentErr); + } + } + + [Test] + public async Task Metadata_TextOutput_SucceedsAndContainsExpectedFields() + { + // Use PlayerWithTypeTrees test data, which is known to be a supported SerializedFile + // with TypeTrees enabled. + var dataDir = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "PlayerWithTypeTrees"); + var serializedFilePath = Path.Combine(dataDir, "globalgamemanagers"); + + if (!File.Exists(serializedFilePath)) + { + Assert.Ignore("PlayerWithTypeTrees serialized file not found"); + return; + } + + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + + var result = await Program.Main(new string[] + { + "serialized-file", + "metadata", + serializedFilePath + }); + + Assert.AreEqual(0, result, "Metadata command should succeed for supported file"); + + var output = sw.ToString(); + + // Basic sanity checks on text output; exact formatting is left flexible, + // but the output should mention metadata and TypeTree-related information. + StringAssert.Contains("Metadata", output, "Text output should mention metadata"); + StringAssert.Contains("Type", output, "Text output should mention type information"); + StringAssert.Contains("Tree", output, "Text output should mention TypeTree information"); + } + finally + { + Console.SetOut(currentOut); + } + } + + [Test] + public async Task Metadata_JsonOutput_SucceedsAndContainsTypeTreeInfo() + { + // Use PlayerWithTypeTrees test data to validate JSON metadata output, + // including TypeTree-related properties. + var dataDir = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "PlayerWithTypeTrees"); + var serializedFilePath = Path.Combine(dataDir, "globalgamemanagers"); + + if (!File.Exists(serializedFilePath)) + { + Assert.Ignore("PlayerWithTypeTrees serialized file not found"); + return; + } + + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + + var result = await Program.Main(new string[] + { + "serialized-file", + "metadata", + serializedFilePath, + "-f", + "json" + }); + + Assert.AreEqual(0, result, "Metadata command should succeed for supported file in JSON mode"); + + var output = sw.ToString(); + Assert.IsNotEmpty(output, "JSON output should not be empty"); + + using var doc = JsonDocument.Parse(output); + var root = doc.RootElement; + + Assert.AreEqual(JsonValueKind.Object, root.ValueKind, "Root JSON element should be an object"); + + // Validate the presence of some core metadata properties. + Assert.IsTrue(root.TryGetProperty("filePath", out _) + || root.TryGetProperty("path", out _), + "JSON metadata should contain a file path property"); + + Assert.IsTrue(root.TryGetProperty("unityVersion", out _) + || root.TryGetProperty("version", out _), + "JSON metadata should contain a Unity version property"); + + // Validate TypeTree-related information: either a count or an array of TypeTrees. + JsonElement typeTreesElement; + bool hasTypeTrees = + root.TryGetProperty("typeTrees", out typeTreesElement) || + root.TryGetProperty("typeTree", out typeTreesElement) || + root.TryGetProperty("typeTreeInfos", out typeTreesElement); + + Assert.IsTrue(hasTypeTrees, "JSON metadata should contain TypeTree information"); + + if (typeTreesElement.ValueKind == JsonValueKind.Array) + { + Assert.Greater(typeTreesElement.GetArrayLength(), 0, "TypeTree array should contain at least one entry"); + + var first = typeTreesElement[0]; + Assert.AreEqual(JsonValueKind.Object, first.ValueKind, "Each TypeTree entry should be an object"); + + // Check for some typical fields on a TypeTree entry (IDs/counts/arrays). + Assert.IsTrue(first.TryGetProperty("typeId", out _) + || first.TryGetProperty("classId", out _), + "TypeTree entry should contain a type or class ID"); + + Assert.IsTrue(first.TryGetProperty("nodes", out var nodesElement) + ? nodesElement.ValueKind == JsonValueKind.Array + : true, + "If present, TypeTree nodes should be an array"); + } + else if (typeTreesElement.ValueKind == JsonValueKind.Number) + { + // Some formats may expose only a count. + Assert.Greater(typeTreesElement.GetInt32(), 0, "TypeTree count should be greater than zero"); + } + } + finally + { + Console.SetOut(currentOut); + } + } + #endregion + #region Cross-Validation with Analyze Command [Test] diff --git a/UnityDataTool.Tests/WebBundleSupportTests.cs b/UnityDataTool.Tests/WebBundleSupportTests.cs index 14b8e72..d70a9f5 100644 --- a/UnityDataTool.Tests/WebBundleSupportTests.cs +++ b/UnityDataTool.Tests/WebBundleSupportTests.cs @@ -40,14 +40,14 @@ public void Teardown() public void IsWebBundle_True() { var webBundlePath = Path.Combine(m_TestDataFolder, "WebBundles", "HelloWorld.data"); - Assert.IsTrue(Archive.IsWebBundle(new FileInfo(webBundlePath))); + Assert.IsTrue(Archive.IsWebBundle(webBundlePath)); } [Test] public void IsWebBundle_False() { var nonWebBundlePath = Path.Combine(m_TestDataFolder, "WebBundles", "NotAWebBundle.txt"); - Assert.IsFalse(Archive.IsWebBundle(new FileInfo(nonWebBundlePath))); + Assert.IsFalse(Archive.IsWebBundle(nonWebBundlePath)); } [Test] diff --git a/UnityDataTool/Archive.cs b/UnityDataTool/Archive.cs index ba4e720..755252b 100644 --- a/UnityDataTool/Archive.cs +++ b/UnityDataTool/Archive.cs @@ -4,6 +4,7 @@ using System.IO.Compression; using System.Linq; using System.Text; +using UnityDataTools.BinaryFormat; using UnityDataTools.FileSystem; namespace UnityDataTools.UnityDataTool; @@ -16,14 +17,20 @@ public static int HandleExtract(FileInfo filename, DirectoryInfo outputFolder) { try { - if (IsWebBundle(filename)) + var path = filename.ToString(); + if (IsWebBundle(path)) { ExtractWebBundle(filename, outputFolder); } - else + else if (ArchiveDetector.IsUnityArchive(path)) { ExtractAssetBundle(filename, outputFolder); } + else + { + Console.Error.WriteLine("File is not a supported archive type."); + return 1; + } } catch (Exception err) when ( err is NotSupportedException @@ -40,14 +47,20 @@ public static int HandleList(FileInfo filename) { try { - if (IsWebBundle(filename)) + var path = filename.ToString(); + if (IsWebBundle(path)) { ListWebBundle(filename); } - else + else if (ArchiveDetector.IsUnityArchive(path)) { ListAssetBundle(filename); } + else + { + Console.Error.WriteLine("File is not a supported archive type."); + return 1; + } } catch (Exception err) when ( err is NotSupportedException @@ -62,9 +75,8 @@ err is NotSupportedException } - public static bool IsWebBundle(FileInfo filename) + public static bool IsWebBundle(string path) { - var path = filename.ToString(); return ( path.EndsWith(".data") || path.EndsWith(".data.gz") diff --git a/UnityDataTool/Program.cs b/UnityDataTool/Program.cs index ba6c512..2e4904d 100644 --- a/UnityDataTool/Program.cs +++ b/UnityDataTool/Program.cs @@ -164,11 +164,22 @@ public static async Task Main(string[] args) (FileInfo fi, OutputFormat f) => Task.FromResult(SerializedFileCommands.HandleHeader(fi, f)), pathArg, fOpt); + var metadataCommand = new Command("metadata", "Show information from the metadata section of the SerializedFile (use `-f Json` for detailed information).") + { + pathArg, + fOpt, + }; + + metadataCommand.SetHandler( + (FileInfo fi, OutputFormat f) => Task.FromResult(SerializedFileCommands.HandleMetadata(fi, f)), + pathArg, fOpt); + var serializedFileCommand = new Command("serialized-file", "Inspect a SerializedFile (scene, assets, etc.).") { externalRefsCommand, objectListCommand, headerCommand, + metadataCommand, }; serializedFileCommand.AddAlias("sf"); diff --git a/UnityDataTool/SerializedFileCommands.cs b/UnityDataTool/SerializedFileCommands.cs index 3098a8a..7f68ee5 100644 --- a/UnityDataTool/SerializedFileCommands.cs +++ b/UnityDataTool/SerializedFileCommands.cs @@ -1,7 +1,8 @@ using System; using System.IO; +using System.Linq; using System.Text.Json; -using UnityDataTools.Analyzer.Util; +using UnityDataTools.BinaryFormat; using UnityDataTools.FileSystem; namespace UnityDataTools.UnityDataTool; @@ -10,46 +11,65 @@ public static class SerializedFileCommands { public static int HandleExternalRefs(FileInfo filename, OutputFormat format) { - if (!ValidateSerializedFile(filename.FullName, out _)) + // External references are read directly from the parsed metadata rather than via UnityFileSystemApi. + // + // Advantages: works for any modern SerializedFile (version >= 19), including Player builds + // that were compiled without TypeTrees — files that UnityFileSystemApi cannot open at all. + // + // Trade-offs: Files older than version 19 (Unity 2019.1) are not supported by the metadata parser. + // + // These trade-offs are minor compared to the benefit of handling the common no-TypeTree case, + // so there is no need to keep the UnityFileSystemApi code path. + if (!ValidateSerializedFile(filename.FullName, out var fileInfo)) return 1; - try + if (!SerializedFileDetector.TryParseMetadata(filename.FullName, fileInfo, out var metadata, out var errorMessage)) { - using var sf = UnityFileSystem.OpenSerializedFile(filename.FullName); - if (format == OutputFormat.Json) - OutputExternalRefsJson(sf); - else - OutputExternalRefsText(sf); - return 0; + Console.Error.WriteLine($"Error: Failed to parse external references for: {filename.FullName}"); + Console.Error.WriteLine(errorMessage); + return 1; } - catch (Exception err) when (err is NotSupportedException || err is FileFormatException) + + if (metadata.ExternalReferences == null) { - Console.Error.WriteLine($"Error opening SerializedFile: {filename.FullName}"); - Console.Error.WriteLine(err.Message); + Console.Error.WriteLine($"Error: External references could not be parsed for: {filename.FullName}"); return 1; } + + if (format == OutputFormat.Json) + OutputExternalRefsJson(metadata.ExternalReferences); + else + OutputExternalRefsText(metadata.ExternalReferences); + + return 0; } public static int HandleObjectList(FileInfo filename, OutputFormat format) { - if (!ValidateSerializedFile(filename.FullName, out _)) + // The object list is read directly from the parsed metadata rather than via UnityFileSystemApi. + // (See comment in HandleExternalRefs() for the reasons for doing it that way) + if (!ValidateSerializedFile(filename.FullName, out var fileInfo)) return 1; - try + if (!SerializedFileDetector.TryParseMetadata(filename.FullName, fileInfo, out var metadata, out var errorMessage)) { - using var sf = UnityFileSystem.OpenSerializedFile(filename.FullName); - if (format == OutputFormat.Json) - OutputObjectListJson(sf); - else - OutputObjectListText(sf); - return 0; + Console.Error.WriteLine($"Error: Failed to parse object list for: {filename.FullName}"); + Console.Error.WriteLine(errorMessage); + return 1; } - catch (Exception err) when (err is NotSupportedException || err is FileFormatException) + + if (metadata.ObjectList == null) { - Console.Error.WriteLine($"Error opening SerializedFile: {filename.FullName}"); - Console.Error.WriteLine(err.Message); + Console.Error.WriteLine($"Error: Object list could not be parsed for: {filename.FullName}"); return 1; } + + if (format == OutputFormat.Json) + OutputObjectListJson(metadata.ObjectList); + else + OutputObjectListText(metadata.ObjectList); + + return 0; } public static int HandleHeader(FileInfo filename, OutputFormat format) @@ -65,6 +85,26 @@ public static int HandleHeader(FileInfo filename, OutputFormat format) return 0; } + public static int HandleMetadata(FileInfo filename, OutputFormat format) + { + if (!ValidateSerializedFile(filename.FullName, out var fileInfo)) + return 1; + + if (!SerializedFileDetector.TryParseMetadata(filename.FullName, fileInfo, out var metadata, out var errorMessage)) + { + Console.Error.WriteLine($"Error: Failed to parse metadata for: {filename.FullName}"); + Console.Error.WriteLine(errorMessage); + return 1; + } + + if (format == OutputFormat.Json) + OutputMetadataJson(metadata); + else + OutputMetadataText(metadata); + + return 0; + } + /// /// Validates that a file is a SerializedFile and provides helpful error messages if not. /// @@ -113,11 +153,9 @@ private static bool ValidateSerializedFile(string filePath, out SerializedFileIn return true; } - private static void OutputExternalRefsText(SerializedFile sf) + private static void OutputExternalRefsText(ExternalReference[] refs) { - var refs = sf.ExternalReferences; - - for (int i = 0; i < refs.Count; i++) + for (int i = 0; i < refs.Length; i++) { var extRef = refs[i]; var displayValue = !string.IsNullOrEmpty(extRef.Path) ? extRef.Path : extRef.Guid; @@ -125,12 +163,11 @@ private static void OutputExternalRefsText(SerializedFile sf) } } - private static void OutputExternalRefsJson(SerializedFile sf) + private static void OutputExternalRefsJson(ExternalReference[] refs) { - var refs = sf.ExternalReferences; - var jsonArray = new object[refs.Count]; + var jsonArray = new object[refs.Length]; - for (int i = 0; i < refs.Count; i++) + for (int i = 0; i < refs.Length; i++) { var extRef = refs[i]; jsonArray[i] = new @@ -146,36 +183,27 @@ private static void OutputExternalRefsJson(SerializedFile sf) Console.WriteLine(json); } - private static void OutputObjectListText(SerializedFile sf) + private static void OutputObjectListText(ObjectInfo[] objects) { - var objects = sf.Objects; - - // Print header Console.WriteLine($"{"Id",-20} {"Type",-40} {"Offset",-15} {"Size",-15}"); Console.WriteLine(new string('-', 90)); foreach (var obj in objects) - { - string typeName = GetTypeName(sf, obj); - Console.WriteLine($"{obj.Id,-20} {typeName,-40} {obj.Offset,-15} {obj.Size,-15}"); - } + Console.WriteLine($"{obj.Id,-20} {TypeIdRegistry.GetTypeName(obj.TypeId),-40} {obj.Offset,-15} {obj.Size,-15}"); } - private static void OutputObjectListJson(SerializedFile sf) + private static void OutputObjectListJson(ObjectInfo[] objects) { - var objects = sf.Objects; - var jsonArray = new object[objects.Count]; + var jsonArray = new object[objects.Length]; - for (int i = 0; i < objects.Count; i++) + for (int i = 0; i < objects.Length; i++) { var obj = objects[i]; - string typeName = GetTypeName(sf, obj); - jsonArray[i] = new { id = obj.Id, typeId = obj.TypeId, - typeName = typeName, + typeName = TypeIdRegistry.GetTypeName(obj.TypeId), offset = obj.Offset, size = obj.Size }; @@ -185,21 +213,6 @@ private static void OutputObjectListJson(SerializedFile sf) Console.WriteLine(json); } - private static string GetTypeName(SerializedFile sf, ObjectInfo obj) - { - try - { - // Try to get type name from TypeTree first (most accurate) - var root = sf.GetTypeTreeRoot(obj.Id); - return root.Type; - } - catch - { - // Fall back to registry if TypeTree is not available - return TypeIdRegistry.GetTypeName(obj.TypeId); - } - } - private static void OutputHeaderText(SerializedFileInfo info) { Console.WriteLine($"{"Version",-20} {info.Version}"); @@ -225,4 +238,62 @@ private static void OutputHeaderJson(SerializedFileInfo info) var json = JsonSerializer.Serialize(jsonObject, new JsonSerializerOptions { WriteIndented = true }); Console.WriteLine(json); } + + private static void OutputMetadataText(SerializedFileMetadata metadata) + { + string typeTreeDefinitions; + if (!metadata.EnableTypeTree) + typeTreeDefinitions = "No"; + else if (metadata.TypeTrees == null || metadata.TypeTrees.Length == 0) + typeTreeDefinitions = "Unknown"; + else if (metadata.TypeTrees.All(t => t.InlineTypeTree)) + typeTreeDefinitions = "Inline"; + else if (metadata.TypeTrees.Any(t => t.InlineTypeTree)) + typeTreeDefinitions = "Mixed"; // unexpected: entries disagree on inline vs external + else + typeTreeDefinitions = "External"; + + Console.WriteLine($"{"Unity Version",-20} {metadata.UnityVersion}"); + Console.WriteLine($"{"Target Platform",-20} {metadata.TargetPlatform}"); + Console.WriteLine($"{"TypeTree Definitions",-20} {typeTreeDefinitions}"); + Console.WriteLine($"{"TypeTree Count",-20} {metadata.TypeTreeCount}"); + Console.WriteLine($"{"RefType Count",-20} {metadata.SerializedReferenceTypeTreeCount}"); + } + + private static void OutputMetadataJson(SerializedFileMetadata metadata) + { + var jsonObject = new + { + unityVersion = metadata.UnityVersion, + targetPlatform = metadata.TargetPlatform, + enableTypeTree = metadata.EnableTypeTree, + typeTreeCount = metadata.TypeTreeCount, + serializedReferenceTypeTreeCount = metadata.SerializedReferenceTypeTreeCount, + typeTrees = metadata.TypeTrees?.Select(TypeTreeInfoToJson).ToArray(), + serializedReferenceTypeTrees = metadata.SerializedReferenceTypeTrees?.Select(TypeTreeInfoToJson).ToArray(), + scriptTypes = metadata.ScriptTypes?.Select(s => new { fileID = s.FileID, pathID = s.PathID }).ToArray(), + }; + + var json = JsonSerializer.Serialize(jsonObject, new JsonSerializerOptions { WriteIndented = true }); + Console.WriteLine(json); + } + + private static object TypeTreeInfoToJson(TypeTreeInfo info) + { + return new + { + persistentTypeID = info.PersistentTypeID, + isStrippedType = info.IsStrippedType, + scriptTypeIndex = info.ScriptTypeIndex, + scriptID = info.ScriptID.ToString(), + typeTreeStructureHash = info.TypeTreeStructureHash.ToString(), + typeTreeContentHash = info.TypeTreeContentHash.ToString(), + typeTreeSerializedSize = info.TypeTreeSerializedSize, + inlineTypeTree = info.InlineTypeTree, + className = info.ClassName, + namespaceName = info.Namespace, + assemblyName = info.AssemblyName, + typeDependencies = info.TypeDependencies, + }; + } } diff --git a/UnityDataTool/UnityDataTool.csproj b/UnityDataTool/UnityDataTool.csproj index 8beed2c..115122c 100644 --- a/UnityDataTool/UnityDataTool.csproj +++ b/UnityDataTool/UnityDataTool.csproj @@ -4,10 +4,10 @@ Exe net9.0 latest - 1.3.1 - 1.3.1.0 - 1.3.1.0 - 1.3.1 + 1.3.2 + 1.3.2.0 + 1.3.2.0 + 1.3.2 @@ -27,6 +27,7 @@ + diff --git a/UnityDataTools.sln b/UnityDataTools.sln index 37aafa1..64cc76f 100644 --- a/UnityDataTools.sln +++ b/UnityDataTools.sln @@ -27,48 +27,138 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Analyzer.Tests", "Analyzer. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestCommon", "TestCommon\TestCommon.csproj", "{D7D1A570-A003-4E69-8F36-7FF94D0BB224}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnityBinaryFormat", "UnityBinaryFormat\UnityBinaryFormat.csproj", "{08119AC0-ABDB-40A9-AF14-94B1D4A57492}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {5305FAF9-046E-4A45-82E1-56853E6D64DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5305FAF9-046E-4A45-82E1-56853E6D64DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5305FAF9-046E-4A45-82E1-56853E6D64DF}.Debug|x64.ActiveCfg = Debug|Any CPU + {5305FAF9-046E-4A45-82E1-56853E6D64DF}.Debug|x64.Build.0 = Debug|Any CPU + {5305FAF9-046E-4A45-82E1-56853E6D64DF}.Debug|x86.ActiveCfg = Debug|Any CPU + {5305FAF9-046E-4A45-82E1-56853E6D64DF}.Debug|x86.Build.0 = Debug|Any CPU {5305FAF9-046E-4A45-82E1-56853E6D64DF}.Release|Any CPU.ActiveCfg = Release|Any CPU {5305FAF9-046E-4A45-82E1-56853E6D64DF}.Release|Any CPU.Build.0 = Release|Any CPU + {5305FAF9-046E-4A45-82E1-56853E6D64DF}.Release|x64.ActiveCfg = Release|Any CPU + {5305FAF9-046E-4A45-82E1-56853E6D64DF}.Release|x64.Build.0 = Release|Any CPU + {5305FAF9-046E-4A45-82E1-56853E6D64DF}.Release|x86.ActiveCfg = Release|Any CPU + {5305FAF9-046E-4A45-82E1-56853E6D64DF}.Release|x86.Build.0 = Release|Any CPU {9A681FA2-8CC0-4B76-B91D-3F3C657B8EEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9A681FA2-8CC0-4B76-B91D-3F3C657B8EEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A681FA2-8CC0-4B76-B91D-3F3C657B8EEB}.Debug|x64.ActiveCfg = Debug|Any CPU + {9A681FA2-8CC0-4B76-B91D-3F3C657B8EEB}.Debug|x64.Build.0 = Debug|Any CPU + {9A681FA2-8CC0-4B76-B91D-3F3C657B8EEB}.Debug|x86.ActiveCfg = Debug|Any CPU + {9A681FA2-8CC0-4B76-B91D-3F3C657B8EEB}.Debug|x86.Build.0 = Debug|Any CPU {9A681FA2-8CC0-4B76-B91D-3F3C657B8EEB}.Release|Any CPU.ActiveCfg = Release|Any CPU {9A681FA2-8CC0-4B76-B91D-3F3C657B8EEB}.Release|Any CPU.Build.0 = Release|Any CPU + {9A681FA2-8CC0-4B76-B91D-3F3C657B8EEB}.Release|x64.ActiveCfg = Release|Any CPU + {9A681FA2-8CC0-4B76-B91D-3F3C657B8EEB}.Release|x64.Build.0 = Release|Any CPU + {9A681FA2-8CC0-4B76-B91D-3F3C657B8EEB}.Release|x86.ActiveCfg = Release|Any CPU + {9A681FA2-8CC0-4B76-B91D-3F3C657B8EEB}.Release|x86.Build.0 = Release|Any CPU {58EA2DEE-FD97-43EA-9FF6-FD7BC6E99E48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {58EA2DEE-FD97-43EA-9FF6-FD7BC6E99E48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58EA2DEE-FD97-43EA-9FF6-FD7BC6E99E48}.Debug|x64.ActiveCfg = Debug|Any CPU + {58EA2DEE-FD97-43EA-9FF6-FD7BC6E99E48}.Debug|x64.Build.0 = Debug|Any CPU + {58EA2DEE-FD97-43EA-9FF6-FD7BC6E99E48}.Debug|x86.ActiveCfg = Debug|Any CPU + {58EA2DEE-FD97-43EA-9FF6-FD7BC6E99E48}.Debug|x86.Build.0 = Debug|Any CPU {58EA2DEE-FD97-43EA-9FF6-FD7BC6E99E48}.Release|Any CPU.ActiveCfg = Release|Any CPU {58EA2DEE-FD97-43EA-9FF6-FD7BC6E99E48}.Release|Any CPU.Build.0 = Release|Any CPU + {58EA2DEE-FD97-43EA-9FF6-FD7BC6E99E48}.Release|x64.ActiveCfg = Release|Any CPU + {58EA2DEE-FD97-43EA-9FF6-FD7BC6E99E48}.Release|x64.Build.0 = Release|Any CPU + {58EA2DEE-FD97-43EA-9FF6-FD7BC6E99E48}.Release|x86.ActiveCfg = Release|Any CPU + {58EA2DEE-FD97-43EA-9FF6-FD7BC6E99E48}.Release|x86.Build.0 = Release|Any CPU {DDD3A31C-35C1-4274-8A2D-8E7CB25F40E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DDD3A31C-35C1-4274-8A2D-8E7CB25F40E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DDD3A31C-35C1-4274-8A2D-8E7CB25F40E2}.Debug|x64.ActiveCfg = Debug|Any CPU + {DDD3A31C-35C1-4274-8A2D-8E7CB25F40E2}.Debug|x64.Build.0 = Debug|Any CPU + {DDD3A31C-35C1-4274-8A2D-8E7CB25F40E2}.Debug|x86.ActiveCfg = Debug|Any CPU + {DDD3A31C-35C1-4274-8A2D-8E7CB25F40E2}.Debug|x86.Build.0 = Debug|Any CPU {DDD3A31C-35C1-4274-8A2D-8E7CB25F40E2}.Release|Any CPU.ActiveCfg = Release|Any CPU {DDD3A31C-35C1-4274-8A2D-8E7CB25F40E2}.Release|Any CPU.Build.0 = Release|Any CPU + {DDD3A31C-35C1-4274-8A2D-8E7CB25F40E2}.Release|x64.ActiveCfg = Release|Any CPU + {DDD3A31C-35C1-4274-8A2D-8E7CB25F40E2}.Release|x64.Build.0 = Release|Any CPU + {DDD3A31C-35C1-4274-8A2D-8E7CB25F40E2}.Release|x86.ActiveCfg = Release|Any CPU + {DDD3A31C-35C1-4274-8A2D-8E7CB25F40E2}.Release|x86.Build.0 = Release|Any CPU {9DE34BF7-86C8-4A63-A434-15E82D857C1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9DE34BF7-86C8-4A63-A434-15E82D857C1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DE34BF7-86C8-4A63-A434-15E82D857C1F}.Debug|x64.ActiveCfg = Debug|Any CPU + {9DE34BF7-86C8-4A63-A434-15E82D857C1F}.Debug|x64.Build.0 = Debug|Any CPU + {9DE34BF7-86C8-4A63-A434-15E82D857C1F}.Debug|x86.ActiveCfg = Debug|Any CPU + {9DE34BF7-86C8-4A63-A434-15E82D857C1F}.Debug|x86.Build.0 = Debug|Any CPU {9DE34BF7-86C8-4A63-A434-15E82D857C1F}.Release|Any CPU.ActiveCfg = Release|Any CPU {9DE34BF7-86C8-4A63-A434-15E82D857C1F}.Release|Any CPU.Build.0 = Release|Any CPU + {9DE34BF7-86C8-4A63-A434-15E82D857C1F}.Release|x64.ActiveCfg = Release|Any CPU + {9DE34BF7-86C8-4A63-A434-15E82D857C1F}.Release|x64.Build.0 = Release|Any CPU + {9DE34BF7-86C8-4A63-A434-15E82D857C1F}.Release|x86.ActiveCfg = Release|Any CPU + {9DE34BF7-86C8-4A63-A434-15E82D857C1F}.Release|x86.Build.0 = Release|Any CPU {10460607-DAB5-4C67-A03F-ED389A0E808C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {10460607-DAB5-4C67-A03F-ED389A0E808C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10460607-DAB5-4C67-A03F-ED389A0E808C}.Debug|x64.ActiveCfg = Debug|Any CPU + {10460607-DAB5-4C67-A03F-ED389A0E808C}.Debug|x64.Build.0 = Debug|Any CPU + {10460607-DAB5-4C67-A03F-ED389A0E808C}.Debug|x86.ActiveCfg = Debug|Any CPU + {10460607-DAB5-4C67-A03F-ED389A0E808C}.Debug|x86.Build.0 = Debug|Any CPU {10460607-DAB5-4C67-A03F-ED389A0E808C}.Release|Any CPU.ActiveCfg = Release|Any CPU {10460607-DAB5-4C67-A03F-ED389A0E808C}.Release|Any CPU.Build.0 = Release|Any CPU + {10460607-DAB5-4C67-A03F-ED389A0E808C}.Release|x64.ActiveCfg = Release|Any CPU + {10460607-DAB5-4C67-A03F-ED389A0E808C}.Release|x64.Build.0 = Release|Any CPU + {10460607-DAB5-4C67-A03F-ED389A0E808C}.Release|x86.ActiveCfg = Release|Any CPU + {10460607-DAB5-4C67-A03F-ED389A0E808C}.Release|x86.Build.0 = Release|Any CPU {67B7A7B3-96D7-497F-98E3-E9CB28F3CE74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {67B7A7B3-96D7-497F-98E3-E9CB28F3CE74}.Debug|Any CPU.Build.0 = Debug|Any CPU + {67B7A7B3-96D7-497F-98E3-E9CB28F3CE74}.Debug|x64.ActiveCfg = Debug|Any CPU + {67B7A7B3-96D7-497F-98E3-E9CB28F3CE74}.Debug|x64.Build.0 = Debug|Any CPU + {67B7A7B3-96D7-497F-98E3-E9CB28F3CE74}.Debug|x86.ActiveCfg = Debug|Any CPU + {67B7A7B3-96D7-497F-98E3-E9CB28F3CE74}.Debug|x86.Build.0 = Debug|Any CPU {67B7A7B3-96D7-497F-98E3-E9CB28F3CE74}.Release|Any CPU.ActiveCfg = Release|Any CPU {67B7A7B3-96D7-497F-98E3-E9CB28F3CE74}.Release|Any CPU.Build.0 = Release|Any CPU + {67B7A7B3-96D7-497F-98E3-E9CB28F3CE74}.Release|x64.ActiveCfg = Release|Any CPU + {67B7A7B3-96D7-497F-98E3-E9CB28F3CE74}.Release|x64.Build.0 = Release|Any CPU + {67B7A7B3-96D7-497F-98E3-E9CB28F3CE74}.Release|x86.ActiveCfg = Release|Any CPU + {67B7A7B3-96D7-497F-98E3-E9CB28F3CE74}.Release|x86.Build.0 = Release|Any CPU {1F272DC7-59DB-4633-A10B-BA80269058C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1F272DC7-59DB-4633-A10B-BA80269058C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F272DC7-59DB-4633-A10B-BA80269058C1}.Debug|x64.ActiveCfg = Debug|Any CPU + {1F272DC7-59DB-4633-A10B-BA80269058C1}.Debug|x64.Build.0 = Debug|Any CPU + {1F272DC7-59DB-4633-A10B-BA80269058C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {1F272DC7-59DB-4633-A10B-BA80269058C1}.Debug|x86.Build.0 = Debug|Any CPU {1F272DC7-59DB-4633-A10B-BA80269058C1}.Release|Any CPU.ActiveCfg = Release|Any CPU {1F272DC7-59DB-4633-A10B-BA80269058C1}.Release|Any CPU.Build.0 = Release|Any CPU + {1F272DC7-59DB-4633-A10B-BA80269058C1}.Release|x64.ActiveCfg = Release|Any CPU + {1F272DC7-59DB-4633-A10B-BA80269058C1}.Release|x64.Build.0 = Release|Any CPU + {1F272DC7-59DB-4633-A10B-BA80269058C1}.Release|x86.ActiveCfg = Release|Any CPU + {1F272DC7-59DB-4633-A10B-BA80269058C1}.Release|x86.Build.0 = Release|Any CPU {D7D1A570-A003-4E69-8F36-7FF94D0BB224}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D7D1A570-A003-4E69-8F36-7FF94D0BB224}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D7D1A570-A003-4E69-8F36-7FF94D0BB224}.Debug|x64.ActiveCfg = Debug|Any CPU + {D7D1A570-A003-4E69-8F36-7FF94D0BB224}.Debug|x64.Build.0 = Debug|Any CPU + {D7D1A570-A003-4E69-8F36-7FF94D0BB224}.Debug|x86.ActiveCfg = Debug|Any CPU + {D7D1A570-A003-4E69-8F36-7FF94D0BB224}.Debug|x86.Build.0 = Debug|Any CPU {D7D1A570-A003-4E69-8F36-7FF94D0BB224}.Release|Any CPU.ActiveCfg = Release|Any CPU {D7D1A570-A003-4E69-8F36-7FF94D0BB224}.Release|Any CPU.Build.0 = Release|Any CPU + {D7D1A570-A003-4E69-8F36-7FF94D0BB224}.Release|x64.ActiveCfg = Release|Any CPU + {D7D1A570-A003-4E69-8F36-7FF94D0BB224}.Release|x64.Build.0 = Release|Any CPU + {D7D1A570-A003-4E69-8F36-7FF94D0BB224}.Release|x86.ActiveCfg = Release|Any CPU + {D7D1A570-A003-4E69-8F36-7FF94D0BB224}.Release|x86.Build.0 = Release|Any CPU + {08119AC0-ABDB-40A9-AF14-94B1D4A57492}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08119AC0-ABDB-40A9-AF14-94B1D4A57492}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08119AC0-ABDB-40A9-AF14-94B1D4A57492}.Debug|x64.ActiveCfg = Debug|Any CPU + {08119AC0-ABDB-40A9-AF14-94B1D4A57492}.Debug|x64.Build.0 = Debug|Any CPU + {08119AC0-ABDB-40A9-AF14-94B1D4A57492}.Debug|x86.ActiveCfg = Debug|Any CPU + {08119AC0-ABDB-40A9-AF14-94B1D4A57492}.Debug|x86.Build.0 = Debug|Any CPU + {08119AC0-ABDB-40A9-AF14-94B1D4A57492}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08119AC0-ABDB-40A9-AF14-94B1D4A57492}.Release|Any CPU.Build.0 = Release|Any CPU + {08119AC0-ABDB-40A9-AF14-94B1D4A57492}.Release|x64.ActiveCfg = Release|Any CPU + {08119AC0-ABDB-40A9-AF14-94B1D4A57492}.Release|x64.Build.0 = Release|Any CPU + {08119AC0-ABDB-40A9-AF14-94B1D4A57492}.Release|x86.ActiveCfg = Release|Any CPU + {08119AC0-ABDB-40A9-AF14-94B1D4A57492}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/UnityFileSystem/DllWrapper.cs b/UnityFileSystem/DllWrapper.cs index 0917bf2..6593378 100644 --- a/UnityFileSystem/DllWrapper.cs +++ b/UnityFileSystem/DllWrapper.cs @@ -119,6 +119,14 @@ public struct ObjectInfo public readonly long Offset; public readonly long Size; public readonly int TypeId; + + public ObjectInfo(long id, long offset, long size, int typeId) + { + Id = id; + Offset = offset; + Size = size; + TypeId = typeId; + } } [Flags] public enum TypeTreeFlags diff --git a/UnityFileSystem/SerializedFileOpenException.cs b/UnityFileSystem/SerializedFileOpenException.cs new file mode 100644 index 0000000..e004f56 --- /dev/null +++ b/UnityFileSystem/SerializedFileOpenException.cs @@ -0,0 +1,16 @@ +using System; + +namespace UnityDataTools.FileSystem; + +/// +/// Thrown when a SerializedFile cannot be opened, typically due to a content-related +/// parsing failure (e.g. type mismatch, missing TypeTrees) rather than an I/O error. +/// +public class SerializedFileOpenException : Exception +{ + public string FilePath { get; } + + public SerializedFileOpenException(string filePath) + : base($"Failed to open serialized file: \"{filePath}\"") + => FilePath = filePath; +} diff --git a/UnityFileSystem/UnityFileSystem.cs b/UnityFileSystem/UnityFileSystem.cs index 92612bf..7836165 100644 --- a/UnityFileSystem/UnityFileSystem.cs +++ b/UnityFileSystem/UnityFileSystem.cs @@ -47,6 +47,10 @@ public static UnityFile OpenFile(string path) public static SerializedFile OpenSerializedFile(string path) { var r = DllWrapper.OpenSerializedFile(path, out var handle); + + if (r == ReturnCode.UnknownError) + throw new SerializedFileOpenException(path); + UnityFileSystem.HandleErrors(r, path); return new SerializedFile() { m_Handle = handle }; diff --git a/UnityFileSystem/UnityFileSystemApi.dll b/UnityFileSystem/UnityFileSystemApi.dll index 4725fd3..1a6b897 100644 Binary files a/UnityFileSystem/UnityFileSystemApi.dll and b/UnityFileSystem/UnityFileSystemApi.dll differ diff --git a/UnityFileSystem/UnityFileSystemApi.pdb b/UnityFileSystem/UnityFileSystemApi.pdb new file mode 100644 index 0000000..cc572c7 Binary files /dev/null and b/UnityFileSystem/UnityFileSystemApi.pdb differ