From cba01e9d8328eec288efec9f1efa130500764efa Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 10 Feb 2026 14:55:46 -0600 Subject: [PATCH 01/13] fix Fixing the issue with the missing `OnSpawned` override within `NetworkVariable`. --- .../Runtime/NetworkVariable/NetworkVariable.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs index e8604a66aa..90e2d8c67c 100644 --- a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs +++ b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs @@ -407,5 +407,11 @@ internal override void WriteFieldSynchronization(FastBufferWriter writer) base.WriteFieldSynchronization(writer); } } + + internal override void OnSpawned() + { + m_NetworkBehaviour.PostNetworkVariableWrite(true); + base.OnSpawned(); + } } } From f6a7c79e1a2e7e8e2097a1dcba3ba6f6eff5a067 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 10 Feb 2026 15:58:17 -0600 Subject: [PATCH 02/13] test Adding a general test for scenarios like this one. --- .../NetworkVariableGeneralTests.cs | 168 ++++++++++++++++++ .../NetworkVariableGeneralTests.cs.meta | 2 + 2 files changed, 170 insertions(+) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableGeneralTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableGeneralTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableGeneralTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableGeneralTests.cs new file mode 100644 index 0000000000..7f80881542 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableGeneralTests.cs @@ -0,0 +1,168 @@ +using System.Collections; +using System.Text; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// General integration tests. + /// + [TestFixture(HostOrServer.Host)] + [TestFixture(HostOrServer.DAHost)] + [TestFixture(HostOrServer.Server)] + internal class NetworkVariableGeneralTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 2; + + private GameObject m_PrefabToSpawn; + private GeneralNetVarTest m_AuthorityNetVarTest; + + internal class GeneralNetVarTest : NetworkBehaviour + { + /// + /// NetworkVariable that is set by the authority during spawn. + /// + public NetworkVariable TestValueOnSpawn = new NetworkVariable(default); + /// + /// NetworkVariable that is set by the authority during post spawn. + /// + public NetworkVariable TestValueOnPostSpawn = new NetworkVariable(default); + + /// + /// Field value set to during . + /// + public float OnNetworkSpawnValue; + + /// + /// Field value set to during . + /// + public float OnNetworkPostSpawnValue; + + public override void OnNetworkSpawn() + { + if (HasAuthority) + { + TestValueOnSpawn.Value = Random.Range(0.01f, 100.0f); + } + else + { + // Only set these values during OnNetworkSpawn to + // verify this value is valid for non-authority instances. + OnNetworkSpawnValue = TestValueOnSpawn.Value; + OnNetworkPostSpawnValue = TestValueOnPostSpawn.Value; + } + base.OnNetworkSpawn(); + } + + protected override void OnNetworkPostSpawn() + { + if (HasAuthority) + { + TestValueOnPostSpawn.Value = Random.Range(0.01f, 100.0f); + } + base.OnNetworkPostSpawn(); + } + } + + public NetworkVariableGeneralTests(HostOrServer host) : base(host) { } + + protected override void OnServerAndClientsCreated() + { + m_PrefabToSpawn = CreateNetworkObjectPrefab("TestNetVar"); + m_PrefabToSpawn.AddComponent(); + base.OnServerAndClientsCreated(); + } + + /// + /// Verifies that upon spawn or post spawn the value is set within OnNetworkSpawn on the + /// non-authority instances. + /// + private bool SpawnValuesMatch(StringBuilder errorLog) + { + var authority = GetAuthorityNetworkManager(); + var authorityOnSpawnValue = m_AuthorityNetVarTest.TestValueOnSpawn.Value; + var authorityOnPostSpawnValue = m_AuthorityNetVarTest.TestValueOnPostSpawn.Value; + foreach (var networkManager in m_NetworkManagers) + { + if (authority) + { + continue; + } + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_AuthorityNetVarTest.NetworkObjectId)) + { + errorLog.AppendLine($"[{networkManager.name}] Does not have a spawned instance of {m_AuthorityNetVarTest.name}!"); + continue; + } + var netVarTest = networkManager.SpawnManager.SpawnedObjects[m_AuthorityNetVarTest.NetworkObjectId].GetComponent(); + if (netVarTest.OnNetworkSpawnValue != authorityOnSpawnValue) + { + errorLog.AppendLine($"[{networkManager.name}][OnNetworkSpawn Value] Non-authority value: {netVarTest.OnNetworkSpawnValue} does not match " + + $"the authority value: {authorityOnSpawnValue}!"); + } + if (netVarTest.OnNetworkPostSpawnValue != authorityOnPostSpawnValue) + { + errorLog.AppendLine($"[{networkManager.name}][OnNetworkPostSpawn Value] Non-authority value: {netVarTest.OnNetworkPostSpawnValue} does not match " + + $"the authority value: {authorityOnPostSpawnValue}!"); + } + } + return errorLog.Length == 0; + } + + /// + /// Verifies that changing the value synchronizes properly. + /// + private bool ChangedValueMatches(StringBuilder errorLog) + { + var authority = GetAuthorityNetworkManager(); + var authorityValue = m_AuthorityNetVarTest.TestValueOnSpawn.Value; + foreach (var networkManager in m_NetworkManagers) + { + if (authority) + { + continue; + } + var netVarTest = networkManager.SpawnManager.SpawnedObjects[m_AuthorityNetVarTest.NetworkObjectId].GetComponent(); + if (netVarTest.TestValueOnSpawn.Value != authorityValue) + { + errorLog.AppendLine($"[{networkManager.name}][Changed] Non-auhoroty value: {netVarTest.TestValueOnSpawn.Value} does not match " + + $"the authority value: {authorityValue}!"); + } + } + return errorLog.Length == 0; + } + + /// + /// Validates when the authority applies a value during spawn or + /// post spawn of a newly instantiated and spawned object the value is set by the time non-authority + /// instances invoke . + /// + [UnityTest] + public IEnumerator ApplyValueDuringSpawnSequence() + { + var authority = GetAuthorityNetworkManager(); + m_AuthorityNetVarTest = SpawnObject(m_PrefabToSpawn, authority).GetComponent(); + yield return WaitForSpawnedOnAllOrTimeOut(m_AuthorityNetVarTest.gameObject); + AssertOnTimeout($"Not all clients spawned {m_AuthorityNetVarTest.name}!"); + + yield return WaitForConditionOrTimeOut(SpawnValuesMatch); + AssertOnTimeout($"Values did not match for {m_AuthorityNetVarTest.name}!"); + + // Verify late joined clients synchronize correctly + yield return CreateAndStartNewClient(); + + yield return WaitForSpawnedOnAllOrTimeOut(m_AuthorityNetVarTest.gameObject); + AssertOnTimeout($"Not all clients spawned {m_AuthorityNetVarTest.name}!"); + + yield return WaitForConditionOrTimeOut(SpawnValuesMatch); + AssertOnTimeout($"Values did not match for {m_AuthorityNetVarTest.name}!"); + + // Verify changing the value synchronizes properly + m_AuthorityNetVarTest.TestValueOnSpawn.Value += Random.Range(0.01f, 100.0f); + yield return WaitForConditionOrTimeOut(ChangedValueMatches); + AssertOnTimeout($"Values did not match for {m_AuthorityNetVarTest.name}!"); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableGeneralTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableGeneralTests.cs.meta new file mode 100644 index 0000000000..e744b6506b --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableGeneralTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e5a5c7d93dfa81642a2138c3cc5e3300 \ No newline at end of file From 45c10ab3b93c577c4477827cedaff1dd9819c716 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 10 Feb 2026 16:06:47 -0600 Subject: [PATCH 03/13] update Adding changelog entry. --- com.unity.netcode.gameobjects/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 127203e7d3..8730fc4cb2 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -24,6 +24,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed +- Fixed issue where `NetworkVariable` was not properly synchronizing to changes made by the spawn and write authority during `OnNetworkSpawn` and `OnNetworkPostSpawn`. (#3878) - Fixed issue where `NetworkManager` was not cleaning itself up if an exception was thrown while starting. (#3864) - Prevented a `NullReferenceException` in `UnityTransport` when using a custom `INetworkStreamDriverConstructor` that doesn't use all the default pipelines and the multiplayer tools package is installed. (#3853) From 533fb1cfa893a425cd74af68d799596f1a29b110 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 10 Feb 2026 16:11:09 -0600 Subject: [PATCH 04/13] style Adding comments for clarity of what `OnSpawned` and the call to `PostNetworkVariableWrite` is for. Removing trailing whitespaces. --- .../Runtime/NetworkVariable/NetworkVariable.cs | 5 +++++ .../Runtime/NetworkVariable/NetworkVariableGeneralTests.cs | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs index 90e2d8c67c..9769362a64 100644 --- a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs +++ b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs @@ -408,8 +408,13 @@ internal override void WriteFieldSynchronization(FastBufferWriter writer) } } + /// + /// Notification we have fully spawned the associated . + /// internal override void OnSpawned() { + // Assure any changes made to any NetworkVariable during spawn or post-spawn are + // serialized with the CreateObjectMessage. m_NetworkBehaviour.PostNetworkVariableWrite(true); base.OnSpawned(); } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableGeneralTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableGeneralTests.cs index 7f80881542..b0f3e8be56 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableGeneralTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableGeneralTests.cs @@ -49,7 +49,7 @@ public override void OnNetworkSpawn() } else { - // Only set these values during OnNetworkSpawn to + // Only set these values during OnNetworkSpawn to // verify this value is valid for non-authority instances. OnNetworkSpawnValue = TestValueOnSpawn.Value; OnNetworkPostSpawnValue = TestValueOnPostSpawn.Value; From a0524166ebbcb016dd70484aad2bbf682bb8f07d Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 10 Feb 2026 16:13:53 -0600 Subject: [PATCH 05/13] refactor Just apply the changes to the NetworkVariable instance and not all NetworkVariables of the NetworkBehaviour. --- .../Runtime/NetworkVariable/NetworkVariable.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs index 9769362a64..0fc226b37f 100644 --- a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs +++ b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs @@ -415,7 +415,12 @@ internal override void OnSpawned() { // Assure any changes made to any NetworkVariable during spawn or post-spawn are // serialized with the CreateObjectMessage. - m_NetworkBehaviour.PostNetworkVariableWrite(true); + if (IsDirty() && CanSend()) + { + UpdateLastSentTime(); + ResetDirty(); + SetDirty(false); + } base.OnSpawned(); } } From 722985a797a74136a9dc6d8e06b80b8b959c9ad7 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 10 Feb 2026 16:15:32 -0600 Subject: [PATCH 06/13] style correcting the comment. --- .../Runtime/NetworkVariable/NetworkVariable.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs index 0fc226b37f..9e960e1b36 100644 --- a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs +++ b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs @@ -413,7 +413,7 @@ internal override void WriteFieldSynchronization(FastBufferWriter writer) /// internal override void OnSpawned() { - // Assure any changes made to any NetworkVariable during spawn or post-spawn are + // Assure any changes made to this NetworkVariable during spawn or post-spawn are // serialized with the CreateObjectMessage. if (IsDirty() && CanSend()) { From b70e5b115a1450114b6a4ae5c3d951c323025744 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 10 Feb 2026 19:37:45 -0600 Subject: [PATCH 07/13] fix Very similar to NetworkList (we might collapse the OnSpawned logic into NetworkVariableBase. --- .../NetworkVariable/Collections/NetworkList.cs | 4 ++-- .../Runtime/NetworkVariable/NetworkVariable.cs | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/Collections/NetworkList.cs b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/Collections/NetworkList.cs index 3b2117bf11..2cad57714a 100644 --- a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/Collections/NetworkList.cs +++ b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/Collections/NetworkList.cs @@ -61,17 +61,17 @@ public NetworkList(IEnumerable values = default, internal override void OnSpawned() { // If the NetworkList is: + // - On the spawn authority side. // - Dirty // - State updates can be sent: // -- The instance has write permissions. // -- The last sent time plus the max send time period is less than the current time. // - User script has modified the list during spawn. - // - This instance is on the spawn authority side. // When the NetworkObject is finished spawning (on the same frame), go ahead and reset // the dirty related properties and last sent time to prevent duplicate entries from // being sent (i.e. CreateObjectMessage will contain the changes so we don't need to // send a proceeding NetworkVariableDeltaMessage). - if (IsDirty() && CanSend() && m_NetworkObject.IsSpawnAuthority) + if (m_NetworkObject.IsSpawnAuthority && IsDirty() && CanWrite() && CanSend()) { UpdateLastSentTime(); ResetDirty(); diff --git a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs index 9e960e1b36..397966f2b5 100644 --- a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs +++ b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs @@ -413,9 +413,18 @@ internal override void WriteFieldSynchronization(FastBufferWriter writer) /// internal override void OnSpawned() { - // Assure any changes made to this NetworkVariable during spawn or post-spawn are - // serialized with the CreateObjectMessage. - if (IsDirty() && CanSend()) + // If the NetworkVariable is: + // - On the spawn authority side. + // - Dirty. + // - State updates can be sent: + // -- The instance has write permissions. + // -- The last sent time plus the max send time period is less than the current time. + // - User script has modified the list during spawn. + // When the NetworkObject is finished spawning (on the same frame), go ahead and reset + // the dirty related properties and last sent time to prevent duplicate updates from + // being sent (i.e. CreateObjectMessage will contain the changes so we don't need to + // send a proceeding NetworkVariableDeltaMessage). + if (m_NetworkObject.IsSpawnAuthority && IsDirty() && CanWrite() && CanSend()) { UpdateLastSentTime(); ResetDirty(); From 13942e730f92bd6e80463ea860c74e8be541f5c4 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 10 Feb 2026 19:40:10 -0600 Subject: [PATCH 08/13] test-fix OwnerModifiedTests was not properly spawning from the non-session owner client (it was spawning with ownership on the session owner side). NetworkVariableCollectionTests is showing an issue with changes for only the host instance and only within the TestDictionaryCollections (has to do with trying to add and then reverting vs the tracked changes). --- .../NetworkVariableCollectionsTests.cs | 21 ++++++++++----- .../NetworkVariable/OwnerModifiedTests.cs | 26 ++++++++++--------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs index 4bad45e088..fee041485b 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs @@ -781,6 +781,12 @@ public IEnumerator TestDictionaryCollections() } m_CurrentKey = 1000; + // Temporarily enabling debug mode on host only. + // TODO: Need to track down why host is the only failing test. + // NOTES: It seems the tracked changes get adjusted for only a host which would have the player object. + // This could be due to when the player is spawned on the host relative to the other clients. + m_EnableDebug = m_ServerNetworkManager.IsHost; + m_EnableVerboseDebug = m_ServerNetworkManager.IsHost; if (m_EnableDebug) { VerboseDebug(">>>>>>>>>>>>>>>>>>>>>>>>>>>>> Init Values <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<"); @@ -831,7 +837,7 @@ public IEnumerator TestDictionaryCollections() { // Server-side add same key and SerializableObject prior to being added to the owner side compDictionaryServer.ListCollectionOwner.Value.Add(newEntry.Item1, newEntry.Item2); - // Checking if dirty on server side should revert back to origina known current dictionary state + // Checking if dirty on server side should revert back to original known current dictionary state compDictionaryServer.ListCollectionOwner.IsDirty(); yield return WaitForConditionOrTimeOut(() => compDictionaryServer.CompareTrackedChanges(ListTestHelperBase.Targets.Owner)); AssertOnTimeout($"Server add to owner write collection property failed to restore on {className} {compDictionaryServer.name}! {compDictionaryServer.GetLog()}"); @@ -840,7 +846,6 @@ public IEnumerator TestDictionaryCollections() // Server-side add a completely new key and SerializableObject to to owner write permission property compDictionaryServer.ListCollectionOwner.Value.Add(GetNextKey(), SerializableObject.GetRandomObject()); // Both should be overridden by the owner-side update - } VerboseDebug($"[{compDictionary.name}][Owner] Adding Key: {newEntry.Item1}"); // Add key and SerializableObject to owner side @@ -857,7 +862,7 @@ public IEnumerator TestDictionaryCollections() { // Client-side add same key and SerializableObject to server write permission property compDictionary.ListCollectionServer.Value.Add(newEntry.Item1, newEntry.Item2); - // Checking if dirty on client side should revert back to origina known current dictionary state + // Checking if dirty on client side should revert back to original known current dictionary state compDictionary.ListCollectionServer.IsDirty(); yield return WaitForConditionOrTimeOut(() => compDictionary.CompareTrackedChanges(ListTestHelperBase.Targets.Server)); AssertOnTimeout($"Client-{client.LocalClientId} add to server write collection property failed to restore on {className} {compDictionary.name}! {compDictionary.GetLog()}"); @@ -892,7 +897,6 @@ public IEnumerator TestDictionaryCollections() yield return WaitForConditionOrTimeOut(() => compDictionaryServer.ValidateInstances()); AssertOnTimeout($"[Server] Not all instances of client-{compDictionaryServer.OwnerClientId}'s {className} {compDictionaryServer.name} component match! {compDictionaryServer.GetLog()}"); - ValidateClientsFlat(client); //////////////////////////////////// // Owner Change SerializableObject Entry @@ -915,7 +919,7 @@ public IEnumerator TestDictionaryCollections() { // Server-side update same key value prior to being updated to the owner side compDictionaryServer.ListCollectionOwner.Value[valueInt] = randomObject; - // Checking if dirty on server side should revert back to origina known current dictionary state + // Checking if dirty on server side should revert back to original known current dictionary state compDictionaryServer.ListCollectionOwner.IsDirty(); yield return WaitForConditionOrTimeOut(() => compDictionaryServer.CompareTrackedChanges(ListTestHelperBase.Targets.Owner)); AssertOnTimeout($"Server update collection entry value to local owner write collection property failed to restore on {className} {compDictionaryServer.name}! {compDictionaryServer.GetLog()}"); @@ -956,7 +960,7 @@ public IEnumerator TestDictionaryCollections() { // Owner-side update same key value prior to being updated to the server side compDictionary.ListCollectionServer.Value[valueInt] = randomObject; - // Checking if dirty on owner side should revert back to origina known current dictionary state + // Checking if dirty on owner side should revert back to original known current dictionary state compDictionary.ListCollectionServer.IsDirty(); yield return WaitForConditionOrTimeOut(() => compDictionary.CompareTrackedChanges(ListTestHelperBase.Targets.Server)); AssertOnTimeout($"Client-{client.LocalClientId} update collection entry value to local server write collection property failed to restore on {className} {compDictionary.name}! {compDictionary.GetLog()}"); @@ -1014,6 +1018,9 @@ public IEnumerator TestDictionaryCollections() m_Stage = 0; } } + + m_EnableDebug = false; + m_EnableVerboseDebug = false; } [UnityTest] @@ -1844,7 +1851,7 @@ private bool ChangesMatch(ulong clientId, Dictionary 20); } - - private ChangeValueOnAuthority m_SessionAuthorityInstance; private ChangeValueOnAuthority m_InstanceAuthority; private bool NetworkVariablesMatch(StringBuilder errorLog) { foreach (var networkManager in m_NetworkManagers) { + var changeValue = networkManager.SpawnManager.SpawnedObjects[m_InstanceAuthority.NetworkObjectId].GetComponent(); if (networkManager == m_InstanceAuthority.NetworkManager) { + if (m_InstanceAuthority.SomeIntValue.Value != 2) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] {changeValue.name} value is {changeValue.SomeIntValue.Value} but was expecting 2!"); + } continue; } + if (changeValue.SomeIntValue.Value != 2) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] {changeValue.name} value is {changeValue.SomeIntValue.Value} but was expecting 2!"); + } - var changeValue = networkManager.SpawnManager.SpawnedObjects[m_InstanceAuthority.NetworkObjectId].GetComponent(); if (changeValue.SomeIntValue.Value != m_InstanceAuthority.SomeIntValue.Value) { errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] {changeValue.name} value is {changeValue.SomeIntValue.Value} but was expecting {m_InstanceAuthority.SomeIntValue.Value}!"); @@ -185,19 +191,15 @@ public IEnumerator OwnershipSpawnedAndUpdatedDuringSpawn() { var authority = GetAuthorityNetworkManager(); var nonAuthority = GetNonAuthorityNetworkManager(); - m_SessionAuthorityInstance = Object.Instantiate(m_SpawnObject).GetComponent(); - - SpawnInstanceWithOwnership(m_SessionAuthorityInstance.GetComponent(), authority, nonAuthority.LocalClientId); - yield return WaitForSpawnedOnAllOrTimeOut(m_SessionAuthorityInstance.NetworkObjectId); - AssertOnTimeout($"Failed to spawn {m_SessionAuthorityInstance.name} on all clients!"); + // If running in distributed authority mode, we use the nonauthority (i.e. not SessionOwner) instance to spawn. + var spawnAuthority = m_DistributedAuthority ? nonAuthority : authority; + m_InstanceAuthority = SpawnObject(m_SpawnObject, spawnAuthority).GetComponent(); - m_InstanceAuthority = nonAuthority.SpawnManager.SpawnedObjects[m_SessionAuthorityInstance.NetworkObjectId].GetComponent(); + yield return WaitForSpawnedOnAllOrTimeOut(m_InstanceAuthority.NetworkObjectId); + AssertOnTimeout($"Failed to spawn {m_InstanceAuthority.name} on all clients!"); yield return WaitForConditionOrTimeOut(NetworkVariablesMatch); AssertOnTimeout($"The {nameof(ChangeValueOnAuthority.SomeIntValue)} failed to synchronize on all clients!"); - - Assert.IsTrue(m_SessionAuthorityInstance.SomeIntValue.Value == 2, "No values were updated on the spawn authority instance!"); - Assert.IsTrue(m_InstanceAuthority.SomeIntValue.Value == 2, "No values were updated on the owner's instance!"); } } } From 4cd4bbccc0a76633cb846204f056b123a9450b18 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 10 Feb 2026 23:01:19 -0600 Subject: [PATCH 09/13] test Narrowed down the issue to the changes tracked (i.e. added, removed, changed, unchanged) not matching when running a host but when comparing the actual dictionaries that all passes... --- .../NetworkVariableCollectionsTests.cs | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs index fee041485b..f74030e9f0 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs @@ -780,13 +780,12 @@ public IEnumerator TestDictionaryCollections() m_Clients.Insert(0, m_ServerNetworkManager); } + foreach (var client in m_Clients) + { + client.LogLevel = LogLevel.Developer; + } + m_CurrentKey = 1000; - // Temporarily enabling debug mode on host only. - // TODO: Need to track down why host is the only failing test. - // NOTES: It seems the tracked changes get adjusted for only a host which would have the player object. - // This could be due to when the player is spawned on the host relative to the other clients. - m_EnableDebug = m_ServerNetworkManager.IsHost; - m_EnableVerboseDebug = m_ServerNetworkManager.IsHost; if (m_EnableDebug) { VerboseDebug(">>>>>>>>>>>>>>>>>>>>>>>>>>>>> Init Values <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<"); @@ -857,6 +856,7 @@ public IEnumerator TestDictionaryCollections() ////////////////////////////////// // Server Add SerializableObject Entry newEntry = (GetNextKey(), SerializableObject.GetRandomObject()); + // Only test restore on non-host clients (otherwise a host is both server and client/owner) if (!client.IsServer) { @@ -864,8 +864,30 @@ public IEnumerator TestDictionaryCollections() compDictionary.ListCollectionServer.Value.Add(newEntry.Item1, newEntry.Item2); // Checking if dirty on client side should revert back to original known current dictionary state compDictionary.ListCollectionServer.IsDirty(); - yield return WaitForConditionOrTimeOut(() => compDictionary.CompareTrackedChanges(ListTestHelperBase.Targets.Server)); - AssertOnTimeout($"Client-{client.LocalClientId} add to server write collection property failed to restore on {className} {compDictionary.name}! {compDictionary.GetLog()}"); + if (!m_ServerNetworkManager.IsHost) + { + yield return WaitForConditionOrTimeOut(() => compDictionary.CompareTrackedChanges(ListTestHelperBase.Targets.Server)); + AssertOnTimeout($"Client-{client.LocalClientId} add to server write collection property failed to restore on {className} {compDictionary.name}! {compDictionary.GetLog()}"); + } + else // Host, for some reason, does not have changes tracked but the dictionaries match... ???? + { + // TODO: Need to track down why host is the only failing test. + // NOTES: It seems only the host doesn't track changes made on the owner write permissions (i.e. issue with test itself?), + // but when comparing the values of the dictionaries everything passes (i.e. dictionaries are synchronized) + var compDictionaryTest = (DictionaryTestHelper)null; + var compDictionaryServerTest = (DictionaryTestHelper)null; + var classNameTest = $"{nameof(DictionaryTestHelper)}"; + foreach (var clientTest in m_Clients) + { + /////////////////////////////////////////////////////////////////////////// + // Dictionary> nested dictionaries + compDictionaryTest = clientTest.LocalClient.PlayerObject.GetComponent(); + compDictionaryServerTest = m_PlayerNetworkObjects[NetworkManager.ServerClientId][clientTest.LocalClientId].GetComponent(); + Assert.True(compDictionaryTest.ValidateInstances(), $"[Owner] Not all instances of client-{compDictionaryTest.OwnerClientId}'s {classNameTest} {compDictionaryTest.name} component match! {compDictionaryTest.GetLog()}"); + Assert.True(compDictionaryServerTest.ValidateInstances(), $"[Server] Not all instances of client-{compDictionaryServerTest.OwnerClientId}'s {classNameTest} {compDictionaryServerTest.name} component match! {compDictionaryServerTest.GetLog()}"); + } + } + // Client-side add the same key and SerializableObject to server write permission property (would throw key exists exception too if previous failed) compDictionary.ListCollectionServer.Value.Add(newEntry.Item1, newEntry.Item2); // Client-side add a completely new key and SerializableObject to to server write permission property @@ -1851,7 +1873,7 @@ private bool ChangesMatch(ulong clientId, Dictionary Date: Tue, 10 Feb 2026 23:05:34 -0600 Subject: [PATCH 10/13] test Removing developer logging. --- .../NetworkVariable/NetworkVariableCollectionsTests.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs index f74030e9f0..3a5d279c1c 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs @@ -780,11 +780,6 @@ public IEnumerator TestDictionaryCollections() m_Clients.Insert(0, m_ServerNetworkManager); } - foreach (var client in m_Clients) - { - client.LogLevel = LogLevel.Developer; - } - m_CurrentKey = 1000; if (m_EnableDebug) { From 584541ec30785628ffcd112db8a2348a92589ac3 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 11 Feb 2026 11:31:14 -0600 Subject: [PATCH 11/13] test - fix Fixed the issue in the `TestDictionaryCollections` test where clients that did not have the initial added target changes for server changes when running a host. Now, upon clients spawning players locally on the clients, the server write dictionary that is already populated by the host will have the added target changes injected during spawn to assure the changes match. (This is only for this specific test when running the host `TestFixture` pass. --- .../NetworkVariableCollectionsTests.cs | 54 ++++++++++--------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs index 3a5d279c1c..96dcf3d638 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs @@ -859,29 +859,8 @@ public IEnumerator TestDictionaryCollections() compDictionary.ListCollectionServer.Value.Add(newEntry.Item1, newEntry.Item2); // Checking if dirty on client side should revert back to original known current dictionary state compDictionary.ListCollectionServer.IsDirty(); - if (!m_ServerNetworkManager.IsHost) - { - yield return WaitForConditionOrTimeOut(() => compDictionary.CompareTrackedChanges(ListTestHelperBase.Targets.Server)); - AssertOnTimeout($"Client-{client.LocalClientId} add to server write collection property failed to restore on {className} {compDictionary.name}! {compDictionary.GetLog()}"); - } - else // Host, for some reason, does not have changes tracked but the dictionaries match... ???? - { - // TODO: Need to track down why host is the only failing test. - // NOTES: It seems only the host doesn't track changes made on the owner write permissions (i.e. issue with test itself?), - // but when comparing the values of the dictionaries everything passes (i.e. dictionaries are synchronized) - var compDictionaryTest = (DictionaryTestHelper)null; - var compDictionaryServerTest = (DictionaryTestHelper)null; - var classNameTest = $"{nameof(DictionaryTestHelper)}"; - foreach (var clientTest in m_Clients) - { - /////////////////////////////////////////////////////////////////////////// - // Dictionary> nested dictionaries - compDictionaryTest = clientTest.LocalClient.PlayerObject.GetComponent(); - compDictionaryServerTest = m_PlayerNetworkObjects[NetworkManager.ServerClientId][clientTest.LocalClientId].GetComponent(); - Assert.True(compDictionaryTest.ValidateInstances(), $"[Owner] Not all instances of client-{compDictionaryTest.OwnerClientId}'s {classNameTest} {compDictionaryTest.name} component match! {compDictionaryTest.GetLog()}"); - Assert.True(compDictionaryServerTest.ValidateInstances(), $"[Server] Not all instances of client-{compDictionaryServerTest.OwnerClientId}'s {classNameTest} {compDictionaryServerTest.name} component match! {compDictionaryServerTest.GetLog()}"); - } - } + yield return WaitForConditionOrTimeOut(() => compDictionary.CompareTrackedChanges(ListTestHelperBase.Targets.Server)); + AssertOnTimeout($"Client-{client.LocalClientId} add to server write collection property failed to restore on {className} {compDictionary.name}! {compDictionary.GetLog()}"); // Client-side add the same key and SerializableObject to server write permission property (would throw key exists exception too if previous failed) compDictionary.ListCollectionServer.Value.Add(newEntry.Item1, newEntry.Item2); @@ -1865,7 +1844,7 @@ private bool ChangesMatch(ulong clientId, Dictionary().ToList(); foreach (var deltaType in deltaTypes) { - LogMessage($"Comparing {deltaType}:"); + LogMessage($"[Comparing {deltaType}] Local: {local[deltaType].Count} | Other: {other[deltaType].Count}"); if (local[deltaType].Count != other[deltaType].Count) { LogMessage($"[Client-{clientId}] Local {deltaType}s count of {local[deltaType].Count} did not match the other's count of {other[deltaType].Count}!"); @@ -1994,6 +1973,18 @@ public void TrackChanges(Targets target, Dictionary pre contextTable[DeltaTypes.Removed] = whatWasRemoved; contextTable[DeltaTypes.Changed] = whatChanged; contextTable[DeltaTypes.UnChanged] = whatRemainedTheSame; + + // Log all incoming changes when debug mode is enabled + if (!IsOwner && IsDebugMode) + { + LogMessage($"[{NetworkManager.name}][TrackChanges-> Client-{OwnerClientId}] Collection was updated!"); + LogMessage($"Added: {whatWasAdded.Count} "); + LogMessage($"Removed: {whatWasRemoved.Count} "); + LogMessage($"Changed: {whatChanged.Count} "); + LogMessage($"UnChanged: {whatRemainedTheSame.Count} "); + UnityEngine.Debug.Log($"{GetLog()}"); + LogStart(); + } } public void OnServerListValuesChanged(Dictionary previous, Dictionary current) @@ -2058,13 +2049,24 @@ public void InitValues() if (IsServer) { ListCollectionServer.Value = OnSetServerValues(); - //ListCollectionOwner.CheckDirtyState(); + ListCollectionServer.CheckDirtyState(); } if (IsOwner) { ListCollectionOwner.Value = OnSetOwnerValues(); - //ListCollectionOwner.CheckDirtyState(); + ListCollectionOwner.CheckDirtyState(); + } + + // When running a host, the changes being tracked will not match because clients will be synchronized with changes + // already applied. This fixing this issue by injecting "added" server targeted changes during initialization on + // the connected clients' side. + if (!IsServer) + { + if (ListCollectionServer.Value.Count > 0 && NetworkVariableChanges[Targets.Server][DeltaTypes.Added].Count == 0) + { + TrackChanges(Targets.Server, new Dictionary(), ListCollectionServer.Value); + } } } From 7fd76310b602d1835bdb4e7f7d7c3d59926f105a Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 11 Feb 2026 11:55:36 -0600 Subject: [PATCH 12/13] style removing unused debug related script. --- .../Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs index 96dcf3d638..7e882d0085 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkVariable/NetworkVariableCollectionsTests.cs @@ -1014,9 +1014,6 @@ public IEnumerator TestDictionaryCollections() m_Stage = 0; } } - - m_EnableDebug = false; - m_EnableVerboseDebug = false; } [UnityTest] From e6f4bf29c1e9a7cca80444163438adb1351bea71 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 11 Feb 2026 12:45:15 -0600 Subject: [PATCH 13/13] update Based on peer review, folding the same logic for spawn authority resetting dirty once spawned to assure no duplicate changes are sent. --- .../Runtime/Core/NetworkBehaviour.cs | 4 +-- .../Collections/NetworkList.cs | 22 --------------- .../NetworkVariable/NetworkVariable.cs | 25 ----------------- .../NetworkVariable/NetworkVariableBase.cs | 28 +++++++++++++------ 4 files changed, 21 insertions(+), 58 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs index 81a2aa03a1..3d3c919986 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs @@ -838,7 +838,7 @@ internal void NetworkPostSpawn() // all spawn related methods have been invoked. for (int i = 0; i < NetworkVariableFields.Count; i++) { - NetworkVariableFields[i].OnSpawned(); + NetworkVariableFields[i].InternalOnSpawned(); } } @@ -891,7 +891,7 @@ internal void InternalOnNetworkPreDespawn() // all spawn related methods have been invoked. for (int i = 0; i < NetworkVariableFields.Count; i++) { - NetworkVariableFields[i].OnPreDespawn(); + NetworkVariableFields[i].InternalOnPreDespawn(); } } diff --git a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/Collections/NetworkList.cs b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/Collections/NetworkList.cs index 2cad57714a..18322ee332 100644 --- a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/Collections/NetworkList.cs +++ b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/Collections/NetworkList.cs @@ -58,28 +58,6 @@ public NetworkList(IEnumerable values = default, Dispose(); } - internal override void OnSpawned() - { - // If the NetworkList is: - // - On the spawn authority side. - // - Dirty - // - State updates can be sent: - // -- The instance has write permissions. - // -- The last sent time plus the max send time period is less than the current time. - // - User script has modified the list during spawn. - // When the NetworkObject is finished spawning (on the same frame), go ahead and reset - // the dirty related properties and last sent time to prevent duplicate entries from - // being sent (i.e. CreateObjectMessage will contain the changes so we don't need to - // send a proceeding NetworkVariableDeltaMessage). - if (m_NetworkObject.IsSpawnAuthority && IsDirty() && CanWrite() && CanSend()) - { - UpdateLastSentTime(); - ResetDirty(); - SetDirty(false); - } - base.OnSpawned(); - } - /// public override void ResetDirty() { diff --git a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs index 397966f2b5..e8604a66aa 100644 --- a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs +++ b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariable.cs @@ -407,30 +407,5 @@ internal override void WriteFieldSynchronization(FastBufferWriter writer) base.WriteFieldSynchronization(writer); } } - - /// - /// Notification we have fully spawned the associated . - /// - internal override void OnSpawned() - { - // If the NetworkVariable is: - // - On the spawn authority side. - // - Dirty. - // - State updates can be sent: - // -- The instance has write permissions. - // -- The last sent time plus the max send time period is less than the current time. - // - User script has modified the list during spawn. - // When the NetworkObject is finished spawning (on the same frame), go ahead and reset - // the dirty related properties and last sent time to prevent duplicate updates from - // being sent (i.e. CreateObjectMessage will contain the changes so we don't need to - // send a proceeding NetworkVariableDeltaMessage). - if (m_NetworkObject.IsSpawnAuthority && IsDirty() && CanWrite() && CanSend()) - { - UpdateLastSentTime(); - ResetDirty(); - SetDirty(false); - } - base.OnSpawned(); - } } } diff --git a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariableBase.cs b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariableBase.cs index 5186c7a13f..0c3532208a 100644 --- a/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariableBase.cs +++ b/com.unity.netcode.gameobjects/Runtime/NetworkVariable/NetworkVariableBase.cs @@ -140,27 +140,37 @@ public void Initialize(NetworkBehaviour networkBehaviour) } } - /// TODO-API: After further vetting and alignment on these, we might make them part of the public API. - /// Could actually be like an interface that gets automatically registered for these kinds of notifications - /// without having to be a NetworkBehaviour. - #region OnSpawn and OnPreDespawn (ETC) - /// /// Invoked after the associated has been invoked. /// - internal virtual void OnSpawned() + internal void InternalOnSpawned() { - + // If the NetworkVariableBase derived class is: + // - On the spawn authority side. + // - Dirty. + // - State updates can be sent: + // -- The instance has write permissions. + // -- The last sent time plus the max send time period is less than the current time. + // - User script has modified the list during spawn. + // When the NetworkObject is finished spawning (on the same frame), go ahead and reset + // the dirty related properties and last sent time to prevent duplicate updates from + // being sent (i.e. CreateObjectMessage will contain the changes so we don't need to + // send a proceeding NetworkVariableDeltaMessage). + if (m_NetworkObject.IsSpawnAuthority && IsDirty() && CanWrite() && CanSend()) + { + UpdateLastSentTime(); + ResetDirty(); + SetDirty(false); + } } /// /// Invoked after the associated has been invoked. /// - internal virtual void OnPreDespawn() + internal void InternalOnPreDespawn() { } - #endregion /// /// Deinitialize is invoked when a NetworkObject is despawned.