Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 179 additions & 1 deletion Analyzer.Tests/FileDetectionTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Linq;
using NUnit.Framework;
using UnityDataTools.Analyzer.Util;
using UnityDataTools.FileSystem;
Expand Down Expand Up @@ -168,6 +169,183 @@

#endregion

#region SerializedFile Metadata Parsing Tests

[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 Windows Standalone (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 (Windows Standalone)");
Comment thread
SkowronskiAndrew marked this conversation as resolved.
Outdated
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.OldTypeHash.IsZero,
$"OldTypeHash 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.AreNotEqual(114, entry.PersistentTypeID,

Check warning on line 214 in Analyzer.Tests/FileDetectionTests.cs

View workflow job for this annotation

GitHub Actions / test (windows, x64)

Consider using the constraint model, Assert.That(actual, Is.Not.EqualTo(expected)), instead of the classic model, Assert.AreNotEqual(expected, actual) (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2006.md)

Check warning on line 214 in Analyzer.Tests/FileDetectionTests.cs

View workflow job for this annotation

GitHub Actions / test (windows, x64)

Consider using the constraint model, Assert.That(actual, Is.Not.EqualTo(expected)), instead of the classic model, Assert.AreNotEqual(expected, actual) (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2006.md)

Check warning on line 214 in Analyzer.Tests/FileDetectionTests.cs

View workflow job for this annotation

GitHub Actions / test (macos, arm64)

Consider using the constraint model, Assert.That(actual, Is.Not.EqualTo(expected)), instead of the classic model, Assert.AreNotEqual(expected, actual) (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2006.md)

Check warning on line 214 in Analyzer.Tests/FileDetectionTests.cs

View workflow job for this annotation

GitHub Actions / test (macos, arm64)

Consider using the constraint model, Assert.That(actual, Is.Not.EqualTo(expected)), instead of the classic model, Assert.AreNotEqual(expected, actual) (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2006.md)
"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.OldTypeHash.IsZero,
$"OldTypeHash 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");

// --- 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"));
}

#endregion

#region YAML SerializedFile Detection Tests

[Test]
Expand Down Expand Up @@ -241,7 +419,7 @@
[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);

Expand Down
122 changes: 122 additions & 0 deletions Analyzer/Util/BinaryFileHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System;
using System.IO;
using System.Text;

namespace UnityDataTools.Analyzer.Util;

/// <summary>
/// A 128-bit hash stored as four 32-bit unsigned integers, matching Unity's Hash128 binary layout.
/// </summary>
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}";
}

/// <summary>
/// Helpers for reading primitive types and Unity-specific data from binary streams and byte
/// arrays, with optional endianness swapping.
/// </summary>
public static class BinaryFileHelper
{
// -----------------------------------------------------------------------
// Stream / BinaryReader helpers
// -----------------------------------------------------------------------

/// <summary>Advances the stream to the next 4-byte boundary measured from <paramref name="baseOffset"/>.</summary>
public static void AlignTo4(Stream stream, long baseOffset)
{
long rel = stream.Position - baseOffset;
long aligned = (rel + 3) & ~3L;
stream.Position = baseOffset + aligned;
}

/// <summary>Reads a null-terminated ASCII string from the stream.</summary>
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 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
// -----------------------------------------------------------------------

/// <summary>Reads a UInt32 from a byte array at the specified offset, optionally swapping endianness.</summary>
public static uint ReadUInt32(byte[] buffer, int offset, bool swap)
{
uint value = BitConverter.ToUInt32(buffer, offset);
return swap ? SwapUInt32(value) : value;
}

/// <summary>Reads a UInt64 from a byte array at the specified offset, optionally swapping endianness.</summary>
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);
}
}
Loading