diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md
index b440b033c6..f902782de2 100644
--- a/com.unity.netcode.gameobjects/CHANGELOG.md
+++ b/com.unity.netcode.gameobjects/CHANGELOG.md
@@ -15,6 +15,7 @@ Additional documentation and release notes are available at [Multiplayer Documen
### Fixed
+- Fixed inconsistencies in the `OnSceneEvent` callback. (#3487)
- Fixed issue where `NetworkClient` could persist some settings if re-using the same `NetworkManager` instance. (#3494)
- Fixed issue where a pooled `NetworkObject` was not resetting the internal latest parent property when despawned. (#3494)
- Fixed issue where the initial client synchronization pre-serialization process was not excluding spawned `NetworkObjects` that already had pending visibility for the client being synchronized. (#3493)
diff --git a/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs b/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs
index 1843c65e05..243502c3d6 100644
--- a/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs
+++ b/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Runtime.CompilerServices;
using Unity.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
@@ -62,6 +63,20 @@ public class SceneEvent
///
public string SceneName;
+ ///
+ /// This will be set to the path to the scene that the event pertains to.
+ /// This is set for the following s:
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public string ScenePath;
+
///
/// When a scene is loaded, the Scene structure is returned.
/// This is set for the following s:
@@ -709,6 +724,14 @@ internal string ScenePathFromHash(uint sceneHash)
}
else
{
+ // In the event there is no scene associated with the scene event then just return "No Scene"
+ // This can happen during unit tests when clients first connect and the only scene loaded is the
+ // unit test scene (which is ignored by default) that results in a scene event that has no associated
+ // scene. Under this specific special case, we just return "No Scene".
+ if (sceneHash == 0)
+ {
+ return "No Scene";
+ }
throw new Exception($"Scene Hash {sceneHash} does not exist in the {nameof(HashToBuildIndex)} table! Verify that all scenes requiring" +
$" server to client synchronization are in the scenes in build list.");
}
@@ -1125,24 +1148,7 @@ private bool OnSceneEventProgressCompleted(SceneEventProgress sceneEventProgress
size);
// Send a local notification to the server that all clients are done loading or unloading
- OnSceneEvent?.Invoke(new SceneEvent()
- {
- SceneEventType = sceneEventProgress.SceneEventType,
- SceneName = SceneNameFromHash(sceneEventProgress.SceneHash),
- ClientId = NetworkManager.ServerClientId,
- LoadSceneMode = sceneEventProgress.LoadSceneMode,
- ClientsThatCompleted = clientsThatCompleted,
- ClientsThatTimedOut = clientsThatTimedOut,
- });
-
- if (sceneEventData.SceneEventType == SceneEventType.LoadEventCompleted)
- {
- OnLoadEventCompleted?.Invoke(SceneNameFromHash(sceneEventProgress.SceneHash), sceneEventProgress.LoadSceneMode, sceneEventData.ClientsCompleted, sceneEventData.ClientsTimedOut);
- }
- else
- {
- OnUnloadEventCompleted?.Invoke(SceneNameFromHash(sceneEventProgress.SceneHash), sceneEventProgress.LoadSceneMode, sceneEventData.ClientsCompleted, sceneEventData.ClientsTimedOut);
- }
+ InvokeSceneEvents(NetworkManager.ServerClientId, sceneEventData);
EndSceneEvent(sceneEventData.SceneEventId);
return true;
@@ -1177,6 +1183,7 @@ public SceneEventProgressStatus UnloadScene(Scene scene)
Debug.LogError($"{nameof(UnloadScene)} internal error! {sceneName} with handle {scene.handle} is not within the internal scenes loaded dictionary!");
return SceneEventProgressStatus.InternalNetcodeError;
}
+ sceneEventProgress.LoadSceneMode = LoadSceneMode.Additive;
// Any NetworkObjects marked to not be destroyed with a scene and reside within the scene about to be unloaded
// should be migrated temporarily into the DDOL, once the scene is unloaded they will be migrated into the
@@ -1199,16 +1206,7 @@ public SceneEventProgressStatus UnloadScene(Scene scene)
sceneEventProgress.OnSceneEventCompleted = OnSceneUnloaded;
var sceneUnload = SceneManagerHandler.UnloadSceneAsync(scene, sceneEventProgress);
// Notify local server that a scene is going to be unloaded
- OnSceneEvent?.Invoke(new SceneEvent()
- {
- AsyncOperation = sceneUnload,
- SceneEventType = sceneEventData.SceneEventType,
- LoadSceneMode = sceneEventData.LoadSceneMode,
- SceneName = sceneName,
- ClientId = NetworkManager.ServerClientId // Server can only invoke this
- });
-
- OnUnload?.Invoke(NetworkManager.ServerClientId, sceneName, sceneUnload);
+ InvokeSceneEvents(NetworkManager.ServerClientId, sceneEventData, sceneUnload);
//Return the status
return sceneEventProgress.Status;
@@ -1264,17 +1262,11 @@ private void OnClientUnloadScene(uint sceneEventId)
throw new Exception($"Failed to remove server scene handle ({sceneEventData.SceneHandle}) or client scene handle({sceneHandle})! Happened during scene unload for {sceneName}.");
}
- // Notify the local client that a scene is going to be unloaded
- OnSceneEvent?.Invoke(new SceneEvent()
- {
- AsyncOperation = sceneUnload,
- SceneEventType = sceneEventData.SceneEventType,
- LoadSceneMode = LoadSceneMode.Additive, // The only scenes unloaded are scenes that were additively loaded
- SceneName = sceneName,
- ClientId = NetworkManager.LocalClientId // Server sent this message to the client, but client is executing it
- });
+ // The only scenes unloaded are scenes that were additively loaded
+ sceneEventData.LoadSceneMode = LoadSceneMode.Additive;
- OnUnload?.Invoke(NetworkManager.LocalClientId, sceneName, sceneUnload);
+ // Notify the local client that a scene is going to be unloaded
+ InvokeSceneEvents(NetworkManager.LocalClientId, sceneEventData, sceneUnload);
}
///
@@ -1312,15 +1304,8 @@ private void OnSceneUnloaded(uint sceneEventId)
sceneEventData.SceneEventType = SceneEventType.UnloadComplete;
//Notify the client or server that a scene was unloaded
- OnSceneEvent?.Invoke(new SceneEvent()
- {
- SceneEventType = sceneEventData.SceneEventType,
- LoadSceneMode = sceneEventData.LoadSceneMode,
- SceneName = SceneNameFromHash(sceneEventData.SceneHash),
- ClientId = NetworkManager.IsServer ? NetworkManager.ServerClientId : NetworkManager.LocalClientId
- });
-
- OnUnloadComplete?.Invoke(NetworkManager.LocalClientId, SceneNameFromHash(sceneEventData.SceneHash));
+ var client = NetworkManager.IsServer ? NetworkManager.ServerClientId : NetworkManager.LocalClientId;
+ InvokeSceneEvents(client, sceneEventData);
// Clients send a notification back to the server they have completed the unload scene event
if (!NetworkManager.IsServer)
@@ -1395,6 +1380,8 @@ public SceneEventProgressStatus LoadScene(string sceneName, LoadSceneMode loadSc
sceneEventData.SceneHash = SceneHashFromNameOrPath(sceneName);
sceneEventData.LoadSceneMode = loadSceneMode;
var sceneEventId = sceneEventData.SceneEventId;
+ // LoadScene can be called with either a sceneName or a scenePath. Ensure that sceneName is correct at this point
+ sceneName = SceneNameFromHash(sceneEventData.SceneHash);
// This both checks to make sure the scene is valid and if not resets the active scene event
m_IsSceneEventActive = ValidateSceneBeforeLoading(sceneEventData.SceneHash, loadSceneMode);
if (!m_IsSceneEventActive)
@@ -1430,16 +1417,7 @@ public SceneEventProgressStatus LoadScene(string sceneName, LoadSceneMode loadSc
sceneEventProgress.OnSceneEventCompleted = OnSceneLoaded;
var sceneLoad = SceneManagerHandler.LoadSceneAsync(sceneName, loadSceneMode, sceneEventProgress);
// Notify the local server that a scene loading event has begun
- OnSceneEvent?.Invoke(new SceneEvent()
- {
- AsyncOperation = sceneLoad,
- SceneEventType = sceneEventData.SceneEventType,
- LoadSceneMode = sceneEventData.LoadSceneMode,
- SceneName = sceneName,
- ClientId = NetworkManager.ServerClientId
- });
-
- OnLoad?.Invoke(NetworkManager.ServerClientId, sceneName, sceneEventData.LoadSceneMode, sceneLoad);
+ InvokeSceneEvents(NetworkManager.ServerClientId, sceneEventData, sceneLoad);
//Return our scene progress instance
return sceneEventProgress.Status;
@@ -1521,6 +1499,7 @@ private void SceneUnloaded(Scene scene)
AsyncOperation = m_AsyncOperation,
SceneEventType = SceneEventType.UnloadComplete,
SceneName = m_Scene.name,
+ ScenePath = m_Scene.path,
LoadSceneMode = m_LoadSceneMode,
ClientId = m_ClientId
});
@@ -1545,6 +1524,7 @@ private SceneUnloadEventHandler(NetworkSceneManager networkSceneManager, Scene s
AsyncOperation = m_AsyncOperation,
SceneEventType = SceneEventType.Unload,
SceneName = m_Scene.name,
+ ScenePath = m_Scene.path,
LoadSceneMode = m_LoadSceneMode,
ClientId = clientId
});
@@ -1599,16 +1579,7 @@ private void OnClientSceneLoadingEvent(uint sceneEventId)
};
var sceneLoad = SceneManagerHandler.LoadSceneAsync(sceneName, sceneEventData.LoadSceneMode, sceneEventProgress);
- OnSceneEvent?.Invoke(new SceneEvent()
- {
- AsyncOperation = sceneLoad,
- SceneEventType = sceneEventData.SceneEventType,
- LoadSceneMode = sceneEventData.LoadSceneMode,
- SceneName = sceneName,
- ClientId = NetworkManager.LocalClientId
- });
-
- OnLoad?.Invoke(NetworkManager.LocalClientId, sceneName, sceneEventData.LoadSceneMode, sceneLoad);
+ InvokeSceneEvents(NetworkManager.LocalClientId, sceneEventData, sceneLoad);
}
///
@@ -1723,17 +1694,9 @@ private void OnServerLoadedScene(uint sceneEventId, Scene scene)
}
m_IsSceneEventActive = false;
+ sceneEventData.SceneEventType = SceneEventType.LoadComplete;
//First, notify local server that the scene was loaded
- OnSceneEvent?.Invoke(new SceneEvent()
- {
- SceneEventType = SceneEventType.LoadComplete,
- LoadSceneMode = sceneEventData.LoadSceneMode,
- SceneName = SceneNameFromHash(sceneEventData.SceneHash),
- ClientId = NetworkManager.ServerClientId,
- Scene = scene,
- });
-
- OnLoadComplete?.Invoke(NetworkManager.ServerClientId, SceneNameFromHash(sceneEventData.SceneHash), sceneEventData.LoadSceneMode);
+ InvokeSceneEvents(NetworkManager.ServerClientId, sceneEventData, scene: scene);
//Second, only if we are a host do we want register having loaded for the associated SceneEventProgress
if (SceneEventProgressTracking.ContainsKey(sceneEventData.SceneEventProgressId) && NetworkManager.IsHost)
@@ -1760,16 +1723,7 @@ private void OnClientLoadedScene(uint sceneEventId, Scene scene)
ProcessDeferredCreateObjectMessages();
// Notify local client that the scene was loaded
- OnSceneEvent?.Invoke(new SceneEvent()
- {
- SceneEventType = SceneEventType.LoadComplete,
- LoadSceneMode = sceneEventData.LoadSceneMode,
- SceneName = SceneNameFromHash(sceneEventData.SceneHash),
- ClientId = NetworkManager.LocalClientId,
- Scene = scene,
- });
-
- OnLoadComplete?.Invoke(NetworkManager.LocalClientId, SceneNameFromHash(sceneEventData.SceneHash), sceneEventData.LoadSceneMode);
+ InvokeSceneEvents(NetworkManager.LocalClientId, sceneEventData, scene: scene);
EndSceneEvent(sceneEventId);
}
@@ -1931,6 +1885,7 @@ private void OnClientBeginSync(uint sceneEventId)
SceneEventType = SceneEventType.Load,
LoadSceneMode = loadSceneMode,
SceneName = sceneName,
+ ScenePath = ScenePathFromHash(sceneHash),
ClientId = NetworkManager.LocalClientId,
});
@@ -1999,16 +1954,7 @@ private void ClientLoadedSynchronization(uint sceneEventId)
EndSceneEvent(responseSceneEventData.SceneEventId);
// Send notification to local client that the scene has finished loading
- OnSceneEvent?.Invoke(new SceneEvent()
- {
- SceneEventType = SceneEventType.LoadComplete,
- LoadSceneMode = loadSceneMode,
- SceneName = sceneName,
- Scene = nextScene,
- ClientId = NetworkManager.LocalClientId,
- });
-
- OnLoadComplete?.Invoke(NetworkManager.LocalClientId, sceneName, loadSceneMode);
+ InvokeSceneEvents(NetworkManager.LocalClientId, responseSceneEventData, scene: nextScene);
// Check to see if we still have scenes to load and synchronize with
HandleClientSceneEvent(sceneEventId);
@@ -2180,27 +2126,8 @@ private void HandleClientSceneEvent(uint sceneEventId)
case SceneEventType.UnloadEventCompleted:
{
// Notify the local client that all clients have finished loading or unloading
- OnSceneEvent?.Invoke(new SceneEvent()
- {
- SceneEventType = sceneEventData.SceneEventType,
- LoadSceneMode = sceneEventData.LoadSceneMode,
- SceneName = SceneNameFromHash(sceneEventData.SceneHash),
- ClientId = NetworkManager.ServerClientId,
- ClientsThatCompleted = sceneEventData.ClientsCompleted,
- ClientsThatTimedOut = sceneEventData.ClientsTimedOut,
- });
-
- if (sceneEventData.SceneEventType == SceneEventType.LoadEventCompleted)
- {
- OnLoadEventCompleted?.Invoke(SceneNameFromHash(sceneEventData.SceneHash), sceneEventData.LoadSceneMode, sceneEventData.ClientsCompleted, sceneEventData.ClientsTimedOut);
- }
- else
- {
- OnUnloadEventCompleted?.Invoke(SceneNameFromHash(sceneEventData.SceneHash), sceneEventData.LoadSceneMode, sceneEventData.ClientsCompleted, sceneEventData.ClientsTimedOut);
- }
-
+ InvokeSceneEvents(NetworkManager.ServerClientId, sceneEventData);
EndSceneEvent(sceneEventId);
-
break;
}
default:
@@ -2221,41 +2148,15 @@ private void HandleServerSceneEvent(uint sceneEventId, ulong clientId)
switch (sceneEventData.SceneEventType)
{
case SceneEventType.LoadComplete:
- {
- // Notify the local server that the client has finished loading a scene
- OnSceneEvent?.Invoke(new SceneEvent()
- {
- SceneEventType = sceneEventData.SceneEventType,
- LoadSceneMode = sceneEventData.LoadSceneMode,
- SceneName = SceneNameFromHash(sceneEventData.SceneHash),
- ClientId = clientId
- });
-
- OnLoadComplete?.Invoke(clientId, SceneNameFromHash(sceneEventData.SceneHash), sceneEventData.LoadSceneMode);
-
- if (SceneEventProgressTracking.ContainsKey(sceneEventData.SceneEventProgressId))
- {
- SceneEventProgressTracking[sceneEventData.SceneEventProgressId].ClientFinishedSceneEvent(clientId);
- }
- EndSceneEvent(sceneEventId);
- break;
- }
case SceneEventType.UnloadComplete:
{
+ // Notify the local server that the client has finished unloading a scene
+ InvokeSceneEvents(clientId, sceneEventData);
+
if (SceneEventProgressTracking.ContainsKey(sceneEventData.SceneEventProgressId))
{
SceneEventProgressTracking[sceneEventData.SceneEventProgressId].ClientFinishedSceneEvent(clientId);
}
- // Notify the local server that the client has finished unloading a scene
- OnSceneEvent?.Invoke(new SceneEvent()
- {
- SceneEventType = sceneEventData.SceneEventType,
- LoadSceneMode = sceneEventData.LoadSceneMode,
- SceneName = SceneNameFromHash(sceneEventData.SceneHash),
- ClientId = clientId
- });
-
- OnUnloadComplete?.Invoke(clientId, SceneNameFromHash(sceneEventData.SceneHash));
EndSceneEvent(sceneEventId);
break;
@@ -2670,5 +2571,45 @@ private void ProcessDeferredCreateObjectMessages()
DeferredObjectCreationCount = DeferredObjectCreationList.Count;
DeferredObjectCreationList.Clear();
}
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void InvokeSceneEvents(ulong clientId, SceneEventData eventData, AsyncOperation asyncOperation = null, Scene scene = default)
+ {
+ var sceneName = SceneNameFromHash(eventData.SceneHash);
+ OnSceneEvent?.Invoke(new SceneEvent()
+ {
+ AsyncOperation = asyncOperation,
+ SceneEventType = eventData.SceneEventType,
+ SceneName = sceneName,
+ ScenePath = ScenePathFromHash(eventData.SceneHash),
+ ClientId = clientId,
+ LoadSceneMode = eventData.LoadSceneMode,
+ ClientsThatCompleted = eventData.ClientsCompleted,
+ ClientsThatTimedOut = eventData.ClientsTimedOut,
+ Scene = scene,
+ });
+
+ switch (eventData.SceneEventType)
+ {
+ case SceneEventType.Load:
+ OnLoad?.Invoke(clientId, sceneName, eventData.LoadSceneMode, asyncOperation);
+ break;
+ case SceneEventType.Unload:
+ OnUnload?.Invoke(clientId, sceneName, asyncOperation);
+ break;
+ case SceneEventType.LoadComplete:
+ OnLoadComplete?.Invoke(clientId, sceneName, eventData.LoadSceneMode);
+ break;
+ case SceneEventType.UnloadComplete:
+ OnUnloadComplete?.Invoke(clientId, sceneName);
+ break;
+ case SceneEventType.LoadEventCompleted:
+ OnLoadEventCompleted?.Invoke(SceneNameFromHash(eventData.SceneHash), eventData.LoadSceneMode, eventData.ClientsCompleted, eventData.ClientsTimedOut);
+ break;
+ case SceneEventType.UnloadEventCompleted:
+ OnUnloadEventCompleted?.Invoke(SceneNameFromHash(eventData.SceneHash), eventData.LoadSceneMode, eventData.ClientsCompleted, eventData.ClientsTimedOut);
+ break;
+ }
+ }
}
}
diff --git a/com.unity.netcode.gameobjects/TestHelpers/Runtime/NetcodeIntegrationTest.cs b/com.unity.netcode.gameobjects/TestHelpers/Runtime/NetcodeIntegrationTest.cs
index f0056538ba..49e7142df7 100644
--- a/com.unity.netcode.gameobjects/TestHelpers/Runtime/NetcodeIntegrationTest.cs
+++ b/com.unity.netcode.gameobjects/TestHelpers/Runtime/NetcodeIntegrationTest.cs
@@ -4,6 +4,7 @@
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
+using System.Text;
using NUnit.Framework;
using Unity.Netcode.RuntimeTests;
using Unity.Netcode.Transports.UTP;
@@ -36,6 +37,8 @@ public abstract class NetcodeIntegrationTest
///
protected static WaitForSecondsRealtime s_DefaultWaitForTick = new WaitForSecondsRealtime(1.0f / k_DefaultTickRate);
+ private readonly StringBuilder m_InternalErrorLog = new StringBuilder();
+
///
/// An instance of used to capture and assert log messages during integration tests.
/// This helps in verifying that expected log messages are produced and unexpected log messages are not.
@@ -1466,6 +1469,27 @@ public bool WaitForConditionOrTimeOutWithTimeTravel(IConditionalPredicate condit
return success;
}
+ ///
+ /// Waits until the specified condition returns true or a timeout occurs, then asserts if the timeout was reached.
+ /// This overload allows the condition to provide additional error details via a .
+ ///
+ /// A delegate that takes a for error details and returns true when the desired condition is met.
+ /// An optional to control the timeout period. If null, the default timeout is used.
+ /// An for use in Unity coroutines.
+ protected IEnumerator WaitForConditionOrTimeOut(Func checkForCondition, TimeoutHelper timeOutHelper = null)
+ {
+ if (checkForCondition == null)
+ {
+ throw new ArgumentNullException($"checkForCondition cannot be null!");
+ }
+
+ yield return WaitForConditionOrTimeOut(() =>
+ {
+ m_InternalErrorLog.Clear();
+ return checkForCondition(m_InternalErrorLog);
+ }, timeOutHelper);
+ }
+
///
/// Validation for clients connected.
///
@@ -1736,6 +1760,13 @@ public NetcodeIntegrationTest(HostOrServer hostOrServer)
protected void AssertOnTimeout(string timeOutErrorMessage, TimeoutHelper assignedTimeoutHelper = null)
{
var timeoutHelper = assignedTimeoutHelper ?? s_GlobalTimeoutHelper;
+ if (m_InternalErrorLog.Length > 0)
+ {
+ Assert.False(timeoutHelper.TimedOut, $"{timeOutErrorMessage}\n{m_InternalErrorLog}");
+ m_InternalErrorLog.Clear();
+ return;
+ }
+
Assert.False(timeoutHelper.TimedOut, timeOutErrorMessage);
}
@@ -1757,7 +1788,7 @@ private void UnloadRemainingScenes()
}
}
- private System.Text.StringBuilder m_WaitForLog = new System.Text.StringBuilder();
+ private StringBuilder m_WaitForLog = new StringBuilder();
private void LogWaitForMessages()
{
diff --git a/testproject/Assets/Tests/Runtime/NetworkSceneManager/OnSceneEventCallbackTests.cs b/testproject/Assets/Tests/Runtime/NetworkSceneManager/OnSceneEventCallbackTests.cs
new file mode 100644
index 0000000000..0e8f010623
--- /dev/null
+++ b/testproject/Assets/Tests/Runtime/NetworkSceneManager/OnSceneEventCallbackTests.cs
@@ -0,0 +1,281 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using NUnit.Framework;
+using Unity.Netcode;
+using Unity.Netcode.TestHelpers.Runtime;
+using UnityEngine.SceneManagement;
+using UnityEngine.TestTools;
+
+namespace TestProject.RuntimeTests
+{
+ [TestFixture(HostOrServer.Host)]
+ [TestFixture(HostOrServer.Server)]
+ public class OnSceneEventCallbackTests : NetcodeIntegrationTest
+ {
+ protected override int NumberOfClients => 1;
+
+ private const string k_SceneToLoad = "EmptyScene";
+ private const string k_PathToLoad = "Assets/Scenes/EmptyScene.unity";
+
+ public OnSceneEventCallbackTests(HostOrServer hostOrServer) : base(hostOrServer)
+ {
+ }
+
+ private struct ExpectedEvent
+ {
+ public SceneEvent SceneEvent;
+ public string SceneName;
+ public string ScenePath;
+ }
+
+ private readonly Queue m_ExpectedEventQueue = new();
+
+ private static int s_NumEventsProcessed;
+ private void OnSceneEvent(SceneEvent sceneEvent)
+ {
+ VerboseDebug($"OnSceneEvent! Type: {sceneEvent.SceneEventType}. for client: {sceneEvent.ClientId}");
+ if (m_ExpectedEventQueue.Count > 0)
+ {
+ var expectedEvent = m_ExpectedEventQueue.Dequeue();
+
+ ValidateEventsAreEqual(expectedEvent.SceneEvent, sceneEvent);
+
+ // Only LoadComplete events have an attached scene
+ if (sceneEvent.SceneEventType == SceneEventType.LoadComplete)
+ {
+ ValidateReceivedScene(expectedEvent, sceneEvent.Scene);
+ }
+ }
+ else
+ {
+ Assert.Fail($"Received unexpected event at index {s_NumEventsProcessed}: {sceneEvent.SceneEventType}");
+ }
+ s_NumEventsProcessed++;
+ }
+
+ public enum ClientType
+ {
+ Authority,
+ NonAuthority,
+ }
+
+ public enum Action
+ {
+ Load,
+ Unload,
+ }
+
+ [UnityTest]
+ public IEnumerator LoadAndUnloadCallbacks([Values] ClientType clientType, [Values] Action action)
+ {
+ yield return RunSceneEventCallbackTest(clientType, action, k_SceneToLoad);
+ }
+
+ [UnityTest]
+ public IEnumerator LoadSceneFromPath([Values] ClientType clientType)
+ {
+ yield return RunSceneEventCallbackTest(clientType, Action.Load, k_PathToLoad);
+ }
+
+ private IEnumerator RunSceneEventCallbackTest(ClientType clientType, Action action, string loadCall)
+ {
+ m_EnableVerboseDebug = true;
+ var client = m_ClientNetworkManagers[0];
+ var managerToTest = clientType == ClientType.Authority ? m_ServerNetworkManager : client;
+
+
+ var expectedCompletedClients = new List { client.LocalClientId };
+ // the authority ID is not inside ClientsThatCompleted when running as a server
+ if (m_UseHost)
+ {
+ expectedCompletedClients.Insert(0, m_ServerNetworkManager.LocalClientId);
+ }
+
+ Scene loadedScene = default;
+ if (action == Action.Unload)
+ {
+ // Load the scene initially
+ m_ServerNetworkManager.SceneManager.LoadScene(k_SceneToLoad, LoadSceneMode.Additive);
+
+ yield return WaitForConditionOrTimeOut(ValidateSceneIsLoaded);
+ AssertOnTimeout($"[Setup] Timed out waiting for client to load the scene {k_SceneToLoad}!");
+
+ // Wait for any pending messages to be processed
+ yield return s_DefaultWaitForTick;
+
+ // Get a reference to the scene to test
+ loadedScene = SceneManager.GetSceneByName(k_SceneToLoad);
+ }
+
+ s_NumEventsProcessed = 0;
+ m_ExpectedEventQueue.Clear();
+ m_ExpectedEventQueue.Enqueue(new ExpectedEvent()
+ {
+ SceneEvent = new SceneEvent()
+ {
+ SceneEventType = action == Action.Load ? SceneEventType.Load : SceneEventType.Unload,
+ LoadSceneMode = LoadSceneMode.Additive,
+ SceneName = k_SceneToLoad,
+ ScenePath = k_PathToLoad,
+ ClientId = managerToTest.LocalClientId,
+ },
+ });
+ m_ExpectedEventQueue.Enqueue(new ExpectedEvent()
+ {
+ SceneEvent = new SceneEvent()
+ {
+ SceneEventType = action == Action.Load ? SceneEventType.LoadComplete : SceneEventType.UnloadComplete,
+ LoadSceneMode = LoadSceneMode.Additive,
+ SceneName = k_SceneToLoad,
+ ScenePath = k_PathToLoad,
+ ClientId = managerToTest.LocalClientId,
+ },
+ SceneName = action == Action.Load ? k_SceneToLoad : null,
+ ScenePath = action == Action.Load ? k_PathToLoad : null
+ });
+
+ if (clientType == ClientType.Authority)
+ {
+ m_ExpectedEventQueue.Enqueue(new ExpectedEvent()
+ {
+ SceneEvent = new SceneEvent()
+ {
+ SceneEventType = action == Action.Load ? SceneEventType.LoadComplete : SceneEventType.UnloadComplete,
+ LoadSceneMode = LoadSceneMode.Additive,
+ SceneName = k_SceneToLoad,
+ ScenePath = k_PathToLoad,
+ ClientId = client.LocalClientId,
+ }
+ });
+ }
+
+ m_ExpectedEventQueue.Enqueue(new ExpectedEvent()
+ {
+ SceneEvent = new SceneEvent()
+ {
+ SceneEventType = action == Action.Load ? SceneEventType.LoadEventCompleted : SceneEventType.UnloadEventCompleted,
+ LoadSceneMode = LoadSceneMode.Additive,
+ SceneName = k_SceneToLoad,
+ ScenePath = k_PathToLoad,
+ ClientId = m_ServerNetworkManager.LocalClientId,
+ ClientsThatCompleted = expectedCompletedClients,
+ ClientsThatTimedOut = new List()
+ }
+ });
+
+ //////////////////////////////////////////
+ // Testing event notifications
+ managerToTest.SceneManager.OnSceneEvent += OnSceneEvent;
+
+ if (action == Action.Load)
+ {
+ Assert.That(m_ServerNetworkManager.SceneManager.LoadScene(loadCall, LoadSceneMode.Additive) == SceneEventProgressStatus.Started);
+
+ yield return WaitForConditionOrTimeOut(ValidateSceneIsLoaded);
+ AssertOnTimeout($"[Test] Timed out waiting for client to load the scene {k_SceneToLoad}!");
+ }
+ else
+ {
+ Assert.That(loadedScene.name, Is.EqualTo(k_SceneToLoad), "scene was not loaded!");
+ Assert.That(m_ServerNetworkManager.SceneManager.UnloadScene(loadedScene) == SceneEventProgressStatus.Started);
+
+ yield return WaitForConditionOrTimeOut(ValidateSceneIsUnloaded);
+ AssertOnTimeout($"[Test] Timed out waiting for client to unload the scene {k_SceneToLoad}!");
+ }
+
+ // Wait for all messages to process
+ yield return s_DefaultWaitForTick;
+
+ if (m_ExpectedEventQueue.Count > 0)
+ {
+ Assert.Fail($"Failed to invoke all expected OnSceneEvent callbacks. {m_ExpectedEventQueue.Count} callbacks missing. First missing event is {m_ExpectedEventQueue.Dequeue().SceneEvent.SceneEventType}");
+ }
+
+ managerToTest.SceneManager.OnSceneEvent -= OnSceneEvent;
+ }
+
+ private bool ValidateSceneIsLoaded(StringBuilder errorBuilder)
+ {
+ var loadedScene = m_ServerNetworkManager.SceneManager.ScenesLoaded.Values.FirstOrDefault(scene => scene.name == k_SceneToLoad);
+ if (!loadedScene.isLoaded)
+ {
+ errorBuilder.AppendLine($"[ValidateIsLoaded] Scene {loadedScene.name} exists but is not loaded!");
+ return false;
+ }
+ if (m_ServerNetworkManager.SceneManager.SceneEventProgressTracking.Count > 0)
+ {
+ errorBuilder.AppendLine($"[ValidateIsLoaded] Server NetworkManager still has progress tracking events.");
+ return false;
+ }
+
+ foreach (var manager in m_ClientNetworkManagers)
+ {
+ // default will have isLoaded as false so we can get the scene or default and test on isLoaded
+ loadedScene = manager.SceneManager.ScenesLoaded.Values.FirstOrDefault(scene => scene.name == k_SceneToLoad);
+ if (!loadedScene.isLoaded)
+ {
+ errorBuilder.AppendLine($"[ValidateIsLoaded] Scene {loadedScene.name} exists but is not loaded!");
+ return false;
+ }
+
+ if (manager.SceneManager.SceneEventProgressTracking.Count > 0)
+ {
+ errorBuilder.AppendLine($"[ValidateIsLoaded] Client-{manager.name} still has progress tracking events.");
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool ValidateSceneIsUnloaded()
+ {
+ if (m_ServerNetworkManager.SceneManager.ScenesLoaded.Values.Any(scene => scene.name == k_SceneToLoad))
+ {
+ return false;
+ }
+ if (m_ServerNetworkManager.SceneManager.SceneEventProgressTracking.Count > 0)
+ {
+ return false;
+ }
+
+ foreach (var manager in m_ClientNetworkManagers)
+ {
+ if (manager.SceneManager.ScenesLoaded.Values.Any(scene => scene.name == k_SceneToLoad))
+ {
+ return false;
+ }
+
+ if (manager.SceneManager.SceneEventProgressTracking.Count > 0)
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static void ValidateEventsAreEqual(SceneEvent expectedEvent, SceneEvent sceneEvent)
+ {
+ AssertField(expectedEvent.SceneEventType, sceneEvent.SceneEventType, nameof(sceneEvent.SceneEventType), sceneEvent.SceneEventType);
+ AssertField(expectedEvent.LoadSceneMode, sceneEvent.LoadSceneMode, nameof(sceneEvent.LoadSceneMode), sceneEvent.SceneEventType);
+ AssertField(expectedEvent.SceneName, sceneEvent.SceneName, nameof(sceneEvent.SceneName), sceneEvent.SceneEventType);
+ AssertField(expectedEvent.ClientId, sceneEvent.ClientId, nameof(sceneEvent.ClientId), sceneEvent.SceneEventType);
+ AssertField(expectedEvent.ClientsThatCompleted, sceneEvent.ClientsThatCompleted, nameof(sceneEvent.SceneEventType), sceneEvent.SceneEventType);
+ AssertField(expectedEvent.ClientsThatTimedOut, sceneEvent.ClientsThatTimedOut, nameof(sceneEvent.ClientsThatTimedOut), sceneEvent.SceneEventType);
+ }
+
+ // The LoadCompleted event includes the scene being loaded
+ private static void ValidateReceivedScene(ExpectedEvent expectedEvent, Scene scene)
+ {
+ AssertField(expectedEvent.SceneName, scene.name, "Scene.name", SceneEventType.LoadComplete);
+ AssertField(expectedEvent.ScenePath, scene.path, "Scene.path", SceneEventType.LoadComplete);
+ }
+
+ private static void AssertField(T expected, T actual, string fieldName, SceneEventType type)
+ {
+ Assert.AreEqual(expected, actual, $"Failed on event {s_NumEventsProcessed} - {type}: Expected {fieldName} to be {expected}. Found {actual}");
+ }
+ }
+}
diff --git a/testproject/Assets/Tests/Runtime/NetworkSceneManager/OnSceneEventCallbackTests.cs.meta b/testproject/Assets/Tests/Runtime/NetworkSceneManager/OnSceneEventCallbackTests.cs.meta
new file mode 100644
index 0000000000..8ea129e864
--- /dev/null
+++ b/testproject/Assets/Tests/Runtime/NetworkSceneManager/OnSceneEventCallbackTests.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: a74db0da82e045c3ba13f46688486efd
+timeCreated: 1748970897
\ No newline at end of file
diff --git a/testproject/Assets/Tests/Runtime/NetworkSceneManager/SceneManagementSynchronizationTests.cs b/testproject/Assets/Tests/Runtime/NetworkSceneManager/SceneManagementSynchronizationTests.cs
new file mode 100644
index 0000000000..3c08910299
--- /dev/null
+++ b/testproject/Assets/Tests/Runtime/NetworkSceneManager/SceneManagementSynchronizationTests.cs
@@ -0,0 +1,246 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Netcode;
+using Unity.Netcode.TestHelpers.Runtime;
+using UnityEngine.TestTools;
+
+namespace TestProject.RuntimeTests
+{
+ [TestFixture(HostOrServer.Host)]
+ [TestFixture(HostOrServer.Server)]
+ public class SceneManagementSynchronizationTests : NetcodeIntegrationTest
+ {
+ protected override int NumberOfClients => 1;
+
+ public SceneManagementSynchronizationTests(HostOrServer hostOrServer) : base(hostOrServer)
+ {
+ }
+
+ private struct ExpectedEvent
+ {
+ public SceneEvent SceneEvent;
+ public ConnectionEventData ConnectionEvent;
+ }
+
+ private readonly Queue m_ExpectedEventQueue = new();
+
+ private static int s_NumEventsProcessed;
+
+ private void OnSceneEvent(SceneEvent sceneEvent)
+ {
+ VerboseDebug($"OnSceneEvent! Type: {sceneEvent.SceneEventType}.");
+ AssertEventMatchesExpectedEvent(expectedEvent =>
+ ValidateSceneEventsAreEqual(expectedEvent, sceneEvent), sceneEvent.SceneEventType);
+ }
+
+ private void OnConnectionEvent(NetworkManager manager, ConnectionEventData eventData)
+ {
+ VerboseDebug($"OnConnectionEvent! Type: {eventData.EventType} - Client-{eventData.ClientId}");
+ AssertEventMatchesExpectedEvent(expectedEvent =>
+ ValidateConnectionEventsAreEqual(expectedEvent, eventData), eventData.EventType);
+ }
+
+ private void AssertEventMatchesExpectedEvent(Action predicate, T eventType)
+ {
+ if (m_ExpectedEventQueue.Count > 0)
+ {
+ var expectedEvent = m_ExpectedEventQueue.Dequeue();
+ predicate(expectedEvent);
+ }
+ else
+ {
+ Assert.Fail($"Received unexpected event at index {s_NumEventsProcessed}: {eventType}");
+ }
+
+ s_NumEventsProcessed++;
+ }
+
+ private NetworkManager m_ManagerToTest;
+
+ private void SetManagerToTest(NetworkManager manager)
+ {
+ m_ManagerToTest = manager;
+ m_ManagerToTest.OnConnectionEvent += OnConnectionEvent;
+ m_ManagerToTest.SceneManager.OnSceneEvent += OnSceneEvent;
+ }
+
+ protected override void OnNewClientStarted(NetworkManager networkManager)
+ {
+ // If m_ManagerToTest isn't set at this point, it means we are testing the newly created NetworkManager
+ if (m_ManagerToTest == null)
+ {
+ SetManagerToTest(networkManager);
+ }
+ base.OnNewClientCreated(networkManager);
+ }
+
+ protected override IEnumerator OnTearDown()
+ {
+ m_ManagerToTest.OnConnectionEvent -= OnConnectionEvent;
+ m_ManagerToTest.SceneManager.OnSceneEvent -= OnSceneEvent;
+ m_ManagerToTest = null;
+ m_ExpectedEventQueue.Clear();
+ s_NumEventsProcessed = 0;
+
+ yield return base.OnTearDown();
+ }
+
+ [UnityTest]
+ public IEnumerator SynchronizationCallbacks_Authority()
+ {
+ SetManagerToTest(m_ServerNetworkManager);
+
+ // Calculate the expected ID of the newly connecting networkManager
+ var expectedClientId = m_ClientNetworkManagers[0].LocalClientId + 1;
+
+ // Setup expected events
+ m_ExpectedEventQueue.Enqueue(new ExpectedEvent()
+ {
+ SceneEvent = new SceneEvent()
+ {
+ SceneEventType = SceneEventType.Synchronize,
+ ClientId = expectedClientId
+ },
+ });
+
+ m_ExpectedEventQueue.Enqueue(new ExpectedEvent()
+ {
+ SceneEvent = new SceneEvent()
+ {
+ SceneEventType = SceneEventType.SynchronizeComplete,
+ ClientId = expectedClientId,
+ },
+ });
+
+ m_ExpectedEventQueue.Enqueue(new ExpectedEvent()
+ {
+ ConnectionEvent = new ConnectionEventData()
+ {
+ EventType = ConnectionEvent.ClientConnected,
+ ClientId = expectedClientId,
+ }
+ });
+
+ if (m_UseHost)
+ {
+ m_ExpectedEventQueue.Enqueue(new ExpectedEvent()
+ {
+ ConnectionEvent = new ConnectionEventData()
+ {
+ EventType = ConnectionEvent.PeerConnected,
+ ClientId = expectedClientId,
+ }
+ });
+ }
+
+ m_EnableVerboseDebug = true;
+ //////////////////////////////////////////
+ // Testing event notifications
+ yield return CreateAndStartNewClient();
+ yield return s_DefaultWaitForTick;
+
+ if (m_ExpectedEventQueue.Count > 0)
+ {
+ Assert.Fail($"Failed to invoke all expected callbacks. {m_ExpectedEventQueue.Count} callbacks missing. First missing event is {m_ExpectedEventQueue.Dequeue().SceneEvent.SceneEventType}");
+ }
+ }
+
+ [UnityTest]
+ public IEnumerator SynchronizationCallbacks_NonAuthority()
+ {
+ var authorityId = m_ServerNetworkManager.LocalClientId;
+ var peerClientId = m_ClientNetworkManagers[0].LocalClientId;
+ var expectedClientId = peerClientId + 1;
+
+ var expectedPeerClientIds = m_UseHost ? new[] { authorityId, peerClientId } : new[] { peerClientId };
+
+ // Setup expected events
+ m_ExpectedEventQueue.Enqueue(new ExpectedEvent()
+ {
+ ConnectionEvent = new ConnectionEventData()
+ {
+ EventType = ConnectionEvent.ClientConnected,
+ ClientId = expectedClientId,
+ PeerClientIds = new NativeArray(expectedPeerClientIds.ToArray(), Allocator.Persistent),
+ }
+ });
+
+ m_ExpectedEventQueue.Enqueue(new ExpectedEvent()
+ {
+ SceneEvent = new SceneEvent()
+ {
+ SceneEventType = SceneEventType.SynchronizeComplete,
+ ClientId = expectedClientId,
+ },
+ });
+
+ Assert.Null(m_ManagerToTest, "m_ManagerToTest should be null as we should be testing newly created client");
+
+ //////////////////////////////////////////
+ // Testing event notifications
+
+ // CreateAndStartNewClient will configure m_ManagerToTest inside OnNewClientStarted
+ yield return CreateAndStartNewClient();
+ yield return s_DefaultWaitForTick;
+
+ Assert.IsEmpty(m_ExpectedEventQueue, "Not all expected callbacks were received");
+ }
+
+ [UnityTest]
+ public IEnumerator LateJoiningClient_PeerCallbacks()
+ {
+ var expectedClientId = m_ClientNetworkManagers[0].LocalClientId + 1;
+ SetManagerToTest(m_ClientNetworkManagers[0]);
+
+ m_ExpectedEventQueue.Enqueue(new ExpectedEvent()
+ {
+ ConnectionEvent = new ConnectionEventData()
+ {
+ EventType = ConnectionEvent.PeerConnected,
+ ClientId = expectedClientId,
+ }
+ });
+
+ //////////////////////////////////////////
+ // Testing event notifications
+ yield return CreateAndStartNewClient();
+ yield return s_DefaultWaitForTick;
+
+ Assert.IsEmpty(m_ExpectedEventQueue, "Not all expected callbacks were received");
+ }
+
+ private static void ValidateSceneEventsAreEqual(ExpectedEvent expectedEvent, SceneEvent sceneEvent)
+ {
+ Assert.NotNull(expectedEvent.SceneEvent, $"Received unexpected scene event {sceneEvent.SceneEventType} at index {s_NumEventsProcessed}");
+ AssertField(expectedEvent.SceneEvent.SceneEventType, sceneEvent.SceneEventType, nameof(sceneEvent.SceneEventType), sceneEvent.SceneEventType);
+ AssertField(expectedEvent.SceneEvent.ClientId, sceneEvent.ClientId, nameof(sceneEvent.ClientId), sceneEvent.SceneEventType);
+ }
+
+ private static void ValidateConnectionEventsAreEqual(ExpectedEvent expectedEvent, ConnectionEventData eventData)
+ {
+ Assert.NotNull(expectedEvent.ConnectionEvent, $"Received unexpected connection event {eventData.EventType} at index {s_NumEventsProcessed}");
+ AssertField(expectedEvent.ConnectionEvent.EventType, eventData.EventType, nameof(eventData.EventType), eventData.EventType);
+ AssertField(expectedEvent.ConnectionEvent.ClientId, eventData.ClientId, nameof(eventData.ClientId), eventData.EventType);
+
+ AssertField(expectedEvent.ConnectionEvent.PeerClientIds.Length, eventData.PeerClientIds.Length, "length of PeerClientIds", eventData.EventType);
+ if (eventData.PeerClientIds.Length > 0)
+ {
+ var peerIds = eventData.PeerClientIds.ToArray();
+ foreach (var expectedClientId in expectedEvent.ConnectionEvent.PeerClientIds)
+ {
+ Assert.Contains(expectedClientId, peerIds, "PeerClientIds does not contain all expected client IDs.");
+ }
+ }
+ }
+
+ private static void AssertField(T expected, T actual, string fieldName, TK type)
+ {
+ Assert.AreEqual(expected, actual, $"Failed on event {s_NumEventsProcessed} - {type}. Incorrect {fieldName}");
+ }
+
+ }
+}
diff --git a/testproject/Assets/Tests/Runtime/NetworkSceneManager/SceneManagementSynchronizationTests.cs.meta b/testproject/Assets/Tests/Runtime/NetworkSceneManager/SceneManagementSynchronizationTests.cs.meta
new file mode 100644
index 0000000000..f1eda32abc
--- /dev/null
+++ b/testproject/Assets/Tests/Runtime/NetworkSceneManager/SceneManagementSynchronizationTests.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: ae2f461cb3da41d3ac68f77bf83f944a
+timeCreated: 1749663194
\ No newline at end of file