From 4a84c3e7748e40d045d6b89bf3626e91905976c3 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 22 Oct 2026 10:17:25 -0500 Subject: [PATCH 1/5] fix: invoke OnNetCodeDisconnect when a hybrid connection closes A destroyed connection entity keeps only its cleanup components, so the query requiring NetworkId alongside ConnectionState never matched and the disconnect was only reported at shutdown. Read the id from ConnectionState instead, and set it when the connection goes in game. --- com.unity.netcode.gameobjects/CHANGELOG.md | 1 + .../Helpers/UnifiedUpdateConnections.cs | 7 +- .../Unified/HybridConnectionEventsTests.cs | 97 +++++++++++++++++++ .../HybridConnectionEventsTests.cs.meta | 2 + 4 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridConnectionEventsTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridConnectionEventsTests.cs.meta diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 38d75261b8..51022c7bb5 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 `NetworkManager.OnNetCodeDisconnect` was not invoked when a connection closed during a hybrid mode session, only when the session shut down. - 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) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs index 93a304aead..7c55386cdd 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs @@ -55,14 +55,15 @@ protected override void OnUpdate() var networkManager = m_HybridNetworkManagerQuery.GetSingleton().NetworkManager.Value; // 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()) + // A destroyed connection keeps only its cleanup components, so the NetworkId comes from ConnectionState. + foreach (var (connectionState, entity) in SystemAPI.Query().WithNone().WithEntityAccess()) { commandBuffer.RemoveComponent(entity); m_TempConnections.Add(new NetcodeConnection { World = World, Entity = entity, - NetworkId = networkId.Value + NetworkId = connectionState.NetworkId }); } @@ -99,7 +100,7 @@ protected override void OnUpdate() { // Set the connection in-game commandBuffer.AddComponent(entry.Value.Entity); - commandBuffer.AddComponent(entry.Value.Entity, default(ConnectionState)); + commandBuffer.AddComponent(entry.Value.Entity, new ConnectionState { NetworkId = entry.Value.NetworkId }); NetworkManager.OnNetCodeConnect?.Invoke(entry.Value); m_TempConnections.Add(entry.Value); } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridConnectionEventsTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridConnectionEventsTests.cs new file mode 100644 index 0000000000..21865a98c8 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridConnectionEventsTests.cs @@ -0,0 +1,97 @@ +#if UNIFIED_NETCODE +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates and fire + /// for each connection, and that a client only goes in game once NGO has synchronized it. + /// + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] + internal class HybridConnectionEventsTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 1; + + private readonly List m_ServerConnects = new List(); + private readonly List m_ServerDisconnects = new List(); + private int m_ClientConnects; + private bool m_ClientSynchronizedOnConnect; + + public HybridConnectionEventsTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override bool UseUnifiedTests() + { + return true; + } + + protected override void OnServerAndClientsCreated() + { + // No world is created for a session without ghost prefabs. + CreateHybridPrefab("ConnectionEventsPrefab"); + m_ServerConnects.Clear(); + m_ServerDisconnects.Clear(); + m_ClientConnects = 0; + m_ClientSynchronizedOnConnect = false; + NetworkManager.OnNetCodeConnect += OnNetCodeConnect; + NetworkManager.OnNetCodeDisconnect += OnNetCodeDisconnect; + base.OnServerAndClientsCreated(); + } + + protected override IEnumerator OnTearDown() + { + NetworkManager.OnNetCodeConnect -= OnNetCodeConnect; + NetworkManager.OnNetCodeDisconnect -= OnNetCodeDisconnect; + return base.OnTearDown(); + } + + private void OnNetCodeConnect(NetcodeConnection connection) + { + if (connection.IsServer) + { + m_ServerConnects.Add(connection.NetworkId); + return; + } + m_ClientConnects++; + m_ClientSynchronizedOnConnect = m_ClientNetworkManagers[0].IsConnectedClient; + } + + private void OnNetCodeDisconnect(NetcodeConnection connection) + { + if (connection.IsServer) + { + m_ServerDisconnects.Add(connection.NetworkId); + } + } + + [UnityTest] + public IEnumerator ConnectFiresOnceSynchronized() + { + var clientNetworkId = m_ClientNetworkManagers[0].NetcodeWorld.LocalConnection.NetworkId.Value; + yield return WaitForConditionOrTimeOut(() => m_ClientConnects > 0 && m_ServerConnects.Contains(clientNetworkId)); + AssertOnTimeout($"Connect events missing! Client connects: {m_ClientConnects}, server saw the client: {m_ServerConnects.Contains(clientNetworkId)}"); + + Assert.AreEqual(1, m_ClientConnects, "The client connection should only be reported once."); + Assert.IsTrue(m_ClientSynchronizedOnConnect, "The client went in game prior to NGO synchronizing it."); + } + + [UnityTest] + public IEnumerator ServerReportsClientDisconnect() + { + var clientNetworkId = m_ClientNetworkManagers[0].NetcodeWorld.LocalConnection.NetworkId.Value; + yield return WaitForConditionOrTimeOut(() => m_ServerConnects.Contains(clientNetworkId)); + AssertOnTimeout("The server never reported the client connection!"); + + yield return StopOneClient(m_ClientNetworkManagers[0]); + yield return WaitForConditionOrTimeOut(() => m_ServerDisconnects.Contains(clientNetworkId)); + AssertOnTimeout("The server never reported the client disconnect!"); + Assert.IsTrue(m_ServerNetworkManager.IsListening, "The disconnect should be reported while the session is still running."); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridConnectionEventsTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridConnectionEventsTests.cs.meta new file mode 100644 index 0000000000..b9c53eb74c --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridConnectionEventsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 04a97d8ebd0744b2a4a59f98d24c7e3b \ No newline at end of file From 7226b8f6d8933ee6e814d8aa437da2cf2bc213ab Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 22 Oct 2026 12:04:32 -0500 Subject: [PATCH 2/5] chore: retrigger CI From e1b537c566091a6bd9e9f83d6586711e7bf75693 Mon Sep 17 00:00:00 2001 From: Emma Date: Mon, 26 Oct 2026 17:28:16 -0400 Subject: [PATCH 3/5] Fix small issues when domain reload is disabled --- .../Runtime/Configuration/NetworkPrefabs.cs | 2 +- .../Runtime/Transports/Unified/UnifiedNetcodeTransport.cs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs index 9ae47ce8d7..3cc3bb7af7 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs @@ -428,7 +428,7 @@ private bool AddPrefabRegistration(NetworkPrefab networkPrefab) if (networkPrefab.HasGhost) { // Registering a hybrid prefab mid-session would switch NetworkManager into hybrid mode and stop its send queue. - if (m_GhostPrefabRejection != null) + if (!string.IsNullOrEmpty(m_GhostPrefabRejection)) { Debug.LogError($"{m_GhostPrefabRejection} {networkPrefab.GetDebugName()} was not added."); return false; diff --git a/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs b/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs index d6efb70e26..e5f9f11f8f 100644 --- a/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs +++ b/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs @@ -435,7 +435,10 @@ public override bool StartServer() public override void DisconnectRemoteClient(ulong clientId) { - m_NetworkManager.NetcodeWorld.DisconnectAClient(m_Connections[(int)clientId].Connection); + if (m_Connections.TryGetValue((int)clientId, out var connection)) + { + m_NetworkManager.NetcodeWorld.DisconnectAClient(connection.Connection); + } m_Connections.Remove((int)clientId); } From 939a42f1f9c7308416d58c55d476805900b58a94 Mon Sep 17 00:00:00 2001 From: Emma Date: Mon, 26 Oct 2026 17:32:19 -0400 Subject: [PATCH 4/5] Revert duplicate fix --- .../Runtime/Transports/Unified/UnifiedNetcodeTransport.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs b/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs index e5f9f11f8f..d6efb70e26 100644 --- a/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs +++ b/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs @@ -435,10 +435,7 @@ public override bool StartServer() public override void DisconnectRemoteClient(ulong clientId) { - if (m_Connections.TryGetValue((int)clientId, out var connection)) - { - m_NetworkManager.NetcodeWorld.DisconnectAClient(connection.Connection); - } + m_NetworkManager.NetcodeWorld.DisconnectAClient(m_Connections[(int)clientId].Connection); m_Connections.Remove((int)clientId); } From ab5149fc92571febf13704ff722fc09bf7071aeb Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 22 Oct 2026 10:17:33 -0500 Subject: [PATCH 5/5] fix: keep connections out of game until NGO synchronizes the client Netcode for Entities now puts connections in game automatically by default (NetcodeConfig.AutomaticStateReplication), which stops UnifiedUpdateConnections from seeing new connections: no connect events, no in-session disconnects, and ghosts stream to a client before NGO has synchronized it. Disable it at runtime alongside the other hybrid NetcodeConfig settings. --- com.unity.netcode.gameobjects/CHANGELOG.md | 3 ++- .../Configuration/HybridNetcodeDefaults.cs | 22 ++++++++++++++++++- .../Runtime/Core/NetworkManager.cs | 5 +++++ .../Editor/HybridNetcodeDefaultsTests.cs | 11 ++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 7f519ae0c4..eaaed3944c 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 ### Changed +- A hybrid mode session now disables `NetcodeConfig.AutomaticStateReplication` at runtime, as Netcode for GameObjects puts each connection in game once the client has synchronized. (#4209) - Renamed the Editor assembly definition files to match their assembly names. Assembly names and GUIDs are unchanged. (#4187) ### Deprecated @@ -23,8 +24,8 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed -- Issue where `NetworkManager.OnNetCodeDisconnect` was not invoked when a connection closed during a hybrid mode session, only when the session shut down. - Issue where a client promoted to session owner could throw an exception when synchronizing a newly joining client. (#4214) +- Issue where `NetworkManager.OnNetCodeDisconnect` was not invoked when a connection closed during a hybrid mode session, only when the session shut down. (#4209) - Issue where a rejected parent change, for example one made by a client without parenting authority, restored the `NetworkObject`'s parent but left it at the moved position when the change was made with `worldPositionStays` disabled. The original position, rotation and scale are now restored. (#4202) - 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. (#4195) - 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. (#4188) diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs index 9b18a57ec2..500f2eda8b 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs @@ -3,7 +3,7 @@ namespace Unity.Netcode { /// - /// This handles applying two settings NGO requires and an optional recommended snapshot size when running in hybrid mode. + /// This handles applying the settings NGO requires and an optional recommended snapshot size when running in hybrid mode. /// /// /// This intentionally does not modify the asset to preserve the default settings and/or any user adjustments other than the ones required. @@ -55,6 +55,26 @@ internal static bool ApplySnapshotPacketSize(NetcodeConfig config) return true; } + /// + /// Assures N4E leaves putting connections in game to NGO. + /// + /// + /// raises the connect events for connections not yet in game, + /// and a client only goes in game once NGO has synchronized it, so it receives no ghosts prior to that. + /// + /// The config to correct. + /// True if anything changed. + internal static bool ApplyManualStateReplication(NetcodeConfig config) + { + if (!config.AutomaticStateReplication) + { + return false; + } + + config.AutomaticStateReplication = false; + return true; + } + /// /// Determines if the NetcodeConfig about to be used to start a session meets the required settings. /// diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 6f565ef903..e8775ed5e9 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1447,6 +1447,11 @@ private void UnifiedApplyNetcodeConfig() Log.Info(new Context(LogLevel.Developer, $"The {nameof(NetcodeConfig)} tick rates have been set to {nameof(NetworkConfig)}.{nameof(NetworkConfig.TickRate)} ({NetworkConfig.TickRate}).").AddTag("Unified")); } + if (HybridNetcodeDefaults.ApplyManualStateReplication(NetcodeConfig.Global)) + { + Log.Info(new Context(LogLevel.Developer, $"The {nameof(NetcodeConfig)}.{nameof(NetcodeConfig.AutomaticStateReplication)} has been disabled, as NGO puts connections in game once synchronized.").AddTag("Unified")); + } + if (NetworkConfig.AutoConfigureSnapshotSize && HybridNetcodeDefaults.ApplySnapshotPacketSize(NetcodeConfig.Global)) { Log.Info(new Context(LogLevel.Developer, $"The {nameof(NetcodeConfig)} snapshot packet size has been set to {HybridNetcodeDefaults.SnapshotPacketSize}. Disable {nameof(NetworkConfig)}.{nameof(NetworkConfig.AutoConfigureSnapshotSize)} to manage it yourself.").AddTag("Unified")); diff --git a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs index 30813ee1ea..27b7f70efc 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs @@ -63,6 +63,17 @@ public void ApplySnapshotPacketSizeLeavesTheTickRatesAlone() Assert.AreEqual(60, m_Config.ClientServerTickRate.NetworkTickRate, "The snapshot size pass should leave NetworkTickRate alone."); } + [Test] + public void ApplyManualStateReplicationDisablesAutomaticStateReplication() + { + m_Config.AutomaticStateReplication = true; + + Assert.IsTrue(HybridNetcodeDefaults.ApplyManualStateReplication(m_Config), "The first apply should report a change."); + Assert.IsFalse(m_Config.AutomaticStateReplication, "NGO should own putting connections in game."); + + Assert.IsFalse(HybridNetcodeDefaults.ApplyManualStateReplication(m_Config), "Re-applying an unchanged config should report no change."); + } + [Test] public void IsMissingRequiredDetectsBinaryWorlds() {