From bc6a99d6b6b9ba22996d0730d12700b43396e565 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 30 Sep 2026 18:26:56 -0500 Subject: [PATCH 1/9] test: add hybrid prefab prediction and interop tests CreateHybridPrefab takes an optional GhostMode. Predicted modes are applied after NetworkObjectBridge is added, because its editor OnValidate resets the supported ghost modes to interpolated. HybridPredictionTests: an owner-predicted hybrid prefab predicts and re-simulates on the owning client. HybridInteropTests combines N4E and NGO features on one hybrid prefab: - An NGO RPC sent from PredictionUpdate repeats for re-simulated ticks, and sends each tick once when gated on IsFirstTimeFullyPredictingTick. - Unified remote to NGO RPC to unified remote, and NGO RPC to unified remote to NGO RPC. - A NetworkVariable read during prediction is not tick-aligned, while a tick-stamped value applied from its stamp tick is consistent across re-simulation. --- .../TestHelpers/NetcodeIntegrationTest.cs | 17 +- .../Tests/Runtime/Unified.meta | 8 + .../Runtime/Unified/HybridInteropTests.cs | 354 ++++++++++++++++++ .../Unified/HybridInteropTests.cs.meta | 2 + .../Runtime/Unified/HybridPredictionTests.cs | 80 ++++ .../Unified/HybridPredictionTests.cs.meta | 2 + 6 files changed, 456 insertions(+), 7 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs.meta create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs index 3d24452348..bbc26c2b81 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs @@ -2554,7 +2554,7 @@ protected void CleanupPrefabReferences() } } private bool m_HybridPrefabCreated; - protected GameObject CreateHybridPrefab(string baseName, bool moveToDDOL = true) + protected GameObject CreateHybridPrefab(string baseName, bool moveToDDOL = true, GhostMode ghostMode = GhostMode.Interpolated) { m_HybridPrefabCreated = true; // Prevent from trying to register/spawn when creating this hybrid prefab @@ -2579,12 +2579,6 @@ protected GameObject CreateHybridPrefab(string baseName, bool moveToDDOL = true) // Initialize it as a prefab adapter.InitializeAsPrefab(); - // TODO: This might be part of the CreateHybridPrefab parameters - // For now, just use normal interpolation until we get integration - // tests running. - // Once we have validated prediction works and have a working manual - // test, we can circle back to this (possibly make that a sub-task - // with the dependency to prediction manual test). adapter.SupportedGhostModes = GhostModeMask.Interpolated; // Once done with setting up the GhostObject, we can set it back to active in the hierarchy @@ -2594,6 +2588,15 @@ protected GameObject CreateHybridPrefab(string baseName, bool moveToDDOL = true) // Add the bridge var bridge = gameObject.AddComponent(); + // Set after the bridge is added, since its editor OnValidate resets the supported modes to interpolated. + // The prefab is registered when each world is created, so this still applies before the session starts. + if (ghostMode != GhostMode.Interpolated) + { + adapter.SupportedGhostModes = GhostModeMask.All; + adapter.DefaultGhostMode = ghostMode; + adapter.HasOwner = ghostMode == GhostMode.OwnerPredicted; + } + // Now add NGO components var no = gameObject.AddComponent(); diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta new file mode 100644 index 0000000000..f6b8f32a91 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a3b63559c6f99f34cb5dac72e9c4d695 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs new file mode 100644 index 0000000000..55f8526d3b --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs @@ -0,0 +1,354 @@ +#if UNIFIED_NETCODE +using System; +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// A NetworkVariable value stamped with the tick it applies from, so prediction can apply it tick-aligned. + /// + internal struct TickStampedValue : INetworkSerializable, IEquatable + { + public int Value; + public int PreviousValue; + public uint Tick; + + public void NetworkSerialize(BufferSerializer serializer) where T : IReaderWriter + { + serializer.SerializeValue(ref Value); + serializer.SerializeValue(ref PreviousValue); + serializer.SerializeValue(ref Tick); + } + + public bool Equals(TickStampedValue other) + { + return Value == other.Value && PreviousValue == other.PreviousValue && Tick == other.Tick; + } + } + + /// + /// The N4E half of the interop prefab: sends NGO RPCs from and + /// relays pings between unified remotes and NGO RPCs. + /// + internal partial class HybridInteropGhost : GhostBehaviour + { + public bool SendRpcFromPrediction; + public bool GateOnFirstTimeTick; + public int PredictionSends; + public int ResimulatedTicks; + public int PingValue; + + public bool RecordStampedValue; + public uint LatestPredictedTick; + public int EarlyReadsOfNewValue; + public int InconsistentRawTicks; + public int InconsistentStampedTicks; + public int StampedValueAtStampTick; + private readonly Dictionary m_RawValueByTick = new Dictionary(); + private readonly Dictionary m_StampedValueByTick = new Dictionary(); + + public override void PredictionUpdate(float tickedDeltaTime) + { + if (IsServer) + { + return; + } + var networkTime = Ghost.World.NetworkTime; + if (RecordStampedValue) + { + RecordStampedValueAtTick(networkTime.ServerTick.TickIndexForValidTick); + } + if (!SendRpcFromPrediction) + { + return; + } + if (!networkTime.IsFirstTimeFullyPredictingTick) + { + ResimulatedTicks++; + if (GateOnFirstTimeTick) + { + return; + } + } + PredictionSends++; + GetComponent().PredictionTickRpc(networkTime.ServerTick.SerializedData); + } + + /// + /// Records, per predicted tick, the raw NetworkVariable value and the value the tick stamp says applies to that tick. + /// + private void RecordStampedValueAtTick(uint tick) + { + var stamped = GetComponent().StampedValue.Value; + var hasStamp = stamped.Tick != 0; + if (hasStamp && tick < stamped.Tick && stamped.Value == HybridInteropNetworkBehaviour.StampedNewValue) + { + EarlyReadsOfNewValue++; + } + // The pattern under test: apply the value only when the tick being predicted is at or past its stamp. + var applied = hasStamp && tick >= stamped.Tick ? stamped.Value : stamped.PreviousValue; + if (hasStamp && tick == stamped.Tick) + { + StampedValueAtStampTick = applied; + } + InconsistentRawTicks += RecordValue(m_RawValueByTick, tick, stamped.Value); + InconsistentStampedTicks += RecordValue(m_StampedValueByTick, tick, applied); + if (tick > LatestPredictedTick) + { + LatestPredictedTick = tick; + } + } + + /// 1 if this tick was already predicted with a different value, otherwise 0. + private static int RecordValue(Dictionary valueByTick, uint tick, int value) + { + if (valueByTick.TryGetValue(tick, out var previous)) + { + return previous == value ? 0 : 1; + } + valueByTick.Add(tick, value); + return 0; + } + + [Remote(Directionality.ServerToClient)] + public void PingToClient(int value) + { + PingValue = value; + GetComponent().PingToServerRpc(value + 1); + } + + [Remote(Directionality.ServerToClient)] + public void FinalPingToClient(int value) + { + PingValue = value; + } + + [Remote(Directionality.ClientToServer)] + public void PingToServer(int value) + { + PingValue = value; + GetComponent().FinalPingToClientRpc(value + 1); + } + } + + /// + /// The NGO half of the interop prefab. + /// + internal class HybridInteropNetworkBehaviour : NetworkBehaviour + { + public const int StampedNewValue = 1; + + public readonly List ReceivedPredictionTicks = new List(); + public int PingValue; + public NetworkVariable StampedValue = new NetworkVariable(); + + [Rpc(SendTo.Server)] + public void PredictionTickRpc(uint tick) + { + ReceivedPredictionTicks.Add(tick); + } + + [Rpc(SendTo.Server)] + public void PingToServerRpc(int value) + { + PingValue = value; + GetComponent().FinalPingToClient(value + 1); + } + + [Rpc(SendTo.NotServer)] + public void PingToClientRpc(int value) + { + PingValue = value; + GetComponent().PingToServer(value + 1); + } + + [Rpc(SendTo.NotServer)] + public void FinalPingToClientRpc(int value) + { + PingValue = value; + } + } + + /// + /// Combines N4E remotes and prediction with NGO RPCs on the same hybrid prefab. + /// + /// + /// One client only: a ClientToServer remote is sent from every client world in the process. + /// + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] + internal class HybridInteropTests : NetcodeIntegrationTest + { + private const int k_MinimumPredictionSends = 20; + private const uint k_StampLeadTicks = 20; + private const uint k_TicksPastStamp = 5; + + protected override int NumberOfClients => 1; + + private GameObject m_InteropPrefab; + private NetworkObject m_ServerInstance; + private NetworkObject m_ClientInstance; + + public HybridInteropTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override bool UseUnifiedTests() + { + return true; + } + + protected override void OnServerAndClientsCreated() + { + m_InteropPrefab = CreateHybridPrefab("InteropPrefab", true, GhostMode.OwnerPredicted); + m_InteropPrefab.AddComponent(); + m_InteropPrefab.AddComponent(); + base.OnServerAndClientsCreated(); + } + + protected override IEnumerator OnServerAndClientsConnected() + { + // Remote methods send through ClientServerBootstrap.ServerWorlds, but each bootstrap constructor clears + // that list, and NGO creates one bootstrap per NetworkManager. With several NetworkManagers in one process + // only the last one started stays registered, so the server world has to be added back. + if (!ClientServerBootstrap.ServerWorlds.Contains(m_ServerNetworkManager.NetcodeWorld)) + { + ClientServerBootstrap.ServerWorlds.Add(m_ServerNetworkManager.NetcodeWorld); + } + var client = m_ClientNetworkManagers[0]; + m_ServerInstance = SpawnObject(m_InteropPrefab, client).GetComponent(); + m_ServerInstance.GetComponent().OwnerNetworkId = client.NetcodeWorld.LocalConnection.NetworkId; + yield return WaitForSpawnedOnAllOrTimeOut(m_ServerInstance); + AssertOnTimeout($"Timed out waiting for {m_ServerInstance.name} to spawn on all clients!"); + m_ClientInstance = client.SpawnManager.SpawnedObjects[m_ServerInstance.NetworkObjectId]; + yield return WaitForConditionOrTimeOut(() => m_ClientInstance.GetComponent().IsPredictedGhost); + AssertOnTimeout($"{m_ClientInstance.name} never became predicted on the client!"); + } + + /// + /// An NGO RPC sent from is sent again for every re-simulated tick. + /// + [UnityTest] + public IEnumerator RpcFromPredictionUpdateRepeatsForResimulatedTicks() + { + yield return SendRpcsFromPrediction(false); + var duplicates = CountDuplicateTicks(); + Assert.Greater(duplicates, 0, "Expected re-simulated ticks to send the same tick more than once."); + } + + /// + /// Gating the send on IsFirstTimeFullyPredictingTick sends each predicted tick once. + /// + [UnityTest] + public IEnumerator RpcFromPredictionUpdateGatedOnFirstTimeTickSendsEachTickOnce() + { + yield return SendRpcsFromPrediction(true); + var duplicates = CountDuplicateTicks(); + Assert.AreEqual(0, duplicates, $"{duplicates} ticks were sent more than once."); + } + + /// + /// Unified remote to NGO RPC to unified remote, starting on the server. + /// + [UnityTest] + public IEnumerator UnifiedRemoteToNgoRpcToUnifiedRemote() + { + var clientGhost = m_ClientInstance.GetComponent(); + m_ServerInstance.GetComponent().PingToClient(1); + yield return WaitForConditionOrTimeOut(() => clientGhost.PingValue == 3); + AssertOnTimeout($"Ping did not complete! Client remote value: {clientGhost.PingValue}, " + + $"server RPC value: {m_ServerInstance.GetComponent().PingValue}"); + } + + /// + /// NGO RPC to unified remote to NGO RPC, starting on the server. + /// + [UnityTest] + public IEnumerator NgoRpcToUnifiedRemoteToNgoRpc() + { + var clientBehaviour = m_ClientInstance.GetComponent(); + m_ServerInstance.GetComponent().PingToClientRpc(1); + yield return WaitForConditionOrTimeOut(() => clientBehaviour.PingValue == 3); + AssertOnTimeout($"Ping did not complete! Client RPC value: {clientBehaviour.PingValue}, " + + $"server remote value: {m_ServerInstance.GetComponent().PingValue}"); + } + + /// + /// A NetworkVariable is not rolled back: prediction of a tick before the value's stamp still reads the new value. + /// + [UnityTest] + public IEnumerator NetworkVariableReadDuringPredictionIsNotTickAligned() + { + yield return RecordAcrossStampedValueChange(); + var clientGhost = m_ClientInstance.GetComponent(); + Assert.Greater(clientGhost.EarlyReadsOfNewValue, 0, "Expected prediction of ticks before the stamp to read the new value."); + } + + /// + /// Applying a NetworkVariable only from its stamped tick gives every re-simulation of a tick the same value. + /// + [UnityTest] + public IEnumerator TickStampedNetworkVariableIsConsistentAcrossResimulation() + { + yield return RecordAcrossStampedValueChange(); + var clientGhost = m_ClientInstance.GetComponent(); + Assert.AreEqual(0, clientGhost.InconsistentStampedTicks, $"{clientGhost.InconsistentStampedTicks} ticks applied a different stamped value on re-simulation."); + Assert.AreEqual(HybridInteropNetworkBehaviour.StampedNewValue, clientGhost.StampedValueAtStampTick, "The stamped value was not applied at its stamp tick."); + } + + private IEnumerator RecordAcrossStampedValueChange() + { + var clientGhost = m_ClientInstance.GetComponent(); + clientGhost.RecordStampedValue = true; + + // Stamp far enough ahead that the value reaches the client before it predicts the stamp tick. + var serverTick = m_ServerInstance.GetComponent().World.NetworkTime.ServerTick.TickIndexForValidTick; + var stampTick = serverTick + k_StampLeadTicks; + m_ServerInstance.GetComponent().StampedValue.Value = new TickStampedValue + { + Value = HybridInteropNetworkBehaviour.StampedNewValue, + PreviousValue = 0, + Tick = stampTick, + }; + + yield return WaitForConditionOrTimeOut(() => clientGhost.LatestPredictedTick >= stampTick + k_TicksPastStamp); + clientGhost.RecordStampedValue = false; + AssertOnTimeout($"Client never predicted past tick {stampTick + k_TicksPastStamp}! Latest predicted tick: {clientGhost.LatestPredictedTick}"); + Debug.Log($"Stamp tick: {stampTick}, early reads of the new value: {clientGhost.EarlyReadsOfNewValue}, " + + $"inconsistent raw ticks: {clientGhost.InconsistentRawTicks}, inconsistent stamped ticks: {clientGhost.InconsistentStampedTicks}"); + } + + private IEnumerator SendRpcsFromPrediction(bool gateOnFirstTimeTick) + { + var clientGhost = m_ClientInstance.GetComponent(); + clientGhost.GateOnFirstTimeTick = gateOnFirstTimeTick; + clientGhost.SendRpcFromPrediction = true; + yield return WaitForConditionOrTimeOut(() => clientGhost.PredictionSends >= k_MinimumPredictionSends && clientGhost.ResimulatedTicks > 0); + clientGhost.SendRpcFromPrediction = false; + AssertOnTimeout($"Client prediction did not send enough RPCs! Sends: {clientGhost.PredictionSends}, re-simulated ticks: {clientGhost.ResimulatedTicks}"); + + var serverBehaviour = m_ServerInstance.GetComponent(); + yield return WaitForConditionOrTimeOut(() => serverBehaviour.ReceivedPredictionTicks.Count == clientGhost.PredictionSends); + AssertOnTimeout($"Server received {serverBehaviour.ReceivedPredictionTicks.Count} of {clientGhost.PredictionSends} RPCs!"); + Debug.Log($"Gated: {gateOnFirstTimeTick}, sends: {clientGhost.PredictionSends}, re-simulated ticks: {clientGhost.ResimulatedTicks}, duplicate ticks: {CountDuplicateTicks()}"); + } + + private int CountDuplicateTicks() + { + var seen = new HashSet(); + var duplicates = 0; + foreach (var tick in m_ServerInstance.GetComponent().ReceivedPredictionTicks) + { + if (!seen.Add(tick)) + { + duplicates++; + } + } + return duplicates; + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs.meta new file mode 100644 index 0000000000..1f17a05678 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2c71281769fd67e46b437cb430ce262d \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs new file mode 100644 index 0000000000..5d54e36e24 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs @@ -0,0 +1,80 @@ +#if UNIFIED_NETCODE +using System.Collections; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Counts calls, split into first-time ticks and re-simulated ticks. + /// + internal partial class HybridPredictionProbe : GhostBehaviour + { + public GhostField PredictedCounter; + public int FirstTimeTicks; + public int ResimulatedTicks; + + public override void PredictionUpdate(float tickedDeltaTime) + { + if (Ghost.World.NetworkTime.IsFirstTimeFullyPredictingTick) + { + FirstTimeTicks++; + } + else + { + ResimulatedTicks++; + } + PredictedCounter.Value = PredictedCounter.Value + 1; + } + } + + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] + internal class HybridPredictionTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 1; + + private GameObject m_PredictedPrefab; + + public HybridPredictionTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override bool UseUnifiedTests() + { + return true; + } + + protected override void OnServerAndClientsCreated() + { + m_PredictedPrefab = CreateHybridPrefab("PredictedProbe", true, GhostMode.OwnerPredicted); + m_PredictedPrefab.AddComponent(); + base.OnServerAndClientsCreated(); + } + + /// + /// An owner-predicted hybrid prefab is predicted on the owning client, and the client re-simulates + /// ticks when snapshots arrive even with no added latency. + /// + [UnityTest] + public IEnumerator OwnerPredictedHybridPrefabResimulatesOnClient() + { + var client = m_ClientNetworkManagers[0]; + var serverInstance = SpawnObject(m_PredictedPrefab, client).GetComponent(); + serverInstance.GetComponent().OwnerNetworkId = client.NetcodeWorld.LocalConnection.NetworkId; + + yield return WaitForSpawnedOnAllOrTimeOut(serverInstance); + AssertOnTimeout($"Timed out waiting for {serverInstance.name} to spawn on all clients!"); + + var clientProbe = client.SpawnManager.SpawnedObjects[serverInstance.NetworkObjectId].GetComponent(); + yield return WaitForConditionOrTimeOut(() => clientProbe.Ghost.IsPredictedGhost && clientProbe.ResimulatedTicks > 0); + AssertOnTimeout($"Client never re-simulated {serverInstance.name}! Predicted: {clientProbe.Ghost.IsPredictedGhost}, " + + $"first-time ticks: {clientProbe.FirstTimeTicks}, re-simulated ticks: {clientProbe.ResimulatedTicks}"); + + var serverProbe = serverInstance.GetComponent(); + Debug.Log($"[{m_ServerNetworkManager.name}] first-time: {serverProbe.FirstTimeTicks}, re-simulated: {serverProbe.ResimulatedTicks} | " + + $"[{client.name}] first-time: {clientProbe.FirstTimeTicks}, re-simulated: {clientProbe.ResimulatedTicks}"); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs.meta new file mode 100644 index 0000000000..750cd2414c --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 980b79cfdf0c0a941ab1b1ad5eb79db6 \ No newline at end of file From a683fdc177db89ea9143d7a84e8f7c587e9dadbc Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 30 Sep 2026 18:31:05 -0500 Subject: [PATCH 2/9] test: cover NetworkVariable writes from prediction and NGO ownership on hybrid prefabs - A NetworkVariable written from PredictionUpdate moves backwards when older ticks re-simulate, and only moves forward when gated on IsFirstTimeFullyPredictingTick. - An NGO ownership change does not change the ghost's N4E owner. --- .../Runtime/Unified/HybridInteropTests.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs index 55f8526d3b..99260e29fe 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs @@ -43,6 +43,7 @@ internal partial class HybridInteropGhost : GhostBehaviour public int ResimulatedTicks; public int PingValue; + public bool WriteNetworkVariableFromPrediction; public bool RecordStampedValue; public uint LatestPredictedTick; public int EarlyReadsOfNewValue; @@ -63,6 +64,10 @@ public override void PredictionUpdate(float tickedDeltaTime) { RecordStampedValueAtTick(networkTime.ServerTick.TickIndexForValidTick); } + if (WriteNetworkVariableFromPrediction && (!GateOnFirstTimeTick || networkTime.IsFirstTimeFullyPredictingTick)) + { + GetComponent().OwnerWrittenTick.Value = networkTime.ServerTick.TickIndexForValidTick; + } if (!SendRpcFromPrediction) { return; @@ -146,6 +151,30 @@ internal class HybridInteropNetworkBehaviour : NetworkBehaviour public readonly List ReceivedPredictionTicks = new List(); public int PingValue; public NetworkVariable StampedValue = new NetworkVariable(); + public NetworkVariable OwnerWrittenTick = new NetworkVariable(writePerm: NetworkVariableWritePermission.Owner); + public int OwnerWrittenTickChanges; + public int OwnerWrittenTickDecreases; + public bool GhostWasPredictedOnSpawn; + + public override void OnNetworkSpawn() + { + GhostWasPredictedOnSpawn = GetComponent().IsPredictedGhost; + OwnerWrittenTick.OnValueChanged += OnOwnerWrittenTickChanged; + } + + public override void OnNetworkDespawn() + { + OwnerWrittenTick.OnValueChanged -= OnOwnerWrittenTickChanged; + } + + private void OnOwnerWrittenTickChanged(uint previous, uint current) + { + OwnerWrittenTickChanges++; + if (current < previous) + { + OwnerWrittenTickDecreases++; + } + } [Rpc(SendTo.Server)] public void PredictionTickRpc(uint tick) @@ -299,6 +328,63 @@ public IEnumerator TickStampedNetworkVariableIsConsistentAcrossResimulation() Assert.AreEqual(HybridInteropNetworkBehaviour.StampedNewValue, clientGhost.StampedValueAtStampTick, "The stamped value was not applied at its stamp tick."); } + /// + /// A NetworkVariable written from is written again when older ticks + /// re-simulate, so the owner's value moves backwards. + /// + [UnityTest] + public IEnumerator NetworkVariableWrittenFromPredictionUpdateMovesBackwards() + { + yield return WriteNetworkVariableFromPrediction(false); + var clientBehaviour = m_ClientInstance.GetComponent(); + Assert.Greater(clientBehaviour.OwnerWrittenTickDecreases, 0, "Expected re-simulated ticks to write an older tick."); + } + + /// + /// Gating the write on IsFirstTimeFullyPredictingTick only moves the value forward. + /// + [UnityTest] + public IEnumerator NetworkVariableWrittenFromPredictionUpdateGatedOnFirstTimeTickOnlyMovesForward() + { + yield return WriteNetworkVariableFromPrediction(true); + var clientBehaviour = m_ClientInstance.GetComponent(); + Assert.AreEqual(0, clientBehaviour.OwnerWrittenTickDecreases, $"The value moved backwards {clientBehaviour.OwnerWrittenTickDecreases} times."); + } + + /// + /// NGO ownership and the ghost's N4E owner are separate: an NGO ownership change leaves the ghost owner as it was. + /// + [UnityTest] + public IEnumerator NgoOwnershipChangeDoesNotChangeGhostOwner() + { + var ghostOwner = m_ServerInstance.GetComponent().OwnerNetworkId; + m_ServerInstance.ChangeOwnership(m_ServerNetworkManager.LocalClientId); + yield return WaitForConditionOrTimeOut(() => m_ClientInstance.OwnerClientId == m_ServerNetworkManager.LocalClientId); + AssertOnTimeout($"Client never saw the ownership change to Client-{m_ServerNetworkManager.LocalClientId}!"); + yield return s_DefaultWaitForTick; + Assert.AreEqual(ghostOwner, m_ServerInstance.GetComponent().OwnerNetworkId, "The ghost owner changed with the NGO owner."); + Assert.AreEqual(ghostOwner, m_ClientInstance.GetComponent().OwnerNetworkId, "The client's ghost owner changed with the NGO owner."); + Assert.IsTrue(m_ClientInstance.GetComponent().IsPredictedGhost, "The client stopped predicting the ghost."); + Debug.Log($"Client ghost was predicted on spawn: {m_ClientInstance.GetComponent().GhostWasPredictedOnSpawn}"); + } + + private IEnumerator WriteNetworkVariableFromPrediction(bool gateOnFirstTimeTick) + { + var clientGhost = m_ClientInstance.GetComponent(); + var clientBehaviour = m_ClientInstance.GetComponent(); + clientGhost.GateOnFirstTimeTick = gateOnFirstTimeTick; + clientGhost.WriteNetworkVariableFromPrediction = true; + yield return WaitForConditionOrTimeOut(() => clientBehaviour.OwnerWrittenTickChanges >= k_MinimumPredictionSends); + clientGhost.WriteNetworkVariableFromPrediction = false; + AssertOnTimeout($"Only {clientBehaviour.OwnerWrittenTickChanges} value changes were written!"); + + // Wait for the last write to reach the server, so no NetworkVariable update is still queued at teardown. + var serverBehaviour = m_ServerInstance.GetComponent(); + yield return WaitForConditionOrTimeOut(() => serverBehaviour.OwnerWrittenTick.Value == clientBehaviour.OwnerWrittenTick.Value); + AssertOnTimeout($"Server value {serverBehaviour.OwnerWrittenTick.Value} never matched the client value {clientBehaviour.OwnerWrittenTick.Value}!"); + Debug.Log($"Gated: {gateOnFirstTimeTick}, value changes: {clientBehaviour.OwnerWrittenTickChanges}, backwards moves: {clientBehaviour.OwnerWrittenTickDecreases}"); + } + private IEnumerator RecordAcrossStampedValueChange() { var clientGhost = m_ClientInstance.GetComponent(); From f6fee1d47cddc3496ff7e18023c5b75a479f4a12 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 30 Sep 2026 19:02:11 -0500 Subject: [PATCH 3/9] fix: hybrid prefab issues found by the interop tests - UnifiedBootstrap registers the worlds it created for other NetworkManagers again after each bootstrap, since every ClientServerBootstrap constructor clears N4E's ServerWorlds and ClientWorlds. Remote methods send through those lists, so with several NetworkManagers in one process server-to-client remotes were dropped. - NetworkObjectBridge defaults a GhostObject to interpolation only when the bridge is first added, instead of on every OnValidate, so prediction enabled on a hybrid prefab is kept. - NGO spawn ownership and ownership changes set the ghost's owner, so an owner-predicted ghost is predicted by its NGO owner. - NetworkManager shutdown disposes only its own world instead of every world in the process. NetworkObjectDontDestroyWithOwnerTests and NetworkSpawnManagerTests now run in hybrid prefab mode. NetworkShowThenClientDisconnects is ignored for hybrid prefabs: a scene migration update can reach a client before the object's ghost has spawned there. --- .../Components/Helpers/NetworkObjectBridge.cs | 51 +++++++++++++++++-- .../Components/Helpers/UnifiedBootstrap.cs | 29 +++++++++++ .../Runtime/Core/NetworkManager.cs | 7 ++- .../Runtime/Spawning/NetworkSpawnManager.cs | 7 +++ .../NetworkObjectDontDestroyWithOwnerTests.cs | 17 +++++++ .../Tests/Runtime/NetworkSpawnManagerTests.cs | 10 ++++ .../TestHelpers/NetcodeIntegrationTest.cs | 20 ++++---- .../Runtime/Unified/HybridInteropTests.cs | 39 +++++++------- .../Runtime/Unified/HybridPredictionTests.cs | 1 - 9 files changed, 146 insertions(+), 35 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs index 07818501c5..6725fe458c 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs @@ -25,6 +25,18 @@ internal partial class NetworkObjectBridge : GhostBehaviour // TODO: Define a const for the value used on GhostObject and use that value // to set the execution order so if it changes on GhostObject it updates here. #if UNITY_EDITOR + private void Reset() + { + // Start users with just interpolation (they can adjust this if they want prediction) + // to make the initial transition less problematic for users. + // Only set when the bridge is first added, so a user's prediction setting is kept. + var ghostAdapter = GetComponent(); + if (ghostAdapter != null) + { + ghostAdapter.SupportedGhostModes = GhostModeMask.Interpolated; + } + } + private void OnValidate() { hideFlags = HideFlags.HideInInspector; @@ -35,10 +47,6 @@ private void OnValidate() return; } - // Start users with just interpolation (they can adjust this if they want prediction) - // to make the initial transition less problematic for users. - ghostAdapter.SupportedGhostModes = GhostModeMask.Interpolated; - #if COM_UNITY_MODULES_PHYSICS var rigidBody = GetComponent(); var ghostRigidBody = GetComponent(); @@ -88,6 +96,41 @@ internal void ApplyScale(Vector3 scale) { Ghost.ApplyPostTransformMatrixScale(scale); } + + /// + /// Keeps the ghost's owner in step with the owner, so an owner-predicted + /// ghost is predicted by the NGO owner. + /// + /// Only the server can assign a ghost owner; N4E replicates it to clients. + /// The server's . + /// The new NGO owner. + internal void UpdateGhostOwner(NetworkManager networkManager, ulong ownerClientId) + { + if (!networkManager.IsServer || !Ghost.HasOwner) + { + return; + } + + // Owned by the server with no local client means no client owns the ghost. + var ownerNetworkId = default(NetworkId); + if (ownerClientId == networkManager.LocalClientId) + { + if (networkManager.IsHost) + { + ownerNetworkId = networkManager.NetcodeWorld.LocalConnection.NetworkId; + } + } + else + { + // The unified transport id is the client's N4E network id. + var (transportId, found) = networkManager.ConnectionManager.ClientIdToTransportId(ownerClientId); + if (found) + { + ownerNetworkId = new NetworkId { Value = (int)transportId }; + } + } + Ghost.OwnerNetworkId = ownerNetworkId; + } } #if UNIFIED_NETCODE_7_0_0 diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs index 3a41f7b3a9..352bea3ab7 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs @@ -3,6 +3,7 @@ #endif #if UNIFIED_NETCODE using System; +using System.Collections.Generic; using Unity.Entities; #if !UNIFIED_NETCODE_7_0_0 using Unity.NetCode; @@ -27,6 +28,32 @@ internal class UnifiedBootstrap : ClientServerBootstrap private static int s_WorldCounter = 0; + // Every ClientServerBootstrap constructor clears N4E's ServerWorlds and ClientWorlds, and each NetworkManager + // creates its own bootstrap, so the worlds created for other NetworkManagers are registered again. + private static readonly List s_CreatedWorlds = new List(); + + private static void RegisterCreatedWorlds() + { + for (int i = s_CreatedWorlds.Count - 1; i >= 0; i--) + { + var world = s_CreatedWorlds[i]; + if (!world.IsCreated) + { + s_CreatedWorlds.RemoveAt(i); + continue; + } + // A single world host is registered as both a server and a client world, the same as N4E does. + if (world.IsServer() && !ServerWorlds.Contains(world)) + { + ServerWorlds.Add(world); + } + if (world.IsClient() && !ClientWorlds.Contains(world)) + { + ClientWorlds.Add(world); + } + } + } + public override bool Initialize(string defaultWorldName) { var networkManager = CurrentNetworkManagerForInitialization; @@ -44,6 +71,7 @@ public override bool Initialize(string defaultWorldName) if (networkManager != null) { + RegisterCreatedWorlds(); Debug.Log($"Starting a world for {(networkManager.IsServer ? "Host" : "Client")}"); s_WorldCounter++; LastCreatedWorld = networkManager.IsServer ? CreateSingleWorldHost($"HostSingleWorld-{s_WorldCounter}") @@ -69,6 +97,7 @@ public override bool Initialize(string defaultWorldName) } networkManager.NetcodeWorld = (NetcodeWorld)LastCreatedWorld; + s_CreatedWorlds.Add(networkManager.NetcodeWorld); } else { diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 185d682f66..2ce40447f8 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1834,8 +1834,11 @@ internal void ShutdownInternal() { try { - // Dispose of all worlds - World.DisposeAllWorlds(); + // Dispose only this NetworkManager's world. Other NetworkManagers in the same process keep theirs. + if (NetcodeWorld != null && NetcodeWorld.IsCreated) + { + NetcodeWorld.Dispose(); + } // Clear the world assigned from previous session NetcodeWorld = null; } diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs index 28803104f4..f24f181a3a 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs @@ -591,6 +591,12 @@ internal void ChangeOwnership(NetworkObject networkObject, ulong clientId, bool // Assign the new owner networkObject.OwnerClientId = clientId; +#if UNIFIED_NETCODE + if (networkObject.HasGhost) + { + networkObject.NetworkObjectBridge.UpdateGhostOwner(NetworkManager, clientId); + } +#endif // Notify lost ownership, update the ownership, then notify gained ownership for the network behaviours networkObject.InvokeBehaviourOnOwnershipChanged(originalOwner, clientId); @@ -1177,6 +1183,7 @@ internal bool AuthorityLocalSpawn([NotNull] NetworkObject networkObject, ulong n if (networkObject.HasGhost) { networkObject.NetworkObjectBridge.NetworkObjectId.Value = networkObject.NetworkObjectId; + networkObject.NetworkObjectBridge.UpdateGhostOwner(NetworkManager, networkObject.OwnerClientId); } #endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs index f243918d2d..36d1be609f 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs @@ -13,11 +13,22 @@ namespace Unity.Netcode.RuntimeTests [TestFixture(HostOrServer.DAHost)] [TestFixture(HostOrServer.Host)] [TestFixture(HostOrServer.Server)] +#if UNIFIED_NETCODE + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] +#endif internal class NetworkObjectDontDestroyWithOwnerTests : NetcodeIntegrationTest { private const int k_NumberObjectsToSpawn = 16; protected override int NumberOfClients => 3; +#if UNIFIED_NETCODE + protected override bool UseUnifiedTests() + { + return true; + } +#endif + public enum ParentedPass { NoParent, @@ -278,6 +289,12 @@ public IEnumerator DontDestroyWithOwnerTest([Values] ParentedPass parentedPass) [UnityTest] public IEnumerator NetworkShowThenClientDisconnects() { +#if UNIFIED_NETCODE + if (m_AllPrefabsAsHybrid) + { + Assert.Ignore("A scene migration update can reach a client before the hybrid object's ghost has spawned there."); + } +#endif var authorityManager = GetAuthorityNetworkManager(); var networkObject = SpawnObject(m_PrefabNoObserversSpawn, authorityManager).GetComponent(); var longWait = new WaitForSeconds(0.25f); diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkSpawnManagerTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkSpawnManagerTests.cs index 710c1b0662..8ee89c82e1 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkSpawnManagerTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkSpawnManagerTests.cs @@ -8,10 +8,20 @@ namespace Unity.Netcode.RuntimeTests { [TestFixture(HostOrServer.DAHost)] [TestFixture(HostOrServer.Host)] +#if UNIFIED_NETCODE + [TestFixture(HostOrServer.UnifiedHost)] +#endif internal class NetworkSpawnManagerTests : NetcodeIntegrationTest { protected override int NumberOfClients => 2; +#if UNIFIED_NETCODE + protected override bool UseUnifiedTests() + { + return true; + } +#endif + public NetworkSpawnManagerTests(HostOrServer hostOrServer) : base(hostOrServer) { } [Test] diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs index bbc26c2b81..1b60ac5cda 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs @@ -2579,7 +2579,16 @@ protected GameObject CreateHybridPrefab(string baseName, bool moveToDDOL = true, // Initialize it as a prefab adapter.InitializeAsPrefab(); - adapter.SupportedGhostModes = GhostModeMask.Interpolated; + if (ghostMode == GhostMode.Interpolated) + { + adapter.SupportedGhostModes = GhostModeMask.Interpolated; + } + else + { + adapter.SupportedGhostModes = GhostModeMask.All; + adapter.DefaultGhostMode = ghostMode; + adapter.HasOwner = ghostMode == GhostMode.OwnerPredicted; + } // Once done with setting up the GhostObject, we can set it back to active in the hierarchy gameObject.SetActive(true); @@ -2588,15 +2597,6 @@ protected GameObject CreateHybridPrefab(string baseName, bool moveToDDOL = true, // Add the bridge var bridge = gameObject.AddComponent(); - // Set after the bridge is added, since its editor OnValidate resets the supported modes to interpolated. - // The prefab is registered when each world is created, so this still applies before the session starts. - if (ghostMode != GhostMode.Interpolated) - { - adapter.SupportedGhostModes = GhostModeMask.All; - adapter.DefaultGhostMode = ghostMode; - adapter.HasOwner = ghostMode == GhostMode.OwnerPredicted; - } - // Now add NGO components var no = gameObject.AddComponent(); diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs index 99260e29fe..733cd39f4e 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs @@ -240,16 +240,10 @@ protected override void OnServerAndClientsCreated() protected override IEnumerator OnServerAndClientsConnected() { - // Remote methods send through ClientServerBootstrap.ServerWorlds, but each bootstrap constructor clears - // that list, and NGO creates one bootstrap per NetworkManager. With several NetworkManagers in one process - // only the last one started stays registered, so the server world has to be added back. - if (!ClientServerBootstrap.ServerWorlds.Contains(m_ServerNetworkManager.NetcodeWorld)) - { - ClientServerBootstrap.ServerWorlds.Add(m_ServerNetworkManager.NetcodeWorld); - } var client = m_ClientNetworkManagers[0]; m_ServerInstance = SpawnObject(m_InteropPrefab, client).GetComponent(); - m_ServerInstance.GetComponent().OwnerNetworkId = client.NetcodeWorld.LocalConnection.NetworkId; + Assert.AreEqual(client.NetcodeWorld.LocalConnection.NetworkId, m_ServerInstance.GetComponent().OwnerNetworkId, + "Spawning with an NGO owner did not set the ghost owner!"); yield return WaitForSpawnedOnAllOrTimeOut(m_ServerInstance); AssertOnTimeout($"Timed out waiting for {m_ServerInstance.name} to spawn on all clients!"); m_ClientInstance = client.SpawnManager.SpawnedObjects[m_ServerInstance.NetworkObjectId]; @@ -352,20 +346,29 @@ public IEnumerator NetworkVariableWrittenFromPredictionUpdateGatedOnFirstTimeTic } /// - /// NGO ownership and the ghost's N4E owner are separate: an NGO ownership change leaves the ghost owner as it was. + /// An NGO ownership change also changes the ghost's owner, so the new NGO owner is the one that predicts. /// [UnityTest] - public IEnumerator NgoOwnershipChangeDoesNotChangeGhostOwner() + public IEnumerator NgoOwnershipChangeUpdatesGhostOwner() { - var ghostOwner = m_ServerInstance.GetComponent().OwnerNetworkId; + var client = m_ClientNetworkManagers[0]; + var serverGhost = m_ServerInstance.GetComponent(); + var clientGhost = m_ClientInstance.GetComponent(); + + // The host's own client owns the ghost when the host takes ownership. A server without a local client leaves it unowned. + var serverOwnerNetworkId = m_ServerNetworkManager.IsHost ? m_ServerNetworkManager.NetcodeWorld.LocalConnection.NetworkId : default; m_ServerInstance.ChangeOwnership(m_ServerNetworkManager.LocalClientId); - yield return WaitForConditionOrTimeOut(() => m_ClientInstance.OwnerClientId == m_ServerNetworkManager.LocalClientId); - AssertOnTimeout($"Client never saw the ownership change to Client-{m_ServerNetworkManager.LocalClientId}!"); - yield return s_DefaultWaitForTick; - Assert.AreEqual(ghostOwner, m_ServerInstance.GetComponent().OwnerNetworkId, "The ghost owner changed with the NGO owner."); - Assert.AreEqual(ghostOwner, m_ClientInstance.GetComponent().OwnerNetworkId, "The client's ghost owner changed with the NGO owner."); - Assert.IsTrue(m_ClientInstance.GetComponent().IsPredictedGhost, "The client stopped predicting the ghost."); - Debug.Log($"Client ghost was predicted on spawn: {m_ClientInstance.GetComponent().GhostWasPredictedOnSpawn}"); + Assert.AreEqual(serverOwnerNetworkId, serverGhost.OwnerNetworkId, "The ghost owner did not follow the NGO owner to the server!"); + yield return WaitForConditionOrTimeOut(() => m_ClientInstance.OwnerClientId == m_ServerNetworkManager.LocalClientId && clientGhost.OwnerNetworkId.Equals(serverOwnerNetworkId)); + AssertOnTimeout($"Client never saw the ghost owner change! NGO owner: {m_ClientInstance.OwnerClientId}, ghost owner: {clientGhost.OwnerNetworkId.Value}"); + Debug.Log($"Client ghost predicted after losing ownership: {clientGhost.IsPredictedGhost}. Predicted on spawn: " + + $"{m_ClientInstance.GetComponent().GhostWasPredictedOnSpawn}"); + + var clientNetworkId = client.NetcodeWorld.LocalConnection.NetworkId; + m_ServerInstance.ChangeOwnership(client.LocalClientId); + Assert.AreEqual(clientNetworkId, serverGhost.OwnerNetworkId, "The ghost owner did not follow the NGO owner back to the client!"); + yield return WaitForConditionOrTimeOut(() => clientGhost.OwnerNetworkId.Equals(clientNetworkId) && clientGhost.IsPredictedGhost); + AssertOnTimeout($"The client did not predict the ghost after regaining ownership! Ghost owner: {clientGhost.OwnerNetworkId.Value}, predicted: {clientGhost.IsPredictedGhost}"); } private IEnumerator WriteNetworkVariableFromPrediction(bool gateOnFirstTimeTick) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs index 5d54e36e24..17af9fd822 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridPredictionTests.cs @@ -61,7 +61,6 @@ public IEnumerator OwnerPredictedHybridPrefabResimulatesOnClient() { var client = m_ClientNetworkManagers[0]; var serverInstance = SpawnObject(m_PredictedPrefab, client).GetComponent(); - serverInstance.GetComponent().OwnerNetworkId = client.NetcodeWorld.LocalConnection.NetworkId; yield return WaitForSpawnedOnAllOrTimeOut(serverInstance); AssertOnTimeout($"Timed out waiting for {serverInstance.name} to spawn on all clients!"); From 5fe98da65d991a08b3a2cb98e77d85b918797e7a Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 30 Sep 2026 20:36:12 -0500 Subject: [PATCH 4/9] fix: set the ghost owner before OnNetworkSpawn - The ghost owner is now assigned right after SetupOnSpawn, so OnNetworkSpawn sees the same owner on the NetworkObject and its ghost. The interop test records the ghost owner in OnNetworkSpawn and asserts it. - NetworkVariableReadDuringPredictionIsNotTickAligned asserts that a re-simulated tick reads a different value than its first prediction, rather than counting reads before the stamp tick. - NetworkShowThenClientDisconnects is only ignored for UnifiedServer, the fixture where the scene migration failure occurs. --- .../Runtime/Spawning/NetworkSpawnManager.cs | 8 +++++++- .../NetworkObjectDontDestroyWithOwnerTests.cs | 2 +- .../Tests/Runtime/Unified/HybridInteropTests.cs | 15 ++++++++++----- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs index f24f181a3a..9b93daa06f 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs @@ -1183,7 +1183,6 @@ internal bool AuthorityLocalSpawn([NotNull] NetworkObject networkObject, ulong n if (networkObject.HasGhost) { networkObject.NetworkObjectBridge.NetworkObjectId.Value = networkObject.NetworkObjectId; - networkObject.NetworkObjectBridge.UpdateGhostOwner(NetworkManager, networkObject.OwnerClientId); } #endif @@ -1309,6 +1308,13 @@ internal bool SpawnNetworkObjectLocallyCommon(NetworkObject networkObject, ulong } networkObject.SetupOnSpawn(networkId, playerObject, ownerClientId, destroyWithScene); +#if UNIFIED_NETCODE + // Before OnNetworkSpawn, so owner-specific initialization sees the ghost owner too. + if (networkObject.HasGhost) + { + networkObject.NetworkObjectBridge.UpdateGhostOwner(NetworkManager, ownerClientId); + } +#endif SpawnedObjects.Add(networkObject.NetworkObjectId, networkObject); SpawnedObjectsList.Add(networkObject); diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs index 36d1be609f..db0882b9b0 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs @@ -290,7 +290,7 @@ public IEnumerator DontDestroyWithOwnerTest([Values] ParentedPass parentedPass) public IEnumerator NetworkShowThenClientDisconnects() { #if UNIFIED_NETCODE - if (m_AllPrefabsAsHybrid) + if (m_AllPrefabsAsHybrid && !m_UseHost) { Assert.Ignore("A scene migration update can reach a client before the hybrid object's ghost has spawned there."); } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs index 733cd39f4e..433e87e2d5 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs @@ -155,10 +155,13 @@ internal class HybridInteropNetworkBehaviour : NetworkBehaviour public int OwnerWrittenTickChanges; public int OwnerWrittenTickDecreases; public bool GhostWasPredictedOnSpawn; + public NetworkId GhostOwnerOnSpawn; public override void OnNetworkSpawn() { - GhostWasPredictedOnSpawn = GetComponent().IsPredictedGhost; + var ghost = GetComponent(); + GhostWasPredictedOnSpawn = ghost.IsPredictedGhost; + GhostOwnerOnSpawn = ghost.OwnerNetworkId; OwnerWrittenTick.OnValueChanged += OnOwnerWrittenTickChanged; } @@ -242,8 +245,10 @@ protected override IEnumerator OnServerAndClientsConnected() { var client = m_ClientNetworkManagers[0]; m_ServerInstance = SpawnObject(m_InteropPrefab, client).GetComponent(); - Assert.AreEqual(client.NetcodeWorld.LocalConnection.NetworkId, m_ServerInstance.GetComponent().OwnerNetworkId, - "Spawning with an NGO owner did not set the ghost owner!"); + var clientNetworkId = client.NetcodeWorld.LocalConnection.NetworkId; + Assert.AreEqual(clientNetworkId, m_ServerInstance.GetComponent().OwnerNetworkId, "Spawning with an NGO owner did not set the ghost owner!"); + Assert.AreEqual(clientNetworkId, m_ServerInstance.GetComponent().GhostOwnerOnSpawn, + "The ghost owner was not set yet when OnNetworkSpawn was invoked!"); yield return WaitForSpawnedOnAllOrTimeOut(m_ServerInstance); AssertOnTimeout($"Timed out waiting for {m_ServerInstance.name} to spawn on all clients!"); m_ClientInstance = client.SpawnManager.SpawnedObjects[m_ServerInstance.NetworkObjectId]; @@ -300,14 +305,14 @@ public IEnumerator NgoRpcToUnifiedRemoteToNgoRpc() } /// - /// A NetworkVariable is not rolled back: prediction of a tick before the value's stamp still reads the new value. + /// A NetworkVariable is not rolled back: re-simulating a tick can read a different value than its first prediction did. /// [UnityTest] public IEnumerator NetworkVariableReadDuringPredictionIsNotTickAligned() { yield return RecordAcrossStampedValueChange(); var clientGhost = m_ClientInstance.GetComponent(); - Assert.Greater(clientGhost.EarlyReadsOfNewValue, 0, "Expected prediction of ticks before the stamp to read the new value."); + Assert.Greater(clientGhost.InconsistentRawTicks, 0, "Expected a re-simulated tick to read a different value than its first prediction."); } /// From d4fda26e8760b54a9c71d5d6214d9fdc6ba81d4a Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 30 Sep 2026 21:38:34 -0500 Subject: [PATCH 5/9] fix: guard the host world in UpdateGhostOwner and wait for a re-simulation in the backwards-write test - UpdateGhostOwner leaves the ghost unowned if a host NetworkManager has no NetcodeWorld. - NetworkVariableWrittenFromPredictionUpdateMovesBackwards keeps writing until a backwards move has happened, instead of stopping after 20 changes, which could come before any re-simulation. --- .../Runtime/Components/Helpers/NetworkObjectBridge.cs | 2 +- .../Tests/Runtime/Unified/HybridInteropTests.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs index 6725fe458c..609e42a8d8 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs @@ -115,7 +115,7 @@ internal void UpdateGhostOwner(NetworkManager networkManager, ulong ownerClientI var ownerNetworkId = default(NetworkId); if (ownerClientId == networkManager.LocalClientId) { - if (networkManager.IsHost) + if (networkManager.IsHost && networkManager.NetcodeWorld != null) { ownerNetworkId = networkManager.NetcodeWorld.LocalConnection.NetworkId; } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs index 433e87e2d5..1af4cfa97d 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/HybridInteropTests.cs @@ -382,9 +382,10 @@ private IEnumerator WriteNetworkVariableFromPrediction(bool gateOnFirstTimeTick) var clientBehaviour = m_ClientInstance.GetComponent(); clientGhost.GateOnFirstTimeTick = gateOnFirstTimeTick; clientGhost.WriteNetworkVariableFromPrediction = true; - yield return WaitForConditionOrTimeOut(() => clientBehaviour.OwnerWrittenTickChanges >= k_MinimumPredictionSends); + // Ungated, keep writing until a re-simulation has happened, so a backwards move had a chance to occur. + yield return WaitForConditionOrTimeOut(() => clientBehaviour.OwnerWrittenTickChanges >= k_MinimumPredictionSends && (gateOnFirstTimeTick || clientBehaviour.OwnerWrittenTickDecreases > 0)); clientGhost.WriteNetworkVariableFromPrediction = false; - AssertOnTimeout($"Only {clientBehaviour.OwnerWrittenTickChanges} value changes were written!"); + AssertOnTimeout($"Only {clientBehaviour.OwnerWrittenTickChanges} value changes were written, with {clientBehaviour.OwnerWrittenTickDecreases} backwards moves!"); // Wait for the last write to reach the server, so no NetworkVariable update is still queued at teardown. var serverBehaviour = m_ServerInstance.GetComponent(); From 5e8e7e708e4fb165c91b97bdee461b3beb1f98b4 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 1 Oct 2026 13:53:38 -0500 Subject: [PATCH 6/9] fix: raise the client's own disconnect event when a hybrid client disconnects UnifiedNetcodeTransport.DisconnectLocalClient only requested the N4E disconnect. N4E reports it later, after NGO's shutdown has stopped listening, so the disconnecting client never received its ClientDisconnected event. It now notifies immediately, as UnityTransport does, and ignores N4E's later event. A server-initiated disconnect removes the connection before notifying, so the shutdown it triggers does not notify a second time. DisconnectTests and PeerDisconnectCallbackTests now run in hybrid prefab mode. --- .../Unified/UnifiedNetcodeTransport.cs | 24 ++++++++++++++++--- .../Runtime/Connection/DisconnectTests.cs | 17 ++++++++++--- .../Runtime/PeerDisconnectCallbackTests.cs | 11 +++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs b/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs index de23142229..e19b5167b4 100644 --- a/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs +++ b/com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs @@ -221,7 +221,11 @@ private class ConnectionInfo internal void DispatchMessage(int connectionId, in FixedBytes1280 buffer) { - var connectionInfo = m_Connections[connectionId]; + // The connection is removed on disconnect, which can be ahead of its last messages. + if (!m_Connections.TryGetValue(connectionId, out ConnectionInfo connectionInfo)) + { + return; + } using var arr = FixedBytes1280.ToNativeArray(buffer); var reader = new DataStreamReader(arr); @@ -374,6 +378,8 @@ private void OnClientDisconnectFromServer(Connection connection, NetcodeConnecti GetDisconnectEventFromNetworkStreamDisconnectReason(connectionEvent.DisconnectReason), GetDisconnectMessageFromNetworkStreamDisconnectReason(connectionEvent.DisconnectReason) ); + // Removed before notifying, so the shutdown this triggers does not notify again from DisconnectLocalClient. + m_Connections.Remove(connectionEvent.Id.Value); InvokeOnTransportEvent(NetworkEvent.Disconnect, (ulong)connectionEvent.Id.Value, default, m_RealTimeProvider.RealTimeSinceStartup); } @@ -440,7 +446,7 @@ public override void DisconnectRemoteClient(ulong clientId) public override void DisconnectLocalClient() { // Remove the connection 1st (the world might not be available) - m_Connections.Remove((int)ServerClientId); + var wasConnected = m_Connections.Remove((int)ServerClientId); // TODO-FIX-REVIEW-ME: // This was causing errors to occur upon shutdown during an integration test. @@ -462,6 +468,13 @@ public override void DisconnectLocalClient() } m_NetworkManager.NetcodeWorld.RequestDisconnectFromServer(); + // N4E reports the disconnect a frame or more later, after NGO's shutdown has stopped listening, so the + // client would never be notified. Notify now, as UnityTransport does, and ignore N4E's later event. + m_NetworkManager.NetcodeWorld.OnConnectionEvent -= OnClientConnectionEvent; + if (wasConnected) + { + InvokeOnTransportEvent(NetworkEvent.Disconnect, ServerClientId, default, m_RealTimeProvider.RealTimeSinceStartup); + } } public override ulong GetCurrentRtt(ulong clientId) @@ -479,7 +492,12 @@ public override void Initialize(NetworkManager networkManager = null) public override void Shutdown() { - + var netcodeWorld = m_NetworkManager != null ? m_NetworkManager.NetcodeWorld : null; + if (netcodeWorld != null) + { + netcodeWorld.OnConnectionEvent -= OnClientConnectionEvent; + netcodeWorld.OnConnectionEvent -= OnServerConnectionEvent; + } } } } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/DisconnectTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/DisconnectTests.cs index 82669d7187..f1259f8b7e 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/DisconnectTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/DisconnectTests.cs @@ -19,8 +19,12 @@ namespace Unity.Netcode.RuntimeTests /// - When the server-side player object is destroyed /// - When the server-side player object ownership is transferred back to the server /// - [TestFixture(OwnerPersistence.DestroyWithOwner)] - [TestFixture(OwnerPersistence.DontDestroyWithOwner)] + [TestFixture(OwnerPersistence.DestroyWithOwner, HostOrServer.Host)] + [TestFixture(OwnerPersistence.DontDestroyWithOwner, HostOrServer.Host)] +#if UNIFIED_NETCODE + [TestFixture(OwnerPersistence.DestroyWithOwner, HostOrServer.UnifiedHost)] + [TestFixture(OwnerPersistence.DontDestroyWithOwner, HostOrServer.UnifiedHost)] +#endif internal class DisconnectTests : NetcodeIntegrationTest { public enum OwnerPersistence @@ -37,6 +41,13 @@ public enum ClientDisconnectType protected override int NumberOfClients => 2; +#if UNIFIED_NETCODE + protected override bool UseUnifiedTests() + { + return true; + } +#endif + private OwnerPersistence m_OwnerPersistence; private ClientDisconnectType m_ClientDisconnectType; private bool m_ClientDisconnected; @@ -46,7 +57,7 @@ public enum ClientDisconnectType private ulong m_ClientId; - public DisconnectTests(OwnerPersistence ownerPersistence) : base(HostOrServer.Host) + public DisconnectTests(OwnerPersistence ownerPersistence, HostOrServer hostOrServer) : base(hostOrServer) { m_OwnerPersistence = ownerPersistence; } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/PeerDisconnectCallbackTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/PeerDisconnectCallbackTests.cs index ef6d71fad5..b9895bc733 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/PeerDisconnectCallbackTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/PeerDisconnectCallbackTests.cs @@ -22,6 +22,10 @@ namespace Unity.Netcode.RuntimeTests /// [TestFixture(HostOrServer.Server)] [TestFixture(HostOrServer.Host)] +#if UNIFIED_NETCODE + [TestFixture(HostOrServer.UnifiedServer)] + [TestFixture(HostOrServer.UnifiedHost)] +#endif internal class PeerDisconnectCallbackTests : NetcodeIntegrationTest { @@ -33,6 +37,13 @@ public enum ClientDisconnectType protected override int NumberOfClients => 3; +#if UNIFIED_NETCODE + protected override bool UseUnifiedTests() + { + return true; + } +#endif + private int m_ClientDisconnectCount; private int m_PeerDisconnectCount; From b60f7a138fb35b24647a39d48a5f91eb1681fcf6 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 1 Oct 2026 15:45:18 -0500 Subject: [PATCH 7/9] fix: only register hybrid instances as pending ghosts on clients in integration tests The integration test hook that replaces GhostSpawnManager.RegisterGhostBridge also registered the server's own instance. On a dedicated server, which is never a connected client, that moved the instance into the DontDestroyOnLoad scene. The server then sent a scene migration for it to every client, and none of them had spawned it. The hook now skips the server, as the runtime does. NetworkShowThenClientDisconnects no longer needs to be ignored for UnifiedServer. --- .../NetworkObjectDontDestroyWithOwnerTests.cs | 6 ------ .../Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs | 8 +++++++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs index db0882b9b0..a298640bc1 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs @@ -289,12 +289,6 @@ public IEnumerator DontDestroyWithOwnerTest([Values] ParentedPass parentedPass) [UnityTest] public IEnumerator NetworkShowThenClientDisconnects() { -#if UNIFIED_NETCODE - if (m_AllPrefabsAsHybrid && !m_UseHost) - { - Assert.Ignore("A scene migration update can reach a client before the hybrid object's ghost has spawned there."); - } -#endif var authorityManager = GetAuthorityNetworkManager(); var networkObject = SpawnObject(m_PrefabNoObserversSpawn, authorityManager).GetComponent(); var longWait = new WaitForSeconds(0.25f); diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs index 1b60ac5cda..4af4ddd960 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs @@ -860,7 +860,13 @@ private void RegisterPendingGhost(NetworkObject networkObject, ulong networkObje // If the world matches, then register the instance with this NetworkManager's spawn manager. if (networkManager.NetcodeWorld == ghost.World) { - networkManager.SpawnManager.GhostSpawnManager.RegisterGhostPendingSpawn(networkObject, networkObjectId); + // Like GhostSpawnManager.RegisterGhostBridge, only clients wait for a ghost. Registering the server's + // own instance moved it into the DontDestroyOnLoad scene on a dedicated server, which every client + // was then told about. + if (!networkManager.IsServer) + { + networkManager.SpawnManager.GhostSpawnManager.RegisterGhostPendingSpawn(networkObject, networkObjectId); + } return; } } From 2897835b837bff8bc779a34ec9c2a3b7df532de8 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 1 Oct 2026 16:42:31 -0500 Subject: [PATCH 8/9] fix: only send scene migrations to observers, and synchronize late hybrid spawns into their scene ObjectSceneChanged was sent to every connected client, including clients that did not observe the migrated NetworkObjects and had never spawned them, which logged "Trying to synchronize NetworkObjectId but it was not spawned". In client-server mode it is now only sent to clients that observe at least one migrated NetworkObject, and each client is only sent the NetworkObjects it observes. Distributed authority is unchanged. A hybrid prefab instance that is part of a client's initial synchronization can spawn after the synchronization has completed, once its ghost arrives. It missed the step that migrates synchronized NetworkObjects into their server-side scene, and stayed in the active scene. It now runs that step when it spawns. --- .../SceneManagement/NetworkSceneManager.cs | 89 +++++++++--- .../Runtime/SceneManagement/SceneEventData.cs | 35 ++++- .../Runtime/Spawning/GhostSpawnManager.cs | 6 + ...etworkObjectSceneMigrationObserverTests.cs | 131 ++++++++++++++++++ ...kObjectSceneMigrationObserverTests.cs.meta | 11 ++ 5 files changed, 248 insertions(+), 24 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs b/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs index cafce0f7c1..0bb34043f1 100644 --- a/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs @@ -2253,27 +2253,39 @@ private void SynchronizeNetworkObjectScene() { foreach (var networkObject in NetworkManager.SpawnManager.SpawnedObjectsList) { - // This is only done for dynamically spawned NetworkObjects - // Theoretically, a server could have NetworkObjects in a server-side only scene, if the client doesn't have that scene loaded - // then skip it (it will reside in the currently active scene in this scenario on the client-side) - if (!networkObject.InScenePlaced && ServerSceneHandleToClientSceneHandle.ContainsKey(networkObject.NetworkSceneHandle)) - { - networkObject.SceneOriginHandle = ServerSceneHandleToClientSceneHandle[networkObject.NetworkSceneHandle]; + SynchronizeNetworkObjectScene(networkObject); + } + } - // If the NetworkObject does not have a parent and is not in the same scene as it is on the server side, then find the right scene - // and move it to that scene. - if (networkObject.gameObject.scene.handle != networkObject.SceneOriginHandle && networkObject.transform.parent == null) + /// + /// Migrates a single client-side dynamically spawned NetworkObject into the scene it is in on the server-side. + /// + /// + /// Also used for hybrid prefab instances that are part of the initial synchronization but only spawn once + /// their ghost arrives, which can be after the synchronization has completed. + /// + internal void SynchronizeNetworkObjectScene(NetworkObject networkObject) + { + // This is only done for dynamically spawned NetworkObjects + // Theoretically, a server could have NetworkObjects in a server-side only scene, if the client doesn't have that scene loaded + // then skip it (it will reside in the currently active scene in this scenario on the client-side) + if (!networkObject.InScenePlaced && ServerSceneHandleToClientSceneHandle.ContainsKey(networkObject.NetworkSceneHandle)) + { + networkObject.SceneOriginHandle = ServerSceneHandleToClientSceneHandle[networkObject.NetworkSceneHandle]; + + // If the NetworkObject does not have a parent and is not in the same scene as it is on the server side, then find the right scene + // and move it to that scene. + if (networkObject.gameObject.scene.handle != networkObject.SceneOriginHandle && networkObject.transform.parent == null) + { + if (ScenesLoaded.ContainsKey(networkObject.SceneOriginHandle)) { - if (ScenesLoaded.ContainsKey(networkObject.SceneOriginHandle)) - { - var scene = ScenesLoaded[networkObject.SceneOriginHandle]; - SceneManager.MoveGameObjectToScene(networkObject.gameObject, scene); - } - else if (NetworkManager.LogLevel <= LogLevel.Normal) - { - NetworkLog.LogWarningServer($"[Client-{NetworkManager.LocalClientId}][{networkObject.gameObject.name}] Server - " + - $"client scene mismatch detected! Client-side has no scene loaded with handle ({networkObject.SceneOriginHandle})!"); - } + var scene = ScenesLoaded[networkObject.SceneOriginHandle]; + SceneManager.MoveGameObjectToScene(networkObject.gameObject, scene); + } + else if (NetworkManager.LogLevel <= LogLevel.Normal) + { + NetworkLog.LogWarningServer($"[Client-{NetworkManager.LocalClientId}][{networkObject.gameObject.name}] Server - " + + $"client scene mismatch detected! Client-side has no scene loaded with handle ({networkObject.SceneOriginHandle})!"); } } } @@ -2998,6 +3010,26 @@ internal void MigrateNetworkObjectsIntoScenes() private List m_ScenesToRemoveFromObjectMigration = new(); + private List m_SceneMigrationTargetIds = new(); + + private bool ObservesMigratedNetworkObject(ulong clientId) + { + foreach (var sceneEntry in ObjectsMigratedIntoNewScene) + { + if (!sceneEntry.Value.TryGetValue(NetworkManager.LocalClientId, out var migratedObjects)) + { + continue; + } + foreach (var networkObject in migratedObjects) + { + if (networkObject.Observers.Contains(clientId)) + { + return true; + } + } + } + return false; + } /// /// Should be invoked during PostLateUpdate just prior to the NetworkMessageManager processes its outbound message queue. @@ -3062,13 +3094,30 @@ internal void CheckForAndSendNetworkObjectSceneChanged() return; } + // Only send to the clients that observe at least one of the migrated NetworkObjects + m_SceneMigrationTargetIds.Clear(); + foreach (var clientId in NetworkManager.ConnectedClientsIds) + { + if (clientId != NetworkManager.LocalClientId && (NetworkManager.DistributedAuthorityMode || ObservesMigratedNetworkObject(clientId))) + { + m_SceneMigrationTargetIds.Add(clientId); + } + } + + // Distributed authority still sends to the CMB service when there are no other clients + if (m_SceneMigrationTargetIds.Count == 0 && !NetworkManager.DistributedAuthorityMode) + { + ObjectsMigratedIntoNewScene.Clear(); + return; + } + // Some NetworkObjects still exist, send the message var sceneEvent = BeginSceneEvent(); sceneEvent.SceneEventType = SceneEventType.ObjectSceneChanged; // SendSceneEventData can throw an exception. We need to wrap this and recover from the exception gracefully. try { - SendSceneEventData(sceneEvent.SceneEventId, NetworkManager.ConnectedClientsIds.Where(c => c != NetworkManager.LocalClientId).ToArray()); + SendSceneEventData(sceneEvent.SceneEventId, m_SceneMigrationTargetIds.ToArray()); } catch (Exception ex) { diff --git a/com.unity.netcode.gameobjects/Runtime/SceneManagement/SceneEventData.cs b/com.unity.netcode.gameobjects/Runtime/SceneManagement/SceneEventData.cs index 83974d882d..e956bf7a30 100644 --- a/com.unity.netcode.gameobjects/Runtime/SceneManagement/SceneEventData.cs +++ b/com.unity.netcode.gameobjects/Runtime/SceneManagement/SceneEventData.cs @@ -1218,6 +1218,15 @@ private void ReadSceneEventProgressDone(FastBufferReader reader) internal bool IsForwarding; private ulong m_OwnerId; + /// + /// Distributed authority forwards scene migrations through the session owner or the DAHost, so only + /// client-server filters them by the target client's observers. + /// + private bool IsMigrationSentToTarget(NetworkObject networkObject) + { + return m_NetworkManager.DistributedAuthorityMode || networkObject.Observers.Contains(TargetClientId); + } + private void SerializeObjectsMovedIntoNewScene(FastBufferWriter writer) { var sceneManager = m_NetworkManager.SceneManager; @@ -1241,17 +1250,35 @@ private void SerializeObjectsMovedIntoNewScene(FastBufferWriter writer) { // Since these are separated by scene then owner, there could be scenes that have // no changes. - if (!sceneHandleObjects.Value.ContainsKey(networkManagerClientId)) + if (!sceneHandleObjects.Value.TryGetValue(networkManagerClientId, out var migratedObjects)) + { + continue; + } + + // A client is only told about the objects it observes, since it has not spawned the others. + var objectCount = 0; + foreach (var networkObject in migratedObjects) + { + if (IsMigrationSentToTarget(networkObject)) + { + objectCount++; + } + } + if (objectCount == 0) { continue; } + // Write the scene handle writer.WriteValueSafe(sceneHandleObjects.Key); // Write the number of NetworkObjectIds to expect - writer.WriteValueSafe(sceneHandleObjects.Value[networkManagerClientId].Count); - foreach (var networkObject in sceneHandleObjects.Value[networkManagerClientId]) + writer.WriteValueSafe(objectCount); + foreach (var networkObject in migratedObjects) { - writer.WriteValueSafe(networkObject.NetworkObjectId); + if (IsMigrationSentToTarget(networkObject)) + { + writer.WriteValueSafe(networkObject.NetworkObjectId); + } } entriesWritten++; } diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/GhostSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/GhostSpawnManager.cs index ed6b512494..82a926bead 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/GhostSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/GhostSpawnManager.cs @@ -146,6 +146,12 @@ internal NetworkObject ProcessGhostPendingSynchronization(ulong networkObjectId, } var networkObject = NetworkObject.DeserializeAndSpawnObject(serializedObject, reader, m_NetworkManager); + // The synchronization moved the other NetworkObjects into their server-side scenes before this one spawned + if (networkObject != null) + { + m_NetworkManager.SceneManager.SynchronizeNetworkObjectScene(networkObject); + } + // TODO-UNIFIED: How do we handle the "all in-scene placed objects are spawned notification"? //if (serializedObject.IsSceneObject) //{ diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs new file mode 100644 index 0000000000..2d84cecf2d --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs @@ -0,0 +1,131 @@ +using System.Collections; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates that a client is only told about the scene migrations of the s it observes, + /// and that a shown after it migrated spawns in the authority's scene. + /// + [TestFixture(HostOrServer.Host)] + [TestFixture(HostOrServer.Server)] +#if UNIFIED_NETCODE + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] +#endif + internal class NetworkObjectSceneMigrationObserverTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 2; + + private GameObject m_PrefabNoObserversSpawn; + private GameObject m_PrefabWithObservers; + +#if UNIFIED_NETCODE + protected override bool UseUnifiedTests() + { + return true; + } +#endif + + public NetworkObjectSceneMigrationObserverTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override void OnServerAndClientsCreated() + { + m_PrefabNoObserversSpawn = CreateNetworkObjectPrefab("NoObserversObject"); + m_PrefabNoObserversSpawn.GetComponent().SpawnWithObservers = false; + m_PrefabWithObservers = CreateNetworkObjectPrefab("WithObserversObject"); + base.OnServerAndClientsCreated(); + } + + private NetworkObject SpawnAndShow(NetworkManager authority, NetworkManager observer) + { + var networkObject = SpawnObject(m_PrefabNoObserversSpawn, authority).GetComponent(); + networkObject.NetworkShow(observer.LocalClientId); + return networkObject; + } + + private IEnumerator WaitForSpawnedOn(NetworkManager networkManager, NetworkObject networkObject) + { + yield return WaitForConditionOrTimeOut(() => networkManager.SpawnManager.SpawnedObjects.ContainsKey(networkObject.NetworkObjectId)); + AssertOnTimeout($"[Client-{networkManager.LocalClientId}] Failed to spawn {networkObject.name} when it was shown!"); + } + + /// + /// Two objects each shown to a different client migrate into different scenes in the same frame. Each client + /// must only receive the migration of the object it observes. + /// + [UnityTest] + public IEnumerator SceneMigrationIsOnlySentToObservers() + { + var authority = GetAuthorityNetworkManager(); + var firstClient = GetNonAuthorityNetworkManager(0); + var secondClient = GetNonAuthorityNetworkManager(1); + var activeScene = SceneManager.GetActiveScene(); + + var firstObject = SpawnAndShow(authority, firstClient); + var secondObject = SpawnAndShow(authority, secondClient); + // Start the first object in the DontDestroyOnLoad scene so the two objects migrate into different scenes + Object.DontDestroyOnLoad(firstObject.gameObject); + yield return WaitForSpawnedOn(firstClient, firstObject); + yield return WaitForSpawnedOn(secondClient, secondObject); + yield return s_DefaultWaitForTick; + + SceneManager.MoveGameObjectToScene(firstObject.gameObject, activeScene); + Object.DontDestroyOnLoad(secondObject.gameObject); + + var firstClientInstance = firstClient.SpawnManager.SpawnedObjects[firstObject.NetworkObjectId]; + var secondClientInstance = secondClient.SpawnManager.SpawnedObjects[secondObject.NetworkObjectId]; + yield return WaitForConditionOrTimeOut(() => firstClientInstance.gameObject.scene == activeScene + && secondClientInstance.gameObject.scene == secondClient.SceneManager.DontDestroyOnLoadScene); + AssertOnTimeout($"The observing clients did not migrate their instances! " + + $"[Client-{firstClient.LocalClientId}] {firstClientInstance.gameObject.scene.name}, [Client-{secondClient.LocalClientId}] {secondClientInstance.gameObject.scene.name}"); + + Assert.False(firstClient.SpawnManager.SpawnedObjects.ContainsKey(secondObject.NetworkObjectId), $"[Client-{firstClient.LocalClientId}] Spawned {secondObject.name} without observing it!"); + Assert.False(secondClient.SpawnManager.SpawnedObjects.ContainsKey(firstObject.NetworkObjectId), $"[Client-{secondClient.LocalClientId}] Spawned {firstObject.name} without observing it!"); + LogAssert.NoUnexpectedReceived(); + } + + /// + /// An object that migrates while no client observes it sends no migration. + /// + [UnityTest] + public IEnumerator SceneMigrationWithNoObserversIsNotSent() + { + var authority = GetAuthorityNetworkManager(); + + var networkObject = SpawnObject(m_PrefabNoObserversSpawn, authority).GetComponent(); + yield return s_DefaultWaitForTick; + Object.DontDestroyOnLoad(networkObject.gameObject); + + // Wait long enough for a migration to have been sent and processed + yield return new WaitForSeconds(0.25f); + LogAssert.NoUnexpectedReceived(); + } + + /// + /// A late joining client synchronizes an object that is not in the active scene into the authority's scene. + /// A hybrid prefab instance can spawn after the synchronization completes, once its ghost arrives. + /// + [UnityTest] + public IEnumerator LateJoinSynchronizesObjectIntoAuthorityScene() + { + var authority = GetAuthorityNetworkManager(); + var networkObject = SpawnObject(m_PrefabWithObservers, authority).GetComponent(); + yield return WaitForSpawnedOnAllOrTimeOut(networkObject); + AssertOnTimeout($"Failed to spawn {networkObject.name} on all clients!"); + Object.DontDestroyOnLoad(networkObject.gameObject); + yield return s_DefaultWaitForTick; + + yield return CreateAndStartNewClient(); + var lateJoinClient = m_ClientNetworkManagers[m_ClientNetworkManagers.Length - 1]; + yield return WaitForSpawnedOn(lateJoinClient, networkObject); + + var clientInstance = lateJoinClient.SpawnManager.SpawnedObjects[networkObject.NetworkObjectId]; + Assert.AreEqual(lateJoinClient.SceneManager.DontDestroyOnLoadScene, clientInstance.gameObject.scene, $"[Client-{lateJoinClient.LocalClientId}] {networkObject.name} did not spawn in the authority's scene!"); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs.meta new file mode 100644 index 0000000000..94364e42d7 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4a1fe891864e4ae7867dd6f8dda3492c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 423c9e2529cffa69ff2271b525c57b703133d164 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 1 Oct 2026 17:16:02 -0500 Subject: [PATCH 9/9] fix: spawn a NetworkObject shown after a scene migration in the server's scene A client that starts observing a NetworkObject with NetworkShow after the NetworkObject migrated into another scene was never sent that migration, and instantiated the NetworkObject in its active scene. The client now moves a spawned NetworkObject into its server-side scene, as it already does for NetworkObjects that are part of its initial synchronization. Adds CHANGELOG entries for this and for scene migrations being sent to clients that do not observe the NetworkObject. --- com.unity.netcode.gameobjects/CHANGELOG.md | 3 +++ .../Messaging/Messages/CreateObjectMessage.cs | 7 +++++++ ...etworkObjectSceneMigrationObserverTests.cs | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 7b75f45d30..621606f479 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -18,6 +18,9 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed +- 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`. +- Issue where a `NetworkObject` that was moved into another scene while hidden from a client spawned in that client's active scene when it was shown with `NetworkShow`, instead of the scene it is in on the server. + ### Security ### Obsolete diff --git a/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/CreateObjectMessage.cs b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/CreateObjectMessage.cs index 0238e0ddb7..c2ddf7d10e 100644 --- a/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/CreateObjectMessage.cs +++ b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/CreateObjectMessage.cs @@ -197,6 +197,13 @@ internal static void CreateObject(ref NetworkManager networkManager, ulong sende networkManager.Log.ErrorServer(new Context(LogLevel.Developer, $"Failed to deserialize {nameof(NetworkObject)}.").AddInfo(nameof(NetworkObject.GlobalObjectIdHash), serializedObject.Hash).AddInfo(nameof(NetworkObject.NetworkObjectId), serializedObject.NetworkObjectId)); return; } + + // A NetworkObject shown to this client after it migrated into another scene was instantiated in the + // active scene, and the migration was only sent to the clients observing it at the time. + if (networkManager.NetworkConfig.EnableSceneManagement) + { + networkManager.SceneManager.SynchronizeNetworkObjectScene(networkObject); + } } else { diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs index 2d84cecf2d..55ceca9f9a 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs @@ -106,6 +106,27 @@ public IEnumerator SceneMigrationWithNoObserversIsNotSent() LogAssert.NoUnexpectedReceived(); } + /// + /// An object shown to a client after it migrated while hidden from that client spawns in the authority's scene. + /// + [UnityTest] + public IEnumerator NetworkShowAfterSceneMigrationSpawnsInAuthorityScene() + { + var authority = GetAuthorityNetworkManager(); + var client = GetNonAuthorityNetworkManager(); + + var networkObject = SpawnObject(m_PrefabNoObserversSpawn, authority).GetComponent(); + yield return s_DefaultWaitForTick; + Object.DontDestroyOnLoad(networkObject.gameObject); + yield return s_DefaultWaitForTick; + + networkObject.NetworkShow(client.LocalClientId); + yield return WaitForSpawnedOn(client, networkObject); + + var clientInstance = client.SpawnManager.SpawnedObjects[networkObject.NetworkObjectId]; + Assert.AreEqual(client.SceneManager.DontDestroyOnLoadScene, clientInstance.gameObject.scene, $"[Client-{client.LocalClientId}] {networkObject.name} did not spawn in the authority's scene!"); + } + /// /// A late joining client synchronizes an object that is not in the active scene into the authority's scene. /// A hybrid prefab instance can spawn after the synchronization completes, once its ghost arrives.