using System; using System.Collections.Generic; using System.IO; using System.Reflection; using NUnit.Framework; using UnityEditor; using UnityEditor.Build.Content; using UnityEditor.Build.Pipeline.Utilities; using UnityEditor.Experimental; using UnityEngine; namespace UnityEditor.Build.Pipeline.Tests { /// /// Mirrors the IN-104030 repro idea: one texture worth of "letter" subsprites (here A–F) and one /// texture worth of "digit" subsprites (0–9), modeled as s that share /// guid+path per atlas. Contiguous bundles require every object from the same source asset to share /// the same asset-derived prefix of the serialization index (length depends on Prefab Packed Header Size; UUM-131143). /// /// /// This file includes both UnityEditor and UnityEngine so the GUID type resolves across Unity /// versions (it may live in either assembly). Do not drop one of those usings as redundant cleanup. /// [TestFixture] public class PrefabPackedIdentifiersSerializationIndexTests { // Same layout spirit as IN-104030: two atlases, different guids. static readonly GUID kLetterAtlasGuid = new GUID("10385bcdad8d47fda22f5921a3185d58"); const string kLetterAtlasPath = "Assets/Repro/letters.png"; static readonly GUID kDigitAtlasGuid = new GUID("20385bcdad8d47fda22f5921a3185d59"); const string kDigitAtlasPath = "Assets/Repro/numbers.png"; static readonly GUID kExtraArtifactGuid = new GUID("30385bcdad8d47fda22f5921a3185d5a"); const BindingFlags kObjectIdentifierFieldFlags = BindingFlags.NonPublic | BindingFlags.Instance; static readonly string[] kObjectIdentifierReflectFieldNames = { "m_GUID", "m_LocalIdentifierInFile", "m_FileType", "m_FilePath", }; static PrefabPackedIdentifiersSerializationIndexTests() { AssertObjectIdentifierReflectionLayout(); } static void AssertObjectIdentifierReflectionLayout() { var type = typeof(ObjectIdentifier); var missing = new List(); foreach (string name in kObjectIdentifierReflectFieldNames) { if (type.GetField(name, kObjectIdentifierFieldFlags) == null) missing.Add(name); } if (missing.Count != 0) { Assert.Fail( $"UnityEngine.ObjectIdentifier private fields missing or renamed: {string.Join(", ", missing)}. " + $"Update {nameof(MakeObjectId)} for this Unity version."); } } /// Sprite names A–F; local ids are arbitrary but stable and unique per subsprite. static readonly (char Label, long LocalId)[] kLetterSprites = { ('A', 1001), ('B', 1002), ('C', 1003), ('D', 1004), ('E', 1005), ('F', 1006), }; /// Sprite names 0–9; local ids arbitrary stable per subsprite. static readonly (char Label, long LocalId)[] kDigitSprites = { ('0', 2000), ('1', 2001), ('2', 2002), ('3', 2003), ('4', 2004), ('5', 2005), ('6', 2006), ('7', 2007), ('8', 2008), ('9', 2009), }; static ObjectIdentifier MakeObjectId(GUID guid, long localIdentifierInFile, FileType fileType, string filePath) { var objectId = new ObjectIdentifier(); var boxed = (object)objectId; var type = typeof(ObjectIdentifier); type.GetField("m_GUID", kObjectIdentifierFieldFlags).SetValue(boxed, guid); type.GetField("m_LocalIdentifierInFile", kObjectIdentifierFieldFlags).SetValue(boxed, localIdentifierInFile); type.GetField("m_FileType", kObjectIdentifierFieldFlags).SetValue(boxed, fileType); type.GetField("m_FilePath", kObjectIdentifierFieldFlags).SetValue(boxed, filePath); return (ObjectIdentifier)boxed; } static ObjectIdentifier LetterObject(long localId) => MakeObjectId(kLetterAtlasGuid, localId, FileType.SerializedAssetType, kLetterAtlasPath); static ObjectIdentifier DigitObject(long localId) => MakeObjectId(kDigitAtlasGuid, localId, FileType.SerializedAssetType, kDigitAtlasPath); static ObjectIdentifier ExtraArtifactObject(string forwardSlashPath, long localId) => MakeObjectId(kExtraArtifactGuid, localId, FileType.SerializedAssetType, forwardSlashPath); static void PushSbpState(out bool prevV2Hasher, out int prevSeed, out int prevHeader, int headerSize, int hashSeed = 0) { #if UNITY_2020_1_OR_NEWER prevV2Hasher = ScriptableBuildPipeline.useV2Hasher; ScriptableBuildPipeline.useV2Hasher = false; #else prevV2Hasher = false; #endif prevSeed = ScriptableBuildPipeline.fileIDHashSeed; ScriptableBuildPipeline.fileIDHashSeed = hashSeed; prevHeader = ScriptableBuildPipeline.prefabPackedHeaderSize; ScriptableBuildPipeline.prefabPackedHeaderSize = headerSize; } static void PopSbpState(bool prevV2Hasher, int prevSeed, int prevHeader) { #if UNITY_2020_1_OR_NEWER ScriptableBuildPipeline.useV2Hasher = prevV2Hasher; #endif ScriptableBuildPipeline.fileIDHashSeed = prevSeed; ScriptableBuildPipeline.prefabPackedHeaderSize = prevHeader; } /// /// Verifies that the first and last letter subsprites (A and F) of the same atlas share the /// asset-only prefix of their serialization indices, for every supported header size. /// /// Prefab Packed header size (in bytes) that determines how many upper bits of the serialization index are asset-derived. [TestCase(1)] [TestCase(2)] [TestCase(3)] [TestCase(4)] public void SerializationIndexFromObjectIdentifier_LetterAtlas_A_SharesUpperBitsWith_F(int headerSize) { PushSbpState(out bool prevV2, out int prevSeed, out int prevHeader, headerSize); try { var packing = new PrefabPackedIdentifiers(); var idA = LetterObject(1001); var idF = LetterObject(1006); ulong prefixA = PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(packing.SerializationIndexFromObjectIdentifier(idA), headerSize); ulong prefixF = PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(packing.SerializationIndexFromObjectIdentifier(idF), headerSize); Assert.AreEqual(prefixA, prefixF, $"Letter atlas: subsprites A and F must share the asset-only prefix (headerSize={headerSize}, UUM-131143)."); } finally { PopSbpState(prevV2, prevSeed, prevHeader); } } /// /// Verifies that the first and last digit subsprites (0 and 9) of the same atlas share the /// asset-only prefix of their serialization indices, for every supported header size. /// /// Prefab Packed header size (in bytes) that determines how many upper bits of the serialization index are asset-derived. [TestCase(1)] [TestCase(2)] [TestCase(3)] [TestCase(4)] public void SerializationIndexFromObjectIdentifier_DigitAtlas_0_SharesUpperBitsWith_9(int headerSize) { PushSbpState(out bool prevV2, out int prevSeed, out int prevHeader, headerSize); try { var packing = new PrefabPackedIdentifiers(); var id0 = DigitObject(2000); var id9 = DigitObject(2009); ulong prefix0 = PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(packing.SerializationIndexFromObjectIdentifier(id0), headerSize); ulong prefix9 = PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(packing.SerializationIndexFromObjectIdentifier(id9), headerSize); Assert.AreEqual(prefix0, prefix9, $"Digit atlas: subsprites 0 and 9 must share the asset-only prefix (headerSize={headerSize}, UUM-131143)."); } finally { PopSbpState(prevV2, prevSeed, prevHeader); } } // Previously: SerializationIndexFromObjectIdentifier_AllLetterSubsprites_ShareOneUpperUInt32 /// /// Verifies that every letter subsprite (A–F) of the letter atlas maps to the same asset-only /// prefix, so a contiguous bundle keeps the whole source asset together. /// [Test] public void SerializationIndexFromObjectIdentifier_AllLetterSubsprites_ShareOneAssetPrefix() { const int headerSize = 2; PushSbpState(out bool prevV2, out int prevSeed, out int prevHeader, headerSize); try { var packing = new PrefabPackedIdentifiers(); var prefixes = new List(kLetterSprites.Length); foreach (var (label, localId) in kLetterSprites) { var id = LetterObject(localId); prefixes.Add(PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(packing.SerializationIndexFromObjectIdentifier(id), headerSize)); } ulong first = prefixes[0]; for (int i = 1; i < prefixes.Count; i++) { Assert.AreEqual(first, prefixes[i], $"Letter atlas: subsprite {kLetterSprites[i].Label} should match A's asset prefix (UUM-131143)."); } } finally { PopSbpState(prevV2, prevSeed, prevHeader); } } // Previously: SerializationIndexFromObjectIdentifier_AllDigitSubsprites_ShareOneUpperUInt32 /// /// Verifies that every digit subsprite (0–9) of the digit atlas maps to the same asset-only /// prefix, so a contiguous bundle keeps the whole source asset together. /// [Test] public void SerializationIndexFromObjectIdentifier_AllDigitSubsprites_ShareOneAssetPrefix() { const int headerSize = 2; PushSbpState(out bool prevV2, out int prevSeed, out int prevHeader, headerSize); try { var packing = new PrefabPackedIdentifiers(); var prefixes = new List(kDigitSprites.Length); foreach (var (label, localId) in kDigitSprites) { var id = DigitObject(localId); prefixes.Add(PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(packing.SerializationIndexFromObjectIdentifier(id), headerSize)); } ulong first = prefixes[0]; for (int i = 1; i < prefixes.Count; i++) { Assert.AreEqual(first, prefixes[i], $"Digit atlas: subsprite {kDigitSprites[i].Label} should match 0's asset prefix (UUM-131143)."); } } finally { PopSbpState(prevV2, prevSeed, prevHeader); } } // Previously: SerializationIndexFromObjectIdentifier_LetterAtlasAndDigitAtlas_DifferInUpperUInt32 /// /// Verifies that subsprites from two different atlases land on different asset-only prefixes and /// do not collide on the full serialization index. /// [Test] public void SerializationIndexFromObjectIdentifier_LetterAtlasAndDigitAtlas_DifferInAssetPrefix() { const int headerSize = 2; PushSbpState(out bool prevV2, out int prevSeed, out int prevHeader, headerSize); try { var packing = new PrefabPackedIdentifiers(); long letterIndex = packing.SerializationIndexFromObjectIdentifier(LetterObject(1003)); long digitIndex = packing.SerializationIndexFromObjectIdentifier(DigitObject(2005)); Assert.AreNotEqual( PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(letterIndex, headerSize), PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(digitIndex, headerSize), "Letter atlas vs digit atlas should not share the same asset-only prefix."); Assert.AreNotEqual(letterIndex, digitIndex, "Distinct subsprites should not collide on the full index."); } finally { PopSbpState(prevV2, prevSeed, prevHeader); } } /// /// Verifies that subsprites of the letter atlas still share the asset-only prefix when a non-zero /// fileIDHashSeed is configured. /// /// Prefab Packed header size (in bytes) that determines how many upper bits of the serialization index are asset-derived. [TestCase(1)] [TestCase(2)] [TestCase(3)] [TestCase(4)] public void SerializationIndexFromObjectIdentifier_NonZeroFileIdHashSeed_LetterAtlas_SharesAssetPrefix(int headerSize) { const int kNonZeroSeed = 42; PushSbpState(out bool prevV2, out int prevSeed, out int prevHeader, headerSize, kNonZeroSeed); try { var packing = new PrefabPackedIdentifiers(); var idA = LetterObject(1001); var idF = LetterObject(1006); ulong prefixA = PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(packing.SerializationIndexFromObjectIdentifier(idA), headerSize); ulong prefixF = PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(packing.SerializationIndexFromObjectIdentifier(idF), headerSize); Assert.AreEqual(prefixA, prefixF, $"Letter atlas with fileIDHashSeed={kNonZeroSeed}: A and F must share asset-only prefix (headerSize={headerSize})."); } finally { PopSbpState(prevV2, prevSeed, prevHeader); } } /// /// Verifies that the letter and digit atlases keep distinct asset-only prefixes when a non-zero /// fileIDHashSeed is configured. /// [Test] public void SerializationIndexFromObjectIdentifier_NonZeroFileIdHashSeed_LetterAndDigitAtlases_DifferInAssetPrefix() { const int headerSize = 2; const int kNonZeroSeed = 42; PushSbpState(out bool prevV2, out int prevSeed, out int prevHeader, headerSize, kNonZeroSeed); try { var packing = new PrefabPackedIdentifiers(); long letterIndex = packing.SerializationIndexFromObjectIdentifier(LetterObject(1003)); long digitIndex = packing.SerializationIndexFromObjectIdentifier(DigitObject(2005)); Assert.AreNotEqual( PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(letterIndex, headerSize), PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(digitIndex, headerSize), "Letter vs digit atlas should not share the same asset-only prefix when fileIDHashSeed is non-zero."); } finally { PopSbpState(prevV2, prevSeed, prevHeader); } } /// /// Same asset-prefix expectations, but for object identifiers whose file path points at a /// non-primary artifact under VirtualArtifacts/Extra/ rather than at an asset in the project. /// Those paths are produced on demand by . /// [TestFixture] public sealed class VirtualArtifactsExtraSerializationIndex { const string kTildeFixtureFileA = "~extra_a.uum131143_virt"; const string kTildeFixtureFileB = "~extra_b.uum131143_virt"; const string kStagingAssetPathA = "Packages/com.unity.scriptablebuildpipeline/Tests/Editor/Fixtures/sbp_uum131143_staging_a.uum131143_virt"; const string kStagingAssetPathB = "Packages/com.unity.scriptablebuildpipeline/Tests/Editor/Fixtures/sbp_uum131143_staging_b.uum131143_virt"; const string kFixturesVirtualRoot = "Packages/com.unity.scriptablebuildpipeline/Tests/Editor/Fixtures"; static string s_virtualExtraPathA; static string s_virtualExtraPathB; static string FixturesPhysicalDirectory => Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Packages", "com.unity.scriptablebuildpipeline", "Tests", "Editor", "Fixtures")); /// /// Deploys the staging fixtures and forces production of their scripted-importer artifacts, caching /// the two distinct VirtualArtifacts/Extra/ paths the tests in this fixture run against. /// [OneTimeSetUp] public static void ProduceExtraArtifactPaths() { DeployStagingFixturesFromTildeSources(); s_virtualExtraPathA = ProduceOneVirtualArtifactsExtraPath(kStagingAssetPathA); s_virtualExtraPathB = ProduceOneVirtualArtifactsExtraPath(kStagingAssetPathB); Assert.AreNotEqual(s_virtualExtraPathA, s_virtualExtraPathB, "Two preview artifacts must resolve to distinct VirtualArtifacts/Extra paths."); } /// /// Deletes the staging fixture assets copied into the package by . /// [OneTimeTearDown] public static void RemoveStagingFixtures() { AssetDatabase.DeleteAsset(kStagingAssetPathA); AssetDatabase.DeleteAsset(kStagingAssetPathB); } static void DeployStagingFixturesFromTildeSources() { string dir = FixturesPhysicalDirectory; string srcA = Path.Combine(dir, kTildeFixtureFileA); string srcB = Path.Combine(dir, kTildeFixtureFileB); if (!File.Exists(srcA)) throw new IOException($"Missing tilde fixture: {srcA}"); if (!File.Exists(srcB)) throw new IOException($"Missing tilde fixture: {srcB}"); // File.Copy does not create .meta; under Packages/ Unity ignores assets without a sibling .meta, // so these scripted-importer fixtures never import and ForceProduceArtifact fails in CI. string srcAssetPathA = $"{kFixturesVirtualRoot}/{kTildeFixtureFileA}"; string srcAssetPathB = $"{kFixturesVirtualRoot}/{kTildeFixtureFileB}"; RemovePhysicalAssetIfMissingMeta(Path.Combine(dir, Path.GetFileName(kStagingAssetPathA))); RemovePhysicalAssetIfMissingMeta(Path.Combine(dir, Path.GetFileName(kStagingAssetPathB))); CopyStagingFixtureViaAssetDatabase(srcAssetPathA, kStagingAssetPathA); CopyStagingFixtureViaAssetDatabase(srcAssetPathB, kStagingAssetPathB); AssetDatabase.ImportAsset(kStagingAssetPathA, ImportAssetOptions.ForceUpdate); AssetDatabase.ImportAsset(kStagingAssetPathB, ImportAssetOptions.ForceUpdate); } static void RemovePhysicalAssetIfMissingMeta(string physicalAssetPath) { if (!File.Exists(physicalAssetPath)) return; if (File.Exists(physicalAssetPath + ".meta")) return; File.Delete(physicalAssetPath); } static void CopyStagingFixtureViaAssetDatabase(string srcUnityPath, string dstUnityPath) { if (AssetDatabase.LoadMainAssetAtPath(dstUnityPath) != null) AssetDatabase.DeleteAsset(dstUnityPath); if (!AssetDatabase.CopyAsset(srcUnityPath, dstUnityPath)) throw new IOException($"AssetDatabase.CopyAsset failed: {srcUnityPath} -> {dstUnityPath}"); } static string ProduceOneVirtualArtifactsExtraPath(string assetPath) { GUID guid = AssetDatabase.GUIDFromAssetPath(assetPath); Assert.False(guid.Empty(), $"Missing test asset (expected in this package): {assetPath}"); AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate); var key = new ArtifactKey(guid, typeof(VirtualArtifactSerializationIndexTestImporter)); var artifactId = AssetDatabaseExperimental.ForceProduceArtifact(key); Assert.IsTrue(artifactId.isValid, "ForceProduceArtifact should yield a valid ArtifactID."); Assert.IsTrue(AssetDatabaseExperimental.GetArtifactPaths(artifactId, out string[] paths), "GetArtifactPaths should succeed after production."); Assert.IsNotNull(paths); Assert.IsNotEmpty(paths, "ScriptedImporter artifact should list at least one produced path."); foreach (string p in paths) { if (p.Replace('\\', '/').StartsWith("VirtualArtifacts/Extra/", StringComparison.Ordinal)) return p; } Assert.Fail($"Expected at least one path under VirtualArtifacts/Extra/ for {assetPath}. Got: {string.Join(", ", paths)}"); return null; } /// /// Verifies that two objects with different local ids inside the same VirtualArtifacts/Extra/ /// file share the asset-only prefix of their serialization indices. /// /// Value assigned to ScriptableBuildPipeline.fileIDHashSeed for the duration of the test. [TestCase(0)] [TestCase(42)] public void SerializationIndexFromObjectIdentifier_VirtualArtifactsExtra_SameFileDifferentLocalIds_ShareAssetPrefix(int fileIdHashSeed) { const int headerSize = 2; PushSbpState(out bool prevV2, out int prevSeed, out int prevHeader, headerSize, fileIdHashSeed); try { var packing = new PrefabPackedIdentifiers(); var id1 = ExtraArtifactObject(s_virtualExtraPathA, 3001); var id2 = ExtraArtifactObject(s_virtualExtraPathA, 3002); ulong p1 = PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(packing.SerializationIndexFromObjectIdentifier(id1), headerSize); ulong p2 = PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(packing.SerializationIndexFromObjectIdentifier(id2), headerSize); Assert.AreEqual(p1, p2, $"VirtualArtifacts/Extra same file: local ids must share asset-only prefix (fileIDHashSeed={fileIdHashSeed})."); } finally { PopSbpState(prevV2, prevSeed, prevHeader); } } /// /// Verifies that objects living in two different VirtualArtifacts/Extra/ files land on /// different asset-only prefixes. /// /// Value assigned to ScriptableBuildPipeline.fileIDHashSeed for the duration of the test. [TestCase(0)] [TestCase(42)] public void SerializationIndexFromObjectIdentifier_VirtualArtifactsExtra_DifferentFiles_DifferInAssetPrefix(int fileIdHashSeed) { const int headerSize = 2; PushSbpState(out bool prevV2, out int prevSeed, out int prevHeader, headerSize, fileIdHashSeed); try { var packing = new PrefabPackedIdentifiers(); long idxA = packing.SerializationIndexFromObjectIdentifier(ExtraArtifactObject(s_virtualExtraPathA, 3001)); long idxB = packing.SerializationIndexFromObjectIdentifier(ExtraArtifactObject(s_virtualExtraPathB, 3002)); Assert.AreNotEqual( PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(idxA, headerSize), PrefabPackedSerializationIndexTestUtil.AssetOnlyPrefix(idxB, headerSize), $"Different VirtualArtifacts/Extra files should not share asset-only prefix (fileIDHashSeed={fileIdHashSeed})."); } finally { PopSbpState(prevV2, prevSeed, prevHeader); } } } } }