diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md
index ed55793aba..09ffeee3e6 100644
--- a/com.unity.netcode.gameobjects/CHANGELOG.md
+++ b/com.unity.netcode.gameobjects/CHANGELOG.md
@@ -26,6 +26,7 @@ Additional documentation and release notes are available at [Multiplayer Documen
### Fixed
+- Fixed issue where either an `AttachableBehaviour` or an `AttachableNode` can throw an exception if they are attached during a scene unload where one of the two persists the scene unload event and the other does not. (#3931)
- Fixed issue where attempts to use `NetworkLog` when there is no `NetworkManager` instance would result in an exception. (#3917)
### Security
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableBehaviour.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableBehaviour.cs
index 7038e74cd1..52997eb7e5 100644
--- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableBehaviour.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableBehaviour.cs
@@ -278,6 +278,14 @@ internal void ForceDetach()
///
public override void OnNetworkPreDespawn()
{
+ // If the NetworkObject is being destroyed and not completely detached, then destroy the GameObject for
+ // this attachable since the associated default parent is being destroyed.
+ if (IsDestroying && m_AttachState != AttachState.Detached)
+ {
+ Destroy(gameObject);
+ return;
+ }
+
if (NetworkManager.ShutdownInProgress || AutoDetach.HasFlag(AutoDetachTypes.OnDespawn))
{
ForceDetach();
@@ -286,7 +294,7 @@ public override void OnNetworkPreDespawn()
}
///
- /// This will apply the final attach or detatch state based on the current value of .
+ /// This will apply the final attach or detach state based on the current value of .
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void UpdateAttachedState()
@@ -392,7 +400,8 @@ internal void ForceComponentChange(bool isAttaching, bool forcedChange)
foreach (var componentControllerEntry in ComponentControllers)
{
- if (componentControllerEntry.AutoTrigger.HasFlag(triggerType))
+ // Only if the component controller still exists and has the appropriate flag.
+ if (componentControllerEntry.ComponentController && componentControllerEntry.AutoTrigger.HasFlag(triggerType))
{
componentControllerEntry.ComponentController.ForceChangeEnabled(componentControllerEntry.EnableOnAttach ? isAttaching : !isAttaching, forcedChange);
}
@@ -457,7 +466,7 @@ public void Attach(AttachableNode attachableNode)
///
internal void InternalDetach()
{
- if (m_AttachableNode)
+ if (!IsDestroying && m_AttachableNode && !m_AttachableNode.IsDestroying)
{
if (m_DefaultParent)
{
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableNode.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableNode.cs
index d1a2ca9c16..edd507cd67 100644
--- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableNode.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableNode.cs
@@ -71,20 +71,20 @@ public override void OnNetworkPreDespawn()
{
for (int i = m_AttachedBehaviours.Count - 1; i >= 0; i--)
{
- if (!m_AttachedBehaviours[i])
+ var attachable = m_AttachedBehaviours[i];
+ if (!attachable)
{
continue;
}
- // If we don't have authority but should detach on despawn,
- // then proceed to detach.
- if (!m_AttachedBehaviours[i].HasAuthority)
+
+ if (attachable.HasAuthority && attachable.IsSpawned)
{
- m_AttachedBehaviours[i].ForceDetach();
+ // Detach the normal way with authority
+ attachable.Detach();
}
- else
+ else if (!attachable.HasAuthority || !attachable.IsDestroying)
{
- // Detach the normal way with authority
- m_AttachedBehaviours[i].Detach();
+ attachable.ForceDetach();
}
}
}
diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs
index 049f28070b..12decd3b7a 100644
--- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs
@@ -643,6 +643,20 @@ protected NetworkBehaviour GetNetworkBehaviour(ushort behaviourId)
///
public ulong OwnerClientId { get; internal set; }
+ ///
+ /// Returns true if the NetworkObject is in the middle of being destroyed or
+ /// if there is no valid assigned NetworkObject.
+ ///
+ ///
+ ///
+ ///
+ internal bool IsDestroying { get; private set; }
+
+ internal void SetDestroying()
+ {
+ IsDestroying = true;
+ }
+
///
/// Updates properties with network session related
/// dependencies such as a NetworkObject's spawned
diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs
index bd518b3048..0966d7965d 100644
--- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs
@@ -1688,8 +1688,51 @@ public static void NetworkHide(List networkObjects, ulong clientI
}
}
+ ///
+ /// Returns true if the NetworkObject is in the middle of being destroyed.
+ ///
+ ///
+ /// This is particularly useful when determining if something is being de-spawned
+ /// normally or if it is being de-spawned because the NetworkObject/GameObject is
+ /// being destroyed.
+ ///
+ internal bool IsDestroying { get; private set; }
+
+ ///
+ /// Applies the despawning flag for the local instance and
+ /// its child NetworkBehaviours. Private to assure this is
+ /// only invoked from within OnDestroy.
+ ///
+ private void SetIsDestroying()
+ {
+ IsDestroying = true;
+
+ // Exit early if null
+ if (m_ChildNetworkBehaviours == null)
+ {
+ return;
+ }
+
+ foreach (var childBehaviour in m_ChildNetworkBehaviours)
+ {
+ // Just ignore and continue processing through the entries
+ if (!childBehaviour)
+ {
+ continue;
+ }
+
+ // Keeping the property a private set to assure this is
+ // the only way it can be set as it should never be reset
+ // back to false once invoked.
+ childBehaviour.SetDestroying();
+ }
+ }
+
private void OnDestroy()
{
+ // Apply the is destroying flag
+ SetIsDestroying();
+
var networkManager = NetworkManager;
// If no NetworkManager is assigned, then just exit early
if (!networkManager)
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/AttachableBehaviourTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/AttachableBehaviourTests.cs
index 383651d446..7324fe6df4 100644
--- a/com.unity.netcode.gameobjects/Tests/Runtime/AttachableBehaviourTests.cs
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/AttachableBehaviourTests.cs
@@ -55,7 +55,7 @@ protected override void OnServerAndClientsCreated()
attachableNetworkObject.SetOwnershipStatus(NetworkObject.OwnershipStatus.Transferable);
// The target prefab that the source prefab will attach
- // will be parented under the target prefab.
+ // to will be parented under the target prefab.
m_TargetNodePrefabA = CreateNetworkObjectPrefab("TargetA");
m_TargetNodePrefabB = CreateNetworkObjectPrefab("TargetB");
var sourceChild = new GameObject("SourceChild");
@@ -676,10 +676,13 @@ internal class TestAttachable : AttachableBehaviour
public GameObject DefaultParent => m_DefaultParent;
public AttachState State => m_AttachState;
+ public bool DestroyWithScene;
+
public override void OnNetworkSpawn()
{
AttachStateChange += OnAttachStateChangeEvent;
name = $"{name}-{NetworkManager.LocalClientId}";
+ NetworkObject.DestroyWithScene = DestroyWithScene;
base.OnNetworkSpawn();
}
@@ -780,9 +783,16 @@ public bool CheckForState(bool checkAttached, bool checkEvent)
///
internal class TestNode : AttachableNode
{
+ public bool DestroyWithScene;
public bool OnAttachedInvoked { get; private set; }
public bool OnDetachedInvoked { get; private set; }
+ public override void OnNetworkSpawn()
+ {
+ NetworkObject.DestroyWithScene = DestroyWithScene;
+ base.OnNetworkSpawn();
+ }
+
public bool IsAttached(AttachableBehaviour attachableBehaviour)
{
return m_AttachedBehaviours.Contains(attachableBehaviour);
diff --git a/testproject/Assets/Tests/Runtime/NetworkSceneManager/AttachableBehaviourSceneLoadTests.cs b/testproject/Assets/Tests/Runtime/NetworkSceneManager/AttachableBehaviourSceneLoadTests.cs
new file mode 100644
index 0000000000..619bab5dab
--- /dev/null
+++ b/testproject/Assets/Tests/Runtime/NetworkSceneManager/AttachableBehaviourSceneLoadTests.cs
@@ -0,0 +1,413 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.Text;
+using NUnit.Framework;
+using Unity.Netcode;
+using Unity.Netcode.Components;
+using Unity.Netcode.TestHelpers.Runtime;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using UnityEngine.TestTools;
+using static Unity.Netcode.RuntimeTests.AttachableBehaviourTests;
+
+namespace TestProject.RuntimeTests
+{
+ ///
+ /// Validates that attachables do not log errors or throw exceptions
+ /// during a scene transition (or unload) where either the attachable
+ /// or the node persists and the other does not but they are attached
+ /// to each other prior to the scene being unloaded.
+ ///
+ [TestFixture(HostOrServer.Host, Persists.AttachableBehaviour)]
+ [TestFixture(HostOrServer.Server, Persists.AttachableBehaviour)]
+ [TestFixture(HostOrServer.DAHost, Persists.AttachableBehaviour)]
+ [TestFixture(HostOrServer.Host, Persists.AttachableNode)]
+ [TestFixture(HostOrServer.Server, Persists.AttachableNode)]
+ [TestFixture(HostOrServer.DAHost, Persists.AttachableNode)]
+ internal class AttachableBehaviourSceneLoadTests : NetcodeIntegrationTest
+ {
+ public enum Persists
+ {
+ AttachableBehaviour,
+ AttachableNode
+ }
+
+ protected override int NumberOfClients => 2;
+
+ private const string k_SceneToLoad = "EmptyScene";
+
+ private GameObject m_AttachablePrefab;
+ private GameObject m_TargetNodePrefabA;
+ private TestAttachable m_PrefabAttachableBehaviour;
+ private TestNode m_PrefabAttachableNode;
+
+ private NetworkObject m_SourceInstance;
+ private NetworkObject m_TargetInstance;
+ private TestAttachable m_AttachableBehaviourInstance;
+ private AttachableNode m_AttachableNodeInstance;
+
+ private List m_DoesNotPersistNetworkObjectIds = new List();
+
+ private Scene m_AuthoritySceneLoaded;
+ private Scene m_TestRunnerScene;
+
+ private bool m_SceneLoadCompleted;
+
+ private Persists m_Persists;
+ public AttachableBehaviourSceneLoadTests(HostOrServer hostOrServer, Persists persists) : base(hostOrServer)
+ {
+ m_Persists = persists;
+ }
+
+ protected override IEnumerator OnSetup()
+ {
+ m_TestRunnerScene = SceneManager.GetActiveScene();
+ m_DoesNotPersistNetworkObjectIds.Clear();
+ return base.OnSetup();
+ }
+
+ protected override void OnCreatePlayerPrefab()
+ {
+ var networkObject = m_PlayerPrefab.GetComponent();
+ networkObject.ActiveSceneSynchronization = false;
+ base.OnCreatePlayerPrefab();
+ }
+
+ ///
+ /// Create an attachable with a node that should be destroyed upon
+ /// the scene it currently resides in being unloaded.
+ ///
+ protected override void OnServerAndClientsCreated()
+ {
+ // The source prefab contains the nested NetworkBehaviour that
+ // will be parented under the target prefab.
+ m_AttachablePrefab = CreateNetworkObjectPrefab("Source");
+ m_AttachablePrefab.AddComponent();
+ var attachableNetworkObject = m_AttachablePrefab.GetComponent();
+ attachableNetworkObject.DontDestroyWithOwner = false;
+ attachableNetworkObject.SetOwnershipStatus(NetworkObject.OwnershipStatus.Transferable);
+ attachableNetworkObject.ActiveSceneSynchronization = false;
+ attachableNetworkObject.SceneMigrationSynchronization = false;
+
+ // The "attachable" that has a world item (source) as its original root parent.
+ var sourceChild = new GameObject("SourceChild");
+ sourceChild.transform.parent = m_AttachablePrefab.transform;
+ m_PrefabAttachableBehaviour = sourceChild.AddComponent();
+ m_PrefabAttachableBehaviour.AutoDetach = AttachableBehaviour.AutoDetachTypes.OnDespawn | AttachableBehaviour.AutoDetachTypes.OnAttachNodeDestroy;
+
+ // This particular test validates that the attachable's world item is destroyed on a scene transition while the attachable
+ // is attached to something that persists through scene loading.
+ m_PrefabAttachableBehaviour.DestroyWithScene = m_Persists != Persists.AttachableBehaviour;
+
+ // The target prefab that the source prefab will attach
+ // to will be parented under the target prefab.
+ m_TargetNodePrefabA = CreateNetworkObjectPrefab("TargetA");
+ m_TargetNodePrefabA.AddComponent();
+ var targetNetworkObject = m_TargetNodePrefabA.GetComponent();
+ targetNetworkObject.DontDestroyWithOwner = true;
+ targetNetworkObject.SceneMigrationSynchronization = false;
+
+ // The "target node" to attach the source child to
+ var targetChildA = new GameObject("TargetChildA");
+ targetChildA.transform.parent = m_TargetNodePrefabA.transform;
+ m_PrefabAttachableNode = targetChildA.AddComponent();
+ m_PrefabAttachableNode.DetachOnDespawn = true;
+
+ // This particular test validates that the attachable's world item is destroyed on a scene transition while the attachable
+ // is attached to something that persists through scene loading.
+ m_PrefabAttachableNode.DestroyWithScene = m_Persists != Persists.AttachableNode;
+
+ // For this test & only when using a distributed authority topology, we want to switch the default synchronization
+ // mode back to single (even though it still is effectively loaded additively).
+ if (m_DistributedAuthority)
+ {
+ foreach (var networkManager in m_NetworkManagers)
+ {
+ m_ApplyClientSynchronizationModeInstances.Add(new ApplyClientSynchronizationMode(networkManager, LoadSceneMode.Single));
+ }
+ }
+ base.OnServerAndClientsCreated();
+ }
+
+ #region Silly helper class to handle setting client synchronization mode for all distributed authority clients
+ // TODO: We should be able to apply NetworkSceneManager properties like this within the NetworkConfig.
+ private List m_ApplyClientSynchronizationModeInstances = new List();
+ internal class ApplyClientSynchronizationMode
+ {
+ private NetworkManager m_NetworkManager;
+ private LoadSceneMode m_LoadSceneMode;
+ public ApplyClientSynchronizationMode(NetworkManager networkManager, LoadSceneMode loadSceneMode)
+ {
+ m_NetworkManager = networkManager;
+ m_LoadSceneMode = loadSceneMode;
+ m_NetworkManager.OnClientStarted += OnClientStarted;
+ }
+
+ private void OnClientStarted()
+ {
+ m_NetworkManager.OnClientStarted -= OnClientStarted;
+ m_NetworkManager.SceneManager.SetClientSynchronizationMode(m_LoadSceneMode);
+ }
+ }
+ #endregion
+
+ protected override IEnumerator OnServerAndClientsConnected()
+ {
+ m_ApplyClientSynchronizationModeInstances.Clear();
+ foreach (var networkManager in m_NetworkManagers)
+ {
+ networkManager.SceneManager.ActiveSceneSynchronizationEnabled = true;
+ }
+ return base.OnServerAndClientsConnected();
+ }
+
+ ///
+ /// Conditional that validates a specific spawned attachable instance
+ /// has been attached for the original and all cloned instances.
+ ///
+ private bool AllInstancesAttachedStateChanged(StringBuilder errorLog)
+ {
+ var target = m_TargetInstance;
+ var targetId = target.NetworkObjectId;
+ // The attachable can move between the two spawned instances so we have to use the appropriate one depending upon the authority's current state.
+ var currentAttachableRoot = m_AttachableBehaviourInstance.State == AttachableBehaviour.AttachState.Attached ? target : m_SourceInstance;
+ var attachable = (TestAttachable)null;
+ var node = (TestNode)null;
+ foreach (var networkManager in m_NetworkManagers)
+ {
+ if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(currentAttachableRoot.NetworkObjectId))
+ {
+ errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has no spawned instance of {currentAttachableRoot.name}!");
+ continue;
+ }
+ else
+ {
+ attachable = networkManager.SpawnManager.SpawnedObjects[currentAttachableRoot.NetworkObjectId].GetComponentInChildren();
+ }
+
+ if (!attachable)
+ {
+ attachable = networkManager.SpawnManager.SpawnedObjects[targetId].GetComponentInChildren();
+ if (!attachable)
+ {
+ errorLog.AppendLine($"[Client-{networkManager.LocalClientId}][Attachable] Attachable was not found!");
+ }
+ continue;
+ }
+
+ if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(targetId))
+ {
+ errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has no spawned instance of {target.name}!");
+ continue;
+ }
+ else
+ {
+ node = networkManager.SpawnManager.SpawnedObjects[targetId].GetComponentInChildren();
+ }
+
+ if (!attachable.CheckForState(true, false))
+ {
+ errorLog.AppendLine($"[Client-{networkManager.LocalClientId}][{attachable.name}] Did not have its override invoked!");
+ }
+ if (!attachable.CheckForState(true, true))
+ {
+ errorLog.AppendLine($"[Client-{networkManager.LocalClientId}][{attachable.name}] Did not have its event invoked!");
+ }
+ if (!node.OnAttachedInvoked)
+ {
+ errorLog.AppendLine($"[Client-{networkManager.LocalClientId}][{node.name}] Did not have its override invoked!");
+ }
+ if (attachable.transform.parent != node.transform)
+ {
+ errorLog.AppendLine($"[Client-{networkManager.LocalClientId}][{attachable.name}] {node.name} is not the parent of {attachable.name}!");
+ }
+ }
+ return errorLog.Length == 0;
+ }
+
+ ///
+ /// Conditional that waits for the expected, scene load non-persistent, spawned objects
+ /// to be despawned.
+ ///
+ private bool WaitForAllToDespawn(StringBuilder errorLog)
+ {
+ foreach (var networkManager in m_NetworkManagers)
+ {
+ for (int i = 0; i < m_DoesNotPersistNetworkObjectIds.Count; i++)
+ {
+ if (networkManager.SpawnManager == null)
+ {
+ continue;
+ }
+ var sourceId = m_DoesNotPersistNetworkObjectIds[i];
+ if (networkManager.SpawnManager.SpawnedObjects.ContainsKey(sourceId))
+ {
+ errorLog.AppendLine($"[{networkManager.name}] Still has NetworkObjectId-{sourceId} spawned!");
+ }
+ }
+ }
+ return errorLog.Length == 0;
+ }
+
+ ///
+ /// Since everything spawns in the active scene, we want to assure that these instances are in their
+ /// NetworkManager relative scene so upon receiving a scene unload event each client will handle destroying
+ /// anything still spawned that does not persist a scene loading event.
+ /// This is required to replicate of this issue.
+ ///
+ private void SynchronizeScene()
+ {
+ var sourceId = m_SourceInstance.NetworkObjectId;
+ var targetId = m_TargetInstance.NetworkObjectId;
+ foreach (var networkManager in m_NetworkManagers)
+ {
+ var synchronizer = networkManager.SpawnManager.SpawnedObjects[sourceId].GetComponent();
+ Assert.IsTrue(synchronizer.SynchronizeScene(), $"[{synchronizer.name}] Failed to synchronize instance to local scene!");
+ synchronizer = networkManager.SpawnManager.SpawnedObjects[targetId].GetComponent();
+ Assert.IsTrue(synchronizer.SynchronizeScene(), $"[{synchronizer.name}] Failed to synchronize instance to local scene!");
+ }
+ }
+
+ [UnityTest]
+ public IEnumerator AttachedUponSceneTransition([Values] bool detachOnDespawn)
+ {
+ m_SceneLoadCompleted = false;
+
+ // Handle detach on despawn differently to validate both paths.
+ m_PrefabAttachableNode.DetachOnDespawn = detachOnDespawn;
+
+ var authority = GetAuthorityNetworkManager();
+
+ // Load a scene so all clients load this scene.
+ authority.SceneManager.OnSceneEvent += OnSceneEvent;
+ var response = authority.SceneManager.LoadScene(k_SceneToLoad, LoadSceneMode.Additive);
+ Assert.IsTrue(response == SceneEventProgressStatus.Started, $"Failed to begin scene loading event for {k_SceneToLoad} with a status of {response}!");
+ yield return WaitForConditionOrTimeOut(() => m_AuthoritySceneLoaded.IsValid() && m_AuthoritySceneLoaded.isLoaded && m_SceneLoadCompleted);
+ AssertOnTimeout($"Timed out waiting for all clients to load scene {k_SceneToLoad}!");
+
+ // Now, make the newly loaded scene the currently active scene so everything instantiates in the scene authority's newly loaded scene instance.
+ SceneManager.SetActiveScene(m_AuthoritySceneLoaded);
+ foreach (var networkManager in m_NetworkManagers)
+ {
+ // Spawn our instances for this client
+ m_SourceInstance = SpawnObject(m_AttachablePrefab, networkManager).GetComponent();
+ m_TargetInstance = SpawnObject(m_TargetNodePrefabA, networkManager).GetComponent();
+
+ yield return WaitForSpawnedOnAllOrTimeOut(m_SourceInstance);
+ AssertOnTimeout($"Timed out waiting for all clients to spawn {m_SourceInstance.name}!");
+
+ yield return WaitForSpawnedOnAllOrTimeOut(m_TargetInstance);
+ AssertOnTimeout($"Timed out waiting for all clients to spawn {m_TargetInstance.name}!");
+
+ m_AttachableBehaviourInstance = m_SourceInstance.GetComponentInChildren();
+ Assert.NotNull(m_AttachableBehaviourInstance, $"{m_SourceInstance.name} does not have a nested child {nameof(AttachableBehaviour)}!");
+
+ m_AttachableNodeInstance = m_TargetInstance.GetComponentInChildren();
+ Assert.NotNull(m_AttachableNodeInstance, $"{m_TargetInstance.name} does not have a nested child {nameof(AttachableNode)}!");
+
+ m_AttachableBehaviourInstance.Attach(m_AttachableNodeInstance);
+
+ yield return WaitForConditionOrTimeOut(AllInstancesAttachedStateChanged);
+ AssertOnTimeout($"Timed out waiting for all clients to attach {m_AttachableBehaviourInstance.name} to {m_AttachableNodeInstance.name}!");
+
+ // Now migrate all instances from the scene authority's scene instance to the NetworkManager relative scene.
+ SynchronizeScene();
+
+ // Keep track of the spawned instances we expect to not persist a scene load
+ var doesNotPersist = m_Persists == Persists.AttachableNode ? m_SourceInstance.NetworkObjectId : m_TargetInstance.NetworkObjectId;
+ m_DoesNotPersistNetworkObjectIds.Add(doesNotPersist);
+ }
+
+ // This is the actual validation point where the scene is unloaded and either the attachable or
+ // the node will be destroyed while the other persists (and detects that the other has been despawned and destroyed).
+ response = authority.SceneManager.UnloadScene(m_AuthoritySceneLoaded);
+ Assert.IsTrue(response == SceneEventProgressStatus.Started, $"Failed to begin scene unloading event for {k_SceneToLoad} with a status of {response}!");
+
+ yield return WaitForConditionOrTimeOut(WaitForAllToDespawn);
+ AssertOnTimeout($"Timed out waiting for all clients to despawn NetworkObjects!");
+ }
+
+ private void OnSceneEvent(SceneEvent sceneEvent)
+ {
+ if ((sceneEvent.SceneEventType != SceneEventType.LoadEventCompleted && sceneEvent.SceneEventType != SceneEventType.LoadComplete)
+ || sceneEvent.ClientId != GetAuthorityNetworkManager().LocalClientId)
+ {
+ return;
+ }
+
+ if (sceneEvent.SceneName == k_SceneToLoad)
+ {
+ if (sceneEvent.SceneEventType == SceneEventType.LoadComplete)
+ {
+ m_AuthoritySceneLoaded = sceneEvent.Scene;
+ }
+
+ if (sceneEvent.SceneEventType == SceneEventType.LoadEventCompleted)
+ {
+ m_SceneLoadCompleted = true;
+ }
+ }
+ }
+
+ protected override IEnumerator OnTearDown()
+ {
+ // Assure we set the active scene back to the integration test's scene if needed.
+ var currentActiveScene = SceneManager.GetActiveScene();
+ if (m_AuthoritySceneLoaded != null && currentActiveScene == m_AuthoritySceneLoaded && m_AuthoritySceneLoaded.isLoaded && m_AuthoritySceneLoaded.IsValid())
+ {
+ SceneManager.SetActiveScene(m_TestRunnerScene);
+ SceneManager.UnloadSceneAsync(m_AuthoritySceneLoaded);
+ }
+ return base.OnTearDown();
+ }
+ }
+
+ ///
+ /// This helper class will assure that the spawned object clone
+ /// (or original) instance will be migrated into its appropriate
+ /// NetworkManager relative instance.
+ ///
+ ///
+ /// Since we share the same scene root, there is only 1 active scene
+ /// and all new instances are always instantiated within that.
+ /// If you load a new scene and make that the active scene prior to
+ /// spawning instances, then all instances will reside in the scene
+ /// authority's loaded scene instance.
+ /// This helper assures all instances are migrated into their NetworkManager
+ /// relative scene in order to replicate a more "real world" scenario
+ /// where each spawned clone instance for each connected client will be
+ /// destroyed during that client's processing of the unload scene
+ /// event.
+ /// Without this helper (or similar logic elsewhere), everything would be
+ /// destroyed upon the authority's scene being unloaded while the rest of
+ /// the clients would unload empty scenes.
+ ///
+ internal class SceneSynchronizer : NetworkBehaviour
+ {
+ ///
+ /// We are using the NetworkBehaviour to provide us with the relative
+ /// NetworkManager instance (for this spawned instance) in order to
+ /// then check against the client relative scenes loaded.
+ ///
+ /// Success if true. Failed to find the scene if false.
+ public bool SynchronizeScene()
+ {
+ var scenes = NetworkManager.SceneManager.GetSynchronizedScenes();
+ foreach (var scene in scenes)
+ {
+ // Find the matching scene name
+ if (gameObject.scene.name == scene.name)
+ {
+ // If the scene handles are different, then migrate to the
+ // one registered with this NetworkSceneManager instance.
+ if (scene.handle != gameObject.scene.handle)
+ {
+ SceneManager.MoveGameObjectToScene(gameObject, scene);
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/testproject/Assets/Tests/Runtime/NetworkSceneManager/AttachableBehaviourSceneLoadTests.cs.meta b/testproject/Assets/Tests/Runtime/NetworkSceneManager/AttachableBehaviourSceneLoadTests.cs.meta
new file mode 100644
index 0000000000..4166ba969e
--- /dev/null
+++ b/testproject/Assets/Tests/Runtime/NetworkSceneManager/AttachableBehaviourSceneLoadTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d0fa3eb3f5e1b6f4da462ff095a46f84
\ No newline at end of file