From 7da6821840897aef229834c87d751ca8292904e3 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 07:35:17 -0500 Subject: [PATCH 01/15] fix: stop the network clock adjustment on its target instead of stepping past it Once a client's clock reached the offset it adjusts towards, each frame stepped the full AdjustmentRatio past it and back, so LocalTime and ServerTime alternated between running fast and slow every frame. --- com.unity.netcode.gameobjects/CHANGELOG.md | 1 + .../Runtime/Timing/NetworkTimeSystem.cs | 7 +++-- .../Timing/ClientNetworkTimeSystemTests.cs | 30 +++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index b7e3070241..36b401f91b 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -23,6 +23,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed +- Issue where a client's `LocalTime` and `ServerTime` alternated every frame between running faster and slower than real time by `NetworkTimeSystem.AdjustmentRatio` once they reached the server's time, making interpolated objects judder at higher ratios. - Issue where the IL post-processor could not resolve core library types, such as `Guid` and `List`, in assemblies compiled against .NET reference assemblies, for example when targeting .NET 10. Those split the core library across `System.Runtime`, `System.Collections` and other assemblies. - Fixed issue where a prefab added to a `NetworkPrefabsList` while a session was running was registered more than once and logged a duplicate `GlobalObjectIdHash` error. (#4184) - Issue where moving a `NetworkObject` into another scene made the clients that did not observe it log "Trying to synchronize NetworkObjectId but it was not spawned". The scene migration is now only sent to the clients that observe the `NetworkObject`. (#4176) diff --git a/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs b/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs index f2bac8bb23..65f632af70 100644 --- a/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs +++ b/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs @@ -260,8 +260,11 @@ public bool Advance(double deltaTimeSec) return true; } - m_CurrentLocalTimeOffset += deltaTimeSec * (m_DesiredLocalTimeOffset > m_CurrentLocalTimeOffset ? AdjustmentRatio : -AdjustmentRatio); - m_CurrentServerTimeOffset += deltaTimeSec * (m_DesiredServerTimeOffset > m_CurrentServerTimeOffset ? AdjustmentRatio : -AdjustmentRatio); + // Stop on the desired offset rather than step past it, or a converged clock alternates between running + // fast and slow by the whole ratio every frame, and everything rendered from it judders. + var maximumStep = deltaTimeSec * AdjustmentRatio; + m_CurrentLocalTimeOffset += Math.Clamp(m_DesiredLocalTimeOffset - m_CurrentLocalTimeOffset, -maximumStep, maximumStep); + m_CurrentServerTimeOffset += Math.Clamp(m_DesiredServerTimeOffset - m_CurrentServerTimeOffset, -maximumStep, maximumStep); return false; } diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Timing/ClientNetworkTimeSystemTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/Timing/ClientNetworkTimeSystemTests.cs index f810c45c28..a8b0846e26 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/Timing/ClientNetworkTimeSystemTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Editor/Timing/ClientNetworkTimeSystemTests.cs @@ -138,6 +138,36 @@ public void RttCatchupSlowdownTest() Assert.True(Math.Abs(totalServerSpeedUpTime) < k_AcceptableRttOffset, $"Expected server speed up time to be less than {k_AcceptableRttOffset}ms but it was {expectedServerSpeedUpTime}!"); } + /// + /// Tests that once the clock reaches the offset it is adjusting towards, it advances at the real rate on every + /// frame instead of alternating between faster and slower. + /// + [Test] + public void ConvergedClockAdvancesAtRealRate([Values(0.01d, 0.1d)] double adjustmentRatio) + { + const double deltaTime = 1.0d / 60.0d; + double receivedServerTime = 2; + var timeSystem = new NetworkTimeSystem(0.05d, 0.05d, 0.1d, adjustmentRatio); + timeSystem.Reset(receivedServerTime, 0.1d); + // Moves the desired offset, within the hard reset threshold, so the clock has to adjust to it first. + receivedServerTime += 0.01d; + timeSystem.Sync(receivedServerTime, 0.1d); + + for (int i = 0; i < 600; i++) + { + timeSystem.Advance(deltaTime); + } + + for (int i = 0; i < 10; i++) + { + var localTime = timeSystem.LocalTime; + var serverTime = timeSystem.ServerTime; + timeSystem.Advance(deltaTime); + Assert.AreEqual(deltaTime, timeSystem.LocalTime - localTime, 1e-9d, $"Local time advanced at the wrong rate on frame {i}."); + Assert.AreEqual(deltaTime, timeSystem.ServerTime - serverTime, 1e-9d, $"Server time advanced at the wrong rate on frame {i}."); + } + } + /// /// Tests whether time resets when there is a huge spike in RTT and is able to stabilize again. /// From c2fe9ac7e2ea78277f2c05fe532197a3bc92dbd1 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 15 Oct 2026 21:52:49 -0500 Subject: [PATCH 02/15] fix: keep a list of NetworkManagers instead of searching the scene for them every frame UnifiedUpdateConnections called Object.FindObjectsByType() on every update, whose cost grows with the number of MonoBehaviours in the scene: about 1.5 ms a call with 10,000 and 14 ms with 50,000. NetworkManager.Instances is filled in Awake and emptied in OnDestroy, and the system visits that. --- .../Helpers/UnifiedUpdateConnections.cs | 14 +++++++++++-- .../Runtime/Core/NetworkManager.cs | 9 +++++++++ .../Runtime/NetworkManagerEventsTests.cs | 20 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs index 513f84cfc7..d19d7c3146 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using Unity.Collections; using Unity.Entities; -using UnityEngine; namespace Unity.Netcode.Components { @@ -32,12 +31,23 @@ internal partial class UnifiedUpdateConnections : SystemBase private Dictionary m_NewConnections = new Dictionary(); + // A copy of NetworkManager.Instances, which a callback below could change. Searching the scene for them + // instead costs time in proportion to every MonoBehaviour in it, every frame. + private List m_NetworkManagers = new List(); + protected override void OnUpdate() { var isServer = World.IsServer(); var commandBuffer = new EntityCommandBuffer(Allocator.Temp); - foreach (var networkManager in Object.FindObjectsByType()) + m_NetworkManagers.Clear(); + m_NetworkManagers.AddRange(NetworkManager.Instances); + foreach (var networkManager in m_NetworkManagers) { + if (networkManager == null || !networkManager.gameObject.activeInHierarchy) + { + continue; + } + foreach (var (networkId, connectionState, entity) in SystemAPI.Query().WithNone().WithEntityAccess()) { commandBuffer.RemoveComponent(entity); diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 713aaa74ed..eb88466726 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -44,6 +44,7 @@ public class NetworkManager : MonoBehaviour, INetworkUpdateSystem private static void ResetStaticsOnLoad() { Singleton = null; + Instances.Clear(); OnInstantiated = null; OnDestroying = null; OnSingletonReady = null; @@ -899,6 +900,12 @@ public struct ConnectionApprovalRequest /// public static NetworkManager Singleton { get; private set; } + /// + /// Every from Awake to OnDestroy, for code that has to visit them + /// all without searching the scene. + /// + internal static readonly List Instances = new List(); + internal static event Action OnSingletonReady; /// @@ -1084,6 +1091,7 @@ public void SetSingleton() private void Awake() { + Instances.Add(this); if (Log == null) { Log = new ContextualLogger(this, false); @@ -1971,6 +1979,7 @@ private void OnDestroy() { Singleton = null; } + Instances.Remove(this); #if UNITY_EDITOR EditorApplication.playModeStateChanged -= ModeChanged; #endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkManagerEventsTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkManagerEventsTests.cs index 32d998c0b7..f2e9644ffa 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkManagerEventsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkManagerEventsTests.cs @@ -64,6 +64,26 @@ public IEnumerator InstantiatedAndDestroyingNotifications() NetworkManager.OnDestroying -= NetworkManager_OnDestroying; } + /// + /// Validates that holds each from Awake + /// until it is destroyed. + /// + [Test] + public void InstancesHoldsEachNetworkManagerUntilDestroyed() + { + var first = new GameObject("InstancesFirst").AddComponent(); + var second = new GameObject("InstancesSecond").AddComponent(); + Assert.IsTrue(NetworkManager.Instances.Contains(first), $"The first {nameof(NetworkManager)} is not in {nameof(NetworkManager.Instances)}!"); + Assert.IsTrue(NetworkManager.Instances.Contains(second), $"The second {nameof(NetworkManager)} is not in {nameof(NetworkManager.Instances)}!"); + + Object.DestroyImmediate(first.gameObject); + Assert.IsFalse(NetworkManager.Instances.Contains(first), $"The destroyed {nameof(NetworkManager)} is still in {nameof(NetworkManager.Instances)}!"); + Assert.IsTrue(NetworkManager.Instances.Contains(second), $"Destroying one {nameof(NetworkManager)} removed another from {nameof(NetworkManager.Instances)}!"); + + Object.DestroyImmediate(second.gameObject); + Assert.IsFalse(NetworkManager.Instances.Contains(second), $"The destroyed {nameof(NetworkManager)} is still in {nameof(NetworkManager.Instances)}!"); + } + private void NetworkManager_OnInstantiated(NetworkManager networkManager) { m_Instantiated = true; From 84cbb03acadf1933701395351b4746423215e67e Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 10:55:12 -0500 Subject: [PATCH 03/15] docs: describe the clock adjustment fix by what users saw --- com.unity.netcode.gameobjects/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 36b401f91b..38d75261b8 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -23,7 +23,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed -- Issue where a client's `LocalTime` and `ServerTime` alternated every frame between running faster and slower than real time by `NetworkTimeSystem.AdjustmentRatio` once they reached the server's time, making interpolated objects judder at higher ratios. +- Issue where a client's clock kept alternating between running slightly fast and slightly slow on every frame, even after it had synchronized with the server. Interpolated objects juddered (a periodic motion artifact caused by mismatched frame rates): they moved in uneven steps instead of smoothly. Barely noticeable at the default `NetworkTimeSystem.AdjustmentRatio`, and clearly visible at higher values. - Issue where the IL post-processor could not resolve core library types, such as `Guid` and `List`, in assemblies compiled against .NET reference assemblies, for example when targeting .NET 10. Those split the core library across `System.Runtime`, `System.Collections` and other assemblies. - Fixed issue where a prefab added to a `NetworkPrefabsList` while a session was running was registered more than once and logged a duplicate `GlobalObjectIdHash` error. (#4184) - Issue where moving a `NetworkObject` into another scene made the clients that did not observe it log "Trying to synchronize NetworkObjectId but it was not spawned". The scene migration is now only sent to the clients that observe the `NetworkObject`. (#4176) From a2eff6e7cf3076e47ad93c224a6d9d94e5df02c6 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 12:09:31 -0500 Subject: [PATCH 04/15] fix: bind connection updates to the hybrid world's own NetworkManager UnifiedUpdateConnections now runs only in client and server worlds that hold the NetworkManager they were created for, and updates that NetworkManager alone. This replaces the NetworkManager.Instances list, which a derived NetworkManager declaring its own Awake never entered. --- .../Helpers/UnifiedUpdateConnections.cs | 127 +++++++++--------- .../Runtime/Core/NetworkManager.cs | 15 +-- .../Runtime/NetworkManagerEventsTests.cs | 20 --- .../UnifiedNetworkTransformTest.cs | 20 +++ .../Unified/UnifiedUpdateConnectionsTests.cs | 56 ++++++++ .../UnifiedUpdateConnectionsTests.cs.meta | 11 ++ 6 files changed, 159 insertions(+), 90 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedUpdateConnectionsTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedUpdateConnectionsTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs index d19d7c3146..10c45f2124 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs @@ -25,95 +25,100 @@ public void SendMessage(T message) where T : unmanaged, IRpcCommand } } + /// + /// The a hybrid session's world was created for, as a singleton in that world. + /// + internal struct HybridNetworkManager : IComponentData + { + public UnityObjectRef Value; + } + + // Created only in client and server worlds, and updated only in one a NetworkManager created for a hybrid session. + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation | WorldSystemFilterFlags.ClientSimulation)] internal partial class UnifiedUpdateConnections : SystemBase { private List m_TempConnections = new List(); private Dictionary m_NewConnections = new Dictionary(); - // A copy of NetworkManager.Instances, which a callback below could change. Searching the scene for them - // instead costs time in proportion to every MonoBehaviour in it, every frame. - private List m_NetworkManagers = new List(); + private EntityQuery m_HybridNetworkManagerQuery; + + protected override void OnCreate() + { + m_HybridNetworkManagerQuery = GetEntityQuery(ComponentType.ReadOnly()); + RequireForUpdate(m_HybridNetworkManagerQuery); + } protected override void OnUpdate() { var isServer = World.IsServer(); var commandBuffer = new EntityCommandBuffer(Allocator.Temp); - m_NetworkManagers.Clear(); - m_NetworkManagers.AddRange(NetworkManager.Instances); - foreach (var networkManager in m_NetworkManagers) + var networkManager = m_HybridNetworkManagerQuery.GetSingleton().Value.Value; + foreach (var (networkId, connectionState, entity) in SystemAPI.Query().WithNone().WithEntityAccess()) { - if (networkManager == null || !networkManager.gameObject.activeInHierarchy) + commandBuffer.RemoveComponent(entity); + m_TempConnections.Add(new NetcodeConnection { - continue; - } + World = World, + Entity = entity, + NetworkId = networkId.Value + }); + } - foreach (var (networkId, connectionState, entity) in SystemAPI.Query().WithNone().WithEntityAccess()) - { - commandBuffer.RemoveComponent(entity); - m_TempConnections.Add(new NetcodeConnection - { - World = World, - Entity = entity, - NetworkId = networkId.Value - }); - } + foreach (var con in m_TempConnections) + { + NetworkManager.OnNetCodeDisconnect?.Invoke(con); + } - foreach (var con in m_TempConnections) + m_TempConnections.Clear(); + + // TODO: We should figure out how to associate the N4E NetworkId with the NGO ClientId + foreach (var (networkId, entity) in SystemAPI.Query().WithAll() + .WithNone().WithEntityAccess()) + { + if (!m_NewConnections.ContainsKey(networkId.Value)) { - NetworkManager.OnNetCodeDisconnect?.Invoke(con); + var newConnection = new NetcodeConnection + { World = World, Entity = entity, NetworkId = networkId.Value }; + m_NewConnections.Add(networkId.Value, newConnection); } + } - m_TempConnections.Clear(); - - // TODO: We should figure out how to associate the N4E NetworkId with the NGO ClientId - foreach (var (networkId, entity) in SystemAPI.Query().WithAll() - .WithNone().WithEntityAccess()) + // If we have any pending connections + if (m_NewConnections.Count > 0) + { + foreach (var entry in m_NewConnections) { - if (!m_NewConnections.ContainsKey(networkId.Value)) + // Server: always connect + // Client: wait until we have synchronized before announcing we are ready to receive snapshots + if (networkManager.IsServer || (!networkManager.IsServer && networkManager.IsConnectedClient)) { - var newConnection = new NetcodeConnection - { World = World, Entity = entity, NetworkId = networkId.Value }; - m_NewConnections.Add(networkId.Value, newConnection); + // Set the connection in-game + commandBuffer.AddComponent(entry.Value.Entity); + commandBuffer.AddComponent(entry.Value.Entity, default(ConnectionState)); + NetworkManager.OnNetCodeConnect?.Invoke(entry.Value); + m_TempConnections.Add(entry.Value); } } - // If we have any pending connections - if (m_NewConnections.Count > 0) + // Remove any connections that have "gone in-game". + foreach (var connection in m_TempConnections) { - foreach (var entry in m_NewConnections) - { - // Server: always connect - // Client: wait until we have synchronized before announcing we are ready to receive snapshots - if (networkManager.IsServer || (!networkManager.IsServer && networkManager.IsConnectedClient)) - { - // Set the connection in-game - commandBuffer.AddComponent(entry.Value.Entity); - commandBuffer.AddComponent(entry.Value.Entity, default(ConnectionState)); - NetworkManager.OnNetCodeConnect?.Invoke(entry.Value); - m_TempConnections.Add(entry.Value); - } - } - - // Remove any connections that have "gone in-game". - foreach (var connection in m_TempConnections) - { - m_NewConnections.Remove(connection.NetworkId); - } + m_NewConnections.Remove(connection.NetworkId); } + } - m_TempConnections.Clear(); + m_TempConnections.Clear(); - // If the local NetworkManager is shutting down or no longer connected, then - // make sure we have disconnected all known connections. - if (networkManager.ShutdownInProgress || !networkManager.IsListening) + // If the local NetworkManager is shutting down or no longer connected, then + // make sure we have disconnected all known connections. + if (networkManager.ShutdownInProgress || !networkManager.IsListening) + { + foreach (var (networkId, entity) in SystemAPI.Query().WithEntityAccess()) { - foreach (var (networkId, entity) in SystemAPI.Query().WithEntityAccess()) - { - commandBuffer.RemoveComponent(entity); - NetworkManager.OnNetCodeDisconnect?.Invoke(new NetcodeConnection - { World = World, Entity = entity, NetworkId = networkId.Value }); - } + commandBuffer.RemoveComponent(entity); + NetworkManager.OnNetCodeDisconnect?.Invoke(new NetcodeConnection + { World = World, Entity = entity, NetworkId = networkId.Value }); } } commandBuffer.Playback(EntityManager); diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index eb88466726..a124a46ee5 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -44,7 +44,6 @@ public class NetworkManager : MonoBehaviour, INetworkUpdateSystem private static void ResetStaticsOnLoad() { Singleton = null; - Instances.Clear(); OnInstantiated = null; OnDestroying = null; OnSingletonReady = null; @@ -900,12 +899,6 @@ public struct ConnectionApprovalRequest /// public static NetworkManager Singleton { get; private set; } - /// - /// Every from Awake to OnDestroy, for code that has to visit them - /// all without searching the scene. - /// - internal static readonly List Instances = new List(); - internal static event Action OnSingletonReady; /// @@ -1091,7 +1084,6 @@ public void SetSingleton() private void Awake() { - Instances.Add(this); if (Log == null) { Log = new ContextualLogger(this, false); @@ -1403,6 +1395,12 @@ internal void InitializeNetcodeWorld() TypeManager.Initialize(); UnifiedBootstrap.CurrentNetworkManagerForInitialization = this; new UnifiedBootstrap().Initialize("Default World"); + + if (NetcodeWorld != null && NetcodeWorld.IsCreated) + { + var entityManager = NetcodeWorld.EntityManager; + entityManager.AddComponentData(entityManager.CreateEntity(), new HybridNetworkManager { Value = this }); + } } /// @@ -1979,7 +1977,6 @@ private void OnDestroy() { Singleton = null; } - Instances.Remove(this); #if UNITY_EDITOR EditorApplication.playModeStateChanged -= ModeChanged; #endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkManagerEventsTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkManagerEventsTests.cs index f2e9644ffa..32d998c0b7 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkManagerEventsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkManagerEventsTests.cs @@ -64,26 +64,6 @@ public IEnumerator InstantiatedAndDestroyingNotifications() NetworkManager.OnDestroying -= NetworkManager_OnDestroying; } - /// - /// Validates that holds each from Awake - /// until it is destroyed. - /// - [Test] - public void InstancesHoldsEachNetworkManagerUntilDestroyed() - { - var first = new GameObject("InstancesFirst").AddComponent(); - var second = new GameObject("InstancesSecond").AddComponent(); - Assert.IsTrue(NetworkManager.Instances.Contains(first), $"The first {nameof(NetworkManager)} is not in {nameof(NetworkManager.Instances)}!"); - Assert.IsTrue(NetworkManager.Instances.Contains(second), $"The second {nameof(NetworkManager)} is not in {nameof(NetworkManager.Instances)}!"); - - Object.DestroyImmediate(first.gameObject); - Assert.IsFalse(NetworkManager.Instances.Contains(first), $"The destroyed {nameof(NetworkManager)} is still in {nameof(NetworkManager.Instances)}!"); - Assert.IsTrue(NetworkManager.Instances.Contains(second), $"Destroying one {nameof(NetworkManager)} removed another from {nameof(NetworkManager.Instances)}!"); - - Object.DestroyImmediate(second.gameObject); - Assert.IsFalse(NetworkManager.Instances.Contains(second), $"The destroyed {nameof(NetworkManager)} is still in {nameof(NetworkManager.Instances)}!"); - } - private void NetworkManager_OnInstantiated(NetworkManager networkManager) { m_Instantiated = true; diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs index a0faaa26da..8af72801ec 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Text; using NUnit.Framework; +using Unity.Entities; using Unity.Netcode.Components; using Unity.Netcode.TestHelpers.Runtime; using UnityEngine; @@ -100,6 +101,25 @@ public IEnumerator BasicMovementTest() AssertOnTimeout($"A client failed to synchronize the changed position: {GetVector3Values(newPos)}!"); VerboseDebug("Test Passed!"); } + + /// + /// Validates that each 's world updates the connections of that + /// alone. + /// + [Test] + public void EachWorldUpdatesConnectionsForItsOwnNetworkManager() + { + foreach (var networkManager in m_NetworkManagers) + { + var world = networkManager.NetcodeWorld; + Assert.IsNotNull(world, $"[Client-{networkManager.LocalClientId}] Has no world!"); + Assert.IsNotNull(world.GetExistingSystemManaged(), $"[{world.Name}] Has no {nameof(UnifiedUpdateConnections)} system!"); + using var query = world.EntityManager.CreateEntityQuery(typeof(HybridNetworkManager)); + Assert.AreEqual(1, query.CalculateEntityCount(), $"[{world.Name}] Does not have exactly one {nameof(HybridNetworkManager)}!"); + var bound = query.GetSingleton().Value.Value; + Assert.AreSame(networkManager, bound, $"[{world.Name}] Is bound to another {nameof(NetworkManager)}!"); + } + } } } #endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedUpdateConnectionsTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedUpdateConnectionsTests.cs new file mode 100644 index 0000000000..439dc9e82a --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedUpdateConnectionsTests.cs @@ -0,0 +1,56 @@ +#if UNIFIED_NETCODE +using System.Collections; +using NUnit.Framework; +using Unity.Entities; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using Unity.Netcode.Transports.UTP; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + internal class UnifiedUpdateConnectionsTests + { + private GameObject m_NetworkManagerObject; + + [OneTimeSetUp] + public void OneTimeSetup() + { + NetcodeIntegrationTestHelpers.IgnoreIfServiceEnviromentVariableSet(); + } + + /// + /// Validates that a session without ghost prefabs leaves no world with a + /// system. + /// + [UnityTest] + public IEnumerator NoWorldUpdatesConnectionsWithoutGhostPrefabs() + { + m_NetworkManagerObject = new GameObject("NoGhostPrefabs"); + var networkManager = m_NetworkManagerObject.AddComponent(); + networkManager.NetworkConfig = new NetworkConfig() { NetworkTransport = m_NetworkManagerObject.AddComponent() }; + Assert.IsTrue(networkManager.StartServer(), $"The {nameof(NetworkManager)} failed to start!"); + yield return null; + + Assert.IsNull(networkManager.NetcodeWorld, $"A session without ghost prefabs created a world!"); + Assert.IsTrue(World.All.Count > 0, "There is no world to check!"); + foreach (var world in World.All) + { + Assert.IsNull(world.GetExistingSystemManaged(), $"[{world.Name}] Has a {nameof(UnifiedUpdateConnections)} system!"); + } + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (m_NetworkManagerObject != null) + { + Object.DestroyImmediate(m_NetworkManagerObject); + m_NetworkManagerObject = null; + } + yield return null; + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedUpdateConnectionsTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedUpdateConnectionsTests.cs.meta new file mode 100644 index 0000000000..eb6b79e372 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedUpdateConnectionsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 75d991d6f40241c9a640915c1b1fc7c9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 7195c50b865c9f6e1e9ba2fb155718d341ea3d72 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 12:31:14 -0500 Subject: [PATCH 05/15] fix: create a server only world for hybrid mode in dedicated server builds Netcode for Entities cannot create a single world host in a dedicated server build, which every hybrid server used. A build for only one side now logs an error when asked to start the side it does not support. --- .../Components/Helpers/UnifiedBootstrap.cs | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs index 6629cdc43b..572867ae37 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs @@ -71,8 +71,7 @@ public override bool Initialize(string defaultWorldName) RegisterCreatedWorlds(); Debug.Log($"Starting a world for {(networkManager.IsServer ? "Host" : "Client")}"); s_WorldCounter++; - LastCreatedWorld = networkManager.IsServer ? CreateSingleWorldHost($"HostSingleWorld-{s_WorldCounter}") - : CreateClientWorld($"ClientWorld-{s_WorldCounter}"); + LastCreatedWorld = CreateWorld(networkManager); if (LastCreatedWorld == null) { @@ -106,6 +105,34 @@ public override bool Initialize(string defaultWorldName) return true; } + /// + /// Creates the world a runs its hybrid session in. + /// + /// + /// A server, host or not, runs in a single world host, except in a dedicated server build, where Netcode for + /// Entities cannot create a world that is also a client. A build for only one side cannot run a host. + /// + private static World CreateWorld(NetworkManager networkManager) + { +#if UNITY_SERVER && !UNITY_EDITOR + if (networkManager.IsClient) + { + Debug.LogError($"[{nameof(UnifiedBootstrap)}] A dedicated server build cannot start a {(networkManager.IsServer ? "host" : "client")} in hybrid mode."); + return null; + } + return CreateServerWorld($"ServerWorld-{s_WorldCounter}"); +#elif UNITY_CLIENT && !UNITY_EDITOR + if (networkManager.IsServer) + { + Debug.LogError($"[{nameof(UnifiedBootstrap)}] A client only build cannot start a {(networkManager.IsClient ? "host" : "server")} in hybrid mode. Set the Netcode for Entities client build target to ClientAndServer."); + return null; + } + return CreateClientWorld($"ClientWorld-{s_WorldCounter}"); +#else + return networkManager.IsServer ? CreateSingleWorldHost($"HostSingleWorld-{s_WorldCounter}") : CreateClientWorld($"ClientWorld-{s_WorldCounter}"); +#endif + } + ~UnifiedBootstrap() { LastCreatedWorld = null; From 4c9666c52ce2a2820dacd6262a5eb70748858531 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 12:55:34 -0500 Subject: [PATCH 06/15] fix: treat a destroyed NetworkManager as shutting down in connection updates The hybrid world outlives its NetworkManager if ShutdownInternal throws before disposing it, so UnifiedUpdateConnections now disconnects every connection and announces none instead of throwing each frame. HybridNetworkManager.Value is renamed to NetworkManager, the clock adjustment comment says what the clamp does, and the unused Unity.Entities using that failed the Standards check is removed. --- .../Components/Helpers/UnifiedUpdateConnections.cs | 10 ++++++---- .../Runtime/Core/NetworkManager.cs | 2 +- .../Runtime/Timing/NetworkTimeSystem.cs | 5 +++-- .../NetworkTransform/UnifiedNetworkTransformTest.cs | 3 +-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs index 10c45f2124..af562f3e7f 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs @@ -30,7 +30,7 @@ public void SendMessage(T message) where T : unmanaged, IRpcCommand /// internal struct HybridNetworkManager : IComponentData { - public UnityObjectRef Value; + public UnityObjectRef NetworkManager; } // Created only in client and server worlds, and updated only in one a NetworkManager created for a hybrid session. @@ -53,7 +53,9 @@ protected override void OnUpdate() { var isServer = World.IsServer(); var commandBuffer = new EntityCommandBuffer(Allocator.Temp); - var networkManager = m_HybridNetworkManagerQuery.GetSingleton().Value.Value; + var networkManager = m_HybridNetworkManagerQuery.GetSingleton().NetworkManager.Value; + // The world outlives its NetworkManager if ShutdownInternal throws before disposing it. + var isShuttingDown = !networkManager || networkManager.ShutdownInProgress || !networkManager.IsListening; foreach (var (networkId, connectionState, entity) in SystemAPI.Query().WithNone().WithEntityAccess()) { commandBuffer.RemoveComponent(entity); @@ -85,7 +87,7 @@ protected override void OnUpdate() } // If we have any pending connections - if (m_NewConnections.Count > 0) + if (!isShuttingDown && m_NewConnections.Count > 0) { foreach (var entry in m_NewConnections) { @@ -112,7 +114,7 @@ protected override void OnUpdate() // If the local NetworkManager is shutting down or no longer connected, then // make sure we have disconnected all known connections. - if (networkManager.ShutdownInProgress || !networkManager.IsListening) + if (isShuttingDown) { foreach (var (networkId, entity) in SystemAPI.Query().WithEntityAccess()) { diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index a124a46ee5..6496483aa1 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1399,7 +1399,7 @@ internal void InitializeNetcodeWorld() if (NetcodeWorld != null && NetcodeWorld.IsCreated) { var entityManager = NetcodeWorld.EntityManager; - entityManager.AddComponentData(entityManager.CreateEntity(), new HybridNetworkManager { Value = this }); + entityManager.AddComponentData(entityManager.CreateEntity(), new HybridNetworkManager { NetworkManager = this }); } } diff --git a/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs b/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs index 65f632af70..9f14b7f192 100644 --- a/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs +++ b/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs @@ -260,8 +260,9 @@ public bool Advance(double deltaTimeSec) return true; } - // Stop on the desired offset rather than step past it, or a converged clock alternates between running - // fast and slow by the whole ratio every frame, and everything rendered from it judders. + // Move each offset toward its target by at most AdjustmentRatio of this frame's delta, stopping once it gets there. + // Stepping the full amount every frame overshot the target, so a synced clock alternated between running fast and + // slow, and anything driven by it juddered. var maximumStep = deltaTimeSec * AdjustmentRatio; m_CurrentLocalTimeOffset += Math.Clamp(m_DesiredLocalTimeOffset - m_CurrentLocalTimeOffset, -maximumStep, maximumStep); m_CurrentServerTimeOffset += Math.Clamp(m_DesiredServerTimeOffset - m_CurrentServerTimeOffset, -maximumStep, maximumStep); diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs index 8af72801ec..f65381e63b 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs @@ -2,7 +2,6 @@ using System.Collections; using System.Text; using NUnit.Framework; -using Unity.Entities; using Unity.Netcode.Components; using Unity.Netcode.TestHelpers.Runtime; using UnityEngine; @@ -116,7 +115,7 @@ public void EachWorldUpdatesConnectionsForItsOwnNetworkManager() Assert.IsNotNull(world.GetExistingSystemManaged(), $"[{world.Name}] Has no {nameof(UnifiedUpdateConnections)} system!"); using var query = world.EntityManager.CreateEntityQuery(typeof(HybridNetworkManager)); Assert.AreEqual(1, query.CalculateEntityCount(), $"[{world.Name}] Does not have exactly one {nameof(HybridNetworkManager)}!"); - var bound = query.GetSingleton().Value.Value; + var bound = query.GetSingleton().NetworkManager.Value; Assert.AreSame(networkManager, bound, $"[{world.Name}] Is bound to another {nameof(NetworkManager)}!"); } } From 8738af8b6437e486c22f32d23ac2f0b0200f3f3f Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 16:03:21 -0500 Subject: [PATCH 07/15] ci: TEMPORARY run the unified job on b1.xlarge to test the memory pressure theory Revert before merge. --- .yamato/unified-tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.yamato/unified-tests.yml b/.yamato/unified-tests.yml index 1475e0bbf5..0e7db31af6 100644 --- a/.yamato/unified-tests.yml +++ b/.yamato/unified-tests.yml @@ -22,7 +22,8 @@ unified_test_{{ project.name }}_{{ platform.name }}_{{ editor }}: agent: type: {{ platform.type }} image: {{ platform.image }} - flavor: {{ platform.flavor }} + # TEMPORARY (#4195): b1.xlarge to test whether b1.large memory pressure causes the unified connect timeouts. Revert before merge. + flavor: b1.xlarge {% if platform.model %} model: {{ platform.model }} # This is set only in platforms where we want non-default model to use (more information in project.metafile) {% endif %} From 982e854c3339346c97ca8458b39b48562064cb16 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 16:50:34 -0500 Subject: [PATCH 08/15] fix: return no RTT for a hybrid connection that has already been removed A client drains its incoming queue while it disconnects, after the unified transport has removed the server connection, so a queued TimeSyncMessage made GetCurrentRtt throw KeyNotFoundException. It now returns 0 for a connection it no longer has, as UnityTransport does. --- .../Runtime/Transports/Unified/UnifiedNetcodeTransport.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs b/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs index a7ddf73f32..d9887119c1 100644 --- a/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs +++ b/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs @@ -476,7 +476,8 @@ public override void DisconnectLocalClient() public override ulong GetCurrentRtt(ulong clientId) { var (transportId, _) = m_NetworkManager.ConnectionManager.ClientIdToTransportId(clientId); - return (ulong)m_Connections[(int)transportId].Connection.RTT; + // A message handled after the connection was removed (for example while shutting down) has no RTT, as with UnityTransport. + return m_Connections.TryGetValue((int)transportId, out ConnectionInfo connectionInfo) ? (ulong)connectionInfo.Connection.RTT : 0; } public override void Initialize(NetworkManager networkManager = null) From 830384ee75bb1eb0e3d3931bc09bac2bd83fc766 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 16:50:44 -0500 Subject: [PATCH 09/15] ci: TEMPORARY run the unified job on b1.large with 6000.7.0b4 to test whether 6000.7.0b6 raised memory use Replaces the b1.xlarge test. Revert before merge. --- .yamato/unified-tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.yamato/unified-tests.yml b/.yamato/unified-tests.yml index 0e7db31af6..4331553836 100644 --- a/.yamato/unified-tests.yml +++ b/.yamato/unified-tests.yml @@ -22,8 +22,7 @@ unified_test_{{ project.name }}_{{ platform.name }}_{{ editor }}: agent: type: {{ platform.type }} image: {{ platform.image }} - # TEMPORARY (#4195): b1.xlarge to test whether b1.large memory pressure causes the unified connect timeouts. Revert before merge. - flavor: b1.xlarge + flavor: {{ platform.flavor }} {% if platform.model %} model: {{ platform.model }} # This is set only in platforms where we want non-default model to use (more information in project.metafile) {% endif %} @@ -35,7 +34,8 @@ unified_test_{{ project.name }}_{{ platform.name }}_{{ editor }}: UNIFIED_TESTS: "true" commands: - - unity-downloader-cli --fast --wait -u {{ editor }} -c Editor --arch {{ platform.architecture }} + # TEMPORARY (#4195): pinned to 6000.7.0b4 on b1.large to test whether 6000.7.0b6 raised memory use. Revert before merge. + - unity-downloader-cli --fast --wait -u 6000.7.0b4 -c Editor --arch {{ platform.architecture }} - UnifiedTestRunner --testproject={{ project.path }} --suite=playmode --artifacts-path=test-results --editor-location=.Editor --rerun-strategy=Test --retry={{ num_test_retries }} --clean-library-on-rerun --timeout={{ test_timeout }} artifacts: logs: From 10807fe6735e739ae0900073c1a78add4de1c233 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 17:18:20 -0500 Subject: [PATCH 10/15] ci: compile testproject in a separate editor session before the unified test pass The IL post-processor and compiler processes the editor starts stay resident through the test pass and, on a b1.large agent, leave too little memory for the hybrid tests. The project is now imported and compiled in its own batchmode session that exits first, any build process that outlives it is stopped, and the job log lists the largest processes left before the tests start. Also drops the temporary 6000.7.0b4 editor pin. --- .yamato/unified-tests.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.yamato/unified-tests.yml b/.yamato/unified-tests.yml index 4331553836..1dd62e66d5 100644 --- a/.yamato/unified-tests.yml +++ b/.yamato/unified-tests.yml @@ -34,13 +34,23 @@ unified_test_{{ project.name }}_{{ platform.name }}_{{ editor }}: UNIFIED_TESTS: "true" commands: - # TEMPORARY (#4195): pinned to 6000.7.0b4 on b1.large to test whether 6000.7.0b6 raised memory use. Revert before merge. - - unity-downloader-cli --fast --wait -u 6000.7.0b4 -c Editor --arch {{ platform.architecture }} + - unity-downloader-cli --fast --wait -u {{ editor }} -c Editor --arch {{ platform.architecture }} + # Import and compile testproject in its own editor session first. The IL post-processor and compiler processes it starts + # otherwise stay resident for the whole test pass and, on this agent, leave too little memory for the tests. + - mkdir -p warmup + - .Editor/Unity -batchmode -nographics -logFile warmup/editor.log -projectPath {{ project.path }} -quit + # Stop any build processes that outlive the warm-up editor, then list what is left so the job log shows it. + - dotnet build-server shutdown || true + - pkill -f Unity.ILPP.Runner || true + - pkill -f VBCSCompiler || true + - pkill -f bee_backend || true + - ps -eo rss,comm --sort=-rss | head -15 - UnifiedTestRunner --testproject={{ project.path }} --suite=playmode --artifacts-path=test-results --editor-location=.Editor --rerun-strategy=Test --retry={{ num_test_retries }} --clean-library-on-rerun --timeout={{ test_timeout }} artifacts: logs: paths: - "test-results/**/*" + - "warmup/**/*" dependencies: - .yamato/_run-all.yml#run_quick_checks {% endfor -%} From 3c4cb32a64ba2b69b83bee8ff6b3d4196e046757 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 18:33:25 -0500 Subject: [PATCH 11/15] ci: pin the unified warm-up to the Release code configuration The test runner compiles in Release. An editor whose code optimization preference is Debug builds the other configuration in the warm-up, and the test session then recompiles every assembly and restarts the IL post-processor. CI's agents already default to Release, so this is a guard rather than a fix there; it removed the recompile on a machine set to Debug. --- .yamato/unified-tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.yamato/unified-tests.yml b/.yamato/unified-tests.yml index 1dd62e66d5..5b00a72393 100644 --- a/.yamato/unified-tests.yml +++ b/.yamato/unified-tests.yml @@ -38,7 +38,9 @@ unified_test_{{ project.name }}_{{ platform.name }}_{{ editor }}: # Import and compile testproject in its own editor session first. The IL post-processor and compiler processes it starts # otherwise stay resident for the whole test pass and, on this agent, leave too little memory for the tests. - mkdir -p warmup - - .Editor/Unity -batchmode -nographics -logFile warmup/editor.log -projectPath {{ project.path }} -quit + # -releaseCodeOptimization pins the Release configuration the test runner compiles. An editor whose code optimization + # preference is Debug would otherwise build the other configuration, and the test session would recompile everything. + - .Editor/Unity -batchmode -nographics -releaseCodeOptimization -logFile warmup/editor.log -projectPath {{ project.path }} -quit # Stop any build processes that outlive the warm-up editor, then list what is left so the job log shows it. - dotnet build-server shutdown || true - pkill -f Unity.ILPP.Runner || true From a49ad61ca1415db08acdd114b92e27beacfa9428 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 19:21:26 -0500 Subject: [PATCH 12/15] fix: report each hybrid disconnect once while shutting down, and guard a negative clock step While its NetworkManager is shutting down, UnifiedUpdateConnections now disconnects only the connections that still have ConnectionState, which it removes, so OnNetCodeDisconnect is not invoked again every frame for the same connections. It also stops collecting new connections while shutting down and drops an unused local. NetworkTimeSystem.Advance no longer throws when the delta time or AdjustmentRatio is negative. --- .../Helpers/UnifiedUpdateConnections.cs | 19 +++++++++++-------- .../Runtime/Timing/NetworkTimeSystem.cs | 3 ++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs index af562f3e7f..5ae671f9fd 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs @@ -51,7 +51,6 @@ protected override void OnCreate() protected override void OnUpdate() { - var isServer = World.IsServer(); var commandBuffer = new EntityCommandBuffer(Allocator.Temp); var networkManager = m_HybridNetworkManagerQuery.GetSingleton().NetworkManager.Value; // The world outlives its NetworkManager if ShutdownInternal throws before disposing it. @@ -75,14 +74,17 @@ protected override void OnUpdate() m_TempConnections.Clear(); // TODO: We should figure out how to associate the N4E NetworkId with the NGO ClientId - foreach (var (networkId, entity) in SystemAPI.Query().WithAll() - .WithNone().WithEntityAccess()) + if (!isShuttingDown) { - if (!m_NewConnections.ContainsKey(networkId.Value)) + foreach (var (networkId, entity) in SystemAPI.Query().WithAll() + .WithNone().WithEntityAccess()) { - var newConnection = new NetcodeConnection - { World = World, Entity = entity, NetworkId = networkId.Value }; - m_NewConnections.Add(networkId.Value, newConnection); + if (!m_NewConnections.ContainsKey(networkId.Value)) + { + var newConnection = new NetcodeConnection + { World = World, Entity = entity, NetworkId = networkId.Value }; + m_NewConnections.Add(networkId.Value, newConnection); + } } } @@ -116,7 +118,8 @@ protected override void OnUpdate() // make sure we have disconnected all known connections. if (isShuttingDown) { - foreach (var (networkId, entity) in SystemAPI.Query().WithEntityAccess()) + // Only connections that went in-game have ConnectionState, which is removed here, so each is reported once. + foreach (var (networkId, entity) in SystemAPI.Query().WithAll().WithEntityAccess()) { commandBuffer.RemoveComponent(entity); NetworkManager.OnNetCodeDisconnect?.Invoke(new NetcodeConnection diff --git a/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs b/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs index 9f14b7f192..3ad6fb9662 100644 --- a/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs +++ b/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs @@ -263,7 +263,8 @@ public bool Advance(double deltaTimeSec) // Move each offset toward its target by at most AdjustmentRatio of this frame's delta, stopping once it gets there. // Stepping the full amount every frame overshot the target, so a synced clock alternated between running fast and // slow, and anything driven by it juddered. - var maximumStep = deltaTimeSec * AdjustmentRatio; + // A negative step would make Math.Clamp throw, so a negative delta or AdjustmentRatio adjusts nothing. + var maximumStep = Math.Max(0.0, deltaTimeSec * AdjustmentRatio); m_CurrentLocalTimeOffset += Math.Clamp(m_DesiredLocalTimeOffset - m_CurrentLocalTimeOffset, -maximumStep, maximumStep); m_CurrentServerTimeOffset += Math.Clamp(m_DesiredServerTimeOffset - m_CurrentServerTimeOffset, -maximumStep, maximumStep); From ab4234e4ed6e93cf1d9c4030cccb7b4dd15a5e83 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 20:31:37 -0500 Subject: [PATCH 13/15] fix: stop a discarded UnifiedBootstrap from clearing the world being created Every NetworkManager that starts a hybrid session creates a UnifiedBootstrap that is then discarded, and its finalizer cleared the static LastCreatedWorld and Instance from the GC finalizer thread. When one ran while another session was starting, Initialize found its new world null, logged "World is null!" and returned false: the NetworkManager had no world, its transport threw a NullReferenceException on start, and the created world was never disposed. The finalizer is removed and Initialize checks the world it created rather than the static. --- .../Components/Helpers/UnifiedBootstrap.cs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs index 572867ae37..9839898b9c 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs @@ -71,16 +71,17 @@ public override bool Initialize(string defaultWorldName) RegisterCreatedWorlds(); Debug.Log($"Starting a world for {(networkManager.IsServer ? "Host" : "Client")}"); s_WorldCounter++; - LastCreatedWorld = CreateWorld(networkManager); + var world = CreateWorld(networkManager); + LastCreatedWorld = world; - if (LastCreatedWorld == null) + if (world == null) { s_WorldCounter--; Debug.LogError($"[{nameof(UnifiedBootstrap)}] World is null!"); return false; } - if (!LastCreatedWorld.IsCreated) + if (!world.IsCreated) { s_WorldCounter--; Debug.LogError($"[{nameof(UnifiedBootstrap)}] World was not created!"); @@ -89,10 +90,10 @@ public override bool Initialize(string defaultWorldName) if (networkManager.LogLevel <= LogLevel.Developer) { - NetworkLog.LogInfo($"[{nameof(UnifiedBootstrap)}] Created world: {LastCreatedWorld.Name} / {LastCreatedWorld.SequenceNumber}"); + NetworkLog.LogInfo($"[{nameof(UnifiedBootstrap)}] Created world: {world.Name} / {world.SequenceNumber}"); } - networkManager.NetcodeWorld = (NetcodeWorld)LastCreatedWorld; + networkManager.NetcodeWorld = (NetcodeWorld)world; s_CreatedWorlds.Add(networkManager.NetcodeWorld); } else @@ -132,12 +133,6 @@ private static World CreateWorld(NetworkManager networkManager) return networkManager.IsServer ? CreateSingleWorldHost($"HostSingleWorld-{s_WorldCounter}") : CreateClientWorld($"ClientWorld-{s_WorldCounter}"); #endif } - - ~UnifiedBootstrap() - { - LastCreatedWorld = null; - Instance = null; - } } } #endif From 0abd90c895a4bcc7f3e5cd3ea347051eb73d9744 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 20:45:52 -0500 Subject: [PATCH 14/15] docs: plainer wording for the comments this PR adds --- .../Runtime/Components/Helpers/UnifiedBootstrap.cs | 7 ++++--- .../Components/Helpers/UnifiedUpdateConnections.cs | 8 ++++---- .../Runtime/Timing/NetworkTimeSystem.cs | 2 +- .../Runtime/Transports/Unified/UnifiedNetcodeTransport.cs | 2 +- .../NetworkTransform/UnifiedNetworkTransformTest.cs | 3 +-- .../Runtime/Unified/UnifiedUpdateConnectionsTests.cs | 3 +-- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs index 9839898b9c..47297d30b4 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs @@ -107,11 +107,12 @@ public override bool Initialize(string defaultWorldName) } /// - /// Creates the world a runs its hybrid session in. + /// Creates the world used by a 's hybrid session. /// /// - /// A server, host or not, runs in a single world host, except in a dedicated server build, where Netcode for - /// Entities cannot create a world that is also a client. A build for only one side cannot run a host. + /// A server or host uses a single world host. A dedicated server build uses a server-only world instead, because + /// Netcode for Entities cannot create a client world there. A build that only targets the client or the server + /// cannot start a host. /// private static World CreateWorld(NetworkManager networkManager) { diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs index 5ae671f9fd..93a304aead 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs @@ -26,14 +26,14 @@ public void SendMessage(T message) where T : unmanaged, IRpcCommand } /// - /// The a hybrid session's world was created for, as a singleton in that world. + /// This is used to track the , as a singleton, when running a hybrid session. /// internal struct HybridNetworkManager : IComponentData { public UnityObjectRef NetworkManager; } - // Created only in client and server worlds, and updated only in one a NetworkManager created for a hybrid session. + // Only exists in client and server worlds, and only updates in a world created for a hybrid session. [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation | WorldSystemFilterFlags.ClientSimulation)] internal partial class UnifiedUpdateConnections : SystemBase { @@ -53,7 +53,7 @@ protected override void OnUpdate() { var commandBuffer = new EntityCommandBuffer(Allocator.Temp); var networkManager = m_HybridNetworkManagerQuery.GetSingleton().NetworkManager.Value; - // The world outlives its NetworkManager if ShutdownInternal throws before disposing it. + // A missing NetworkManager counts as shutting down: the world outlives it if ShutdownInternal throws before disposing the world. var isShuttingDown = !networkManager || networkManager.ShutdownInProgress || !networkManager.IsListening; foreach (var (networkId, connectionState, entity) in SystemAPI.Query().WithNone().WithEntityAccess()) { @@ -118,7 +118,7 @@ protected override void OnUpdate() // make sure we have disconnected all known connections. if (isShuttingDown) { - // Only connections that went in-game have ConnectionState, which is removed here, so each is reported once. + // Only in-game connections have ConnectionState, and removing it here means each one is only reported once. foreach (var (networkId, entity) in SystemAPI.Query().WithAll().WithEntityAccess()) { commandBuffer.RemoveComponent(entity); diff --git a/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs b/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs index 3ad6fb9662..632cbdaac8 100644 --- a/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs +++ b/com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs @@ -263,7 +263,7 @@ public bool Advance(double deltaTimeSec) // Move each offset toward its target by at most AdjustmentRatio of this frame's delta, stopping once it gets there. // Stepping the full amount every frame overshot the target, so a synced clock alternated between running fast and // slow, and anything driven by it juddered. - // A negative step would make Math.Clamp throw, so a negative delta or AdjustmentRatio adjusts nothing. + // Math.Clamp throws on a negative step, so a negative delta or AdjustmentRatio applies no adjustment. var maximumStep = Math.Max(0.0, deltaTimeSec * AdjustmentRatio); m_CurrentLocalTimeOffset += Math.Clamp(m_DesiredLocalTimeOffset - m_CurrentLocalTimeOffset, -maximumStep, maximumStep); m_CurrentServerTimeOffset += Math.Clamp(m_DesiredServerTimeOffset - m_CurrentServerTimeOffset, -maximumStep, maximumStep); diff --git a/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs b/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs index d9887119c1..d6efb70e26 100644 --- a/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs +++ b/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs @@ -476,7 +476,7 @@ public override void DisconnectLocalClient() public override ulong GetCurrentRtt(ulong clientId) { var (transportId, _) = m_NetworkManager.ConnectionManager.ClientIdToTransportId(clientId); - // A message handled after the connection was removed (for example while shutting down) has no RTT, as with UnityTransport. + // Returns 0 when the connection was already removed (for example while shutting down), the same as UnityTransport. return m_Connections.TryGetValue((int)transportId, out ConnectionInfo connectionInfo) ? (ulong)connectionInfo.Connection.RTT : 0; } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs index f65381e63b..64b25da186 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs @@ -102,8 +102,7 @@ public IEnumerator BasicMovementTest() } /// - /// Validates that each 's world updates the connections of that - /// alone. + /// Validates that each 's world only updates that 's connections. /// [Test] public void EachWorldUpdatesConnectionsForItsOwnNetworkManager() diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedUpdateConnectionsTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedUpdateConnectionsTests.cs index 439dc9e82a..ad6880253d 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedUpdateConnectionsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedUpdateConnectionsTests.cs @@ -21,8 +21,7 @@ public void OneTimeSetup() } /// - /// Validates that a session without ghost prefabs leaves no world with a - /// system. + /// Validates that no world has a system when the session has no ghost prefabs. /// [UnityTest] public IEnumerator NoWorldUpdatesConnectionsWithoutGhostPrefabs() From b46fac896bdc5175c29423c583dafb38de2413bc Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 16 Oct 2026 21:08:09 -0500 Subject: [PATCH 15/15] docs: clearer description of the converged clock test --- .../Tests/Editor/Timing/ClientNetworkTimeSystemTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Timing/ClientNetworkTimeSystemTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/Timing/ClientNetworkTimeSystemTests.cs index a8b0846e26..35bcbe47f4 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/Timing/ClientNetworkTimeSystemTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Editor/Timing/ClientNetworkTimeSystemTests.cs @@ -139,8 +139,8 @@ public void RttCatchupSlowdownTest() } /// - /// Tests that once the clock reaches the offset it is adjusting towards, it advances at the real rate on every - /// frame instead of alternating between faster and slower. + /// Validates that once the clock has caught up with a time sync, its local and server time both advance by + /// exactly the frame's delta time on every frame, instead of alternating between slightly fast and slightly slow. /// [Test] public void ConvergedClockAdvancesAtRealRate([Values(0.01d, 0.1d)] double adjustmentRatio) @@ -149,7 +149,7 @@ public void ConvergedClockAdvancesAtRealRate([Values(0.01d, 0.1d)] double adjust double receivedServerTime = 2; var timeSystem = new NetworkTimeSystem(0.05d, 0.05d, 0.1d, adjustmentRatio); timeSystem.Reset(receivedServerTime, 0.1d); - // Moves the desired offset, within the hard reset threshold, so the clock has to adjust to it first. + // A sync 10 ms ahead, below the hard reset threshold, makes the clock catch up gradually instead of resetting. receivedServerTime += 0.01d; timeSystem.Sync(receivedServerTime, 0.1d);