From 8f44ad6a4d372619ae6cf44e2bb22e37478104a6 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Sat, 12 Sep 2026 22:34:37 +0000 Subject: [PATCH] finalize cilbox build and test-in-editor pipeline - add an explicit pre-serialization build phase so Cilbox runs after structural processors - keep Test In Editor clones inactive through build-time conversion and clean up failed clones safely - isolate Cilbox staging scenes and preserve/rebind serialized assembly data correctly - persist transient renderer materials before prefab staging - move JigglePhysics Test In Editor settling into the JigglePhysics editor integration - add regression coverage for build ordering, material persistence, play-mode conversion, and Cilbox type-check failure cleanup --- .../Sync/Basis.Framework.Sync.Tests.asmdef | 2 + .../Sync/BasisCilboxBuildPipelineTests.cs | 595 ++++++++++++++++++ .../BasisCilboxBuildPipelineTests.cs.meta | 11 + .../BasisTemporaryStorageMaterialTests.cs | 121 ++++ ...BasisTemporaryStorageMaterialTests.cs.meta | 11 + .../BasisAssetBundlePipeline.cs | 8 + .../BasisTemporaryStorageHandler.cs | 128 ++++ .../SDKInspector/BasisAvatarSDKInspector.cs | 222 +++++-- .../Shims/BasisCilboxBuildHook.cs | 401 ++++++------ Basis/Packages/com.cnlohr.cilbox/Cilbox.cs | 15 +- .../BasisJigglePhysicsTestInEditorHook.cs | 32 + ...BasisJigglePhysicsTestInEditorHook.cs.meta | 2 + ...r-dragon-games.jigglephysics.editor.asmdef | 3 +- .../Scripts/Editor/BasisNDMFBuildHook.cs | 4 +- 14 files changed, 1312 insertions(+), 243 deletions(-) create mode 100644 Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisCilboxBuildPipelineTests.cs create mode 100644 Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisCilboxBuildPipelineTests.cs.meta create mode 100644 Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisTemporaryStorageMaterialTests.cs create mode 100644 Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisTemporaryStorageMaterialTests.cs.meta create mode 100644 Basis/Packages/com.gator-dragon-games.jigglephysics/Editor/BasisJigglePhysicsTestInEditorHook.cs create mode 100644 Basis/Packages/com.gator-dragon-games.jigglephysics/Editor/BasisJigglePhysicsTestInEditorHook.cs.meta diff --git a/Basis/Packages/com.basis.framework/Tests/Editor/Sync/Basis.Framework.Sync.Tests.asmdef b/Basis/Packages/com.basis.framework/Tests/Editor/Sync/Basis.Framework.Sync.Tests.asmdef index ab58d86099..33a604c752 100644 --- a/Basis/Packages/com.basis.framework/Tests/Editor/Sync/Basis.Framework.Sync.Tests.asmdef +++ b/Basis/Packages/com.basis.framework/Tests/Editor/Sync/Basis.Framework.Sync.Tests.asmdef @@ -9,7 +9,9 @@ "Unity.Collections", "Unity.Mathematics", "UnityEngine.TestRunner", + "UnityEditor.TestRunner", "BasisSDK", + "BasisSDKEditor", "BasisShims", "Cilbox" ], diff --git a/Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisCilboxBuildPipelineTests.cs b/Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisCilboxBuildPipelineTests.cs new file mode 100644 index 0000000000..b1b34b73fe --- /dev/null +++ b/Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisCilboxBuildPipelineTests.cs @@ -0,0 +1,595 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using Basis.Scripts.BasisSdk; +using Cilbox; +using NUnit.Framework; +using UnityEditor.Build; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; + +namespace Basis.Tests.Sync +{ + [Cilboxable] + public sealed class BasisCilboxBuildTestBehaviour : MonoBehaviour + { + public static int NativeAwakeCount; + public string Marker = "before"; + + private void Awake() + { + NativeAwakeCount++; + } + + public void TouchMarker() + { + } + } + + [Cilboxable] + public sealed class BasisCilboxBuildTypeFailureBehaviour : MonoBehaviour + { + public IntPtr ReadDisallowedType() + { + return IntPtr.Zero; + } + } + + public sealed class BasisCilboxBuildPipelineTests + { + [Test] + public void PrefabFinalization_SerializesPreparedStateWithoutTouchingOtherScenes() + { + EnsureBuildHookRegistered(); + + Scene previousActiveScene = SceneManager.GetActiveScene(); + Scene externalScene = default; + Scene contentScene = default; + GameObject externalRoot = null; + GameObject contentRoot = null; + Action handler = null; + + try + { + externalScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); + externalRoot = new GameObject("CilboxExternalSceneRoot"); + SceneManager.MoveGameObjectToScene(externalRoot, externalScene); + CilboxAvatarBasis externalCilbox = externalRoot.AddComponent(); + externalCilbox.assemblyData = "external-sentinel"; + + contentScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); + contentRoot = new GameObject("CilboxContentRoot"); + SceneManager.MoveGameObjectToScene(contentRoot, contentScene); + CilboxAvatarBasis contentCilbox = contentRoot.AddComponent(); + BasisCilboxBuildTestBehaviour behaviour = contentRoot.AddComponent(); + behaviour.Marker = "before"; + + int loadedSceneCountBefore = SceneManager.sceneCount; + Scene activeSceneBefore = SceneManager.GetActiveScene(); + bool callbackReceivedValidLoadedScene = false; + string callbackSceneName = null; + handler = scene => + { + callbackReceivedValidLoadedScene = scene.IsValid() && scene.isLoaded; + callbackSceneName = scene.name; + behaviour.Marker = "prepared"; + }; + BasisCilboxBuildEvents.OnBeforeCilboxSerialize += handler; + + BasisAssetBundlePipeline.OnBeforeBuildPrefabSerialization.Invoke(contentRoot, null); + + Assert.AreEqual(contentScene, contentRoot.scene, + "The isolated content root was not restored to its owning scene after Cilbox conversion."); + Assert.AreEqual(activeSceneBefore, SceneManager.GetActiveScene(), + "Cilbox finalization did not restore the active scene before closing its staging scene."); + Assert.IsTrue(externalRoot.activeSelf, + "Cilbox conversion must not deactivate unrelated loaded scene roots."); + Assert.AreEqual("external-sentinel", externalCilbox.assemblyData, + "Cilbox conversion mutated assembly data in another loaded scene."); + Assert.AreEqual(loadedSceneCountBefore, SceneManager.sceneCount, + "The temporary Cilbox staging scene was not cleaned up."); + Assert.IsTrue(callbackReceivedValidLoadedScene, + "The pre-serialization callback did not receive the loaded staging scene."); + Assert.IsNotEmpty(callbackSceneName, + "The pre-serialization callback did not receive a named temporary scene."); + + Assert.IsTrue(behaviour == null, + "The authored Cilboxable behaviour should be replaced by a CilboxProxy."); + CilboxProxy proxy = contentRoot.GetComponent(); + Assert.IsNotNull(proxy, "Cilbox did not create a proxy for the generic Cilboxable behaviour."); + Assert.AreSame(contentCilbox, proxy.box, + "The generated proxy must point to the Cilbox that belongs to the isolated content."); + Assert.AreEqual("prepared", ReadSerializedStringField(proxy, nameof(BasisCilboxBuildTestBehaviour.Marker)), + "The pre-serialization callback ran too late; the proxy captured the pre-build field value."); + } + finally + { + if (handler != null) + { + BasisCilboxBuildEvents.OnBeforeCilboxSerialize -= handler; + } + + DestroyImmediateIfPresent(contentRoot); + DestroyImmediateIfPresent(externalRoot); + CloseEditorSceneIfLoaded(contentScene); + CloseEditorSceneIfLoaded(externalScene); + RestoreActiveScene(previousActiveScene); + } + } + + [Test] + public void PrefabFinalization_ReplacesStaleContentAssemblyData() + { + EnsureBuildHookRegistered(); + + Scene previousActiveScene = SceneManager.GetActiveScene(); + Scene contentScene = default; + GameObject contentRoot = null; + try + { + contentScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); + contentRoot = new GameObject("CilboxStaleAssemblyRoot"); + SceneManager.MoveGameObjectToScene(contentRoot, contentScene); + CilboxAvatarBasis contentCilbox = contentRoot.AddComponent(); + contentCilbox.assemblyData = "stale-assembly"; + contentRoot.AddComponent(); + + BasisAssetBundlePipeline.OnBeforeBuildPrefabSerialization.Invoke(contentRoot, null); + + Assert.AreNotEqual("stale-assembly", contentCilbox.assemblyData, + "Fresh Cilbox conversion did not replace authored stale assembly data."); + Assert.IsNotEmpty(contentCilbox.assemblyData); + Assert.AreSame(contentCilbox, contentRoot.GetComponent().box); + } + finally + { + DestroyImmediateIfPresent(contentRoot); + CloseEditorSceneIfLoaded(contentScene); + RestoreActiveScene(previousActiveScene); + } + } + + [Test] + public void PrefabFinalization_MissingCilboxFailsBeforeFeaturePreparation() + { + EnsureBuildHookRegistered(); + + Scene previousActiveScene = SceneManager.GetActiveScene(); + Scene contentScene = default; + GameObject contentRoot = null; + Action handler = null; + try + { + contentScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); + contentRoot = new GameObject("CilboxMissingHostRoot"); + SceneManager.MoveGameObjectToScene(contentRoot, contentScene); + BasisCilboxBuildTestBehaviour behaviour = contentRoot.AddComponent(); + int sceneCountBefore = SceneManager.sceneCount; + bool featurePreparationRan = false; + handler = _ => featurePreparationRan = true; + BasisCilboxBuildEvents.OnBeforeCilboxSerialize += handler; + + InvalidOperationException error = Assert.Throws( + () => BasisAssetBundlePipeline.OnBeforeBuildPrefabSerialization.Invoke(contentRoot, null)); + + StringAssert.Contains("no Cilbox component", error.Message); + Assert.IsFalse(featurePreparationRan, + "Feature preparation should not mutate content that cannot be safely Cilbox-converted."); + Assert.AreEqual(contentScene, contentRoot.scene); + Assert.AreEqual(sceneCountBefore, SceneManager.sceneCount, + "The failed finalization leaked its staging scene."); + Assert.IsNotNull(behaviour, + "A failed conversion must leave the authored behaviour intact rather than partially converting it."); + Assert.IsNull(contentRoot.GetComponent()); + } + finally + { + if (handler != null) + { + BasisCilboxBuildEvents.OnBeforeCilboxSerialize -= handler; + } + DestroyImmediateIfPresent(contentRoot); + CloseEditorSceneIfLoaded(contentScene); + RestoreActiveScene(previousActiveScene); + } + } + + [Test] + public void PreSerializationHandlers_AllRunThenAggregateFailuresAndCleanup() + { + EnsureBuildHookRegistered(); + + Scene previousActiveScene = SceneManager.GetActiveScene(); + Scene contentScene = default; + GameObject contentRoot = null; + Action failingHandler = null; + Action succeedingHandler = null; + try + { + contentScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); + contentRoot = new GameObject("CilboxSubscriberFailureRoot"); + SceneManager.MoveGameObjectToScene(contentRoot, contentScene); + contentRoot.AddComponent(); + BasisCilboxBuildTestBehaviour behaviour = contentRoot.AddComponent(); + int sceneCountBefore = SceneManager.sceneCount; + bool laterHandlerRan = false; + + failingHandler = _ => throw new InvalidOperationException("expected subscriber failure"); + succeedingHandler = _ => laterHandlerRan = true; + BasisCilboxBuildEvents.OnBeforeCilboxSerialize += failingHandler; + BasisCilboxBuildEvents.OnBeforeCilboxSerialize += succeedingHandler; + + AggregateException error = Assert.Throws( + () => BasisAssetBundlePipeline.OnBeforeBuildPrefabSerialization.Invoke(contentRoot, null)); + + Assert.IsTrue(laterHandlerRan, + "A failing feature preparer hid a later independent preparer."); + Assert.AreEqual(1, error.InnerExceptions.Count); + StringAssert.Contains(nameof(PreSerializationHandlers_AllRunThenAggregateFailuresAndCleanup), + error.InnerExceptions[0].ToString()); + Assert.AreEqual(contentScene, contentRoot.scene, + "Exception cleanup did not restore the content root to its scene."); + Assert.AreEqual(sceneCountBefore, SceneManager.sceneCount, + "Exception cleanup leaked the temporary staging scene."); + Assert.IsNotNull(behaviour, + "Cilbox conversion should not begin after feature preparation has failed."); + Assert.IsNull(contentRoot.GetComponent()); + } + finally + { + if (failingHandler != null) + { + BasisCilboxBuildEvents.OnBeforeCilboxSerialize -= failingHandler; + } + if (succeedingHandler != null) + { + BasisCilboxBuildEvents.OnBeforeCilboxSerialize -= succeedingHandler; + } + DestroyImmediateIfPresent(contentRoot); + CloseEditorSceneIfLoaded(contentScene); + RestoreActiveScene(previousActiveScene); + } + } + + [Test] + public void PrefabFinalization_RebindsAlreadyConvertedProxyToContentCilbox() + { + EnsureBuildHookRegistered(); + + Scene previousActiveScene = SceneManager.GetActiveScene(); + Scene externalScene = default; + Scene contentScene = default; + GameObject externalRoot = null; + GameObject contentRoot = null; + Action handler = null; + try + { + contentScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); + contentRoot = new GameObject("CilboxPreconvertedRoot"); + SceneManager.MoveGameObjectToScene(contentRoot, contentScene); + CilboxAvatarBasis contentCilbox = contentRoot.AddComponent(); + contentRoot.AddComponent(); + BasisAssetBundlePipeline.OnBeforeBuildPrefabSerialization.Invoke(contentRoot, null); + + CilboxProxy proxy = contentRoot.GetComponent(); + string generatedAssembly = contentCilbox.assemblyData; + Assert.IsNotEmpty(generatedAssembly); + + externalScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); + externalRoot = new GameObject("CilboxProxySourceRoot"); + SceneManager.MoveGameObjectToScene(externalRoot, externalScene); + CilboxAvatarBasis externalCilbox = externalRoot.AddComponent(); + externalCilbox.assemblyData = generatedAssembly; + + proxy.box = externalCilbox; + contentCilbox.assemblyData = string.Empty; + contentCilbox.ForceReinit(); + bool preparationRanAgain = false; + handler = _ => preparationRanAgain = true; + BasisCilboxBuildEvents.OnBeforeCilboxSerialize += handler; + + BasisAssetBundlePipeline.OnBeforeBuildPrefabSerialization.Invoke(contentRoot, null); + + Assert.IsFalse(preparationRanAgain, + "An already-converted clone should preserve its serialized program rather than rerunning feature baking."); + Assert.AreSame(contentCilbox, proxy.box, + "The cloned proxy remained dependent on a Cilbox outside its content hierarchy."); + Assert.AreEqual(generatedAssembly, contentCilbox.assemblyData, + "The cloned content Cilbox did not inherit the serialized program backing its proxies."); + Assert.AreEqual(generatedAssembly, externalCilbox.assemblyData, + "Rebinding a clone must not mutate the source/foreign Cilbox."); + } + finally + { + if (handler != null) + { + BasisCilboxBuildEvents.OnBeforeCilboxSerialize -= handler; + } + DestroyImmediateIfPresent(contentRoot); + DestroyImmediateIfPresent(externalRoot); + CloseEditorSceneIfLoaded(contentScene); + CloseEditorSceneIfLoaded(externalScene); + RestoreActiveScene(previousActiveScene); + } + } + + [Test] + public void SceneProcessor_IsOrderedBeforeCilboxAndSkipsUnrelatedScenes() + { + Type basisProcessorType = typeof(BasisCilboxBuildHook).Assembly.GetType( + "BasisCilboxPreSerializeSceneProcessor", throwOnError: true); + Type cilboxProcessorType = typeof(Cilbox.Cilbox).Assembly.GetType( + "Cilbox.CilboxCustomBuildProcessor", throwOnError: true); + IProcessSceneWithReport basisProcessor = (IProcessSceneWithReport)Activator.CreateInstance( + basisProcessorType, nonPublic: true); + IProcessSceneWithReport cilboxProcessor = (IProcessSceneWithReport)Activator.CreateInstance( + cilboxProcessorType, nonPublic: true); + + Assert.Less(basisProcessor.callbackOrder, cilboxProcessor.callbackOrder, + "Basis feature preparation must run before Cilbox's order-0 conversion stage."); + + Scene previousActiveScene = SceneManager.GetActiveScene(); + Scene scene = default; + GameObject root = null; + Action handler = null; + try + { + scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); + root = new GameObject("CilboxSceneProcessorFilterRoot"); + SceneManager.MoveGameObjectToScene(root, scene); + int callbacks = 0; + handler = _ => callbacks++; + BasisCilboxBuildEvents.OnBeforeCilboxSerialize += handler; + + basisProcessor.OnProcessScene(scene, null); + Assert.AreEqual(0, callbacks, + "The Basis scene processor invoked feature preparers for a scene with no Cilboxable content."); + + root.AddComponent(); + root.AddComponent(); + basisProcessor.OnProcessScene(scene, null); + Assert.AreEqual(1, callbacks); + } + finally + { + if (handler != null) + { + BasisCilboxBuildEvents.OnBeforeCilboxSerialize -= handler; + } + DestroyImmediateIfPresent(root); + CloseEditorSceneIfLoaded(scene); + RestoreActiveScene(previousActiveScene); + } + } + + [Test] + public void TestInEditorStages_KeepStructuralWorkInactiveButPreserveLegacyActiveContract() + { + EnsureBuildHookRegistered(); + + GameObject clone = new GameObject("TestInEditorStageContract"); + clone.SetActive(false); + bool inactivePreparationSawInactive = false; + bool finalizationSawInactive = false; + bool legacySawActive = false; + BasisAvatarSDKInspector.BeforeTestInEditorHandler prepare = go => inactivePreparationSawInactive = !go.activeSelf; + BasisAvatarSDKInspector.BeforeTestInEditorHandler finalize = go => finalizationSawInactive = !go.activeSelf; + BasisAvatarSDKInspector.BeforeTestInEditorHandler legacy = go => legacySawActive = go.activeSelf; + + try + { + BasisAvatarSDKInspector.OnBeforeTestInEditorPrepareInactive += prepare; + BasisAvatarSDKInspector.OnBeforeTestInEditorFinalize += finalize; + BasisAvatarSDKInspector.OnBeforeTestInEditor += legacy; + + InvokePrivateStatic(typeof(BasisAvatarSDKInspector), "ProcessTestInEditorClone", clone); + + Assert.IsTrue(inactivePreparationSawInactive); + Assert.IsTrue(finalizationSawInactive); + Assert.IsTrue(legacySawActive, + "The legacy Test In Editor hook no longer receives an active clone as it did before this branch."); + Assert.IsTrue(clone.activeSelf, + "Test In Editor must force the prepared clone active before loading it."); + } + finally + { + BasisAvatarSDKInspector.OnBeforeTestInEditorPrepareInactive -= prepare; + BasisAvatarSDKInspector.OnBeforeTestInEditorFinalize -= finalize; + BasisAvatarSDKInspector.OnBeforeTestInEditor -= legacy; + DestroyImmediateIfPresent(clone); + } + } + + [Test] + public void PrefabFinalization_WorksWithoutPreSerializationSubscribers() + { + EnsureBuildHookRegistered(); + + Scene previousActiveScene = SceneManager.GetActiveScene(); + Scene contentScene = default; + GameObject contentRoot = null; + + try + { + contentScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); + contentRoot = new GameObject("CilboxNoSubscriberRoot"); + SceneManager.MoveGameObjectToScene(contentRoot, contentScene); + CilboxAvatarBasis contentCilbox = contentRoot.AddComponent(); + contentRoot.AddComponent(); + + BasisAssetBundlePipeline.OnBeforeBuildPrefabSerialization.Invoke(contentRoot, null); + + CilboxProxy proxy = contentRoot.GetComponent(); + Assert.IsNotNull(proxy, "Generic Cilbox conversion should not require an event subscriber."); + Assert.AreSame(contentCilbox, proxy.box); + } + finally + { + DestroyImmediateIfPresent(contentRoot); + CloseEditorSceneIfLoaded(contentScene); + RestoreActiveScene(previousActiveScene); + } + } + + [UnityTest] + public IEnumerator TestInEditor_CilboxTypeFailureDestroysPreparedCloneAndRestoresSource() + { + yield return new EnterPlayMode(); + + EnsureBuildHookRegistered(); + Scene contentScene = SceneManager.CreateScene("BasisCilboxTypeFailureCleanupTest"); + GameObject sourceRoot = new GameObject("CilboxTypeFailureSource"); + SceneManager.MoveGameObjectToScene(sourceRoot, contentScene); + BasisAvatar avatar = sourceRoot.AddComponent(); + sourceRoot.AddComponent(); + sourceRoot.AddComponent(); + + int rootCountBeforeLoad = contentScene.rootCount; + int sceneCountBeforeLoad = SceneManager.sceneCount; + LogAssert.Expect(LogType.Error, new Regex( + "Test In Editor failed while preparing the avatar clone:.*CilboxException", + RegexOptions.Singleline)); + + InvokePrivateStatic(typeof(BasisAvatarSDKInspector), "LoadAvatar", avatar); + + Assert.IsTrue(sourceRoot.activeSelf, + "A failed Test In Editor conversion must restore the authored avatar immediately."); + + // LoadAvatar uses Destroy in Play Mode and the Cilbox staging scene unload is asynchronous. + // Give both cleanup paths enough frames to remove their temporary objects/scenes. + yield return null; + yield return null; + + Assert.AreEqual(rootCountBeforeLoad, contentScene.rootCount, + "A failed Cilbox type check left a cloned Test In Editor object in the authored scene."); + Assert.AreEqual(sceneCountBeforeLoad, SceneManager.sceneCount, + "A failed Cilbox type check leaked its temporary staging scene."); + Assert.AreSame(sourceRoot, contentScene.GetRootGameObjects().Single(), + "The authored scene contains an unexpected root after failed Test In Editor preparation."); + Assert.IsTrue(sourceRoot.activeSelf, + "The authored avatar remained disabled after failed Test In Editor preparation."); + + UnityEngine.Object.Destroy(sourceRoot); + yield return null; + AsyncOperation unload = SceneManager.UnloadSceneAsync(contentScene); + if (unload != null) + { + yield return unload; + } + + yield return new ExitPlayMode(); + } + + [UnityTest] + public IEnumerator PrefabFinalization_PlayModeUsesRuntimeStagingSceneAndKeepsNativeCloneInert() + { + yield return new EnterPlayMode(); + + EnsureBuildHookRegistered(); + Scene contentScene = SceneManager.CreateScene("BasisCilboxPlayModeTest"); + GameObject sourceRoot = new GameObject("CilboxPlayModeSource"); + SceneManager.MoveGameObjectToScene(sourceRoot, contentScene); + sourceRoot.AddComponent(); + sourceRoot.AddComponent(); + BasisCilboxBuildTestBehaviour.NativeAwakeCount = 0; + + GameObject clone = (GameObject)InvokePrivateStatic( + typeof(BasisAvatarSDKInspector), "InstantiateInactiveClone", sourceRoot); + Assert.IsFalse(clone.activeSelf, + "The Test In Editor clone was activated before structural/finalization hooks could replace authoring scripts."); + Assert.AreEqual(0, BasisCilboxBuildTestBehaviour.NativeAwakeCount, + "Cloning an active source executed the native Cilboxable Awake before conversion."); + + int sceneCountBeforeFinalization = SceneManager.sceneCount; + BasisAssetBundlePipeline.OnBeforeBuildPrefabSerialization.Invoke(clone, null); + Assert.AreEqual(contentScene, clone.scene); + Assert.IsNull(clone.GetComponent()); + Assert.IsNotNull(clone.GetComponent()); + Assert.AreEqual(0, BasisCilboxBuildTestBehaviour.NativeAwakeCount, + "Native authoring code ran while the play-mode clone was being converted."); + + // Runtime scene unload is asynchronous; give it frames to complete. + yield return null; + yield return null; + Assert.AreEqual(sceneCountBeforeFinalization, SceneManager.sceneCount, + "The play-mode Cilbox staging scene did not unload asynchronously."); + + UnityEngine.Object.Destroy(clone); + UnityEngine.Object.Destroy(sourceRoot); + yield return null; + AsyncOperation unload = SceneManager.UnloadSceneAsync(contentScene); + if (unload != null) + { + yield return unload; + } + + yield return new ExitPlayMode(); + } + + private static void EnsureBuildHookRegistered() + { + InvokePrivateStatic(typeof(BasisCilboxBuildHook), "Initialize"); + } + + private static object InvokePrivateStatic(Type type, string methodName, params object[] args) + { + MethodInfo method = type.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(method, $"Could not find {type.FullName}.{methodName} for regression validation."); + try + { + return method.Invoke(null, args); + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + throw ex.InnerException; + } + } + + private static string ReadSerializedStringField(CilboxProxy proxy, string fieldName) + { + byte[] bytes = Convert.FromBase64String(proxy.serializedObjectData); + Serializee[] fields = new Serializee(bytes, Serializee.ElementType.List).AsArray(); + foreach (Serializee field in fields) + { + Dictionary map = field.AsMap(); + if (map.TryGetValue("n", out Serializee name) && name.AsString() == fieldName) + { + return map["d"].AsString(); + } + } + + Assert.Fail($"Serialized proxy data did not contain field '{fieldName}'. Fields: " + + string.Join(", ", fields.Select(field => field.AsMap()["n"].AsString()))); + return null; + } + + private static void DestroyImmediateIfPresent(GameObject gameObject) + { + if (gameObject != null) + { + UnityEngine.Object.DestroyImmediate(gameObject); + } + } + + private static void CloseEditorSceneIfLoaded(Scene scene) + { + if (scene.IsValid() && scene.isLoaded) + { + EditorSceneManager.CloseScene(scene, true); + } + } + + private static void RestoreActiveScene(Scene scene) + { + if (scene.IsValid() && scene.isLoaded) + { + SceneManager.SetActiveScene(scene); + } + } + } +} diff --git a/Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisCilboxBuildPipelineTests.cs.meta b/Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisCilboxBuildPipelineTests.cs.meta new file mode 100644 index 0000000000..c0d9e07ec0 --- /dev/null +++ b/Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisCilboxBuildPipelineTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 317caf3142be40cebd9c5e5dbcdd6c27 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 93f0d4f9be07b5e4094ac46f24473eb1, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisTemporaryStorageMaterialTests.cs b/Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisTemporaryStorageMaterialTests.cs new file mode 100644 index 0000000000..6e41929489 --- /dev/null +++ b/Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisTemporaryStorageMaterialTests.cs @@ -0,0 +1,121 @@ +using System; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace Basis.Tests.Sync +{ + public sealed class BasisTemporaryStorageMaterialTests + { + [Test] + public void SavePrefabToTemporaryStorage_PreservesTransientRendererMaterial() + { + string folderName = $"BasisTempMaterialTest_{Guid.NewGuid():N}"; + string temporaryStorage = $"Assets/{folderName}"; + string sourceMaterialPath = $"{temporaryStorage}/Source.mat"; + BasisAssetBundleObject settings = null; + GameObject root = null; + Material transientMaterial = null; + + try + { + AssetDatabase.CreateFolder("Assets", folderName); + + Shader shader = Shader.Find("Universal Render Pipeline/Lit"); + Assert.IsNotNull(shader, "URP Lit is required to construct the material test fixture."); + + Texture2D authoringTexture = new Texture2D(1, 1) { name = "AuthoringReference" }; + string authoringTexturePath = $"{temporaryStorage}/AuthoringReference.asset"; + AssetDatabase.CreateAsset(authoringTexture, authoringTexturePath); + AssetDatabase.ImportAsset(authoringTexturePath, ImportAssetOptions.ForceSynchronousImport); + authoringTexture = AssetDatabase.LoadAssetAtPath(authoringTexturePath); + + Material sourceMaterial = new Material(shader) { name = "Source" }; + sourceMaterial.SetTexture("_BaseMap", authoringTexture); + AssetDatabase.CreateAsset(sourceMaterial, sourceMaterialPath); + AssetDatabase.ImportAsset(sourceMaterialPath, ImportAssetOptions.ForceSynchronousImport); + sourceMaterial = AssetDatabase.LoadAssetAtPath(sourceMaterialPath); + Assert.IsNotNull(sourceMaterial); + Assert.IsTrue(EditorUtility.IsPersistent(sourceMaterial)); + Assert.AreSame(authoringTexture, sourceMaterial.GetTexture("_BaseMap")); + + transientMaterial = UnityEngine.Object.Instantiate(sourceMaterial); + transientMaterial.name = "Source_Stripped"; + transientMaterial.SetTexture("_BaseMap", null); + Assert.IsFalse(EditorUtility.IsPersistent(transientMaterial), + "The fixture must exercise a transient material reference."); + Assert.IsNull(transientMaterial.GetTexture("_BaseMap")); + + root = new GameObject("TransientMaterialPrefabRoot"); + MeshRenderer renderer = root.AddComponent(); + renderer.sharedMaterial = transientMaterial; + GameObject child = new GameObject("SharedMaterialChild"); + child.transform.SetParent(root.transform, false); + MeshRenderer childRenderer = child.AddComponent(); + childRenderer.sharedMaterial = transientMaterial; + + settings = ScriptableObject.CreateInstance(); + settings.TemporaryStorage = temporaryStorage; + + bool wasModified = false; + string prefabPath = TemporaryStorageHandler.SavePrefabToTemporaryStorage( + root, settings, ref wasModified, out _); + + Assert.IsTrue(wasModified); + UnityEngine.Object.DestroyImmediate(root); + root = null; + + AssetDatabase.ImportAsset(prefabPath, ImportAssetOptions.ForceSynchronousImport); + GameObject savedPrefab = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.IsNotNull(savedPrefab, "The staged prefab could not be reloaded from the AssetDatabase."); + + MeshRenderer savedRenderer = savedPrefab.GetComponent(); + Assert.IsNotNull(savedRenderer); + Assert.IsNotNull(savedRenderer.sharedMaterial, + "A transient material assigned before prefab staging was serialized as a missing material reference."); + Assert.IsTrue(EditorUtility.IsPersistent(savedRenderer.sharedMaterial), + "The staged prefab still references a non-persistent material after serialization."); + string persistedMaterialPath = AssetDatabase.GetAssetPath(savedRenderer.sharedMaterial); + Assert.IsFalse(string.IsNullOrEmpty(persistedMaterialPath), + "The staged material does not have a durable AssetDatabase path."); + Assert.IsNull(savedRenderer.sharedMaterial.GetTexture("_BaseMap"), + "The persisted build material regained the authoring-only texture that the transient clone removed."); + + string[] stagedDependencies = AssetDatabase.GetDependencies(prefabPath, true); + CollectionAssert.Contains(stagedDependencies, persistedMaterialPath, + "The staged prefab dependency graph does not include its generated persistent material."); + CollectionAssert.DoesNotContain(stagedDependencies, authoringTexturePath, + "The authoring-only texture cleared from the transient material leaked back into staged dependencies."); + + MeshRenderer savedChildRenderer = savedPrefab.transform.Find("SharedMaterialChild") + .GetComponent(); + Assert.AreSame(savedRenderer.sharedMaterial, savedChildRenderer.sharedMaterial, + "One transient material shared by multiple renderers should map to one persistent temporary material asset."); + + Material originalMaterial = AssetDatabase.LoadAssetAtPath(sourceMaterialPath); + Assert.AreSame(authoringTexture, originalMaterial.GetTexture("_BaseMap"), + "Persisting the build clone must not mutate the authored source material asset."); + } + finally + { + if (root != null) + { + UnityEngine.Object.DestroyImmediate(root); + } + + if (transientMaterial != null && !EditorUtility.IsPersistent(transientMaterial)) + { + UnityEngine.Object.DestroyImmediate(transientMaterial); + } + + if (settings != null) + { + UnityEngine.Object.DestroyImmediate(settings); + } + + AssetDatabase.DeleteAsset(temporaryStorage); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + } + } +} diff --git a/Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisTemporaryStorageMaterialTests.cs.meta b/Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisTemporaryStorageMaterialTests.cs.meta new file mode 100644 index 0000000000..1b6a1ace86 --- /dev/null +++ b/Basis/Packages/com.basis.framework/Tests/Editor/Sync/BasisTemporaryStorageMaterialTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c7ee258a2f04635a9c9c0a94fdd20d8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.sdk/Scripts/Editor/AssetBundleBuilder/BasisAssetBundlePipeline.cs b/Basis/Packages/com.basis.sdk/Scripts/Editor/AssetBundleBuilder/BasisAssetBundlePipeline.cs index f7e0806c87..6dab850303 100644 --- a/Basis/Packages/com.basis.sdk/Scripts/Editor/AssetBundleBuilder/BasisAssetBundlePipeline.cs +++ b/Basis/Packages/com.basis.sdk/Scripts/Editor/AssetBundleBuilder/BasisAssetBundlePipeline.cs @@ -19,6 +19,13 @@ public static class BasisAssetBundlePipeline // Static delegates public static BeforeBuildGameobjectHandler OnBeforeBuildPrefab; + /// + /// Final prefab preparation stage. Runs after normal prefab processors and immediately before + /// Basis post-processing/staging. Consumers that replace authoring components during build + /// should subscribe here so earlier processors can finish mutating the isolated clone first. + /// For example have Cilbox serialize after NDMF has done its mutations. + /// + public static BeforeBuildGameobjectHandler OnBeforeBuildPrefabSerialization; public static AfterBuildHandler OnAfterBuildPrefab; public static BuildErrorHandler OnBuildErrorPrefab; @@ -101,6 +108,7 @@ private static BasisBundleContentKind ResolveContentKind(bool isScene, GameObjec prefab = Object.Instantiate(asset); DestroyEditorOnlyInAvatar(prefab); OnBeforeBuildPrefab?.Invoke(prefab, settings); + OnBeforeBuildPrefabSerialization?.Invoke(prefab, settings); PostProcessAvatar(prefab); meta = BasisBundleBuild.GenerateMetaData(prefab); diff --git a/Basis/Packages/com.basis.sdk/Scripts/Editor/AssetBundleBuilder/BasisTemporaryStorageHandler.cs b/Basis/Packages/com.basis.sdk/Scripts/Editor/AssetBundleBuilder/BasisTemporaryStorageHandler.cs index c7df6f5956..527c6f8952 100644 --- a/Basis/Packages/com.basis.sdk/Scripts/Editor/AssetBundleBuilder/BasisTemporaryStorageHandler.cs +++ b/Basis/Packages/com.basis.sdk/Scripts/Editor/AssetBundleBuilder/BasisTemporaryStorageHandler.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.IO; using UnityEditor; using UnityEditor.SceneManagement; @@ -8,12 +10,138 @@ public static class TemporaryStorageHandler public static string SavePrefabToTemporaryStorage(GameObject prefab, BasisAssetBundleObject settings, ref bool wasModified, out string uniqueID) { EnsureDirectoryExists(settings.TemporaryStorage); + PersistTransientRendererMaterials(prefab, settings.TemporaryStorage); + uniqueID = BasisGenerateUniqueID.GenerateUniqueID(); string prefabPath = Path.Combine(settings.TemporaryStorage, $"{uniqueID}.prefab"); prefab = PrefabUtility.SaveAsPrefabAsset(prefab, prefabPath); wasModified = true; return prefabPath; } + + /// + /// Prefab assets cannot safely retain references to in-memory Material instances. Build processors + /// are allowed to clone/modify materials on the isolated prefab, so persist those transient clones + /// into the same temporary storage before crossing the prefab serialization boundary. + /// + private static void PersistTransientRendererMaterials(GameObject prefab, string temporaryStorage) + { + if (prefab == null) + { + throw new ArgumentNullException(nameof(prefab)); + } + + var remappedMaterials = new Dictionary(); + Renderer[] renderers = prefab.GetComponentsInChildren(true); + for (int rendererIndex = 0; rendererIndex < renderers.Length; rendererIndex++) + { + Renderer renderer = renderers[rendererIndex]; + if (renderer == null) + { + continue; + } + + Material[] materials = renderer.sharedMaterials; + bool changed = false; + for (int slot = 0; slot < materials.Length; slot++) + { + Material material = materials[slot]; + if (material == null) + { + continue; + } + + EntityId entityId = material.GetEntityId(); + if (remappedMaterials.TryGetValue(entityId, out Material remapped)) + { + materials[slot] = remapped; + changed = true; + continue; + } + + if (EditorUtility.IsPersistent(material)) + { + continue; + } + + Material persisted = PersistMaterial(material, temporaryStorage); + remappedMaterials.Add(entityId, persisted); + materials[slot] = persisted; + changed = true; + } + + if (changed) + { + renderer.sharedMaterials = materials; + EditorUtility.SetDirty(renderer); + } + } + } + + private static Material PersistMaterial(Material source, string temporaryStorage) + { + string sourceName = source.name; + string assetName = SanitizeAssetFileName(sourceName); + string path = AssetDatabase.GenerateUniqueAssetPath( + Path.Combine(temporaryStorage, $"{assetName}.mat").Replace('\\', '/')); + HideFlags originalHideFlags = source.hideFlags; + + try + { + // Persist the generated instance itself rather than cloning it again. Any other references + // to this same build-time material then become durable as well. + source.hideFlags = HideFlags.None; + AssetDatabase.CreateAsset(source, path); + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport); + Material loaded = AssetDatabase.LoadAssetAtPath(path); + if (loaded == null || !EditorUtility.IsPersistent(loaded)) + { + throw new InvalidOperationException($"Unity did not persist the generated material at '{path}'."); + } + + return loaded; + } + catch (Exception ex) + { + if (source != null && EditorUtility.IsPersistent(source)) + { + AssetDatabase.DeleteAsset(path); + } + else if (AssetDatabase.LoadAssetAtPath(path) != null) + { + AssetDatabase.DeleteAsset(path); + } + else if (source != null) + { + source.hideFlags = originalHideFlags; + } + + throw new InvalidOperationException( + $"Failed to persist transient build material '{sourceName}' before saving the staged prefab. " + + "The build was stopped rather than serializing a missing material reference.", ex); + } + } + + private static string SanitizeAssetFileName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "GeneratedMaterial"; + } + + char[] invalidChars = Path.GetInvalidFileNameChars(); + char[] chars = value.ToCharArray(); + for (int i = 0; i < chars.Length; i++) + { + if (Array.IndexOf(invalidChars, chars[i]) >= 0 || chars[i] == '/' || chars[i] == '\\') + { + chars[i] = '_'; + } + } + + string sanitized = new string(chars).Trim(); + return string.IsNullOrEmpty(sanitized) ? "GeneratedMaterial" : sanitized; + } // SaveScene lived here, but it handed the build the scene's original path while only the bundle // name was unique, which is what let two worlds built from one scene collide. Scenes are now // staged by BasisSceneBuildName instead. diff --git a/Basis/Packages/com.basis.sdk/Scripts/Editor/SDKInspector/BasisAvatarSDKInspector.cs b/Basis/Packages/com.basis.sdk/Scripts/Editor/SDKInspector/BasisAvatarSDKInspector.cs index 5703292540..c434bd7475 100644 --- a/Basis/Packages/com.basis.sdk/Scripts/Editor/SDKInspector/BasisAvatarSDKInspector.cs +++ b/Basis/Packages/com.basis.sdk/Scripts/Editor/SDKInspector/BasisAvatarSDKInspector.cs @@ -7,6 +7,7 @@ using UnityEditor; using UnityEditor.UIElements; using UnityEngine; +using UnityEngine.SceneManagement; using UnityEngine.UIElements; using static BasisAvatarValidator; using Basis.Scripts.BasisSdk.Players; @@ -17,7 +18,41 @@ public partial class BasisAvatarSDKInspector : Editor private const string PendingTestInEditorAvatarIdSessionKey = "BasisAvatarSDKInspector.PendingTestInEditorAvatarId"; public delegate void BeforeTestInEditorHandler(GameObject clone); + public delegate void BeforeTestInEditorOriginalDeactivationHandler( + GameObject original, + TestInEditorOriginalDeactivationContext context); + + public sealed class TestInEditorOriginalDeactivationContext + { + public int SettleFrames { get; private set; } + + public void RequestSettleFrames(int frames) + { + SettleFrames = Math.Max(SettleFrames, Math.Max(0, frames)); + } + } + /// + /// Legacy Test in Editor preparation stage. The clone is active when this runs, matching the + /// historical contract for consumers that expect initialized Animator/renderer state. + /// public static BeforeTestInEditorHandler OnBeforeTestInEditor; + /// + /// Structural Test in Editor preparation stage. The clone is intentionally inactive here so + /// authoring-only behaviours cannot execute before final build-time conversion has completed. + /// Processors registered here must support inactive hierarchies. + /// + public static BeforeTestInEditorHandler OnBeforeTestInEditorPrepareInactive; + /// + /// Final inactive Test in Editor preparation stage. Runs after structural clone processors and + /// immediately before the clone is activated. Build-time component replacement belongs here. + /// + public static BeforeTestInEditorHandler OnBeforeTestInEditorFinalize; + /// + /// Runs while the authored Test in Editor object is still active, immediately before Basis + /// deactivates it. Handlers may inspect feature-specific runtime components and return the number + /// of inactive frames they require before Basis clones the object. The largest request wins. + /// + public static event BeforeTestInEditorOriginalDeactivationHandler OnBeforeTestInEditorOriginalDeactivation; private static BasisAvatar ScheduledTestInEditorAvatar; public static event Action InspectorGuiCreated; @@ -102,7 +137,7 @@ private static void ClearPendingTestInEditorAvatarId() private void OnEnable() { visualTree = AssetDatabase.LoadAssetAtPath(BasisSDKConstants.AvataruxmlPath); - Avatar = (BasisAvatar)target; + Avatar = target as BasisAvatar; } public void OnDisable() { @@ -114,8 +149,12 @@ public void OnDisable() public override VisualElement CreateInspectorGUI() { - Avatar = (BasisAvatar)target; + Avatar = target as BasisAvatar; rootElement = new VisualElement(); + if (Avatar == null) + { + return rootElement; + } if (visualTree != null) { uiElementsRoot = visualTree.CloneTree(); @@ -274,8 +313,11 @@ public void EventCallbackFaceVisemeMesh(ChangeEvent evt, ref } private void OnSceneGUI() { - Avatar = (BasisAvatar)target; - BasisAvatarGizmoEditor.UpdateGizmos(this, Avatar); + Avatar = target as BasisAvatar; + if (Avatar != null) + { + BasisAvatarGizmoEditor.UpdateGizmos(this, Avatar); + } } public void SetupItems() { @@ -615,6 +657,12 @@ public void RequestAvatarLoad() private static void RequestAvatarLoad(BasisAvatar avatar) { + if (avatar == null) + { + BasisDebug.LogError("Unable to Test In Editor because the avatar reference is no longer valid.", BasisDebug.LogTag.Editor); + return; + } + if (BasisLocalPlayerData.PlayerReady) { BasisDebug.Log("Player Ready Loading", BasisDebug.LogTag.Editor); @@ -642,54 +690,154 @@ private static void LoadScheduledAvatar() LoadAvatar(avatar); } - private static async void LoadAvatar(BasisAvatar avatar) + private static GameObject InstantiateInactiveClone(GameObject originalObject) { - BasisDebug.Log("LoadAvatar Called", BasisDebug.LogTag.Editor); + GameObject stagingRoot = new GameObject("Basis Test In Editor Clone Staging") + { + hideFlags = HideFlags.HideAndDontSave + }; + stagingRoot.SetActive(false); - var jigglesToReset = new List(); - foreach (MonoBehaviour jiggle in avatar.gameObject.GetComponentsInChildren(false)) + if (!EditorUtility.IsPersistent(originalObject) && originalObject.scene.IsValid() && originalObject.scene.isLoaded) { - if (jiggle != null - && jiggle.GetType().FullName == "GatorDragonGames.JigglePhysics.JiggleRig" - && jiggle.enabled) - { - jigglesToReset.Add(jiggle); - } + SceneManager.MoveGameObjectToScene(stagingRoot, originalObject.scene); } - GameObject inSceneItem; - if (jigglesToReset.Count > 0) + + try { - BasisDebug.Log("Enabled Jiggles were found when Test in Editor was entered. The avatar will be disabled in order to reset the Jiggle transforms.", BasisDebug.LogTag.Editor); - avatar.gameObject.SetActive(false); - // It's a bit of a hack, but waiting three frames works. - await Awaitable.NextFrameAsync(); - await Awaitable.NextFrameAsync(); - await Awaitable.NextFrameAsync(); - inSceneItem = GameObject.Instantiate(avatar.gameObject); - avatar.gameObject.SetActive(true); - inSceneItem.SetActive(true); + GameObject clone = GameObject.Instantiate(originalObject, stagingRoot.transform, true); + // The inactive parent prevents Awake/OnEnable from running even when the source is an + // active persistent prefab asset. Make activeSelf false before removing that parent. + clone.SetActive(false); + clone.transform.SetParent(null, true); + return clone; } - else + finally { - inSceneItem = GameObject.Instantiate(avatar.gameObject); + if (Application.isPlaying) + { + GameObject.Destroy(stagingRoot); + } + else + { + GameObject.DestroyImmediate(stagingRoot); + } } + } + private static void ProcessTestInEditorClone(GameObject inSceneItem) + { BasisAssetBundlePipeline.DestroyEditorOnlyInAvatar(inSceneItem); + OnBeforeTestInEditorPrepareInactive?.Invoke(inSceneItem); + OnBeforeTestInEditorFinalize?.Invoke(inSceneItem); + + // Finalization has removed/replaced authoring-only runtime scripts. Activate before the + // legacy hook and PostProcessAvatar so consumers see the same initialized hierarchy they + // historically received and active-only bone traversal remains valid. + inSceneItem.SetActive(true); OnBeforeTestInEditor?.Invoke(inSceneItem); BasisAssetBundlePipeline.PostProcessAvatar(inSceneItem); + } - BasisLoadableBundle LoadableBundle = new BasisLoadableBundle + private static int GetTestInEditorOriginalSettleFrames(GameObject originalObject) + { + var context = new TestInEditorOriginalDeactivationContext(); + OnBeforeTestInEditorOriginalDeactivation?.Invoke(originalObject, context); + return context.SettleFrames; + } + + private static async void LoadAvatar(BasisAvatar avatar) + { + if (avatar == null || avatar.gameObject == null) { - LoadableGameobject = new BasisLoadableGameobject() { InSceneItem = inSceneItem } - }; - LoadableBundle.LoadableGameobject.InSceneItem.transform.parent = null; - LoadableBundle.BasisRemoteBundleEncrypted = new BasisRemoteEncyptedBundle + BasisDebug.LogError("Unable to Test In Editor because the avatar reference is no longer valid.", BasisDebug.LogTag.Editor); + return; + } + + BasisDebug.Log("LoadAvatar Called", BasisDebug.LogTag.Editor); + + GameObject originalObject = avatar.gameObject; + bool originalWasActive = originalObject.activeSelf; + bool disabledOriginal = false; + bool cloneHandedToPlayer = false; + GameObject inSceneItem = null; + + try { - RemoteBeeFileLocation = BasisGenerateUniqueID.GenerateUniqueID() - }; - BasisDebug.Log("Requesting Avatar Load", BasisDebug.LogTag.Editor); - await BasisLocalPlayerData.Instance.CreateAvatarFromMode(BasisLoadMode.ByGameobjectReference, LoadableBundle); - BasisDebug.Log("Avatar Load Complete", BasisDebug.LogTag.Editor); + // In play mode the authored scene instance and the Test in Editor clone must never run + // together. Persistent prefab assets are not scene instances and must not be modified. + bool canDisableOriginal = Application.isPlaying + && !EditorUtility.IsPersistent(originalObject) + && originalObject.scene.IsValid() + && originalObject.scene.isLoaded; + + int settleFrames = 0; + if (canDisableOriginal && originalWasActive) + { + settleFrames = GetTestInEditorOriginalSettleFrames(originalObject); + originalObject.SetActive(false); + disabledOriginal = true; + } + + for (int frame = 0; disabledOriginal && frame < settleFrames; frame++) + { + await Awaitable.NextFrameAsync(); + } + + inSceneItem = InstantiateInactiveClone(originalObject); + + ProcessTestInEditorClone(inSceneItem); + + BasisLoadableBundle LoadableBundle = new BasisLoadableBundle + { + LoadableGameobject = new BasisLoadableGameobject() { InSceneItem = inSceneItem } + }; + LoadableBundle.LoadableGameobject.InSceneItem.transform.parent = null; + LoadableBundle.BasisRemoteBundleEncrypted = new BasisRemoteEncyptedBundle + { + RemoteBeeFileLocation = BasisGenerateUniqueID.GenerateUniqueID() + }; + BasisDebug.Log("Requesting Avatar Load", BasisDebug.LogTag.Editor); + IBasisLocalPlayer localPlayer = BasisLocalPlayerData.Instance; + if (localPlayer == null) + { + throw new InvalidOperationException("The local player disappeared before Test In Editor could load the prepared avatar."); + } + + // From this point the local-player avatar path owns the clone. Do not destroy it from the + // editor catch path if loading fails after ownership has been accepted. + cloneHandedToPlayer = true; + await localPlayer.CreateAvatarFromMode(BasisLoadMode.ByGameobjectReference, LoadableBundle); + BasisDebug.Log("Avatar Load Complete", BasisDebug.LogTag.Editor); + + // The in-scene object is now the local player's avatar. Keep the authored scene instance + // disabled for the rest of play mode so there is never a second copy executing beside it. + disabledOriginal = false; + } + catch (Exception ex) + { + BasisDebug.LogError($"Test In Editor failed while preparing the avatar clone: {ex}", BasisDebug.LogTag.Editor); + if (inSceneItem != null && !cloneHandedToPlayer) + { + if (Application.isPlaying) + { + GameObject.Destroy(inSceneItem); + } + else + { + GameObject.DestroyImmediate(inSceneItem); + } + } + } + finally + { + // Only restore if preparation failed before ownership of the clone transferred to the + // local-player avatar path. Successful play-mode tests intentionally leave it disabled. + if (disabledOriginal && originalObject != null) + { + originalObject.SetActive(originalWasActive); + } + } } private void ClearResultLabel() { diff --git a/Basis/Packages/com.basis.shim/Shims/BasisCilboxBuildHook.cs b/Basis/Packages/com.basis.shim/Shims/BasisCilboxBuildHook.cs index 7e19d59afe..2d450826ed 100644 --- a/Basis/Packages/com.basis.shim/Shims/BasisCilboxBuildHook.cs +++ b/Basis/Packages/com.basis.shim/Shims/BasisCilboxBuildHook.cs @@ -1,24 +1,71 @@ #if UNITY_EDITOR using System; using System.Collections.Generic; -using System.Threading.Tasks; using Basis.Scripts.BasisSdk; using Cilbox; using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; +/// +/// Basis-owned extension point for editor preparation that must happen immediately before Cilbox +/// discovers and serializes Cilboxable behaviours. Feature packages may subscribe without adding +/// feature-specific knowledge to Basis or modifying Cilbox's public serialization API. +/// +public static class BasisCilboxBuildEvents +{ + public static event Action OnBeforeCilboxSerialize; + + internal static void InvokeBeforeCilboxSerialize(Scene scene) + { + Action handlers = OnBeforeCilboxSerialize; + if (handlers == null) + { + return; + } + + List failures = null; + Delegate[] invocationList = handlers.GetInvocationList(); + for (int i = 0; i < invocationList.Length; i++) + { + Action handler = (Action)invocationList[i]; + try + { + handler(scene); + } + catch (Exception ex) + { + failures ??= new List(); + string owner = handler.Method.DeclaringType != null + ? handler.Method.DeclaringType.FullName + : ""; + InvalidOperationException wrapped = new InvalidOperationException( + $"Cilbox pre-serialization handler {owner}.{handler.Method.Name} failed.", ex); + failures.Add(wrapped); + } + } + + // Run every independent feature preparer so one package cannot hide failures in packages + // registered after it, but still fail the overall build rather than shipping partial output. + if (failures != null) + { + throw new AggregateException("One or more Cilbox pre-serialization handlers failed.", failures); + } + } +} + public class BasisCilboxBuildHook { [InitializeOnLoadMethod] private static void Initialize() { - //Debug.Log("BasisCilboxBuildHook initialized."); - BasisAssetBundlePipeline.OnBeforeBuildPrefab -= HandleBeforeBuildPrefab; - BasisAssetBundlePipeline.OnBeforeBuildPrefab += HandleBeforeBuildPrefab; - BasisAvatarSDKInspector.OnBeforeTestInEditor -= HandleBeforeTestInEditor; - BasisAvatarSDKInspector.OnBeforeTestInEditor += HandleBeforeTestInEditor; + BasisAssetBundlePipeline.OnBeforeBuildPrefabSerialization -= HandleBeforeBuildPrefab; + BasisAssetBundlePipeline.OnBeforeBuildPrefabSerialization += HandleBeforeBuildPrefab; + BasisAvatarSDKInspector.OnBeforeTestInEditorFinalize -= HandleBeforeTestInEditor; + BasisAvatarSDKInspector.OnBeforeTestInEditorFinalize += HandleBeforeTestInEditor; } private static void HandleBeforeTestInEditor(GameObject prefabRoot) @@ -28,24 +75,31 @@ private static void HandleBeforeTestInEditor(GameObject prefabRoot) private static void HandleBeforeBuildPrefab(GameObject prefabRoot, BasisAssetBundleObject settings) { - if (prefabRoot == null || !HasCilboxableComponents(prefabRoot)) + if (prefabRoot == null) + { + return; + } + + bool hasNativeCilboxables = HasCilboxableComponents(prefabRoot); + CilboxProxy[] existingProxies = prefabRoot.GetComponentsInChildren(true); + if (!hasNativeCilboxables && existingProxies.Length == 0) { return; } - Debug.Log("Basis build prehook: generating Cilbox assembly data on the isolated build clone."); + Debug.Log("Basis build finalization: preparing Cilbox data on the isolated build clone."); + Scene originalScene = prefabRoot.scene; + Scene originalActiveScene = SceneManager.GetActiveScene(); Transform originalParent = prefabRoot.transform.parent; int originalSiblingIndex = originalParent != null ? prefabRoot.transform.GetSiblingIndex() : -1; - Dictionary cilboxAssemblySnapshot = CaptureCilboxAssemblySnapshot(); - List temporarilyDisabledRoots = new List(); Scene temporaryScene = default; - Cilbox.Cilbox temporarySceneCilbox = null; - GameObject temporaryCilboxHost = null; bool detachedFromParent = false; + try { - temporaryScene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); + temporaryScene = CreateTemporaryScene(); + if (originalParent != null) { prefabRoot.transform.SetParent(null, true); @@ -55,175 +109,146 @@ private static void HandleBeforeBuildPrefab(GameObject prefabRoot, BasisAssetBun SceneManager.MoveGameObjectToScene(prefabRoot, temporaryScene); SceneManager.SetActiveScene(temporaryScene); - DeactivateOtherSceneRoots(temporaryScene, temporarilyDisabledRoots); - - temporarySceneCilbox = FindCilboxInScene(temporaryScene); - if (temporarySceneCilbox == null) + Cilbox.Cilbox contentCilbox = FindCilboxInScene(temporaryScene); + if (contentCilbox == null) { - Type fallbackCilboxType = GetFirstLoadedCilboxType(); - if (fallbackCilboxType != null) - { - temporaryCilboxHost = new GameObject("BasisCilboxTempHost"); - SceneManager.MoveGameObjectToScene(temporaryCilboxHost, temporaryScene); - temporarySceneCilbox = temporaryCilboxHost.AddComponent(fallbackCilboxType) as Cilbox.Cilbox; - if (temporarySceneCilbox != null) - { - temporarySceneCilbox.exportDebuggingData = false; - } - } + throw new InvalidOperationException( + "Basis detected Cilboxable scripts/proxies, but this content has no Cilbox component. " + + "Add the appropriate Basis Cilbox component before building or using Test In Editor."); } - if (temporarySceneCilbox == null) + if (hasNativeCilboxables && existingProxies.Length > 0) { - Debug.LogWarning("Basis build detected Cilboxable scripts, but no Cilbox component was found. Skipping Cilbox prebuild assembly."); - return; + throw new InvalidOperationException( + "Basis cannot safely finalize content that mixes already-converted CilboxProxy components " + + "with newly-authored Cilboxable behaviours. Structural processors must finish adding " + + "Cilboxable behaviours before Cilbox conversion runs."); } - CilboxScenePostprocessor.OnPostprocessScene(temporaryScene); - EnsureTemporarySceneHasAssemblyData(temporarySceneCilbox, cilboxAssemblySnapshot); - RebindProxiesToTemporarySceneCilbox(prefabRoot, temporarySceneCilbox); - RestoreExternalCilboxAssemblyData(cilboxAssemblySnapshot, temporaryScene); + if (hasNativeCilboxables) + { + // This is deliberately before Cilbox searches and serializes the scene. Consumers may + // update serialized state on the isolated clone while Basis remains feature-agnostic. + BasisCilboxBuildEvents.InvokeBeforeCilboxSerialize(temporaryScene); + CilboxScenePostprocessor.OnPostprocessScene(temporaryScene); + } + else + { + // Entering Play Mode can already have converted the authored scene before Test In Editor + // clones it. Preserve that serialized program, but bind the clone's proxies to its own + // Cilbox so the loaded avatar never depends on a world/authoring-scene host. + RebindExistingProxiesToContentCilbox(existingProxies, contentCilbox); + } } finally { - RestoreDisabledRoots(temporarilyDisabledRoots); - - if (originalScene.IsValid() && originalScene.isLoaded && prefabRoot != null && prefabRoot.scene.IsValid() && prefabRoot.scene == temporaryScene) + Scene restoreScene = ResolveRestoreScene(originalScene, originalActiveScene, temporaryScene); + if (prefabRoot != null && prefabRoot.scene.IsValid() && prefabRoot.scene == temporaryScene && + restoreScene.IsValid() && restoreScene.isLoaded) { - SceneManager.MoveGameObjectToScene(prefabRoot, originalScene); + SceneManager.MoveGameObjectToScene(prefabRoot, restoreScene); } - if (detachedFromParent && prefabRoot != null && originalParent != null && prefabRoot.scene.IsValid() && prefabRoot.scene == originalScene) + if (detachedFromParent && prefabRoot != null && originalParent != null && + originalScene.IsValid() && originalScene.isLoaded && prefabRoot.scene == originalScene) { prefabRoot.transform.SetParent(originalParent, true); - int siblingIndex = Mathf.Clamp(originalSiblingIndex, 0, originalParent.childCount - 1); + int siblingIndex = Mathf.Clamp(originalSiblingIndex, 0, Math.Max(0, originalParent.childCount - 1)); prefabRoot.transform.SetSiblingIndex(siblingIndex); } - if (temporaryCilboxHost != null) + // Restore a stable active scene before closing/unloading the temporary active scene. + if (originalActiveScene.IsValid() && originalActiveScene.isLoaded && originalActiveScene != temporaryScene) { - UnityEngine.Object.DestroyImmediate(temporaryCilboxHost); + SceneManager.SetActiveScene(originalActiveScene); } - - if (temporaryScene.IsValid() && temporaryScene.isLoaded && (prefabRoot == null || prefabRoot.scene != temporaryScene)) + else if (restoreScene.IsValid() && restoreScene.isLoaded && restoreScene != temporaryScene) { - EditorSceneManager.CloseScene(temporaryScene, true); + SceneManager.SetActiveScene(restoreScene); } - if (originalScene.IsValid() && originalScene.isLoaded) + if (temporaryScene.IsValid() && temporaryScene.isLoaded && + (prefabRoot == null || prefabRoot.scene != temporaryScene)) + { + CloseTemporaryScene(temporaryScene); + } + else if (temporaryScene.IsValid() && temporaryScene.isLoaded && prefabRoot != null && + prefabRoot.scene == temporaryScene) { - SceneManager.SetActiveScene(originalScene); + Debug.LogError( + "Basis could not move the build clone out of its temporary Cilbox scene because no " + + "other loaded scene remained. The staging scene was left loaded to avoid destroying the clone."); } } } - private static void CleanupStaleCilboxHelpers() + private static Scene CreateTemporaryScene() { - GameObject[] allObjects = Resources.FindObjectsOfTypeAll(); - int length = allObjects.Length; - for (int i = 0; i < length; i++) + if (Application.isPlaying) { - GameObject go = allObjects[i]; - if (go == null) - { - continue; - } - - if (!go.scene.IsValid() || !go.scene.isLoaded) - { - continue; - } - - if (go.name == "CilboxDirtier" || go.name.StartsWith("CilboxAsm ")) - { - UnityEngine.Object.DestroyImmediate(go); - } + return SceneManager.CreateScene($"BasisCilboxTemp-{Guid.NewGuid():N}"); } + + return EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); } - private static bool HasCilboxableComponents(GameObject root) + private static void CloseTemporaryScene(Scene temporaryScene) { - if (root == null) + if (Application.isPlaying) { - return false; + // The clone has already been moved back to its owner scene. The asynchronous unload only + // removes the now-empty staging scene; callers are synchronous so there is nothing to await. + SceneManager.UnloadSceneAsync(temporaryScene); } - - MonoBehaviour[] components = root.GetComponentsInChildren(true); - int length = components.Length; - for (int i = 0; i < length; i++) + else { - MonoBehaviour component = components[i]; - if (component == null) - { - continue; - } - - object[] attributes = component.GetType().GetCustomAttributes(typeof(CilboxableAttribute), true); - if (attributes != null && attributes.Length > 0) - { - return true; - } + EditorSceneManager.CloseScene(temporaryScene, true); } - return false; } - private static Dictionary CaptureCilboxAssemblySnapshot() + private static Scene ResolveRestoreScene(Scene originalScene, Scene originalActiveScene, Scene temporaryScene) { - Dictionary snapshot = new Dictionary(); - Cilbox.Cilbox[] allCilboxes = Resources.FindObjectsOfTypeAll(); - int length = allCilboxes.Length; - for (int i = 0; i < length; i++) + if (originalScene.IsValid() && originalScene.isLoaded && originalScene != temporaryScene) { - Cilbox.Cilbox cilbox = allCilboxes[i]; - if (cilbox == null) - { - continue; - } - - snapshot[cilbox.GetEntityId()] = cilbox.assemblyData; + return originalScene; } - return snapshot; - } + if (originalActiveScene.IsValid() && originalActiveScene.isLoaded && originalActiveScene != temporaryScene) + { + return originalActiveScene; + } - private static void DeactivateOtherSceneRoots(Scene keepScene, List disabledRoots) - { int sceneCount = SceneManager.sceneCount; - for (int sceneIndex = 0; sceneIndex < sceneCount; sceneIndex++) + for (int i = 0; i < sceneCount; i++) { - Scene scene = SceneManager.GetSceneAt(sceneIndex); - if (!scene.IsValid() || !scene.isLoaded || scene == keepScene) - { - continue; - } - - GameObject[] roots = scene.GetRootGameObjects(); - int rootLength = roots.Length; - for (int rootIndex = 0; rootIndex < rootLength; rootIndex++) + Scene candidate = SceneManager.GetSceneAt(i); + if (candidate.IsValid() && candidate.isLoaded && candidate != temporaryScene) { - GameObject root = roots[rootIndex]; - if (root == null || !root.activeSelf) - { - continue; - } - - root.SetActive(false); - disabledRoots.Add(root); + return candidate; } } + + return default; } - private static void RestoreDisabledRoots(List disabledRoots) + internal static bool HasCilboxableComponents(GameObject root) { - int length = disabledRoots.Count; - for (int i = 0; i < length; i++) + if (root == null) + { + return false; + } + + MonoBehaviour[] components = root.GetComponentsInChildren(true); + for (int i = 0; i < components.Length; i++) { - GameObject root = disabledRoots[i]; - if (root != null) + MonoBehaviour component = components[i]; + if (component != null && CilboxUtil.HasCilboxableAttribute(component.GetType())) { - root.SetActive(true); + return true; } } + + return false; } private static Cilbox.Cilbox FindCilboxInScene(Scene scene) @@ -234,8 +259,7 @@ private static Cilbox.Cilbox FindCilboxInScene(Scene scene) } GameObject[] roots = scene.GetRootGameObjects(); - int length = roots.Length; - for (int i = 0; i < length; i++) + for (int i = 0; i < roots.Length; i++) { GameObject root = roots[i]; if (root == null) @@ -253,107 +277,84 @@ private static Cilbox.Cilbox FindCilboxInScene(Scene scene) return null; } - private static Type GetFirstLoadedCilboxType() + private static void RebindExistingProxiesToContentCilbox( + CilboxProxy[] proxies, + Cilbox.Cilbox contentCilbox) { - Cilbox.Cilbox[] allCilboxes = Resources.FindObjectsOfTypeAll(); - int length = allCilboxes.Length; - for (int i = 0; i < length; i++) + string sourceAssembly = null; + for (int i = 0; i < proxies.Length; i++) { - Cilbox.Cilbox cilbox = allCilboxes[i]; - if (cilbox != null) + CilboxProxy proxy = proxies[i]; + if (proxy == null || proxy.box == null || string.IsNullOrEmpty(proxy.box.assemblyData)) { - return cilbox.GetType(); + continue; } - } - - return null; - } - private static void EnsureTemporarySceneHasAssemblyData(Cilbox.Cilbox temporarySceneCilbox, Dictionary snapshot) - { - if (temporarySceneCilbox == null) - { - return; + string candidateAssembly = proxy.box.assemblyData; + if (sourceAssembly == null) + { + sourceAssembly = candidateAssembly; + } + else if (sourceAssembly != candidateAssembly) + { + throw new InvalidOperationException( + "The cloned content contains Cilbox proxies backed by different assemblies; Basis cannot " + + "deterministically rebind them to one content Cilbox."); + } } - if (!string.IsNullOrEmpty(temporarySceneCilbox.assemblyData)) + if (string.IsNullOrEmpty(sourceAssembly)) { - return; + sourceAssembly = contentCilbox.assemblyData; } - Cilbox.Cilbox[] allCilboxes = Resources.FindObjectsOfTypeAll(); - int length = allCilboxes.Length; - for (int i = 0; i < length; i++) + if (string.IsNullOrEmpty(sourceAssembly)) { - Cilbox.Cilbox cilbox = allCilboxes[i]; - if (cilbox == null || cilbox == temporarySceneCilbox || string.IsNullOrEmpty(cilbox.assemblyData)) - { - continue; - } - - EntityId id = cilbox.GetEntityId(); - if (snapshot.TryGetValue(id, out string original) && original == cilbox.assemblyData) - { - continue; - } - - temporarySceneCilbox.assemblyData = cilbox.assemblyData; - temporarySceneCilbox.ForceReinit(); - EditorUtility.SetDirty(temporarySceneCilbox); - return; + throw new InvalidOperationException( + "The cloned content already contains CilboxProxy components, but no serialized Cilbox assembly " + + "data is available to run them."); } - } - private static void RebindProxiesToTemporarySceneCilbox(GameObject contentRoot, Cilbox.Cilbox temporarySceneCilbox) - { - if (contentRoot == null || temporarySceneCilbox == null) + if (contentCilbox.assemblyData != sourceAssembly) { - return; + contentCilbox.assemblyData = sourceAssembly; + contentCilbox.ForceReinit(); + EditorUtility.SetDirty(contentCilbox); } - CilboxProxy[] proxies = contentRoot.GetComponentsInChildren(true); - int length = proxies.Length; - for (int i = 0; i < length; i++) + for (int i = 0; i < proxies.Length; i++) { CilboxProxy proxy = proxies[i]; - if (proxy == null || proxy.box == temporarySceneCilbox) + if (proxy == null || proxy.box == contentCilbox) { continue; } - proxy.box = temporarySceneCilbox; + proxy.box = contentCilbox; EditorUtility.SetDirty(proxy); } } +} + +/// +/// Runs Basis-owned Cilbox preparation before Cilbox's callbackOrder 0 scene processor. Unity may +/// invoke scene processors for player builds or Play Mode scene preparation, so subscribers must +/// scope work to the supplied scene and tolerate repeated preparation of separate scene instances. +/// +internal sealed class BasisCilboxPreSerializeSceneProcessor : IProcessSceneWithReport +{ + public int callbackOrder => -100; - private static void RestoreExternalCilboxAssemblyData(Dictionary snapshot, Scene keepScene) + public void OnProcessScene(Scene scene, BuildReport report) { - Cilbox.Cilbox[] allCilboxes = Resources.FindObjectsOfTypeAll(); - int length = allCilboxes.Length; - for (int i = 0; i < length; i++) + // Avoid invoking arbitrary feature preparers for unrelated scenes. Cilbox itself will return + // immediately for the same condition at callbackOrder 0. + if (CilboxUtil.GetAllBehavioursThatNeedCilboxing(scene).Length == 0) { - Cilbox.Cilbox cilbox = allCilboxes[i]; - if (cilbox == null || !cilbox.gameObject.scene.IsValid() || cilbox.gameObject.scene == keepScene) - { - continue; - } - - EntityId id = cilbox.GetEntityId(); - if (!snapshot.TryGetValue(id, out string originalAssemblyData)) - { - continue; - } - - if (cilbox.assemblyData == originalAssemblyData) - { - continue; - } - - cilbox.assemblyData = originalAssemblyData; - cilbox.ForceReinit(); - EditorUtility.SetDirty(cilbox); + return; } - } + BasisCilboxBuildEvents.InvokeBeforeCilboxSerialize(scene); + } } #endif diff --git a/Basis/Packages/com.cnlohr.cilbox/Cilbox.cs b/Basis/Packages/com.cnlohr.cilbox/Cilbox.cs index b98e717f48..4359140129 100644 --- a/Basis/Packages/com.cnlohr.cilbox/Cilbox.cs +++ b/Basis/Packages/com.cnlohr.cilbox/Cilbox.cs @@ -3177,10 +3177,17 @@ public static void OnPostprocessScene(UnityEngine.SceneManagement.Scene? scene) Cilbox tac = null; foreach ( var tacCandidate in se ) { - if ( tacCandidate.gameObject.scene.IsValid() && !EditorUtility.IsPersistent(tacCandidate) ) { - tac = tacCandidate; - break; - } + if( !tacCandidate.gameObject.scene.IsValid() || EditorUtility.IsPersistent(tacCandidate) ) + continue; + + // When processing a specific scene, assembly data and generated proxies must use a + // Cilbox from that scene. Falling through to an arbitrary loaded scene mutates live + // content and can bind proxies to the wrong sandbox/permission model. + if( scene != null && tacCandidate.gameObject.scene != scene.Value ) + continue; + + tac = tacCandidate; + break; } if( tac != null ) diff --git a/Basis/Packages/com.gator-dragon-games.jigglephysics/Editor/BasisJigglePhysicsTestInEditorHook.cs b/Basis/Packages/com.gator-dragon-games.jigglephysics/Editor/BasisJigglePhysicsTestInEditorHook.cs new file mode 100644 index 0000000000..3340867c1f --- /dev/null +++ b/Basis/Packages/com.gator-dragon-games.jigglephysics/Editor/BasisJigglePhysicsTestInEditorHook.cs @@ -0,0 +1,32 @@ +using GatorDragonGames.JigglePhysics; +using UnityEditor; +using UnityEngine; + +[InitializeOnLoad] +internal static class BasisJigglePhysicsTestInEditorHook +{ + private const int JiggleResetFrames = 3; + + static BasisJigglePhysicsTestInEditorHook() + { + BasisAvatarSDKInspector.OnBeforeTestInEditorOriginalDeactivation -= GetRequiredSettleFrames; + BasisAvatarSDKInspector.OnBeforeTestInEditorOriginalDeactivation += GetRequiredSettleFrames; + } + + private static void GetRequiredSettleFrames( + GameObject originalObject, + BasisAvatarSDKInspector.TestInEditorOriginalDeactivationContext context) + { + JiggleRig[] jiggles = originalObject.GetComponentsInChildren(false); + for (int i = 0; i < jiggles.Length; i++) + { + JiggleRig jiggle = jiggles[i]; + if (jiggle != null && jiggle.enabled) + { + Debug.Log("Enabled Jiggles were found when Test in Editor was entered. The avatar will remain disabled while the test clone is prepared so Jiggle transforms can reset."); + context.RequestSettleFrames(JiggleResetFrames); + return; + } + } + } +} diff --git a/Basis/Packages/com.gator-dragon-games.jigglephysics/Editor/BasisJigglePhysicsTestInEditorHook.cs.meta b/Basis/Packages/com.gator-dragon-games.jigglephysics/Editor/BasisJigglePhysicsTestInEditorHook.cs.meta new file mode 100644 index 0000000000..925deeafe0 --- /dev/null +++ b/Basis/Packages/com.gator-dragon-games.jigglephysics/Editor/BasisJigglePhysicsTestInEditorHook.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4f3f5c2e75b7440a90fd1b7372d53c5a diff --git a/Basis/Packages/com.gator-dragon-games.jigglephysics/Editor/com.gator-dragon-games.jigglephysics.editor.asmdef b/Basis/Packages/com.gator-dragon-games.jigglephysics/Editor/com.gator-dragon-games.jigglephysics.editor.asmdef index ac565937b0..83c1b533e6 100644 --- a/Basis/Packages/com.gator-dragon-games.jigglephysics/Editor/com.gator-dragon-games.jigglephysics.editor.asmdef +++ b/Basis/Packages/com.gator-dragon-games.jigglephysics/Editor/com.gator-dragon-games.jigglephysics.editor.asmdef @@ -3,7 +3,8 @@ "rootNamespace": "", "references": [ "GUID:2684ea0d564097444a05d23355ff46a1", - "GUID:d8b63aba1907145bea998dd612889d6b" + "GUID:d8b63aba1907145bea998dd612889d6b", + "BasisSDKEditor" ], "includePlatforms": [ "Editor" diff --git a/Basis/Packages/dev.hai-vr.basis.ndmf/Scripts/Editor/BasisNDMFBuildHook.cs b/Basis/Packages/dev.hai-vr.basis.ndmf/Scripts/Editor/BasisNDMFBuildHook.cs index e94742e86f..f5fb739d25 100644 --- a/Basis/Packages/dev.hai-vr.basis.ndmf/Scripts/Editor/BasisNDMFBuildHook.cs +++ b/Basis/Packages/dev.hai-vr.basis.ndmf/Scripts/Editor/BasisNDMFBuildHook.cs @@ -11,7 +11,9 @@ internal class BasisNDMFBuildHook static BasisNDMFBuildHook() { BasisAssetBundlePipeline.OnBeforeBuildPrefab += (prefab, _) => BasisAvatarPrefabProcessor(prefab); - BasisAvatarSDKInspector.OnBeforeTestInEditor += prefab => BasisAvatarPrefabProcessor(prefab); + // Test In Editor keeps the clone inactive until final build-time conversion is complete. + // NDMF is a structural processor, so it belongs in the explicit inactive preparation stage. + BasisAvatarSDKInspector.OnBeforeTestInEditorPrepareInactive += prefab => BasisAvatarPrefabProcessor(prefab); } private static GameObject BasisAvatarPrefabProcessor(GameObject copy)