From 551b82520789f7edfc969a3c4286b01e12cb53a2 Mon Sep 17 00:00:00 2001 From: Shubham Kumar <146586770+quantum-shubham@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:39:44 +0530 Subject: [PATCH 1/3] fix: NetworkList changes pending during client synchronization are applied twice Override WriteFieldSynchronization in NetworkList so a client synchronized while local changes are still pending receives the list as of the last sent delta, mirroring NetworkVariable. The pending events are then applied exactly once with the next delta. Fixes #4179 --- com.unity.netcode.gameobjects/CHANGELOG.md | 1 + .../Collections/NetworkList.cs | 54 +++++++++++++++++-- .../NetworkVariable/NetworkListTests.cs | 46 ++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 5fa9d87daf..c4c9a30a74 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -22,6 +22,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed +- Issue where a client synchronized while a `NetworkList` still had unsent changes applied those changes twice, resulting in duplicate entries and index-based events landing on the wrong elements on that client. - 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`. (#4185) - 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. (#4185) - Issue where mixed authority nested `NetworkTransform` instances could stop child/nested instances from updating because an authoritative `NetworkTransform` (root or otherwise) would remove the `NetworkObject` from the update group, preventing non-authoritative instances from updating their state on the authority side. (#4169) diff --git a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/Collections/NetworkList.cs b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/Collections/NetworkList.cs index acf139b0d1..c58d3f8837 100644 --- a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/Collections/NetworkList.cs +++ b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/Collections/NetworkList.cs @@ -16,6 +16,10 @@ public class NetworkList : NetworkVariableBase where T : unmanaged, IEquatabl private NativeList m_List = new NativeList(64, Allocator.Persistent); private NativeList> m_DirtyEvents = new NativeList>(64, Allocator.Persistent); + // The list as it was before the first local change that is still pending in m_DirtyEvents + private NativeList m_LastSentList = new NativeList(64, Allocator.Persistent); + private bool m_HasLastSentList; + /// /// Delegate type for list changed event /// @@ -67,6 +71,7 @@ public override void ResetDirty() { m_DirtyEvents.Clear(); } + m_HasLastSentList = false; } /// @@ -149,10 +154,42 @@ private T ReadValue(FastBufferReader reader) /// public override void WriteField(FastBufferWriter writer) { - writer.WriteValueSafe((ushort)m_List.Length); - for (int i = 0; i < m_List.Length; i++) + WriteList(writer, m_List); + } + + internal override void WriteFieldSynchronization(FastBufferWriter writer) + { + // The pending events are sent to the synchronizing client with the next delta, so it must + // receive the list they apply to. Otherwise it would apply them twice. + if (m_HasLastSentList && m_DirtyEvents.Length > 0) + { + WriteList(writer, m_LastSentList); + } + else + { + base.WriteFieldSynchronization(writer); + } + } + + private static void WriteList(FastBufferWriter writer, NativeList list) + { + writer.WriteValueSafe((ushort)list.Length); + for (int i = 0; i < list.Length; i++) { - NetworkVariableSerialization.Serializer.Write(writer, ref m_List.ElementAt(i)); + NetworkVariableSerialization.Serializer.Write(writer, ref list.ElementAt(i)); + } + } + + /// + /// Invoked before a local change. Keeps a copy of the list as of the last sent delta + /// so can send it while changes are pending. + /// + private void CaptureLastSentList() + { + if (m_DirtyEvents.Length == 0) + { + m_LastSentList.CopyFrom(m_List); + m_HasLastSentList = true; } } @@ -439,6 +476,7 @@ public void Add(T item) return; } + CaptureLastSentList(); m_List.Add(item); var listEvent = new NetworkListEvent() @@ -466,6 +504,7 @@ public void Clear() return; } + CaptureLastSentList(); m_List.Clear(); var listEvent = new NetworkListEvent() @@ -510,6 +549,7 @@ public bool Remove(T item) return false; } + CaptureLastSentList(); m_List.RemoveAt(index); var listEvent = new NetworkListEvent() { @@ -553,6 +593,7 @@ public void Insert(int index, T item) return; } + CaptureLastSentList(); if (index < m_List.Length) { m_List.InsertRangeWithBeginEnd(index, index + 1); @@ -589,6 +630,7 @@ public void RemoveAt(int index) } var value = m_List[index]; + CaptureLastSentList(); m_List.RemoveAt(index); var listEvent = new NetworkListEvent() @@ -631,6 +673,7 @@ public void Set(int index, T value, bool forceUpdate = false) return; } + CaptureLastSentList(); m_List[index] = value; var listEvent = new NetworkListEvent() @@ -706,6 +749,11 @@ public override void Dispose() m_DirtyEvents.Dispose(); } + if (m_LastSentList.IsCreated) + { + m_LastSentList.Dispose(); + } + base.Dispose(); } } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkListTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkListTests.cs index c310d55781..10504f061d 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkListTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkListTests.cs @@ -80,6 +80,52 @@ private bool OnVerifyData(StringBuilder errorLog) return true; } + /// + /// Validates that a client synchronized while the list still has unsent changes + /// does not apply those changes twice (snapshot + next delta). + /// + [UnityTest] + public IEnumerator LateJoinWithPendingChanges() + { + var authority = GetAuthorityNetworkManager(); + var instantiatedObject = SpawnObject(m_ListObjectPrefab, authority).GetComponent(); + m_TestObjectId = instantiatedObject.NetworkObjectId; + + yield return WaitForSpawnedOnAllOrTimeOut(instantiatedObject); + AssertOnTimeout("[Setup] Failed to spawn list object"); + + var list = instantiatedObject.GetComponent().TheList; + m_ExpectedValues.Clear(); + for (int i = 0; i < 5; i++) + { + m_ExpectedValues.Add(i); + list.Add(i); + } + + yield return WaitForConditionOrTimeOut(OnVerifyData); + AssertOnTimeout("[Setup] Failed to add items to list"); + + // Hold back deltas so these changes are still pending when the new client synchronizes + list.SetUpdateTraits(new NetworkVariableUpdateTraits { MinSecondsBetweenUpdates = 3600 }); + list.Add(100); + m_ExpectedValues.Add(100); + list.Insert(0, 101); + m_ExpectedValues.Insert(0, 101); + list.RemoveAt(2); + m_ExpectedValues.RemoveAt(2); + list[1] = 102; + m_ExpectedValues[1] = 102; + list.Remove(3); + m_ExpectedValues.Remove(3); + + yield return CreateAndStartNewClient(); + Assert.IsTrue(list.IsDirty(), "[Setup] Changes should still be pending after the new client synchronized"); + + list.SetUpdateTraits(default); + yield return WaitForConditionOrTimeOut(OnVerifyData); + AssertOnTimeout("Late joined client did not match the authority's list"); + } + [UnityTest] public IEnumerator ValidateNetworkListSynchronization() { From cc396000ea053b3526a00a4ee544949cb9e93c4d Mon Sep 17 00:00:00 2001 From: Emma Date: Fri, 23 Oct 2026 17:09:23 -0400 Subject: [PATCH 2/3] Update CHANGELOG --- com.unity.netcode.gameobjects/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index c4c9a30a74..a2b7031d30 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -22,7 +22,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed -- Issue where a client synchronized while a `NetworkList` still had unsent changes applied those changes twice, resulting in duplicate entries and index-based events landing on the wrong elements on that client. +- Issue where a client synchronized while a `NetworkList` still had unsent changes applied those changes twice, resulting in duplicate entries and index-based events landing on the wrong elements on that client. (#4215) - 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`. (#4185) - 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. (#4185) - Issue where mixed authority nested `NetworkTransform` instances could stop child/nested instances from updating because an authoritative `NetworkTransform` (root or otherwise) would remove the `NetworkObject` from the update group, preventing non-authoritative instances from updating their state on the authority side. (#4169) From e57855b87282800ecc26988e449accd747aee9c3 Mon Sep 17 00:00:00 2001 From: Shubham Kumar <146586770+quantum-shubham@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:55:25 +0530 Subject: [PATCH 3/3] fix: keep NetworkObject queued when NetworkShow cannot flush throttled updates ForceSendIfDirtyOnNetworkShow removed the NetworkObject from the updater even when NetworkVariableUpdateTraits held back its dirty variables, so those changes were not sent until the variable was modified again. Re-queue the object when anything is still dirty after the forced flush, and add a regression test for showing an object while NetworkList updates are throttled. --- com.unity.netcode.gameobjects/CHANGELOG.md | 1 + .../Runtime/Core/NetworkBehaviourUpdater.cs | 13 +++++++ .../NetworkVariable/NetworkListTests.cs | 39 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index a2b7031d30..6982831714 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -23,6 +23,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed - Issue where a client synchronized while a `NetworkList` still had unsent changes applied those changes twice, resulting in duplicate entries and index-based events landing on the wrong elements on that client. (#4215) +- Issue where showing a `NetworkObject` with `NetworkShow` while its `NetworkVariable` updates were held back by `NetworkVariableUpdateTraits` stopped those pending changes from being sent until the variable was modified again. (#4215) - 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`. (#4185) - 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. (#4185) - Issue where mixed authority nested `NetworkTransform` instances could stop child/nested instances from updating because an authoritative `NetworkTransform` (root or otherwise) would remove the `NetworkObject` from the update group, preventing non-authoritative instances from updating their state on the authority side. (#4169) diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviourUpdater.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviourUpdater.cs index 871c8afb7d..06f87a5ca7 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviourUpdater.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviourUpdater.cs @@ -138,6 +138,19 @@ internal void ForceSendIfDirtyOnNetworkShow(NetworkObject networkObject) // Remove it from the pending and queued dirty objects lists m_PendingDirtyNetworkObjects.Remove(networkObject); m_DirtyNetworkObjects.Remove(networkObject); + + // Anything still dirty was held back by its update traits, keep it queued so a later tick sends it + foreach (var behaviour in networkObject.ChildNetworkBehaviours.Values) + { + foreach (var networkVariable in behaviour.NetworkVariableFields) + { + if (networkVariable.IsDirty()) + { + AddForUpdate(networkObject); + return; + } + } + } } /// diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkListTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkListTests.cs index 10504f061d..eeac3bab47 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkListTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkListTests.cs @@ -126,6 +126,45 @@ public IEnumerator LateJoinWithPendingChanges() AssertOnTimeout("Late joined client did not match the authority's list"); } + /// + /// Validates that showing an object while list updates are held back by update traits + /// still delivers the pending changes once they can be sent. + /// + [UnityTest] + public IEnumerator NetworkShowWithThrottledPendingChanges() + { + var authority = GetAuthorityNetworkManager(); + var nonAuthority = GetNonAuthorityNetworkManager(); + var instantiatedObject = SpawnObject(m_ListObjectPrefab, authority).GetComponent(); + m_TestObjectId = instantiatedObject.NetworkObjectId; + + yield return WaitForSpawnedOnAllOrTimeOut(instantiatedObject); + AssertOnTimeout("[Setup] Failed to spawn list object"); + + instantiatedObject.NetworkHide(nonAuthority.LocalClientId); + yield return WaitForConditionOrTimeOut(() => !nonAuthority.SpawnManager.SpawnedObjects.ContainsKey(m_TestObjectId)); + AssertOnTimeout("[Setup] Failed to hide list object"); + + // Hold back deltas so these changes are still pending when the object is shown + var list = instantiatedObject.GetComponent().TheList; + list.SetUpdateTraits(new NetworkVariableUpdateTraits { MinSecondsBetweenUpdates = 3600 }); + m_ExpectedValues.Clear(); + for (int i = 0; i < 5; i++) + { + m_ExpectedValues.Add(i); + list.Add(i); + } + + instantiatedObject.NetworkShow(nonAuthority.LocalClientId); + yield return WaitForConditionOrTimeOut(() => nonAuthority.SpawnManager.SpawnedObjects.ContainsKey(m_TestObjectId)); + AssertOnTimeout("[Setup] Failed to show list object"); + Assert.IsTrue(list.IsDirty(), "[Setup] Changes should still be pending after the object was shown"); + + list.SetUpdateTraits(default); + yield return WaitForConditionOrTimeOut(OnVerifyData); + AssertOnTimeout("Clients did not receive the changes that were pending when the object was shown"); + } + [UnityTest] public IEnumerator ValidateNetworkListSynchronization() {