From 5923887fb07e90eedbc7a56bcaae594305255ca4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:26:02 +0000 Subject: [PATCH 01/48] Initial plan From 1ef0fb782ae863eff4007c143b6963f1c6009dcf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:38:32 +0000 Subject: [PATCH 02/48] Implement host sync visibility deferral --- Assets/Mirror/Core/NetworkBehaviour.cs | 474 +++++++++++------- Assets/Mirror/Core/NetworkClient.cs | 16 +- Assets/Mirror/Core/SyncDictionary.cs | 23 +- Assets/Mirror/Core/SyncList.cs | 21 +- Assets/Mirror/Core/SyncSet.cs | 20 +- .../Processors/NetworkBehaviourProcessor.cs | 78 ++- .../Processors/SyncVarAttributeProcessor.cs | 104 +++- Assets/Mirror/Editor/Weaver/WeaverTypes.cs | 16 + ...SyncCollectionActionTest_HostVisibility.cs | 134 +++++ ...SyncVarAttributeHook_HostVisibilityTest.cs | 132 +++++ .../SyncVars/SyncVarHookDeferralTests.cs | 25 +- 11 files changed, 767 insertions(+), 276 deletions(-) create mode 100644 Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs create mode 100644 Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index 1f885f0176b..ff7c20a7f39 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -164,15 +164,33 @@ public bool authority ulong syncVarHookGuard; // Queue for deferred SyncVar hooks during initial spawn. - // Only used on pure client (not host mode) when isSpawnFinished = false. - // Hooks are queued during deserialization and invoked in OnObjectSpawnFinished. + // Used on pure client when isSpawnFinished = false. + // Hooks are queued during deserialization and invoked once the object is visible. internal readonly List deferredSyncVarHooks = new List(); - // Queue for deferred SyncCollection Actions during initial spawn. - // Only used on pure client (not host mode) when isSpawnFinished = false. - // Actions are queued during deserialization and invoked in OnObjectSpawnFinished. + // Queue for deferred SyncCollection Actions during initial spawn / host visibility changes. + // Actions are queued while the object isn't visible to the local client and invoked once it is. internal readonly List deferredSyncCollectionActions = new List(); + protected bool IsHostClientObserved() => + NetworkServer.activeHost && + NetworkServer.localConnection != null && + NetworkClient.localPlayer != null && + netIdentity.observers.ContainsKey(NetworkServer.localConnection.connectionId); + + internal void InvokeDeferredSyncCallbacks() + { + foreach (Action hook in deferredSyncVarHooks) + hook?.Invoke(); + + deferredSyncVarHooks.Clear(); + + foreach (Action action in deferredSyncCollectionActions) + action?.Invoke(); + + deferredSyncCollectionActions.Clear(); + } + protected virtual void OnValidate() { // Skip if Editor is in Play mode @@ -571,15 +589,8 @@ public void GeneratedSyncVarSetter(T value, ref T field, ulong dirtyBit, Acti T oldValue = field; SetSyncVar(value, ref field, dirtyBit); - // call hook (if any) if (OnChanged != null) { - // in host mode, setting a SyncVar calls the hook directly. - // in client-only mode, OnDeserialize would call it. - // we use hook guard to protect against deadlock where hook - // changes syncvar, calling hook again. - // IMPORTANT: only call hook if object is visible to host client (in NetworkClient.spawned). - // This prevents hooks from firing at spawn for objects out of AOI range. if (NetworkServer.activeHost && !GetSyncVarHookGuard(dirtyBit) && NetworkClient.spawned.ContainsKey(netIdentity.netId)) { SetSyncVarHookGuard(dirtyBit, true); @@ -590,6 +601,31 @@ public void GeneratedSyncVarSetter(T value, ref T field, ulong dirtyBit, Acti } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, Action OnChanged, ref T originalValue, ref bool originalValueSet) + { + if (!SyncVarEqual(value, ref field)) + { + T previous = field; + SetSyncVar(value, ref field, dirtyBit); + + if (OnChanged != null && NetworkServer.activeHost && !GetSyncVarHookGuard(dirtyBit)) + { + if (IsHostClientObserved()) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(previous, value); + SetSyncVarHookGuard(dirtyBit, false); + } + else if (!originalValueSet) + { + originalValue = previous; + originalValueSet = true; + } + } + } + } + // GameObject needs custom handling for persistence via netId. // has one extra parameter. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -600,15 +636,8 @@ public void GeneratedSyncVarSetter_GameObject(GameObject value, ref GameObject f GameObject oldValue = field; SetSyncVarGameObject(value, ref field, dirtyBit, ref netIdField); - // call hook (if any) if (OnChanged != null) { - // in host mode, setting a SyncVar calls the hook directly. - // in client-only mode, OnDeserialize would call it. - // we use hook guard to protect against deadlock where hook - // changes syncvar, calling hook again. - // IMPORTANT: only call hook if object is visible to host client (in NetworkClient.spawned). - // This prevents hooks from firing at spawn for objects out of AOI range. if (NetworkServer.activeHost && !GetSyncVarHookGuard(dirtyBit) && NetworkClient.spawned.ContainsKey(netIdentity.netId)) { SetSyncVarHookGuard(dirtyBit, true); @@ -619,6 +648,31 @@ public void GeneratedSyncVarSetter_GameObject(GameObject value, ref GameObject f } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObject field, ulong dirtyBit, Action OnChanged, ref uint netIdField, ref GameObject originalValue, ref bool originalValueSet) + { + if (!SyncVarGameObjectEqual(value, netIdField)) + { + GameObject previous = field; + SetSyncVarGameObject(value, ref field, dirtyBit, ref netIdField); + + if (OnChanged != null && NetworkServer.activeHost && !GetSyncVarHookGuard(dirtyBit)) + { + if (IsHostClientObserved()) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(previous, value); + SetSyncVarHookGuard(dirtyBit, false); + } + else if (!originalValueSet) + { + originalValue = previous; + originalValueSet = true; + } + } + } + } + // NetworkIdentity needs custom handling for persistence via netId. // has one extra parameter. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -629,15 +683,8 @@ public void GeneratedSyncVarSetter_NetworkIdentity(NetworkIdentity value, ref Ne NetworkIdentity oldValue = field; SetSyncVarNetworkIdentity(value, ref field, dirtyBit, ref netIdField); - // call hook (if any) if (OnChanged != null) { - // in host mode, setting a SyncVar calls the hook directly. - // in client-only mode, OnDeserialize would call it. - // we use hook guard to protect against deadlock where hook - // changes syncvar, calling hook again. - // IMPORTANT: only call hook if object is visible to host client (in NetworkClient.spawned). - // This prevents hooks from firing at spawn for objects out of AOI range. if (NetworkServer.activeHost && !GetSyncVarHookGuard(dirtyBit) && NetworkClient.spawned.ContainsKey(netIdentity.netId)) { SetSyncVarHookGuard(dirtyBit, true); @@ -648,6 +695,31 @@ public void GeneratedSyncVarSetter_NetworkIdentity(NetworkIdentity value, ref Ne } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, ref NetworkIdentity field, ulong dirtyBit, Action OnChanged, ref uint netIdField, ref NetworkIdentity originalValue, ref bool originalValueSet) + { + if (!SyncVarNetworkIdentityEqual(value, netIdField)) + { + NetworkIdentity previous = field; + SetSyncVarNetworkIdentity(value, ref field, dirtyBit, ref netIdField); + + if (OnChanged != null && NetworkServer.activeHost && !GetSyncVarHookGuard(dirtyBit)) + { + if (IsHostClientObserved()) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(previous, value); + SetSyncVarHookGuard(dirtyBit, false); + } + else if (!originalValueSet) + { + originalValue = previous; + originalValueSet = true; + } + } + } + } + // NetworkBehaviour needs custom handling for persistence via netId. // has one extra parameter. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -659,15 +731,8 @@ public void GeneratedSyncVarSetter_NetworkBehaviour(T value, ref T field, ulo T oldValue = field; SetSyncVarNetworkBehaviour(value, ref field, dirtyBit, ref netIdField); - // call hook (if any) if (OnChanged != null) { - // in host mode, setting a SyncVar calls the hook directly. - // in client-only mode, OnDeserialize would call it. - // we use hook guard to protect against deadlock where hook - // changes syncvar, calling hook again. - // IMPORTANT: only call hook if object is visible to host client (in NetworkClient.spawned). - // This prevents hooks from firing at spawn for objects out of AOI range. if (NetworkServer.activeHost && !GetSyncVarHookGuard(dirtyBit) && NetworkClient.spawned.ContainsKey(netIdentity.netId)) { SetSyncVarHookGuard(dirtyBit, true); @@ -678,6 +743,32 @@ public void GeneratedSyncVarSetter_NetworkBehaviour(T value, ref T field, ulo } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field, ulong dirtyBit, Action OnChanged, ref NetworkBehaviourSyncVar netIdField, ref T originalValue, ref bool originalValueSet) + where T : NetworkBehaviour + { + if (!SyncVarNetworkBehaviourEqual(value, netIdField)) + { + T previous = field; + SetSyncVarNetworkBehaviour(value, ref field, dirtyBit, ref netIdField); + + if (OnChanged != null && NetworkServer.activeHost && !GetSyncVarHookGuard(dirtyBit)) + { + if (IsHostClientObserved()) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(previous, value); + SetSyncVarHookGuard(dirtyBit, false); + } + else if (!originalValueSet) + { + originalValue = previous; + originalValueSet = true; + } + } + } + } + // helper function for [SyncVar] GameObjects. // needs to be public so that tests & NetworkBehaviours from other // assemblies both find it @@ -810,96 +901,76 @@ public static bool SyncVarNetworkIdentityEqual(NetworkIdentity newIdentity, uint // GeneratedSyncVarDeserialize(reader, ref health, null, reader.ReadInt()); // } // } + public void GeneratedSyncVarDeserialize(ref T field, Action OnChanged, T value) { T previous = field; field = value; - // any hook? then call if changed. - // in host mode initial spawn, also call hook even if value hasn't changed, - // because the field was already set on server but hook wasn't called yet. if (OnChanged != null) { bool changed = !SyncVarEqual(previous, ref field); bool hostInitialSpawnInHostMode = NetworkServer.activeHost && netIdentity.hostInitialSpawn; if (changed || hostInitialSpawnInHostMode) { - // Defer hooks during initial spawn on pure client to eliminate - // cross-object reference race conditions. All objects will be in - // NetworkClient.spawned before any hooks fire. if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) { - // Capture values in closure for deferred execution T capturedPrevious = previous; T capturedNew = field; deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); } else { - // Normal: invoke immediately (host mode, server, or after spawn finished) OnChanged(previous, field); } } } } - // move the [SyncVar] generated OnDeserialize C# to avoid much IL. - // - // before: - // public override void DeserializeSyncVars(NetworkReader reader, bool initialState) - // { - // base.DeserializeSyncVars(reader, initialState); - // if (initialState) - // { - // uint __targetNetId = ___targetNetId; - // GameObject networktarget = Networktarget; - // ___targetNetId = reader.ReadUInt(); - // if (!NetworkBehaviour.SyncVarEqual(__targetNetId, ref ___targetNetId)) - // { - // OnChangedNB(networktarget, Networktarget); - // } - // return; - // } - // long num = (long)reader.ReadULong(); - // if ((num & 1L) != 0L) - // { - // uint __targetNetId2 = ___targetNetId; - // GameObject networktarget2 = Networktarget; - // ___targetNetId = reader.ReadUInt(); - // if (!NetworkBehaviour.SyncVarEqual(__targetNetId2, ref ___targetNetId)) - // { - // OnChangedNB(networktarget2, Networktarget); - // } - // } - // } - // - // after: - // public override void DeserializeSyncVars(NetworkReader reader, bool initialState) - // { - // base.DeserializeSyncVars(reader, initialState); - // if (initialState) - // { - // GeneratedSyncVarDeserialize_GameObject(reader, ref target, OnChangedNB, ref ___targetNetId); - // return; - // } - // long num = (long)reader.ReadULong(); - // if ((num & 1L) != 0L) - // { - // GeneratedSyncVarDeserialize_GameObject(reader, ref target, OnChangedNB, ref ___targetNetId); - // } - // } + public void GeneratedSyncVarDeserialize_Hook(ref T field, Action OnChanged, T value, ulong dirtyBit, ref T originalValue, ref bool originalValueSet) + { + T previous = field; + field = value; + + if (OnChanged != null) + { + if (NetworkServer.activeHost && netIdentity.hostInitialSpawn) + { + if (originalValueSet) + { + if (!SyncVarEqual(originalValue, ref field)) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(originalValue, field); + SetSyncVarHookGuard(dirtyBit, false); + } + + originalValueSet = false; + } + } + else if (!SyncVarEqual(previous, ref field)) + { + if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) + { + T capturedPrevious = previous; + T capturedNew = field; + deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); + } + else + { + OnChanged(previous, field); + } + } + } + } + public void GeneratedSyncVarDeserialize_GameObject(ref GameObject field, Action OnChanged, NetworkReader reader, ref uint netIdField) { uint previousNetId = netIdField; GameObject previousGameObject = field; netIdField = reader.ReadUInt(); - - // get the new GameObject now that netId field is set field = GetSyncVarGameObject(netIdField, ref field); - // any hook? then call if changed. - // in host mode initial spawn, also call hook even if value hasn't changed, - // because the field was already set on server but hook wasn't called yet. if (OnChanged != null) { bool changed = !SyncVarEqual(previousNetId, ref netIdField); @@ -920,64 +991,52 @@ public void GeneratedSyncVarDeserialize_GameObject(ref GameObject field, Action< } } - // move the [SyncVar] generated OnDeserialize C# to avoid much IL. - // - // before: - // public override void DeserializeSyncVars(NetworkReader reader, bool initialState) - // { - // base.DeserializeSyncVars(reader, initialState); - // if (initialState) - // { - // uint __targetNetId = ___targetNetId; - // NetworkIdentity networktarget = Networktarget; - // ___targetNetId = reader.ReadUInt(); - // if (!NetworkBehaviour.SyncVarEqual(__targetNetId, ref ___targetNetId)) - // { - // OnChangedNI(networktarget, Networktarget); - // } - // return; - // } - // long num = (long)reader.ReadULong(); - // if ((num & 1L) != 0L) - // { - // uint __targetNetId2 = ___targetNetId; - // NetworkIdentity networktarget2 = Networktarget; - // ___targetNetId = reader.ReadUInt(); - // if (!NetworkBehaviour.SyncVarEqual(__targetNetId2, ref ___targetNetId)) - // { - // OnChangedNI(networktarget2, Networktarget); - // } - // } - // } - // - // after: - // - // public override void DeserializeSyncVars(NetworkReader reader, bool initialState) - // { - // base.DeserializeSyncVars(reader, initialState); - // if (initialState) - // { - // GeneratedSyncVarDeserialize_NetworkIdentity(reader, ref target, OnChangedNI, ref ___targetNetId); - // return; - // } - // long num = (long)reader.ReadULong(); - // if ((num & 1L) != 0L) - // { - // GeneratedSyncVarDeserialize_NetworkIdentity(reader, ref target, OnChangedNI, ref ___targetNetId); - // } - // } + public void GeneratedSyncVarDeserialize_GameObject_Hook(ref GameObject field, Action OnChanged, NetworkReader reader, ulong dirtyBit, ref uint netIdField, ref GameObject originalValue, ref bool originalValueSet) + { + uint previousNetId = netIdField; + GameObject previousGameObject = field; + netIdField = reader.ReadUInt(); + field = GetSyncVarGameObject(netIdField, ref field); + + if (OnChanged != null) + { + if (NetworkServer.activeHost && netIdentity.hostInitialSpawn) + { + if (originalValueSet) + { + if (!SyncVarEqual(originalValue, ref field)) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(originalValue, field); + SetSyncVarHookGuard(dirtyBit, false); + } + + originalValueSet = false; + } + } + else if (!SyncVarEqual(previousNetId, ref netIdField)) + { + if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) + { + GameObject capturedPrevious = previousGameObject; + GameObject capturedNew = field; + deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); + } + else + { + OnChanged(previousGameObject, field); + } + } + } + } + public void GeneratedSyncVarDeserialize_NetworkIdentity(ref NetworkIdentity field, Action OnChanged, NetworkReader reader, ref uint netIdField) { uint previousNetId = netIdField; NetworkIdentity previousIdentity = field; netIdField = reader.ReadUInt(); - - // get the new NetworkIdentity now that netId field is set field = GetSyncVarNetworkIdentity(netIdField, ref field); - // any hook? then call if changed. - // in host mode initial spawn, also call hook even if value hasn't changed, - // because the field was already set on server but hook wasn't called yet. if (OnChanged != null) { bool changed = !SyncVarEqual(previousNetId, ref netIdField); @@ -998,66 +1057,53 @@ public void GeneratedSyncVarDeserialize_NetworkIdentity(ref NetworkIdentity fiel } } - // move the [SyncVar] generated OnDeserialize C# to avoid much IL. - // - // before: - // - // public override void DeserializeSyncVars(NetworkReader reader, bool initialState) - // { - // base.DeserializeSyncVars(reader, initialState); - // if (initialState) - // { - // NetworkBehaviourSyncVar __targetNetId = ___targetNetId; - // Tank networktarget = Networktarget; - // ___targetNetId = reader.ReadNetworkBehaviourSyncVar(); - // if (!NetworkBehaviour.SyncVarEqual(__targetNetId, ref ___targetNetId)) - // { - // OnChangedNB(networktarget, Networktarget); - // } - // return; - // } - // long num = (long)reader.ReadULong(); - // if ((num & 1L) != 0L) - // { - // NetworkBehaviourSyncVar __targetNetId2 = ___targetNetId; - // Tank networktarget2 = Networktarget; - // ___targetNetId = reader.ReadNetworkBehaviourSyncVar(); - // if (!NetworkBehaviour.SyncVarEqual(__targetNetId2, ref ___targetNetId)) - // { - // OnChangedNB(networktarget2, Networktarget); - // } - // } - // } - // - // after: - // - // public override void DeserializeSyncVars(NetworkReader reader, bool initialState) - // { - // base.DeserializeSyncVars(reader, initialState); - // if (initialState) - // { - // GeneratedSyncVarDeserialize_NetworkBehaviour(reader, ref target, OnChangedNB, ref ___targetNetId); - // return; - // } - // long num = (long)reader.ReadULong(); - // if ((num & 1L) != 0L) - // { - // GeneratedSyncVarDeserialize_NetworkBehaviour(reader, ref target, OnChangedNB, ref ___targetNetId); - // } - // } + public void GeneratedSyncVarDeserialize_NetworkIdentity_Hook(ref NetworkIdentity field, Action OnChanged, NetworkReader reader, ulong dirtyBit, ref uint netIdField, ref NetworkIdentity originalValue, ref bool originalValueSet) + { + uint previousNetId = netIdField; + NetworkIdentity previousIdentity = field; + netIdField = reader.ReadUInt(); + field = GetSyncVarNetworkIdentity(netIdField, ref field); + + if (OnChanged != null) + { + if (NetworkServer.activeHost && netIdentity.hostInitialSpawn) + { + if (originalValueSet) + { + if (!SyncVarEqual(originalValue, ref field)) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(originalValue, field); + SetSyncVarHookGuard(dirtyBit, false); + } + + originalValueSet = false; + } + } + else if (!SyncVarEqual(previousNetId, ref netIdField)) + { + if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) + { + NetworkIdentity capturedPrevious = previousIdentity; + NetworkIdentity capturedNew = field; + deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); + } + else + { + OnChanged(previousIdentity, field); + } + } + } + } + public void GeneratedSyncVarDeserialize_NetworkBehaviour(ref T field, Action OnChanged, NetworkReader reader, ref NetworkBehaviourSyncVar netIdField) where T : NetworkBehaviour { NetworkBehaviourSyncVar previousNetId = netIdField; T previousBehaviour = field; netIdField = reader.ReadNetworkBehaviourSyncVar(); - - // get the new NetworkBehaviour now that netId field is set field = GetSyncVarNetworkBehaviour(netIdField, ref field); - // any hook? then call if changed. - // in host mode initial spawn, also call hook even if value hasn't changed, - // because the field was already set on server but hook wasn't called yet. if (OnChanged != null) { bool changed = !SyncVarEqual(previousNetId, ref netIdField); @@ -1078,6 +1124,46 @@ public void GeneratedSyncVarDeserialize_NetworkBehaviour(ref T field, Action< } } + public void GeneratedSyncVarDeserialize_NetworkBehaviour_Hook(ref T field, Action OnChanged, NetworkReader reader, ulong dirtyBit, ref NetworkBehaviourSyncVar netIdField, ref T originalValue, ref bool originalValueSet) + where T : NetworkBehaviour + { + NetworkBehaviourSyncVar previousNetId = netIdField; + T previousBehaviour = field; + netIdField = reader.ReadNetworkBehaviourSyncVar(); + field = GetSyncVarNetworkBehaviour(netIdField, ref field); + + if (OnChanged != null) + { + if (NetworkServer.activeHost && netIdentity.hostInitialSpawn) + { + if (originalValueSet) + { + if (!SyncVarEqual(originalValue, ref field)) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(originalValue, field); + SetSyncVarHookGuard(dirtyBit, false); + } + + originalValueSet = false; + } + } + else if (!SyncVarEqual(previousNetId, ref netIdField)) + { + if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) + { + T capturedPrevious = previousBehaviour; + T capturedNew = field; + deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); + } + else + { + OnChanged(previousBehaviour, field); + } + } + } + } + // helper function for [SyncVar] NetworkIdentities. // dirtyBit is a mask like 00010 protected void SetSyncVarNetworkIdentity(NetworkIdentity newIdentity, ref NetworkIdentity identityField, ulong dirtyBit, ref uint netIdField) diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index fc77d7941ad..095073e8e22 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -1368,18 +1368,7 @@ internal static void OnObjectSpawnFinished(ObjectSpawnFinishedMessage _) // This ensures all objects are in spawned dictionary (cross-references work) // and hooks fire in declaration order, before user OnStartClient logic runs. foreach (NetworkBehaviour comp in identity.NetworkBehaviours) - { - foreach (Action hook in comp.deferredSyncVarHooks) - hook?.Invoke(); - - comp.deferredSyncVarHooks.Clear(); - - // Invoke deferred SyncCollection Actions AFTER SyncVar hooks - foreach (Action action in comp.deferredSyncCollectionActions) - action?.Invoke(); - - comp.deferredSyncCollectionActions.Clear(); - } + comp.InvokeDeferredSyncCallbacks(); BootstrapIdentity(identity); } @@ -1442,6 +1431,9 @@ internal static void OnHostClientSpawn(SpawnMessage message) // Clear flag after deserialization identity.hostInitialSpawn = false; + foreach (NetworkBehaviour comp in identity.NetworkBehaviours) + comp.InvokeDeferredSyncCallbacks(); + // Invoke callbacks after deserializing InvokeIdentityCallbacks(identity); } diff --git a/Assets/Mirror/Core/SyncDictionary.cs b/Assets/Mirror/Core/SyncDictionary.cs index f9555715beb..c2bf7ed2ee0 100644 --- a/Assets/Mirror/Core/SyncDictionary.cs +++ b/Assets/Mirror/Core/SyncDictionary.cs @@ -359,17 +359,24 @@ void AddOperation(Operation op, TKey key, TValue item, TValue oldItem, bool chec bool hostInitialSpawnInHostMode = NetworkServer.activeHost && networkBehaviour.netIdentity.hostInitialSpawn; bool shouldFireActions = shouldApplyChanges || hostInitialSpawnInHostMode; - // IMPORTANT: For ServerToClient mode, only fire Actions if object is visible to host client - // This prevents Actions from firing at spawn for objects out of AOI range - if (shouldFireActions && NetworkServer.activeHost && networkBehaviour.syncDirection == SyncDirection.ServerToClient) - { - shouldFireActions = NetworkClient.spawned.ContainsKey(networkBehaviour.netIdentity.netId); - } - if (shouldFireActions) { + if (NetworkServer.activeHost && + networkBehaviour.syncDirection == SyncDirection.ServerToClient && + !networkBehaviour.IsHostClientObserved()) + { + Operation capturedOp = op; + TKey capturedKey = key; + TValue capturedOld = oldItem; + TValue capturedNew = newItem; + + networkBehaviour.deferredSyncCollectionActions.Add(() => + InvokeActions(capturedOp, capturedKey, capturedOld, capturedNew)); + return; + } + // Defer Actions during initial spawn on pure client to eliminate - // cross-object reference race conditions. All objects will be in + // cross-object reference race conditions. All objects will be in // NetworkClient.spawned before any Actions fire. if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) { diff --git a/Assets/Mirror/Core/SyncList.cs b/Assets/Mirror/Core/SyncList.cs index eec88f13e86..3f58f0ed284 100644 --- a/Assets/Mirror/Core/SyncList.cs +++ b/Assets/Mirror/Core/SyncList.cs @@ -120,15 +120,22 @@ void AddOperation(Operation op, int itemIndex, T oldItem, T newItem, bool checkA bool hostInitialSpawnInHostMode = NetworkServer.activeHost && networkBehaviour.netIdentity.hostInitialSpawn; bool shouldFireActions = shouldApplyChanges || hostInitialSpawnInHostMode; - // IMPORTANT: For ServerToClient mode, only fire Actions if object is visible to host client - // This prevents Actions from firing at spawn for objects out of AOI range - if (shouldFireActions && NetworkServer.activeHost && networkBehaviour.syncDirection == SyncDirection.ServerToClient) - { - shouldFireActions = NetworkClient.spawned.ContainsKey(networkBehaviour.netIdentity.netId); - } - if (shouldFireActions) { + if (NetworkServer.activeHost && + networkBehaviour.syncDirection == SyncDirection.ServerToClient && + !networkBehaviour.IsHostClientObserved()) + { + Operation capturedOp = op; + int capturedIndex = itemIndex; + T capturedOld = oldItem; + T capturedNew = newItem; + + networkBehaviour.deferredSyncCollectionActions.Add(() => + InvokeActions(capturedOp, capturedIndex, capturedOld, capturedNew)); + return; + } + // Defer Actions during initial spawn on pure client to eliminate // cross-object reference race conditions. All objects will be in // NetworkClient.spawned before any Actions fire. diff --git a/Assets/Mirror/Core/SyncSet.cs b/Assets/Mirror/Core/SyncSet.cs index a098e1164d8..2148a9d1179 100644 --- a/Assets/Mirror/Core/SyncSet.cs +++ b/Assets/Mirror/Core/SyncSet.cs @@ -112,15 +112,21 @@ void AddOperation(Operation op, T oldItem, T newItem, bool checkAccess, bool sho bool hostInitialSpawnInHostMode = NetworkServer.activeHost && networkBehaviour.netIdentity.hostInitialSpawn; bool shouldFireActions = shouldApplyChanges || hostInitialSpawnInHostMode; - // IMPORTANT: For ServerToClient mode, only fire Actions if object is visible to host client - // This prevents Actions from firing at spawn for objects out of AOI range - if (shouldFireActions && NetworkServer.activeHost && networkBehaviour.syncDirection == SyncDirection.ServerToClient) - { - shouldFireActions = NetworkClient.spawned.ContainsKey(networkBehaviour.netIdentity.netId); - } - if (shouldFireActions) { + if (NetworkServer.activeHost && + networkBehaviour.syncDirection == SyncDirection.ServerToClient && + !networkBehaviour.IsHostClientObserved()) + { + Operation capturedOp = op; + T capturedOld = oldItem; + T capturedNew = newItem; + + networkBehaviour.deferredSyncCollectionActions.Add(() => + InvokeActions(capturedOp, capturedOld, capturedNew)); + return; + } + // Defer Actions during initial spawn on pure client to eliminate // cross-object reference race conditions. All objects will be in // NetworkClient.spawned before any Actions fire. diff --git a/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs b/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs index 47f1b940487..0f89e9dec31 100644 --- a/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs +++ b/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs @@ -28,7 +28,7 @@ class NetworkBehaviourProcessor Dictionary syncVarNetIds = new Dictionary(); // - Every syncvar with a hook has a new field created to store the Action delegate so we don't allocate on every hook invocation // This dictionary maps each syncvar field to the field that will store the hook method delegate instance, and the method from which the delegate instance is constructed from - Dictionary syncVarHookDelegates = new Dictionary(); + Dictionary syncVarHookDelegates = new Dictionary(); readonly List commands = new List(); readonly List clientRpcs = new List(); readonly List targetRpcs = new List(); @@ -353,11 +353,11 @@ void InjectIntoInstanceConstructor(ref bool WeavingFailed) } // initialize all delegate fields in ctor - foreach(KeyValuePair entry in syncVarHookDelegates) + foreach (KeyValuePair entry in syncVarHookDelegates) { FieldDefinition syncVarField = entry.Key; - (FieldDefinition hookDelegate, MethodDefinition hookMethod) = entry.Value; - syncVarAttributeProcessor.GenerateSyncVarHookDelegateInitializer(ctorWorker, syncVarField, hookDelegate, hookMethod); + SyncVarAttributeProcessor.SyncVarHookData hookData = entry.Value; + syncVarAttributeProcessor.GenerateSyncVarHookDelegateInitializer(ctorWorker, syncVarField, hookData.hookDelegateField, hookData.hookMethod); } // add final 'Ret' instruction to ctor @@ -576,7 +576,7 @@ void GenerateSerialization(ref bool WeavingFailed) netBehaviourSubclass.Methods.Add(serialize); } - void DeserializeField(FieldDefinition syncVar, ILProcessor worker, ref bool WeavingFailed) + void DeserializeField(FieldDefinition syncVar, ILProcessor worker, long dirtyBit, ref bool WeavingFailed) { // put 'this.' onto stack for 'this.syncvar' below worker.Append(worker.Create(OpCodes.Ldarg_0)); @@ -596,7 +596,7 @@ void DeserializeField(FieldDefinition syncVar, ILProcessor worker, ref bool Weav // If a hook exists, then we need to load the hook delegate on the stack // The hook delegate is created once in the constructor and stored in an instance field // We load the delegate from this instance field to avoid instantiating a new delegate instance every time (drastically reduces allocations) - if(syncVarHookDelegates.TryGetValue(syncVar, out (FieldDefinition hookDelegateField, MethodDefinition) value)) + if (syncVarHookDelegates.TryGetValue(syncVar, out SyncVarAttributeProcessor.SyncVarHookData value)) { // A hook exists. Push this.hookDelegateField onto the stack worker.Emit(OpCodes.Ldarg_0); @@ -611,6 +611,8 @@ void DeserializeField(FieldDefinition syncVar, ILProcessor worker, ref bool Weav // call GeneratedSyncVarDeserialize. // special cases for GameObject/NetworkIdentity/NetworkBehaviour // passing netId too for persistence. + bool hasHook = syncVarHookDelegates.TryGetValue(syncVar, out SyncVarAttributeProcessor.SyncVarHookData hookData); + if (syncVar.FieldType.Is()) { // reader @@ -618,9 +620,23 @@ void DeserializeField(FieldDefinition syncVar, ILProcessor worker, ref bool Weav // GameObject setter needs one more parameter: netId field ref FieldDefinition netIdField = syncVarNetIds[syncVar]; + if (hasHook) + worker.Emit(OpCodes.Ldc_I8, dirtyBit); worker.Emit(OpCodes.Ldarg_0); worker.Emit(OpCodes.Ldflda, netIdField); - worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarDeserialize_GameObject); + + if (hasHook) + { + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarDeserialize_GameObject_Hook); + } + else + { + worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarDeserialize_GameObject); + } } else if (syncVar.FieldType.Is()) { @@ -629,9 +645,23 @@ void DeserializeField(FieldDefinition syncVar, ILProcessor worker, ref bool Weav // NetworkIdentity deserialize needs one more parameter: netId field ref FieldDefinition netIdField = syncVarNetIds[syncVar]; + if (hasHook) + worker.Emit(OpCodes.Ldc_I8, dirtyBit); worker.Emit(OpCodes.Ldarg_0); worker.Emit(OpCodes.Ldflda, netIdField); - worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarDeserialize_NetworkIdentity); + + if (hasHook) + { + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarDeserialize_NetworkIdentity_Hook); + } + else + { + worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarDeserialize_NetworkIdentity); + } } // handle both NetworkBehaviour and inheritors. // fixes: https://github.com/MirrorNetworking/Mirror/issues/2939 @@ -643,10 +673,20 @@ void DeserializeField(FieldDefinition syncVar, ILProcessor worker, ref bool Weav // NetworkIdentity deserialize needs one more parameter: netId field ref // (actually its a NetworkBehaviourSyncVar type) FieldDefinition netIdField = syncVarNetIds[syncVar]; + if (hasHook) + worker.Emit(OpCodes.Ldc_I8, dirtyBit); worker.Emit(OpCodes.Ldarg_0); worker.Emit(OpCodes.Ldflda, netIdField); - // make generic version of GeneratedSyncVarSetter_NetworkBehaviour - MethodReference getFunc = weaverTypes.generatedSyncVarDeserialize_NetworkBehaviour_T.MakeGeneric(assembly.MainModule, syncVar.FieldType); + + if (hasHook) + { + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + } + + MethodReference getFunc = (hasHook ? weaverTypes.generatedSyncVarDeserialize_NetworkBehaviour_Hook_T : weaverTypes.generatedSyncVarDeserialize_NetworkBehaviour_T).MakeGeneric(assembly.MainModule, syncVar.FieldType); worker.Emit(OpCodes.Call, getFunc); } else @@ -667,8 +707,16 @@ void DeserializeField(FieldDefinition syncVar, ILProcessor worker, ref bool Weav // reader.Read() worker.Emit(OpCodes.Call, readFunc); - // make generic version of GeneratedSyncVarDeserialize - MethodReference generic = weaverTypes.generatedSyncVarDeserialize.MakeGeneric(assembly.MainModule, syncVar.FieldType); + if (hasHook) + { + worker.Emit(OpCodes.Ldc_I8, dirtyBit); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + } + + MethodReference generic = (hasHook ? weaverTypes.generatedSyncVarDeserialize_Hook : weaverTypes.generatedSyncVarDeserialize).MakeGeneric(assembly.MainModule, syncVar.FieldType); worker.Emit(OpCodes.Call, generic); } } @@ -715,9 +763,11 @@ void GenerateDeSerialization(ref bool WeavingFailed) serWorker.Append(serWorker.Create(OpCodes.Ldarg_2)); serWorker.Append(serWorker.Create(OpCodes.Brfalse, initialStateLabel)); + int initialDirtyBit = syncVarAccessLists.GetSyncVarStart(netBehaviourSubclass.BaseType.FullName); foreach (FieldDefinition syncVar in syncVars) { - DeserializeField(syncVar, serWorker, ref WeavingFailed); + DeserializeField(syncVar, serWorker, 1L << initialDirtyBit, ref WeavingFailed); + initialDirtyBit += 1; } serWorker.Append(serWorker.Create(OpCodes.Ret)); @@ -743,7 +793,7 @@ void GenerateDeSerialization(ref bool WeavingFailed) serWorker.Append(serWorker.Create(OpCodes.And)); serWorker.Append(serWorker.Create(OpCodes.Brfalse, varLabel)); - DeserializeField(syncVar, serWorker, ref WeavingFailed); + DeserializeField(syncVar, serWorker, 1L << dirtyBit, ref WeavingFailed); serWorker.Append(varLabel); dirtyBit += 1; diff --git a/Assets/Mirror/Editor/Weaver/Processors/SyncVarAttributeProcessor.cs b/Assets/Mirror/Editor/Weaver/Processors/SyncVarAttributeProcessor.cs index 76e3ac7ed5c..ef77216522d 100644 --- a/Assets/Mirror/Editor/Weaver/Processors/SyncVarAttributeProcessor.cs +++ b/Assets/Mirror/Editor/Weaver/Processors/SyncVarAttributeProcessor.cs @@ -11,6 +11,14 @@ namespace Mirror.Weaver // not static, because ILPostProcessor is multithreaded public class SyncVarAttributeProcessor { + public struct SyncVarHookData + { + public FieldDefinition hookDelegateField; + public FieldDefinition originalValueField; + public FieldDefinition originalValueSetField; + public MethodDefinition hookMethod; + } + // ulong = 64 bytes const int SyncVarLimit = 64; @@ -55,6 +63,12 @@ public FieldDefinition CreateNewActionFieldDefinitionFromHookMethod(FieldDefinit return new FieldDefinition(syncVarHookDelegateFieldName, FieldAttributes.Public, syncVarHookActionDelegateType); } + public FieldDefinition CreateOriginalValueFieldDefinition(FieldDefinition syncVarField) => + new FieldDefinition($"_Mirror_SyncVarHookOriginal_{syncVarField.Name}", FieldAttributes.Private, syncVarField.FieldType); + + public FieldDefinition CreateOriginalValueSetFieldDefinition(FieldDefinition syncVarField) => + new FieldDefinition($"_Mirror_SyncVarHookOriginalSet_{syncVarField.Name}", FieldAttributes.Private, weaverTypes.Import()); + // push hook from GetHookMethod() onto the stack as a new Action. // allows for reuse without handling static/virtual cases every time. // perf warning: it is recommended to use this method only when generating IL to create a new Action() in order to store it into a field @@ -261,7 +275,7 @@ public MethodDefinition GenerateSyncVarGetter(FieldDefinition fd, string origina // } // // the setter used to be manually IL generated, but we moved it to C# :) - public MethodDefinition GenerateSyncVarSetter(TypeDefinition td, FieldDefinition fd, string originalName, long dirtyBit, FieldDefinition netFieldId, Dictionary syncVarHookDelegates, ref bool WeavingFailed) + public MethodDefinition GenerateSyncVarSetter(TypeDefinition td, FieldDefinition fd, string originalName, long dirtyBit, FieldDefinition netFieldId, Dictionary syncVarHookDelegates, ref bool WeavingFailed) { //Create the set method MethodDefinition set = new MethodDefinition($"set_Network{originalName}", MethodAttributes.Public | @@ -323,17 +337,12 @@ public MethodDefinition GenerateSyncVarSetter(TypeDefinition td, FieldDefinition // push the dirty bit for this SyncVar worker.Emit(OpCodes.Ldc_I8, dirtyBit); - // hook? then push 'this.HookDelegate' onto stack - MethodDefinition hookMethod = GetHookMethod(td, fd, ref WeavingFailed); - if (hookMethod != null) + bool hasHook = syncVarHookDelegates.TryGetValue(fd, out SyncVarHookData hookData); + if (hasHook) { - // Create the field that will store a single instance of the hook as a delegate (field will be set in constructor) - FieldDefinition hookActionDelegateField = CreateNewActionFieldDefinitionFromHookMethod(fd); - syncVarHookDelegates[fd] = (hookActionDelegateField, hookMethod); - // push this.hookActionDelegateField worker.Emit(OpCodes.Ldarg_0); - worker.Emit(OpCodes.Ldfld, hookActionDelegateField); + worker.Emit(OpCodes.Ldfld, hookData.hookDelegateField); } // otherwise push 'null' as hook else @@ -349,14 +358,38 @@ public MethodDefinition GenerateSyncVarSetter(TypeDefinition td, FieldDefinition // GameObject setter needs one more parameter: netId field ref worker.Emit(OpCodes.Ldarg_0); worker.Emit(OpCodes.Ldflda, netIdFieldReference); - worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarSetter_GameObject); + + if (hasHook) + { + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarSetter_GameObject_Hook); + } + else + { + worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarSetter_GameObject); + } } else if (fd.FieldType.Is()) { // NetworkIdentity setter needs one more parameter: netId field ref worker.Emit(OpCodes.Ldarg_0); worker.Emit(OpCodes.Ldflda, netIdFieldReference); - worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarSetter_NetworkIdentity); + + if (hasHook) + { + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarSetter_NetworkIdentity_Hook); + } + else + { + worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarSetter_NetworkIdentity); + } } // handle both NetworkBehaviour and inheritors. // fixes: https://github.com/MirrorNetworking/Mirror/issues/2939 @@ -366,14 +399,31 @@ public MethodDefinition GenerateSyncVarSetter(TypeDefinition td, FieldDefinition // (actually its a NetworkBehaviourSyncVar type) worker.Emit(OpCodes.Ldarg_0); worker.Emit(OpCodes.Ldflda, netIdFieldReference); - // make generic version of GeneratedSyncVarSetter_NetworkBehaviour - MethodReference getFunc = weaverTypes.generatedSyncVarSetter_NetworkBehaviour_T.MakeGeneric(assembly.MainModule, fd.FieldType); + + MethodReference getFunc = (hasHook ? weaverTypes.generatedSyncVarSetter_NetworkBehaviour_Hook_T : weaverTypes.generatedSyncVarSetter_NetworkBehaviour_T).MakeGeneric(assembly.MainModule, fd.FieldType); + + if (hasHook) + { + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + } + worker.Emit(OpCodes.Call, getFunc); } else { - // make generic version of GeneratedSyncVarSetter - MethodReference generic = weaverTypes.generatedSyncVarSetter.MakeGeneric(assembly.MainModule, fd.FieldType); + MethodReference generic = (hasHook ? weaverTypes.generatedSyncVarSetter_Hook : weaverTypes.generatedSyncVarSetter).MakeGeneric(assembly.MainModule, fd.FieldType); + + if (hasHook) + { + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + } + worker.Emit(OpCodes.Call, generic); } @@ -387,7 +437,7 @@ public MethodDefinition GenerateSyncVarSetter(TypeDefinition td, FieldDefinition return set; } - public void ProcessSyncVar(TypeDefinition td, FieldDefinition fd, Dictionary syncVarNetIds, Dictionary syncVarHookDelegates, long dirtyBit, ref bool WeavingFailed) + public void ProcessSyncVar(TypeDefinition td, FieldDefinition fd, Dictionary syncVarNetIds, Dictionary syncVarHookDelegates, long dirtyBit, ref bool WeavingFailed) { string originalName = fd.Name; @@ -415,6 +465,18 @@ public void ProcessSyncVar(TypeDefinition td, FieldDefinition fd, Dictionary syncVars, Dictionary syncVarNetIds, Dictionary syncVarHookDelegates) ProcessSyncVars(TypeDefinition td, ref bool WeavingFailed) + public (List syncVars, Dictionary syncVarNetIds, Dictionary syncVarHookDelegates) ProcessSyncVars(TypeDefinition td, ref bool WeavingFailed) { List syncVars = new List(); Dictionary syncVarNetIds = new Dictionary(); - Dictionary syncVarHookDelegates = new Dictionary(); + Dictionary syncVarHookDelegates = new Dictionary(); // the mapping of dirtybits to sync-vars is implicit in the order of the fields here. this order is recorded in m_replacementProperties. // start assigning syncvars at the place the base class stopped, if any @@ -499,9 +561,11 @@ public void ProcessSyncVar(TypeDefinition td, FieldDefinition fd, Dictionary fields - foreach((FieldDefinition hookDelegateInstanceField, MethodDefinition) entry in syncVarHookDelegates.Values) + foreach (SyncVarHookData entry in syncVarHookDelegates.Values) { - td.Fields.Add(entry.hookDelegateInstanceField); + td.Fields.Add(entry.hookDelegateField); + td.Fields.Add(entry.originalValueField); + td.Fields.Add(entry.originalValueSetField); } // include parent class syncvars diff --git a/Assets/Mirror/Editor/Weaver/WeaverTypes.cs b/Assets/Mirror/Editor/Weaver/WeaverTypes.cs index 27c3b7ba304..7324ff00237 100644 --- a/Assets/Mirror/Editor/Weaver/WeaverTypes.cs +++ b/Assets/Mirror/Editor/Weaver/WeaverTypes.cs @@ -36,13 +36,21 @@ public class WeaverTypes // syncvar public MethodReference generatedSyncVarSetter; + public MethodReference generatedSyncVarSetter_Hook; public MethodReference generatedSyncVarSetter_GameObject; + public MethodReference generatedSyncVarSetter_GameObject_Hook; public MethodReference generatedSyncVarSetter_NetworkIdentity; + public MethodReference generatedSyncVarSetter_NetworkIdentity_Hook; public MethodReference generatedSyncVarSetter_NetworkBehaviour_T; + public MethodReference generatedSyncVarSetter_NetworkBehaviour_Hook_T; public MethodReference generatedSyncVarDeserialize; + public MethodReference generatedSyncVarDeserialize_Hook; public MethodReference generatedSyncVarDeserialize_GameObject; + public MethodReference generatedSyncVarDeserialize_GameObject_Hook; public MethodReference generatedSyncVarDeserialize_NetworkIdentity; + public MethodReference generatedSyncVarDeserialize_NetworkIdentity_Hook; public MethodReference generatedSyncVarDeserialize_NetworkBehaviour_T; + public MethodReference generatedSyncVarDeserialize_NetworkBehaviour_Hook_T; public MethodReference getSyncVarGameObjectReference; public MethodReference getSyncVarNetworkIdentityReference; public MethodReference getSyncVarNetworkBehaviourReference; @@ -103,14 +111,22 @@ public WeaverTypes(AssemblyDefinition assembly, Logger Log, ref bool WeavingFail NetworkBehaviourConnectionToClientReference = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "get_connectionToClient", ref WeavingFailed); generatedSyncVarSetter = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter", ref WeavingFailed); + generatedSyncVarSetter_Hook = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter_Hook", ref WeavingFailed); generatedSyncVarSetter_GameObject = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter_GameObject", ref WeavingFailed); + generatedSyncVarSetter_GameObject_Hook = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter_GameObject_Hook", ref WeavingFailed); generatedSyncVarSetter_NetworkIdentity = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter_NetworkIdentity", ref WeavingFailed); + generatedSyncVarSetter_NetworkIdentity_Hook = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter_NetworkIdentity_Hook", ref WeavingFailed); generatedSyncVarSetter_NetworkBehaviour_T = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter_NetworkBehaviour", ref WeavingFailed); + generatedSyncVarSetter_NetworkBehaviour_Hook_T = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter_NetworkBehaviour_Hook", ref WeavingFailed); generatedSyncVarDeserialize_GameObject = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarDeserialize_GameObject", ref WeavingFailed); + generatedSyncVarDeserialize_GameObject_Hook = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarDeserialize_GameObject_Hook", ref WeavingFailed); generatedSyncVarDeserialize = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarDeserialize", ref WeavingFailed); + generatedSyncVarDeserialize_Hook = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarDeserialize_Hook", ref WeavingFailed); generatedSyncVarDeserialize_NetworkIdentity = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarDeserialize_NetworkIdentity", ref WeavingFailed); + generatedSyncVarDeserialize_NetworkIdentity_Hook = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarDeserialize_NetworkIdentity_Hook", ref WeavingFailed); generatedSyncVarDeserialize_NetworkBehaviour_T = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarDeserialize_NetworkBehaviour", ref WeavingFailed); + generatedSyncVarDeserialize_NetworkBehaviour_Hook_T = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarDeserialize_NetworkBehaviour_Hook", ref WeavingFailed); getSyncVarGameObjectReference = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GetSyncVarGameObject", ref WeavingFailed); getSyncVarNetworkIdentityReference = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GetSyncVarNetworkIdentity", ref WeavingFailed); diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs new file mode 100644 index 00000000000..5183ea5b805 --- /dev/null +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -0,0 +1,134 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +namespace Mirror.Tests.SyncCollections +{ + class HostVisibilitySyncListBehaviour : NetworkBehaviour + { + public readonly SyncList list = new SyncList(); + public readonly List actions = new List(); + + public void Register() => list.OnAdd += index => actions.Add($"Add:{list[index]}"); + } + + class HostVisibilitySyncDictionaryBehaviour : NetworkBehaviour + { + public readonly SyncDictionary dictionary = new SyncDictionary(); + public readonly List actions = new List(); + + public void Register() => dictionary.OnAdd += key => actions.Add($"Add:{key}:{dictionary[key]}"); + } + + class HostVisibilitySyncSetBehaviour : NetworkBehaviour + { + public readonly SyncHashSet set = new SyncHashSet(); + public readonly List actions = new List(); + + public void Register() => set.OnAdd += item => actions.Add($"Add:{item}"); + } + + public class SyncCollectionActionTest_HostVisibility : MirrorTest + { + DistanceInterestManagement aoi; + + [SetUp] + public override void SetUp() + { + base.SetUp(); + + aoi = holder.AddComponent(); + aoi.visRange = 10; + NetworkServer.aoi = aoi; + NetworkClient.aoi = aoi; + + NetworkServer.Listen(1); + ConnectHostClientBlockingAuthenticatedAndReady(); + } + + [TearDown] + public override void TearDown() + { + NetworkClient.aoi = null; + NetworkServer.aoi = null; + base.TearDown(); + } + + void AddLocalPlayer(Vector3 position) + { + CreateNetworked(out GameObject player, out _); + player.transform.position = position; + NetworkServer.AddPlayerForConnection(NetworkServer.localConnection, player); + ProcessMessages(); + } + + [Test] + public void SyncList_ActionsDeferUntilObserved() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilitySyncListBehaviour behaviour); + go.transform.position = Vector3.zero; + behaviour.Register(); + NetworkServer.Spawn(go); + ProcessMessages(); + + behaviour.list.Add("first"); + behaviour.list.Add("second"); + Assert.That(behaviour.actions, Is.Empty); + + AddLocalPlayer(Vector3.right * (aoi.visRange + 1)); + Assert.That(behaviour.actions, Is.Empty); + + NetworkClient.localPlayer.transform.position = Vector3.zero; + NetworkServer.RebuildObservers(identity, false); + ProcessMessages(); + + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first", "Add:second" })); + } + + [Test] + public void SyncDictionary_ActionsDeferUntilObserved() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilitySyncDictionaryBehaviour behaviour); + go.transform.position = Vector3.zero; + behaviour.Register(); + NetworkServer.Spawn(go); + ProcessMessages(); + + behaviour.dictionary.Add("key1", "first"); + behaviour.dictionary.Add("key2", "second"); + Assert.That(behaviour.actions, Is.Empty); + + AddLocalPlayer(Vector3.right * (aoi.visRange + 1)); + Assert.That(behaviour.actions, Is.Empty); + + NetworkClient.localPlayer.transform.position = Vector3.zero; + NetworkServer.RebuildObservers(identity, false); + ProcessMessages(); + + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first", "Add:key2:second" })); + } + + [Test] + public void SyncSet_ActionsDeferUntilObserved() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilitySyncSetBehaviour behaviour); + go.transform.position = Vector3.zero; + behaviour.Register(); + NetworkServer.Spawn(go); + ProcessMessages(); + + behaviour.set.Add("first"); + behaviour.set.Add("second"); + Assert.That(behaviour.actions, Is.Empty); + + AddLocalPlayer(Vector3.right * (aoi.visRange + 1)); + Assert.That(behaviour.actions, Is.Empty); + + NetworkClient.localPlayer.transform.position = Vector3.zero; + NetworkServer.RebuildObservers(identity, false); + ProcessMessages(); + + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first", "Add:second" })); + } + } +} diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs new file mode 100644 index 00000000000..e77dfbe029c --- /dev/null +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +namespace Mirror.Tests.SyncVars +{ + struct HostHookStructValue + { + public int value; + } + + class HostVisibilityHookBehaviour : NetworkBehaviour + { + [SyncVar(hook = nameof(OnValueChanged))] + public int value = 42; + + public readonly List<(int oldValue, int newValue)> hookValues = new List<(int oldValue, int newValue)>(); + + void OnValueChanged(int oldValue, int newValue) => hookValues.Add((oldValue, newValue)); + } + + class HostVisibilityStructHookBehaviour : NetworkBehaviour + { + [SyncVar(hook = nameof(OnValueChanged))] + public HostHookStructValue value = new HostHookStructValue { value = 5 }; + + public readonly List<(HostHookStructValue oldValue, HostHookStructValue newValue)> hookValues = new List<(HostHookStructValue oldValue, HostHookStructValue newValue)>(); + + void OnValueChanged(HostHookStructValue oldValue, HostHookStructValue newValue) => hookValues.Add((oldValue, newValue)); + } + + public class SyncVarAttributeHook_HostVisibilityTest : MirrorTest + { + DistanceInterestManagement aoi; + + [SetUp] + public override void SetUp() + { + base.SetUp(); + + aoi = holder.AddComponent(); + aoi.visRange = 10; + NetworkServer.aoi = aoi; + NetworkClient.aoi = aoi; + + NetworkServer.Listen(1); + ConnectHostClientBlockingAuthenticatedAndReady(); + } + + [TearDown] + public override void TearDown() + { + NetworkClient.aoi = null; + NetworkServer.aoi = null; + base.TearDown(); + } + + void AddLocalPlayer(Vector3 position) + { + CreateNetworked(out GameObject player, out NetworkIdentity identity); + player.transform.position = position; + NetworkServer.AddPlayerForConnection(NetworkServer.localConnection, player); + ProcessMessages(); + Assert.That(NetworkClient.localPlayer, Is.EqualTo(identity)); + } + + [Test] + public void Hook_UsesDeclarationInitializerBaselineUntilObserved() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilityHookBehaviour behaviour); + go.transform.position = Vector3.zero; + NetworkServer.Spawn(go); + ProcessMessages(); + + behaviour.value = 50; + behaviour.value = 100; + + Assert.That(behaviour.hookValues, Is.Empty); + + AddLocalPlayer(Vector3.right * (aoi.visRange + 1)); + Assert.That(behaviour.hookValues, Is.Empty); + + NetworkClient.localPlayer.transform.position = Vector3.zero; + NetworkServer.RebuildObservers(identity, false); + ProcessMessages(); + + Assert.That(behaviour.hookValues.Count, Is.EqualTo(1)); + Assert.That(behaviour.hookValues[0].oldValue, Is.EqualTo(42)); + Assert.That(behaviour.hookValues[0].newValue, Is.EqualTo(100)); + } + + [Test] + public void Hook_DoesNotFireWhenStateReturnsToBaselineBeforeObserved() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilityHookBehaviour behaviour); + go.transform.position = Vector3.zero; + NetworkServer.Spawn(go); + ProcessMessages(); + + behaviour.value = 100; + behaviour.value = 42; + + AddLocalPlayer(Vector3.zero); + NetworkServer.RebuildObservers(identity, false); + ProcessMessages(); + + Assert.That(behaviour.hookValues, Is.Empty); + } + + [Test] + public void Hook_SupportsStructSyncVars() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilityStructHookBehaviour behaviour); + go.transform.position = Vector3.zero; + NetworkServer.Spawn(go); + ProcessMessages(); + + behaviour.value = new HostHookStructValue { value = 9 }; + + AddLocalPlayer(Vector3.right * (aoi.visRange + 1)); + Assert.That(behaviour.hookValues, Is.Empty); + + NetworkClient.localPlayer.transform.position = Vector3.zero; + NetworkServer.RebuildObservers(identity, false); + ProcessMessages(); + + Assert.That(behaviour.hookValues.Count, Is.EqualTo(1)); + Assert.That(behaviour.hookValues[0].oldValue.value, Is.EqualTo(5)); + Assert.That(behaviour.hookValues[0].newValue.value, Is.EqualTo(9)); + } + } +} diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarHookDeferralTests.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarHookDeferralTests.cs index a83a3701fe7..7dbb8724f5e 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarHookDeferralTests.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarHookDeferralTests.cs @@ -107,7 +107,7 @@ public void GameObject_CrossReferences_HooksCanAccessTargets() } [Test] - public void HostMode_CrossReferences_HooksFireFromSetter_AndDuringDeserialize() + public void HostMode_CrossReferences_HooksFireOnceWhenObjectBecomesVisible() { // Start in HOST mode (server + client together) NetworkServer.Listen(10); @@ -126,15 +126,11 @@ public void HostMode_CrossReferences_HooksFireFromSetter_AndDuringDeserialize() comp2.target = identity3; comp3.target = comp1.netIdentity; - // In host mode, hooks fire TWICE: - // 1. From setter when value is assigned - // 2. From DeserializeClient during OnHostClientSpawn (because hostInitialSpawn=true) - Assert.That(comp1.callCount, Is.EqualTo(2), - "Host mode: Hook should fire from setter immediately"); - Assert.That(comp2.callCount, Is.EqualTo(2), - "Host mode: Hook should fire from setter immediately"); - Assert.That(comp3.callCount, Is.EqualTo(2), - "Host mode: Hook should fire from setter immediately"); + // In host mode, pre-spawn setter changes are deferred until the object + // is visible to the local host client and then flushed exactly once. + Assert.That(comp1.callCount, Is.EqualTo(1)); + Assert.That(comp2.callCount, Is.EqualTo(1)); + Assert.That(comp3.callCount, Is.EqualTo(1)); // All targets should be accessible (host mode, everything is local) Assert.That(comp1.targetWasInSpawnedWhenHookFired, Is.True, @@ -144,13 +140,14 @@ public void HostMode_CrossReferences_HooksFireFromSetter_AndDuringDeserialize() Assert.That(comp3.targetWasInSpawnedWhenHookFired, Is.True, "Host mode: Targets always accessible"); - // Verify no deferred hooks queued (host mode doesn't defer) + // Host visibility flushing happens via weaved baseline state, not the + // client-only deferredSyncVarHooks list. Assert.That(comp1.deferredSyncVarHooks.Count, Is.EqualTo(0), - "Host mode should not defer hooks"); + "Host mode should not queue client-only deferred hooks"); Assert.That(comp2.deferredSyncVarHooks.Count, Is.EqualTo(0), - "Host mode should not defer hooks"); + "Host mode should not queue client-only deferred hooks"); Assert.That(comp3.deferredSyncVarHooks.Count, Is.EqualTo(0), - "Host mode should not defer hooks"); + "Host mode should not queue client-only deferred hooks"); } [Test] From 4a4a3ce095a636a25928a41036309460528d2998 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:40:39 +0000 Subject: [PATCH 03/48] Polish host sync deferral changes --- Assets/Mirror/Core/SyncDictionary.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Mirror/Core/SyncDictionary.cs b/Assets/Mirror/Core/SyncDictionary.cs index c2bf7ed2ee0..0d1c5e49541 100644 --- a/Assets/Mirror/Core/SyncDictionary.cs +++ b/Assets/Mirror/Core/SyncDictionary.cs @@ -376,7 +376,7 @@ void AddOperation(Operation op, TKey key, TValue item, TValue oldItem, bool chec } // Defer Actions during initial spawn on pure client to eliminate - // cross-object reference race conditions. All objects will be in + // cross-object reference race conditions. All objects will be in // NetworkClient.spawned before any Actions fire. if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) { From ebfa5a46dafa0d80405930ce68050c75f0b3bebd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:42:24 +0000 Subject: [PATCH 04/48] Fix host visibility helper access --- Assets/Mirror/Core/NetworkBehaviour.cs | 2 +- .../Editor/Weaver/Processors/NetworkBehaviourProcessor.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index ff7c20a7f39..6d0db0a2230 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -172,7 +172,7 @@ public bool authority // Actions are queued while the object isn't visible to the local client and invoked once it is. internal readonly List deferredSyncCollectionActions = new List(); - protected bool IsHostClientObserved() => + internal bool IsHostClientObserved() => NetworkServer.activeHost && NetworkServer.localConnection != null && NetworkClient.localPlayer != null && diff --git a/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs b/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs index 0f89e9dec31..32c7fca7f8b 100644 --- a/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs +++ b/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs @@ -763,11 +763,11 @@ void GenerateDeSerialization(ref bool WeavingFailed) serWorker.Append(serWorker.Create(OpCodes.Ldarg_2)); serWorker.Append(serWorker.Create(OpCodes.Brfalse, initialStateLabel)); - int initialDirtyBit = syncVarAccessLists.GetSyncVarStart(netBehaviourSubclass.BaseType.FullName); + int syncVarBitOffset = syncVarAccessLists.GetSyncVarStart(netBehaviourSubclass.BaseType.FullName); foreach (FieldDefinition syncVar in syncVars) { - DeserializeField(syncVar, serWorker, 1L << initialDirtyBit, ref WeavingFailed); - initialDirtyBit += 1; + DeserializeField(syncVar, serWorker, 1L << syncVarBitOffset, ref WeavingFailed); + syncVarBitOffset += 1; } serWorker.Append(serWorker.Create(OpCodes.Ret)); From 485ce9049d3d18fc4f5e720baba69914c6cc5a0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:56:57 +0000 Subject: [PATCH 05/48] fix deferred SyncDictionary action capture --- Assets/Mirror/Core/SyncDictionary.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Assets/Mirror/Core/SyncDictionary.cs b/Assets/Mirror/Core/SyncDictionary.cs index 0d1c5e49541..e2aab23e6f6 100644 --- a/Assets/Mirror/Core/SyncDictionary.cs +++ b/Assets/Mirror/Core/SyncDictionary.cs @@ -368,10 +368,10 @@ void AddOperation(Operation op, TKey key, TValue item, TValue oldItem, bool chec Operation capturedOp = op; TKey capturedKey = key; TValue capturedOld = oldItem; - TValue capturedNew = newItem; + TValue capturedNew = item; networkBehaviour.deferredSyncCollectionActions.Add(() => - InvokeActions(capturedOp, capturedKey, capturedOld, capturedNew)); + InvokeActions(capturedOp, capturedKey, capturedNew, capturedOld)); return; } From 7ee058bb0c5847073094a559d2389ce3e8e7b34e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:20:31 +0000 Subject: [PATCH 06/48] fix host-mode SyncVar test expectations --- .../SyncVars/SyncVarAttributeHook_HostModeTest.cs | 4 ++++ .../Editor/SyncVars/SyncVarHookDeferralTests.cs | 12 ++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostModeTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostModeTest.cs index 30fea9f13d6..8212b83617f 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostModeTest.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostModeTest.cs @@ -14,6 +14,10 @@ public override void SetUp() // need host mode! ConnectHostClientBlockingAuthenticatedAndReady(); + + // Host SyncVar hooks now require a local player / visibility, just + // like the deferred remote-client path. + CreateNetworkedAndSpawnPlayer(out _, out _, NetworkServer.localConnection); } [TearDown] diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarHookDeferralTests.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarHookDeferralTests.cs index 7dbb8724f5e..7010e54af7c 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarHookDeferralTests.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarHookDeferralTests.cs @@ -126,8 +126,16 @@ public void HostMode_CrossReferences_HooksFireOnceWhenObjectBecomesVisible() comp2.target = identity3; comp3.target = comp1.netIdentity; - // In host mode, pre-spawn setter changes are deferred until the object - // is visible to the local host client and then flushed exactly once. + // In host mode, setter changes are deferred until the object is + // visible to the local host client. + Assert.That(comp1.callCount, Is.EqualTo(0)); + Assert.That(comp2.callCount, Is.EqualTo(0)); + Assert.That(comp3.callCount, Is.EqualTo(0)); + + // Add a local player so the host client can observe the objects. + CreateNetworkedAndSpawnPlayer(out _, out _, NetworkServer.localConnection); + + // Once visible, each deferred hook should flush exactly once. Assert.That(comp1.callCount, Is.EqualTo(1)); Assert.That(comp2.callCount, Is.EqualTo(1)); Assert.That(comp3.callCount, Is.EqualTo(1)); From efbc424be5b0068c8e2b8bdf8735d1899477419d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:58:07 +0000 Subject: [PATCH 07/48] Flush deferred host sync callbacks after local player setup --- Assets/Mirror/Core/NetworkClient.cs | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index 095073e8e22..4a3cc881042 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -1092,6 +1092,8 @@ internal static void InternalAddPlayer(NetworkIdentity identity) { //Debug.Log("NetworkClient.InternalAddPlayer"); + bool hadLocalPlayer = localPlayer != null; + // NOTE: It can be "normal" when changing scenes for the player to be destroyed and recreated. // But, the player structures are not cleaned up, we'll just replace the old player localPlayer = identity; @@ -1107,6 +1109,50 @@ internal static void InternalAddPlayer(NetworkIdentity identity) connection.identity = identity; } else Debug.LogWarning("NetworkClient can't AddPlayer before being ready. Please call NetworkClient.Ready() first. Clients are considered ready after joining the game world."); + + if (NetworkServer.activeHost && !hadLocalPlayer) + FlushHostVisibilityDeferredCallbacks(); + } + + static void FlushHostVisibilityDeferredCallbacks() + { + if (NetworkServer.localConnection == null || localPlayer == null) + return; + + List observed = new List(NetworkServer.localConnection.observing); + foreach (NetworkIdentity identity in observed) + { + if (identity == null || !spawned.ContainsKey(identity.netId)) + continue; + + identity.hostInitialSpawn = true; + try + { + using (NetworkWriterPooled ownerWriter = NetworkWriterPool.Get(), observersWriter = NetworkWriterPool.Get()) + { + identity.SerializeServer_Spawn(ownerWriter, observersWriter); + + ArraySegment payload = identity.connectionToClient == NetworkServer.localConnection + ? ownerWriter.ToArraySegment() + : observersWriter.ToArraySegment(); + + if (payload.Count > 0) + { + using (NetworkReaderPooled payloadReader = NetworkReaderPool.Get(payload)) + { + identity.DeserializeClient(payloadReader, true); + } + } + } + + foreach (NetworkBehaviour comp in identity.NetworkBehaviours) + comp.InvokeDeferredSyncCallbacks(); + } + finally + { + identity.hostInitialSpawn = false; + } + } } /// Sends AddPlayer message to the server, indicating that we want to join the world. From 52f22a7e0ecf62075c93d226df76a5075ea1541a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:00:09 +0000 Subject: [PATCH 08/48] Document observed callback snapshot copy --- Assets/Mirror/Core/NetworkClient.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index 4a3cc881042..db6f1cdb191 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -1119,6 +1119,7 @@ static void FlushHostVisibilityDeferredCallbacks() if (NetworkServer.localConnection == null || localPlayer == null) return; + // Hooks may change visibility/ownership, so iterate a snapshot. List observed = new List(NetworkServer.localConnection.observing); foreach (NetworkIdentity identity in observed) { From 1a04d7ded93ef8533cae3d65433528687ab35f0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:32:21 +0000 Subject: [PATCH 09/48] Extend host visibility AOI tests --- ...SyncCollectionActionTest_HostVisibility.cs | 100 ++++++++++++++++++ ...SyncVarAttributeHook_HostVisibilityTest.cs | 43 ++++++++ 2 files changed, 143 insertions(+) diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs index 5183ea5b805..f8033502b9c 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -62,6 +62,19 @@ void AddLocalPlayer(Vector3 position) ProcessMessages(); } + void RebuildLocalObserver(NetworkIdentity identity, Vector3 localPlayerPosition) + { + NetworkClient.localPlayer.transform.position = localPlayerPosition; + NetworkServer.RebuildObservers(identity, false); + ProcessMessages(); + } + + void AssertObserved(NetworkIdentity identity, bool expected) + { + Assert.That(NetworkServer.localConnection.observing.Contains(identity), Is.EqualTo(expected)); + Assert.That(identity.observers.ContainsKey(NetworkServer.localConnection.connectionId), Is.EqualTo(expected)); + } + [Test] public void SyncList_ActionsDeferUntilObserved() { @@ -130,5 +143,92 @@ public void SyncSet_ActionsDeferUntilObserved() Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first", "Add:second" })); } + + [Test] + public void SyncList_ActionsDeferAgainAfterLeavingAoi() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilitySyncListBehaviour behaviour); + go.transform.position = Vector3.zero; + behaviour.Register(); + NetworkServer.Spawn(go); + ProcessMessages(); + + behaviour.list.Add("first"); + + AddLocalPlayer(Vector3.right * (aoi.visRange + 1)); + AssertObserved(identity, false); + + RebuildLocalObserver(identity, Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + + RebuildLocalObserver(identity, Vector3.right * (aoi.visRange + 1)); + AssertObserved(identity, false); + + behaviour.list.Add("second"); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + + RebuildLocalObserver(identity, Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first", "Add:second" })); + } + + [Test] + public void SyncDictionary_ActionsDeferAgainAfterLeavingAoi() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilitySyncDictionaryBehaviour behaviour); + go.transform.position = Vector3.zero; + behaviour.Register(); + NetworkServer.Spawn(go); + ProcessMessages(); + + behaviour.dictionary.Add("key1", "first"); + + AddLocalPlayer(Vector3.right * (aoi.visRange + 1)); + AssertObserved(identity, false); + + RebuildLocalObserver(identity, Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first" })); + + RebuildLocalObserver(identity, Vector3.right * (aoi.visRange + 1)); + AssertObserved(identity, false); + + behaviour.dictionary.Add("key2", "second"); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first" })); + + RebuildLocalObserver(identity, Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first", "Add:key2:second" })); + } + + [Test] + public void SyncSet_ActionsDeferAgainAfterLeavingAoi() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilitySyncSetBehaviour behaviour); + go.transform.position = Vector3.zero; + behaviour.Register(); + NetworkServer.Spawn(go); + ProcessMessages(); + + behaviour.set.Add("first"); + + AddLocalPlayer(Vector3.right * (aoi.visRange + 1)); + AssertObserved(identity, false); + + RebuildLocalObserver(identity, Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + + RebuildLocalObserver(identity, Vector3.right * (aoi.visRange + 1)); + AssertObserved(identity, false); + + behaviour.set.Add("second"); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + + RebuildLocalObserver(identity, Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first", "Add:second" })); + } } } diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs index e77dfbe029c..c97684d1169 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -64,6 +64,19 @@ void AddLocalPlayer(Vector3 position) Assert.That(NetworkClient.localPlayer, Is.EqualTo(identity)); } + void RebuildLocalObserver(NetworkIdentity identity, Vector3 localPlayerPosition) + { + NetworkClient.localPlayer.transform.position = localPlayerPosition; + NetworkServer.RebuildObservers(identity, false); + ProcessMessages(); + } + + void AssertObserved(NetworkIdentity identity, bool expected) + { + Assert.That(NetworkServer.localConnection.observing.Contains(identity), Is.EqualTo(expected)); + Assert.That(identity.observers.ContainsKey(NetworkServer.localConnection.connectionId), Is.EqualTo(expected)); + } + [Test] public void Hook_UsesDeclarationInitializerBaselineUntilObserved() { @@ -128,5 +141,35 @@ public void Hook_SupportsStructSyncVars() Assert.That(behaviour.hookValues[0].oldValue.value, Is.EqualTo(5)); Assert.That(behaviour.hookValues[0].newValue.value, Is.EqualTo(9)); } + + [Test] + public void Hook_UsesLastObservedValueAsBaselineAfterLeavingAoi() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilityHookBehaviour behaviour); + go.transform.position = Vector3.zero; + NetworkServer.Spawn(go); + ProcessMessages(); + + behaviour.value = 100; + + AddLocalPlayer(Vector3.right * (aoi.visRange + 1)); + AssertObserved(identity, false); + + RebuildLocalObserver(identity, Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + + RebuildLocalObserver(identity, Vector3.right * (aoi.visRange + 1)); + AssertObserved(identity, false); + + behaviour.value = 150; + behaviour.value = 200; + + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + + RebuildLocalObserver(identity, Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100), (100, 200) })); + } } } From c67aab3c914ef96603809252cdb5e8bab7485f81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:52:29 +0000 Subject: [PATCH 10/48] Fix host AOI SyncVar replay semantics --- Assets/Mirror/Core/NetworkBehaviour.cs | 13 +++++++++---- .../SyncVarAttributeHook_HostVisibilityTest.cs | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index 6d0db0a2230..da89f16f7fe 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -178,6 +178,11 @@ internal bool IsHostClientObserved() => NetworkClient.localPlayer != null && netIdentity.observers.ContainsKey(NetworkServer.localConnection.connectionId); + internal bool ShouldCaptureHostInitialSyncVarBaseline() => + NetworkServer.activeHost && + !IsHostClientObserved() && + !NetworkClient.spawned.ContainsKey(netIdentity.netId); + internal void InvokeDeferredSyncCallbacks() { foreach (Action hook in deferredSyncVarHooks) @@ -617,7 +622,7 @@ public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (!originalValueSet) + else if (!originalValueSet && ShouldCaptureHostInitialSyncVarBaseline()) { originalValue = previous; originalValueSet = true; @@ -664,7 +669,7 @@ public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObj OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (!originalValueSet) + else if (!originalValueSet && ShouldCaptureHostInitialSyncVarBaseline()) { originalValue = previous; originalValueSet = true; @@ -711,7 +716,7 @@ public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, r OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (!originalValueSet) + else if (!originalValueSet && ShouldCaptureHostInitialSyncVarBaseline()) { originalValue = previous; originalValueSet = true; @@ -760,7 +765,7 @@ public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (!originalValueSet) + else if (!originalValueSet && ShouldCaptureHostInitialSyncVarBaseline()) { originalValue = previous; originalValueSet = true; diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs index c97684d1169..27ff97ace87 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -143,7 +143,7 @@ public void Hook_SupportsStructSyncVars() } [Test] - public void Hook_UsesLastObservedValueAsBaselineAfterLeavingAoi() + public void Hook_DoesNotReplayHiddenChangesAfterLeavingAoi() { CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilityHookBehaviour behaviour); go.transform.position = Vector3.zero; @@ -169,7 +169,7 @@ public void Hook_UsesLastObservedValueAsBaselineAfterLeavingAoi() RebuildLocalObserver(identity, Vector3.zero); AssertObserved(identity, true); - Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100), (100, 200) })); + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); } } } From 8b90c52c5dc0f1ffcaf7f28a6b28e9399ae50c58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:54:33 +0000 Subject: [PATCH 11/48] Polish host SyncVar baseline helper --- Assets/Mirror/Core/NetworkBehaviour.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index da89f16f7fe..2c8be83510f 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -178,7 +178,7 @@ internal bool IsHostClientObserved() => NetworkClient.localPlayer != null && netIdentity.observers.ContainsKey(NetworkServer.localConnection.connectionId); - internal bool ShouldCaptureHostInitialSyncVarBaseline() => + internal bool ShouldCaptureHostBaseline() => NetworkServer.activeHost && !IsHostClientObserved() && !NetworkClient.spawned.ContainsKey(netIdentity.netId); @@ -622,7 +622,7 @@ public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (!originalValueSet && ShouldCaptureHostInitialSyncVarBaseline()) + else if (!originalValueSet && ShouldCaptureHostBaseline()) { originalValue = previous; originalValueSet = true; @@ -669,7 +669,7 @@ public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObj OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (!originalValueSet && ShouldCaptureHostInitialSyncVarBaseline()) + else if (!originalValueSet && ShouldCaptureHostBaseline()) { originalValue = previous; originalValueSet = true; @@ -716,7 +716,7 @@ public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, r OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (!originalValueSet && ShouldCaptureHostInitialSyncVarBaseline()) + else if (!originalValueSet && ShouldCaptureHostBaseline()) { originalValue = previous; originalValueSet = true; @@ -765,7 +765,7 @@ public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (!originalValueSet && ShouldCaptureHostInitialSyncVarBaseline()) + else if (!originalValueSet && ShouldCaptureHostBaseline()) { originalValue = previous; originalValueSet = true; From 99cf1b5b922aaab0f206ed8417506bbfe14e0609 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:56:03 +0000 Subject: [PATCH 12/48] Rename AOI visibility test --- .../Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs index 27ff97ace87..34d2a96ad45 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -143,7 +143,7 @@ public void Hook_SupportsStructSyncVars() } [Test] - public void Hook_DoesNotReplayHiddenChangesAfterLeavingAoi() + public void Hook_DoesNotReplayHiddenChangesAfterLeavingAOI() { CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilityHookBehaviour behaviour); go.transform.position = Vector3.zero; From fbe9a60d793496148663494d6558293e8ab6f3f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:58:47 +0000 Subject: [PATCH 13/48] Fix host AOI re-observation SyncVar baseline --- Assets/Mirror/Core/NetworkBehaviour.cs | 43 +++++++++---------- ...SyncVarAttributeHook_HostVisibilityTest.cs | 2 +- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index 2c8be83510f..e7fcf73839d 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -180,8 +180,19 @@ internal bool IsHostClientObserved() => internal bool ShouldCaptureHostBaseline() => NetworkServer.activeHost && - !IsHostClientObserved() && - !NetworkClient.spawned.ContainsKey(netIdentity.netId); + !IsHostClientObserved(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + void CaptureHostBaseline(T previous, ref T originalValue, ref bool originalValueSet) + { + if (originalValueSet) + return; + + if (!NetworkClient.spawned.ContainsKey(netIdentity.netId)) + originalValue = previous; + + originalValueSet = true; + } internal void InvokeDeferredSyncCallbacks() { @@ -622,11 +633,8 @@ public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (!originalValueSet && ShouldCaptureHostBaseline()) - { - originalValue = previous; - originalValueSet = true; - } + else if (ShouldCaptureHostBaseline()) + CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); } } } @@ -669,11 +677,8 @@ public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObj OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (!originalValueSet && ShouldCaptureHostBaseline()) - { - originalValue = previous; - originalValueSet = true; - } + else if (ShouldCaptureHostBaseline()) + CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); } } } @@ -716,11 +721,8 @@ public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, r OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (!originalValueSet && ShouldCaptureHostBaseline()) - { - originalValue = previous; - originalValueSet = true; - } + else if (ShouldCaptureHostBaseline()) + CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); } } } @@ -765,11 +767,8 @@ public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (!originalValueSet && ShouldCaptureHostBaseline()) - { - originalValue = previous; - originalValueSet = true; - } + else if (ShouldCaptureHostBaseline()) + CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); } } } diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs index 34d2a96ad45..912ff6f6d90 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -169,7 +169,7 @@ public void Hook_DoesNotReplayHiddenChangesAfterLeavingAOI() RebuildLocalObserver(identity, Vector3.zero); AssertObserved(identity, true); - Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 200) })); } } } From fbb1dc2d9854f527aa1ef0794be47eeed8152293 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:05:59 +0000 Subject: [PATCH 14/48] Fix host AOI re-observation test assertions --- .../SyncCollectionActionTest_HostVisibility.cs | 15 +++++++++------ .../SyncVarAttributeHook_HostVisibilityTest.cs | 3 ++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs index f8033502b9c..ceccdf6c10d 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -164,13 +164,14 @@ public void SyncList_ActionsDeferAgainAfterLeavingAoi() RebuildLocalObserver(identity, Vector3.right * (aoi.visRange + 1)); AssertObserved(identity, false); + behaviour.actions.Clear(); behaviour.list.Add("second"); - Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + Assert.That(behaviour.actions, Is.Empty); RebuildLocalObserver(identity, Vector3.zero); AssertObserved(identity, true); - Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first", "Add:second" })); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:second" })); } [Test] @@ -193,13 +194,14 @@ public void SyncDictionary_ActionsDeferAgainAfterLeavingAoi() RebuildLocalObserver(identity, Vector3.right * (aoi.visRange + 1)); AssertObserved(identity, false); + behaviour.actions.Clear(); behaviour.dictionary.Add("key2", "second"); - Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first" })); + Assert.That(behaviour.actions, Is.Empty); RebuildLocalObserver(identity, Vector3.zero); AssertObserved(identity, true); - Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first", "Add:key2:second" })); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key2:second" })); } [Test] @@ -222,13 +224,14 @@ public void SyncSet_ActionsDeferAgainAfterLeavingAoi() RebuildLocalObserver(identity, Vector3.right * (aoi.visRange + 1)); AssertObserved(identity, false); + behaviour.actions.Clear(); behaviour.set.Add("second"); - Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + Assert.That(behaviour.actions, Is.Empty); RebuildLocalObserver(identity, Vector3.zero); AssertObserved(identity, true); - Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first", "Add:second" })); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:second" })); } } } diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs index 912ff6f6d90..933e1798654 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -161,11 +161,12 @@ public void Hook_DoesNotReplayHiddenChangesAfterLeavingAOI() RebuildLocalObserver(identity, Vector3.right * (aoi.visRange + 1)); AssertObserved(identity, false); + behaviour.hookValues.Clear(); behaviour.value = 150; behaviour.value = 200; - Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + Assert.That(behaviour.hookValues, Is.Empty); RebuildLocalObserver(identity, Vector3.zero); AssertObserved(identity, true); From bdc5c681118c2d5da5520fbe966b2464ff9611cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:32:21 +0000 Subject: [PATCH 15/48] Fix host sync collection re-observation replay --- Assets/Mirror/Core/SyncDictionary.cs | 27 +++++++++---------- Assets/Mirror/Core/SyncList.cs | 27 +++++++++---------- Assets/Mirror/Core/SyncSet.cs | 25 +++++++++-------- ...SyncCollectionActionTest_HostVisibility.cs | 6 ++--- 4 files changed, 41 insertions(+), 44 deletions(-) diff --git a/Assets/Mirror/Core/SyncDictionary.cs b/Assets/Mirror/Core/SyncDictionary.cs index e2aab23e6f6..5e2a137248b 100644 --- a/Assets/Mirror/Core/SyncDictionary.cs +++ b/Assets/Mirror/Core/SyncDictionary.cs @@ -131,6 +131,19 @@ public override void OnDeserializeAll(NetworkReader reader) objects.Add(key, obj); } + if (NetworkServer.activeHost && + networkBehaviour.syncDirection == SyncDirection.ServerToClient && + networkBehaviour.netIdentity.hostInitialSpawn) + { + foreach (KeyValuePair entry in objects) + { + TKey capturedKey = entry.Key; + TValue capturedValue = entry.Value; + networkBehaviour.deferredSyncCollectionActions.Add(() => + InvokeActions(Operation.OP_ADD, capturedKey, capturedValue, default)); + } + } + // We will need to skip all these changes // the next time the list is synchronized // because they have already been applied @@ -361,20 +374,6 @@ void AddOperation(Operation op, TKey key, TValue item, TValue oldItem, bool chec if (shouldFireActions) { - if (NetworkServer.activeHost && - networkBehaviour.syncDirection == SyncDirection.ServerToClient && - !networkBehaviour.IsHostClientObserved()) - { - Operation capturedOp = op; - TKey capturedKey = key; - TValue capturedOld = oldItem; - TValue capturedNew = item; - - networkBehaviour.deferredSyncCollectionActions.Add(() => - InvokeActions(capturedOp, capturedKey, capturedNew, capturedOld)); - return; - } - // Defer Actions during initial spawn on pure client to eliminate // cross-object reference race conditions. All objects will be in // NetworkClient.spawned before any Actions fire. diff --git a/Assets/Mirror/Core/SyncList.cs b/Assets/Mirror/Core/SyncList.cs index 3f58f0ed284..c426062c224 100644 --- a/Assets/Mirror/Core/SyncList.cs +++ b/Assets/Mirror/Core/SyncList.cs @@ -122,20 +122,6 @@ void AddOperation(Operation op, int itemIndex, T oldItem, T newItem, bool checkA if (shouldFireActions) { - if (NetworkServer.activeHost && - networkBehaviour.syncDirection == SyncDirection.ServerToClient && - !networkBehaviour.IsHostClientObserved()) - { - Operation capturedOp = op; - int capturedIndex = itemIndex; - T capturedOld = oldItem; - T capturedNew = newItem; - - networkBehaviour.deferredSyncCollectionActions.Add(() => - InvokeActions(capturedOp, capturedIndex, capturedOld, capturedNew)); - return; - } - // Defer Actions during initial spawn on pure client to eliminate // cross-object reference race conditions. All objects will be in // NetworkClient.spawned before any Actions fire. @@ -254,6 +240,19 @@ public override void OnDeserializeAll(NetworkReader reader) objects.Add(obj); } + if (NetworkServer.activeHost && + networkBehaviour.syncDirection == SyncDirection.ServerToClient && + networkBehaviour.netIdentity.hostInitialSpawn) + { + for (int i = 0; i < objects.Count; i++) + { + int capturedIndex = i; + T capturedValue = objects[i]; + networkBehaviour.deferredSyncCollectionActions.Add(() => + InvokeActions(Operation.OP_ADD, capturedIndex, default, capturedValue)); + } + } + // We will need to skip all these changes // the next time the list is synchronized // because they have already been applied diff --git a/Assets/Mirror/Core/SyncSet.cs b/Assets/Mirror/Core/SyncSet.cs index 2148a9d1179..57e6437b72f 100644 --- a/Assets/Mirror/Core/SyncSet.cs +++ b/Assets/Mirror/Core/SyncSet.cs @@ -114,19 +114,6 @@ void AddOperation(Operation op, T oldItem, T newItem, bool checkAccess, bool sho if (shouldFireActions) { - if (NetworkServer.activeHost && - networkBehaviour.syncDirection == SyncDirection.ServerToClient && - !networkBehaviour.IsHostClientObserved()) - { - Operation capturedOp = op; - T capturedOld = oldItem; - T capturedNew = newItem; - - networkBehaviour.deferredSyncCollectionActions.Add(() => - InvokeActions(capturedOp, capturedOld, capturedNew)); - return; - } - // Defer Actions during initial spawn on pure client to eliminate // cross-object reference race conditions. All objects will be in // NetworkClient.spawned before any Actions fire. @@ -222,6 +209,18 @@ public override void OnDeserializeAll(NetworkReader reader) objects.Add(obj); } + if (NetworkServer.activeHost && + networkBehaviour.syncDirection == SyncDirection.ServerToClient && + networkBehaviour.netIdentity.hostInitialSpawn) + { + foreach (T item in objects) + { + T capturedValue = item; + networkBehaviour.deferredSyncCollectionActions.Add(() => + InvokeActions(Operation.OP_ADD, default, capturedValue)); + } + } + // We will need to skip all these changes // the next time the list is synchronized // because they have already been applied diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs index ceccdf6c10d..ed70080d298 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -171,7 +171,7 @@ public void SyncList_ActionsDeferAgainAfterLeavingAoi() RebuildLocalObserver(identity, Vector3.zero); AssertObserved(identity, true); - Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:second" })); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first", "Add:second" })); } [Test] @@ -201,7 +201,7 @@ public void SyncDictionary_ActionsDeferAgainAfterLeavingAoi() RebuildLocalObserver(identity, Vector3.zero); AssertObserved(identity, true); - Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key2:second" })); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first", "Add:key2:second" })); } [Test] @@ -231,7 +231,7 @@ public void SyncSet_ActionsDeferAgainAfterLeavingAoi() RebuildLocalObserver(identity, Vector3.zero); AssertObserved(identity, true); - Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:second" })); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first", "Add:second" })); } } } From fb57062019461171d9fcd694d2d7b749e206f16e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:00:13 +0000 Subject: [PATCH 16/48] fix: suppress hidden host sync collection actions --- Assets/Mirror/Core/SyncDictionary.cs | 7 +++++++ Assets/Mirror/Core/SyncList.cs | 7 +++++++ Assets/Mirror/Core/SyncSet.cs | 7 +++++++ 3 files changed, 21 insertions(+) diff --git a/Assets/Mirror/Core/SyncDictionary.cs b/Assets/Mirror/Core/SyncDictionary.cs index 5e2a137248b..78ae60c72d2 100644 --- a/Assets/Mirror/Core/SyncDictionary.cs +++ b/Assets/Mirror/Core/SyncDictionary.cs @@ -374,6 +374,13 @@ void AddOperation(Operation op, TKey key, TValue item, TValue oldItem, bool chec if (shouldFireActions) { + if (NetworkServer.activeHost && + networkBehaviour.syncDirection == SyncDirection.ServerToClient && + !networkBehaviour.IsHostClientObserved()) + { + return; + } + // Defer Actions during initial spawn on pure client to eliminate // cross-object reference race conditions. All objects will be in // NetworkClient.spawned before any Actions fire. diff --git a/Assets/Mirror/Core/SyncList.cs b/Assets/Mirror/Core/SyncList.cs index c426062c224..96680e8a8e8 100644 --- a/Assets/Mirror/Core/SyncList.cs +++ b/Assets/Mirror/Core/SyncList.cs @@ -122,6 +122,13 @@ void AddOperation(Operation op, int itemIndex, T oldItem, T newItem, bool checkA if (shouldFireActions) { + if (NetworkServer.activeHost && + networkBehaviour.syncDirection == SyncDirection.ServerToClient && + !networkBehaviour.IsHostClientObserved()) + { + return; + } + // Defer Actions during initial spawn on pure client to eliminate // cross-object reference race conditions. All objects will be in // NetworkClient.spawned before any Actions fire. diff --git a/Assets/Mirror/Core/SyncSet.cs b/Assets/Mirror/Core/SyncSet.cs index 57e6437b72f..015b081b008 100644 --- a/Assets/Mirror/Core/SyncSet.cs +++ b/Assets/Mirror/Core/SyncSet.cs @@ -114,6 +114,13 @@ void AddOperation(Operation op, T oldItem, T newItem, bool checkAccess, bool sho if (shouldFireActions) { + if (NetworkServer.activeHost && + networkBehaviour.syncDirection == SyncDirection.ServerToClient && + !networkBehaviour.IsHostClientObserved()) + { + return; + } + // Defer Actions during initial spawn on pure client to eliminate // cross-object reference race conditions. All objects will be in // NetworkClient.spawned before any Actions fire. From a93d909dfc8dda47d171471f790dc0a1600f5495 Mon Sep 17 00:00:00 2001 From: MrGadget <9826063+MrGadget1024@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:46:30 -0400 Subject: [PATCH 17/48] Add missing meta files --- .../SyncCollectionActionTest_HostVisibility.cs.meta | 11 +++++++++++ .../SyncVarAttributeHook_HostVisibilityTest.cs.meta | 11 +++++++++++ 2 files changed, 22 insertions(+) create mode 100644 Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs.meta create mode 100644 Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs.meta diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs.meta b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs.meta new file mode 100644 index 00000000000..21c0e04ee66 --- /dev/null +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 92c6aab47ab30734d915fabb61a98677 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs.meta b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs.meta new file mode 100644 index 00000000000..281aa536b87 --- /dev/null +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 85be558d97fa9184fad3693f974d39ee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 744bc2f37f95d1d603be02f2e3cdf37e6ea0dc6c Mon Sep 17 00:00:00 2001 From: MrGadget <9826063+MrGadget1024@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:46:48 -0400 Subject: [PATCH 18/48] Add Debug.Log to RandomColor --- Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs b/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs index 3763e386bed..53732174406 100644 --- a/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs +++ b/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs @@ -1,4 +1,4 @@ -using UnityEngine; +using UnityEngine; namespace Mirror.Examples.Common { @@ -17,6 +17,7 @@ void SetColor(Color32 _, Color32 newColor) { if (cachedMaterial == null) cachedMaterial = GetComponentInChildren().material; cachedMaterial.color = newColor; + Debug.Log($"SetColor: {newColor} for {gameObject.name}", gameObject); } public override void OnStartServer() From 793c7004af1135566dd31279ab26571e95b7ad91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:54:08 +0000 Subject: [PATCH 19/48] fix: replay host SyncVar hooks on re-observe --- Assets/Mirror/Core/NetworkBehaviour.cs | 12 +++++------- .../Mirror/Examples/_Common/Scripts/RandomColor.cs | 1 - .../SyncVarAttributeHook_HostVisibilityTest.cs | 8 ++++++++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index e7fcf73839d..d013ba44177 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -188,9 +188,7 @@ void CaptureHostBaseline(T previous, ref T originalValue, ref bool originalVa if (originalValueSet) return; - if (!NetworkClient.spawned.ContainsKey(netIdentity.netId)) - originalValue = previous; - + originalValue = previous; originalValueSet = true; } @@ -629,6 +627,7 @@ public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, { if (IsHostClientObserved()) { + CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); @@ -673,6 +672,7 @@ public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObj { if (IsHostClientObserved()) { + CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); @@ -717,6 +717,7 @@ public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, r { if (IsHostClientObserved()) { + CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); @@ -763,6 +764,7 @@ public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field { if (IsHostClientObserved()) { + CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); @@ -949,7 +951,6 @@ public void GeneratedSyncVarDeserialize_Hook(ref T field, Action OnChan SetSyncVarHookGuard(dirtyBit, false); } - originalValueSet = false; } } else if (!SyncVarEqual(previous, ref field)) @@ -1015,7 +1016,6 @@ public void GeneratedSyncVarDeserialize_GameObject_Hook(ref GameObject field, Ac SetSyncVarHookGuard(dirtyBit, false); } - originalValueSet = false; } } else if (!SyncVarEqual(previousNetId, ref netIdField)) @@ -1081,7 +1081,6 @@ public void GeneratedSyncVarDeserialize_NetworkIdentity_Hook(ref NetworkIdentity SetSyncVarHookGuard(dirtyBit, false); } - originalValueSet = false; } } else if (!SyncVarEqual(previousNetId, ref netIdField)) @@ -1149,7 +1148,6 @@ public void GeneratedSyncVarDeserialize_NetworkBehaviour_Hook(ref T field, Ac SetSyncVarHookGuard(dirtyBit, false); } - originalValueSet = false; } } else if (!SyncVarEqual(previousNetId, ref netIdField)) diff --git a/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs b/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs index 53732174406..3d784617cf4 100644 --- a/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs +++ b/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs @@ -17,7 +17,6 @@ void SetColor(Color32 _, Color32 newColor) { if (cachedMaterial == null) cachedMaterial = GetComponentInChildren().material; cachedMaterial.color = newColor; - Debug.Log($"SetColor: {newColor} for {gameObject.name}", gameObject); } public override void OnStartServer() diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs index 933e1798654..57ccd1791c8 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -163,6 +163,14 @@ public void Hook_DoesNotReplayHiddenChangesAfterLeavingAOI() AssertObserved(identity, false); behaviour.hookValues.Clear(); + RebuildLocalObserver(identity, Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + + RebuildLocalObserver(identity, Vector3.right * (aoi.visRange + 1)); + AssertObserved(identity, false); + behaviour.hookValues.Clear(); + behaviour.value = 150; behaviour.value = 200; From 3410ecdf74f18475c6af3b44b8cf45ead42a734f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:56:20 +0000 Subject: [PATCH 20/48] refactor: clarify host hook baseline capture --- Assets/Mirror/Core/NetworkBehaviour.cs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index d013ba44177..10fcc7f04a9 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -188,6 +188,8 @@ void CaptureHostBaseline(T previous, ref T originalValue, ref bool originalVa if (originalValueSet) return; + // Re-observation should replay the same hook oldValue as a first + // observation again, even after the host has already spawned/seen it. originalValue = previous; originalValueSet = true; } @@ -627,12 +629,13 @@ public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, { if (IsHostClientObserved()) { - CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); + if (!originalValueSet) + CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (ShouldCaptureHostBaseline()) + else if (ShouldCaptureHostBaseline() && !originalValueSet) CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); } } @@ -672,12 +675,13 @@ public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObj { if (IsHostClientObserved()) { - CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); + if (!originalValueSet) + CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (ShouldCaptureHostBaseline()) + else if (ShouldCaptureHostBaseline() && !originalValueSet) CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); } } @@ -717,12 +721,13 @@ public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, r { if (IsHostClientObserved()) { - CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); + if (!originalValueSet) + CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (ShouldCaptureHostBaseline()) + else if (ShouldCaptureHostBaseline() && !originalValueSet) CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); } } @@ -764,12 +769,13 @@ public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field { if (IsHostClientObserved()) { - CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); + if (!originalValueSet) + CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (ShouldCaptureHostBaseline()) + else if (ShouldCaptureHostBaseline() && !originalValueSet) CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); } } From 3aa87ca77ce40f25fb148885d6c1fbdb025f8cda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:59:08 +0000 Subject: [PATCH 21/48] fix: avoid host replay clearing sync collection deltas --- Assets/Mirror/Core/NetworkBehaviour.cs | 24 ++++++ Assets/Mirror/Core/NetworkClient.cs | 45 +++-------- Assets/Mirror/Core/SyncDictionary.cs | 29 ++++--- Assets/Mirror/Core/SyncList.cs | 29 ++++--- Assets/Mirror/Core/SyncObject.cs | 3 + Assets/Mirror/Core/SyncSet.cs | 27 ++++--- .../Processors/NetworkBehaviourProcessor.cs | 50 ++++++++++++ Assets/Mirror/Editor/Weaver/WeaverTypes.cs | 2 + ...SyncCollectionActionTest_HostVisibility.cs | 76 +++++++++++++++++++ 9 files changed, 214 insertions(+), 71 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index 10fcc7f04a9..b177c93601e 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -207,6 +207,17 @@ internal void InvokeDeferredSyncCallbacks() deferredSyncCollectionActions.Clear(); } + protected virtual void InvokeSyncVarHostVisibilityHooks() {} + + internal void InvokeHostVisibilityDeferredCallbacks() + { + for (int i = 0; i < syncObjects.Count; ++i) + syncObjects[i].QueueHostVisibilityReplay(); + + InvokeSyncVarHostVisibilityHooks(); + InvokeDeferredSyncCallbacks(); + } + protected virtual void OnValidate() { // Skip if Editor is in Play mode @@ -939,6 +950,19 @@ public void GeneratedSyncVarDeserialize(ref T field, Action OnChanged, } } + public void GeneratedSyncVarHostVisibilityHook(ref T field, Action OnChanged, ulong dirtyBit, ref T originalValue, ref bool originalValueSet) + { + if (OnChanged != null && + originalValueSet && + !GetSyncVarHookGuard(dirtyBit) && + !SyncVarEqual(originalValue, ref field)) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(originalValue, field); + SetSyncVarHookGuard(dirtyBit, false); + } + } + public void GeneratedSyncVarDeserialize_Hook(ref T field, Action OnChanged, T value, ulong dirtyBit, ref T originalValue, ref bool originalValueSet) { T previous = field; diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index db6f1cdb191..00027acc6a1 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -1129,25 +1129,8 @@ static void FlushHostVisibilityDeferredCallbacks() identity.hostInitialSpawn = true; try { - using (NetworkWriterPooled ownerWriter = NetworkWriterPool.Get(), observersWriter = NetworkWriterPool.Get()) - { - identity.SerializeServer_Spawn(ownerWriter, observersWriter); - - ArraySegment payload = identity.connectionToClient == NetworkServer.localConnection - ? ownerWriter.ToArraySegment() - : observersWriter.ToArraySegment(); - - if (payload.Count > 0) - { - using (NetworkReaderPooled payloadReader = NetworkReaderPool.Get(payload)) - { - identity.DeserializeClient(payloadReader, true); - } - } - } - foreach (NetworkBehaviour comp in identity.NetworkBehaviours) - comp.InvokeDeferredSyncCallbacks(); + comp.InvokeHostVisibilityDeferredCallbacks(); } finally { @@ -1457,31 +1440,21 @@ internal static void OnHostClientSpawn(SpawnMessage message) identity.isOwned = message.isOwner; - // Ensure SyncVar hooks fire during deserialization for host client initial spawn. - // Fields were already set server-side, but hooks haven't fired yet because the - // object wasn't in NetworkClient.spawned when setters ran during OnStartServer(). + // Ensure host-visible SyncVar hooks and SyncCollection Add replays flush now. + // Fields were already set server-side, but host callbacks may have been deferred + // until the object was actually visible to the host client. identity.hostInitialSpawn = true; - // Configure flags before deserializing + // Configure flags before invoking host-visible callbacks. InitializeIdentityFlags(identity); - // Deserialize components if any payload. - // This will trigger SyncVar hooks via GeneratedSyncVarDeserialize. - if (message.payload.Count > 0) - { - using (NetworkReaderPooled payloadReader = NetworkReaderPool.Get(message.payload)) - { - identity.DeserializeClient(payloadReader, true); - } - } + foreach (NetworkBehaviour comp in identity.NetworkBehaviours) + comp.InvokeHostVisibilityDeferredCallbacks(); - // Clear flag after deserialization + // Clear flag after host-visible callbacks replay. identity.hostInitialSpawn = false; - foreach (NetworkBehaviour comp in identity.NetworkBehaviours) - comp.InvokeDeferredSyncCallbacks(); - - // Invoke callbacks after deserializing + // Invoke callbacks after host-visible state is ready. InvokeIdentityCallbacks(identity); } } diff --git a/Assets/Mirror/Core/SyncDictionary.cs b/Assets/Mirror/Core/SyncDictionary.cs index 78ae60c72d2..7fd64dd0924 100644 --- a/Assets/Mirror/Core/SyncDictionary.cs +++ b/Assets/Mirror/Core/SyncDictionary.cs @@ -131,18 +131,7 @@ public override void OnDeserializeAll(NetworkReader reader) objects.Add(key, obj); } - if (NetworkServer.activeHost && - networkBehaviour.syncDirection == SyncDirection.ServerToClient && - networkBehaviour.netIdentity.hostInitialSpawn) - { - foreach (KeyValuePair entry in objects) - { - TKey capturedKey = entry.Key; - TValue capturedValue = entry.Value; - networkBehaviour.deferredSyncCollectionActions.Add(() => - InvokeActions(Operation.OP_ADD, capturedKey, capturedValue, default)); - } - } + QueueHostVisibilityReplay(); // We will need to skip all these changes // the next time the list is synchronized @@ -150,6 +139,22 @@ public override void OnDeserializeAll(NetworkReader reader) changesAhead = (int)reader.ReadUInt(); } + public override void QueueHostVisibilityReplay() + { + if (!(NetworkServer.activeHost && + networkBehaviour.syncDirection == SyncDirection.ServerToClient && + networkBehaviour.netIdentity.hostInitialSpawn)) + return; + + foreach (KeyValuePair entry in objects) + { + TKey capturedKey = entry.Key; + TValue capturedValue = entry.Value; + networkBehaviour.deferredSyncCollectionActions.Add(() => + InvokeActions(Operation.OP_ADD, capturedKey, capturedValue, default)); + } + } + public override void OnDeserializeDelta(NetworkReader reader) { int changesCount = (int)reader.ReadUInt(); diff --git a/Assets/Mirror/Core/SyncList.cs b/Assets/Mirror/Core/SyncList.cs index 96680e8a8e8..041abeb5b79 100644 --- a/Assets/Mirror/Core/SyncList.cs +++ b/Assets/Mirror/Core/SyncList.cs @@ -247,18 +247,7 @@ public override void OnDeserializeAll(NetworkReader reader) objects.Add(obj); } - if (NetworkServer.activeHost && - networkBehaviour.syncDirection == SyncDirection.ServerToClient && - networkBehaviour.netIdentity.hostInitialSpawn) - { - for (int i = 0; i < objects.Count; i++) - { - int capturedIndex = i; - T capturedValue = objects[i]; - networkBehaviour.deferredSyncCollectionActions.Add(() => - InvokeActions(Operation.OP_ADD, capturedIndex, default, capturedValue)); - } - } + QueueHostVisibilityReplay(); // We will need to skip all these changes // the next time the list is synchronized @@ -266,6 +255,22 @@ public override void OnDeserializeAll(NetworkReader reader) changesAhead = (int)reader.ReadUInt(); } + public override void QueueHostVisibilityReplay() + { + if (!(NetworkServer.activeHost && + networkBehaviour.syncDirection == SyncDirection.ServerToClient && + networkBehaviour.netIdentity.hostInitialSpawn)) + return; + + for (int i = 0; i < objects.Count; i++) + { + int capturedIndex = i; + T capturedValue = objects[i]; + networkBehaviour.deferredSyncCollectionActions.Add(() => + InvokeActions(Operation.OP_ADD, capturedIndex, default, capturedValue)); + } + } + public override void OnDeserializeDelta(NetworkReader reader) { int changesCount = (int)reader.ReadUInt(); diff --git a/Assets/Mirror/Core/SyncObject.cs b/Assets/Mirror/Core/SyncObject.cs index f3246e9e435..e8554ef6c7f 100644 --- a/Assets/Mirror/Core/SyncObject.cs +++ b/Assets/Mirror/Core/SyncObject.cs @@ -50,6 +50,9 @@ public abstract class SyncObject /// Reads the changes made to the object since last sync public abstract void OnDeserializeDelta(NetworkReader reader); + /// Queues host-mode first-observation Add replay actions without mutating server state. + public virtual void QueueHostVisibilityReplay() {} + /// Resets the SyncObject so that it can be re-used public abstract void Reset(); } diff --git a/Assets/Mirror/Core/SyncSet.cs b/Assets/Mirror/Core/SyncSet.cs index 015b081b008..3c50d66b416 100644 --- a/Assets/Mirror/Core/SyncSet.cs +++ b/Assets/Mirror/Core/SyncSet.cs @@ -216,17 +216,7 @@ public override void OnDeserializeAll(NetworkReader reader) objects.Add(obj); } - if (NetworkServer.activeHost && - networkBehaviour.syncDirection == SyncDirection.ServerToClient && - networkBehaviour.netIdentity.hostInitialSpawn) - { - foreach (T item in objects) - { - T capturedValue = item; - networkBehaviour.deferredSyncCollectionActions.Add(() => - InvokeActions(Operation.OP_ADD, default, capturedValue)); - } - } + QueueHostVisibilityReplay(); // We will need to skip all these changes // the next time the list is synchronized @@ -234,6 +224,21 @@ public override void OnDeserializeAll(NetworkReader reader) changesAhead = (int)reader.ReadUInt(); } + public override void QueueHostVisibilityReplay() + { + if (!(NetworkServer.activeHost && + networkBehaviour.syncDirection == SyncDirection.ServerToClient && + networkBehaviour.netIdentity.hostInitialSpawn)) + return; + + foreach (T item in objects) + { + T capturedValue = item; + networkBehaviour.deferredSyncCollectionActions.Add(() => + InvokeActions(Operation.OP_ADD, default, capturedValue)); + } + } + public override void OnDeserializeDelta(NetworkReader reader) { int changesCount = (int)reader.ReadUInt(); diff --git a/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs b/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs index 32c7fca7f8b..7da650ed695 100644 --- a/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs +++ b/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs @@ -99,6 +99,7 @@ public bool Process(ref bool WeavingFailed) } GenerateDeSerialization(ref WeavingFailed); + GenerateHostVisibilityHookInvocation(ref WeavingFailed); return true; } @@ -807,6 +808,55 @@ void GenerateDeSerialization(ref bool WeavingFailed) netBehaviourSubclass.Methods.Add(serialize); } + void GenerateHostVisibilityHookInvocation(ref bool WeavingFailed) + { + const string MethodName = "InvokeSyncVarHostVisibilityHooks"; + if (netBehaviourSubclass.GetMethod(MethodName) != null) + return; + + if (syncVarHookDelegates.Count == 0) + return; + + MethodDefinition method = new MethodDefinition(MethodName, + MethodAttributes.Family | MethodAttributes.Virtual | MethodAttributes.HideBySig, + weaverTypes.Import(typeof(void))); + + ILProcessor worker = method.Body.GetILProcessor(); + + MethodReference baseMethod = Resolvers.TryResolveMethodInParents(netBehaviourSubclass.BaseType, assembly, MethodName); + if (baseMethod != null) + { + worker.Append(worker.Create(OpCodes.Ldarg_0)); + worker.Append(worker.Create(OpCodes.Call, baseMethod)); + } + + int dirtyBit = syncVarAccessLists.GetSyncVarStart(netBehaviourSubclass.BaseType.FullName); + foreach (FieldDefinition syncVar in syncVars) + { + if (syncVarHookDelegates.TryGetValue(syncVar, out SyncVarAttributeProcessor.SyncVarHookData hookData)) + { + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, syncVar); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldfld, hookData.hookDelegateField); + worker.Emit(OpCodes.Ldc_I8, 1L << dirtyBit); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + + MethodReference generic = weaverTypes.generatedSyncVarHostVisibilityHook.MakeGeneric(assembly.MainModule, syncVar.FieldType); + worker.Emit(OpCodes.Call, generic); + } + + dirtyBit += 1; + } + + worker.Emit(OpCodes.Ret); + netBehaviourSubclass.Methods.Add(method); + } + public static bool ReadArguments(MethodDefinition method, Readers readers, Logger Log, ILProcessor worker, RemoteCallType callType, ref bool WeavingFailed) { // read each argument diff --git a/Assets/Mirror/Editor/Weaver/WeaverTypes.cs b/Assets/Mirror/Editor/Weaver/WeaverTypes.cs index 7324ff00237..bbb46b1173b 100644 --- a/Assets/Mirror/Editor/Weaver/WeaverTypes.cs +++ b/Assets/Mirror/Editor/Weaver/WeaverTypes.cs @@ -43,6 +43,7 @@ public class WeaverTypes public MethodReference generatedSyncVarSetter_NetworkIdentity_Hook; public MethodReference generatedSyncVarSetter_NetworkBehaviour_T; public MethodReference generatedSyncVarSetter_NetworkBehaviour_Hook_T; + public MethodReference generatedSyncVarHostVisibilityHook; public MethodReference generatedSyncVarDeserialize; public MethodReference generatedSyncVarDeserialize_Hook; public MethodReference generatedSyncVarDeserialize_GameObject; @@ -118,6 +119,7 @@ public WeaverTypes(AssemblyDefinition assembly, Logger Log, ref bool WeavingFail generatedSyncVarSetter_NetworkIdentity_Hook = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter_NetworkIdentity_Hook", ref WeavingFailed); generatedSyncVarSetter_NetworkBehaviour_T = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter_NetworkBehaviour", ref WeavingFailed); generatedSyncVarSetter_NetworkBehaviour_Hook_T = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter_NetworkBehaviour_Hook", ref WeavingFailed); + generatedSyncVarHostVisibilityHook = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarHostVisibilityHook", ref WeavingFailed); generatedSyncVarDeserialize_GameObject = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarDeserialize_GameObject", ref WeavingFailed); generatedSyncVarDeserialize_GameObject_Hook = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarDeserialize_GameObject_Hook", ref WeavingFailed); diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs index ed70080d298..56f40b21bbb 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -75,6 +75,16 @@ void AssertObserved(NetworkIdentity identity, bool expected) Assert.That(identity.observers.ContainsKey(NetworkServer.localConnection.connectionId), Is.EqualTo(expected)); } + static int GetDeltaChangeCount(SyncObject syncObject) + { + using (NetworkWriterPooled writer = NetworkWriterPool.Get()) + { + syncObject.OnSerializeDelta(writer); + using (NetworkReaderPooled reader = NetworkReaderPool.Get(writer.ToArraySegment())) + return (int)reader.ReadUInt(); + } + } + [Test] public void SyncList_ActionsDeferUntilObserved() { @@ -174,6 +184,28 @@ public void SyncList_ActionsDeferAgainAfterLeavingAoi() Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first", "Add:second" })); } + [Test] + public void SyncList_HostVisibilityReplayPreservesPendingRemoteChanges() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilitySyncListBehaviour behaviour); + go.transform.position = Vector3.zero; + behaviour.Register(); + NetworkServer.Spawn(go); + ProcessMessages(); + + NetworkConnectionToClient remoteConnection = new NetworkConnectionToClient(42); + NetworkServer.connections[remoteConnection.connectionId] = remoteConnection; + identity.AddObserver(remoteConnection); + + behaviour.list.Add("first"); + Assert.That(GetDeltaChangeCount(behaviour.list), Is.EqualTo(1)); + + AddLocalPlayer(Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + Assert.That(GetDeltaChangeCount(behaviour.list), Is.EqualTo(1)); + } + [Test] public void SyncDictionary_ActionsDeferAgainAfterLeavingAoi() { @@ -204,6 +236,28 @@ public void SyncDictionary_ActionsDeferAgainAfterLeavingAoi() Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first", "Add:key2:second" })); } + [Test] + public void SyncDictionary_HostVisibilityReplayPreservesPendingRemoteChanges() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilitySyncDictionaryBehaviour behaviour); + go.transform.position = Vector3.zero; + behaviour.Register(); + NetworkServer.Spawn(go); + ProcessMessages(); + + NetworkConnectionToClient remoteConnection = new NetworkConnectionToClient(42); + NetworkServer.connections[remoteConnection.connectionId] = remoteConnection; + identity.AddObserver(remoteConnection); + + behaviour.dictionary.Add("key1", "first"); + Assert.That(GetDeltaChangeCount(behaviour.dictionary), Is.EqualTo(1)); + + AddLocalPlayer(Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first" })); + Assert.That(GetDeltaChangeCount(behaviour.dictionary), Is.EqualTo(1)); + } + [Test] public void SyncSet_ActionsDeferAgainAfterLeavingAoi() { @@ -233,5 +287,27 @@ public void SyncSet_ActionsDeferAgainAfterLeavingAoi() AssertObserved(identity, true); Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first", "Add:second" })); } + + [Test] + public void SyncSet_HostVisibilityReplayPreservesPendingRemoteChanges() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilitySyncSetBehaviour behaviour); + go.transform.position = Vector3.zero; + behaviour.Register(); + NetworkServer.Spawn(go); + ProcessMessages(); + + NetworkConnectionToClient remoteConnection = new NetworkConnectionToClient(42); + NetworkServer.connections[remoteConnection.connectionId] = remoteConnection; + identity.AddObserver(remoteConnection); + + behaviour.set.Add("first"); + Assert.That(GetDeltaChangeCount(behaviour.set), Is.EqualTo(1)); + + AddLocalPlayer(Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + Assert.That(GetDeltaChangeCount(behaviour.set), Is.EqualTo(1)); + } } } From f99c312bd43fe6075721e8c079486d1695791b86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:16:03 +0000 Subject: [PATCH 22/48] test: isolate host visibility replay delta assertions --- ...SyncCollectionActionTest_HostVisibility.cs | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs index 56f40b21bbb..54582825f70 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -62,6 +62,13 @@ void AddLocalPlayer(Vector3 position) ProcessMessages(); } + void AddLocalPlayerWithoutProcessing(Vector3 position) + { + CreateNetworked(out GameObject player, out _); + player.transform.position = position; + NetworkServer.AddPlayerForConnection(NetworkServer.localConnection, player); + } + void RebuildLocalObserver(NetworkIdentity identity, Vector3 localPlayerPosition) { NetworkClient.localPlayer.transform.position = localPlayerPosition; @@ -69,6 +76,20 @@ void RebuildLocalObserver(NetworkIdentity identity, Vector3 localPlayerPosition) ProcessMessages(); } + static void InvokeHostVisibilityReplay(NetworkIdentity identity) + { + identity.hostInitialSpawn = true; + try + { + foreach (NetworkBehaviour component in identity.NetworkBehaviours) + component.InvokeHostVisibilityDeferredCallbacks(); + } + finally + { + identity.hostInitialSpawn = false; + } + } + void AssertObserved(NetworkIdentity identity, bool expected) { Assert.That(NetworkServer.localConnection.observing.Contains(identity), Is.EqualTo(expected)); @@ -200,8 +221,9 @@ public void SyncList_HostVisibilityReplayPreservesPendingRemoteChanges() behaviour.list.Add("first"); Assert.That(GetDeltaChangeCount(behaviour.list), Is.EqualTo(1)); - AddLocalPlayer(Vector3.zero); + AddLocalPlayerWithoutProcessing(Vector3.zero); AssertObserved(identity, true); + InvokeHostVisibilityReplay(identity); Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); Assert.That(GetDeltaChangeCount(behaviour.list), Is.EqualTo(1)); } @@ -252,8 +274,9 @@ public void SyncDictionary_HostVisibilityReplayPreservesPendingRemoteChanges() behaviour.dictionary.Add("key1", "first"); Assert.That(GetDeltaChangeCount(behaviour.dictionary), Is.EqualTo(1)); - AddLocalPlayer(Vector3.zero); + AddLocalPlayerWithoutProcessing(Vector3.zero); AssertObserved(identity, true); + InvokeHostVisibilityReplay(identity); Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first" })); Assert.That(GetDeltaChangeCount(behaviour.dictionary), Is.EqualTo(1)); } @@ -304,8 +327,9 @@ public void SyncSet_HostVisibilityReplayPreservesPendingRemoteChanges() behaviour.set.Add("first"); Assert.That(GetDeltaChangeCount(behaviour.set), Is.EqualTo(1)); - AddLocalPlayer(Vector3.zero); + AddLocalPlayerWithoutProcessing(Vector3.zero); AssertObserved(identity, true); + InvokeHostVisibilityReplay(identity); Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); Assert.That(GetDeltaChangeCount(behaviour.set), Is.EqualTo(1)); } From c79d45aad88e09b3790e9bb25c4cb928dfc8d6f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:16:32 +0000 Subject: [PATCH 23/48] test: clarify host visibility replay helper --- .../SyncCollectionActionTest_HostVisibility.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs index 54582825f70..dd6cb0a14b7 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -76,7 +76,7 @@ void RebuildLocalObserver(NetworkIdentity identity, Vector3 localPlayerPosition) ProcessMessages(); } - static void InvokeHostVisibilityReplay(NetworkIdentity identity) + static void InvokeHostVisibilityDeferredCallbacks(NetworkIdentity identity) { identity.hostInitialSpawn = true; try @@ -223,7 +223,7 @@ public void SyncList_HostVisibilityReplayPreservesPendingRemoteChanges() AddLocalPlayerWithoutProcessing(Vector3.zero); AssertObserved(identity, true); - InvokeHostVisibilityReplay(identity); + InvokeHostVisibilityDeferredCallbacks(identity); Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); Assert.That(GetDeltaChangeCount(behaviour.list), Is.EqualTo(1)); } @@ -276,7 +276,7 @@ public void SyncDictionary_HostVisibilityReplayPreservesPendingRemoteChanges() AddLocalPlayerWithoutProcessing(Vector3.zero); AssertObserved(identity, true); - InvokeHostVisibilityReplay(identity); + InvokeHostVisibilityDeferredCallbacks(identity); Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first" })); Assert.That(GetDeltaChangeCount(behaviour.dictionary), Is.EqualTo(1)); } @@ -329,7 +329,7 @@ public void SyncSet_HostVisibilityReplayPreservesPendingRemoteChanges() AddLocalPlayerWithoutProcessing(Vector3.zero); AssertObserved(identity, true); - InvokeHostVisibilityReplay(identity); + InvokeHostVisibilityDeferredCallbacks(identity); Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); Assert.That(GetDeltaChangeCount(behaviour.set), Is.EqualTo(1)); } From 1f3b2ea9d7da972857361ac4fbfe22c3eb8b3aa2 Mon Sep 17 00:00:00 2001 From: MrGadget <9826063+MrGadget1024@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:48:23 -0400 Subject: [PATCH 24/48] Add Distance IM to PlayerTest Scene --- .../Examples/PlayerTest/PlayerTestScene.unity | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/Assets/Mirror/Examples/PlayerTest/PlayerTestScene.unity b/Assets/Mirror/Examples/PlayerTest/PlayerTestScene.unity index 40845735073..48b15f8db19 100644 --- a/Assets/Mirror/Examples/PlayerTest/PlayerTestScene.unity +++ b/Assets/Mirror/Examples/PlayerTest/PlayerTestScene.unity @@ -230,6 +230,7 @@ GameObject: - component: {fileID: 856494734} - component: {fileID: 856494736} - component: {fileID: 856494735} + - component: {fileID: 856494742} m_Layer: 0 m_Name: Cube m_TagString: Untagged @@ -270,8 +271,8 @@ MonoBehaviour: syncDirection: 0 syncMode: 0 syncInterval: 0 - updateMethod: 1 target: {fileID: 856494741} + updateMethod: 1 syncPosition: 1 syncRotation: 1 syncScale: 0 @@ -413,12 +414,31 @@ Transform: m_GameObject: {fileID: 856494733} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 2, z: -200} + m_LocalPosition: {x: -325.63, y: 12.57, z: 1.38} m_LocalScale: {x: 4, y: 4, z: 4} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &856494742 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 856494733} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a91a718a70d01b347b75cb768a6f1a92, type: 3} + m_Name: + m_EditorClassIdentifier: + syncMethod: 0 + syncDirection: 0 + syncMode: 0 + syncInterval: 0 + color: + serializedVersion: 2 + rgba: 4278190080 --- !u!1 &890531170 GameObject: m_ObjectHideFlags: 0 @@ -812,6 +832,7 @@ GameObject: - component: {fileID: 2126814506} - component: {fileID: 2126814504} - component: {fileID: 2126814508} + - component: {fileID: 2126814509} m_Layer: 0 m_Name: Network m_TagString: Untagged @@ -882,20 +903,20 @@ MonoBehaviour: sendRate: 60 unreliableBaselineRate: 1 unreliableRedundancy: 0 - offlineScene: - onlineScene: - offlineSceneLoadDelay: 0 transport: {fileID: 2126814504} networkAddress: localhost maxConnections: 100 disconnectInactiveConnections: 0 disconnectInactiveTimeout: 60 + exceptionsDisconnect: 1 authenticator: {fileID: 0} + offlineScene: + onlineScene: + offlineSceneLoadDelay: 0 playerPrefab: {fileID: 7197623925735895267, guid: 26433cf84d806564491a56c685a18f82, type: 3} autoCreatePlayer: 1 playerSpawnMethod: 1 spawnPrefabs: [] - exceptionsDisconnect: 1 snapshotSettings: bufferTimeMultiplier: 2 bufferLimit: 32 @@ -938,6 +959,22 @@ MonoBehaviour: m_EditorClassIdentifier: showGUI: 1 showLog: 0 +--- !u!114 &2126814509 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2126814503} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 8f60becab051427fbdd3c8ac9ab4712b, type: 3} + m_Name: + m_EditorClassIdentifier: + visRange: 10 + minMoveDistance: 0.1 + rebuildInterval: 1 + staticRebuildInterval: 10 --- !u!1660057539 &9223372036854775807 SceneRoots: m_ObjectHideFlags: 0 From 7d5ef90aabf754809a8aa57a23facdf768fbdfa7 Mon Sep 17 00:00:00 2001 From: MrGadget <9826063+MrGadget1024@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:48:37 -0400 Subject: [PATCH 25/48] Add logging to RandomColor --- Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs b/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs index 3d784617cf4..35f1195e1fc 100644 --- a/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs +++ b/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs @@ -13,10 +13,11 @@ public class RandomColor : NetworkBehaviour [SyncVar(hook = nameof(SetColor))] public Color32 color = Color.black; - void SetColor(Color32 _, Color32 newColor) + void SetColor(Color32 oldColor, Color32 newColor) { if (cachedMaterial == null) cachedMaterial = GetComponentInChildren().material; cachedMaterial.color = newColor; + Debug.Log($"SetColor: {oldColor} -> {newColor} for {gameObject.name}", gameObject); } public override void OnStartServer() From 3e2e7d04d2e31b40446676f20e8f8d078663ccac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:00:40 +0000 Subject: [PATCH 26/48] Fix scene object SyncVar hook replay --- Assets/Mirror/Core/NetworkBehaviour.cs | 80 +++++++++++++++++++ Assets/Mirror/Core/NetworkClient.cs | 10 ++- Assets/Mirror/Core/NetworkIdentity.cs | 5 ++ ...rAttributeHook_ClientSceneReobserveTest.cs | 54 +++++++++++++ ...ibuteHook_ClientSceneReobserveTest.cs.meta | 11 +++ 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_ClientSceneReobserveTest.cs create mode 100644 Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_ClientSceneReobserveTest.cs.meta diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index b177c93601e..21b2a85d332 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -194,6 +194,18 @@ void CaptureHostBaseline(T previous, ref T originalValue, ref bool originalVa originalValueSet = true; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + T GetClientInitialBaseline(T previous, ref T originalValue, ref bool originalValueSet) + { + if (!originalValueSet) + { + originalValue = previous; + originalValueSet = true; + } + + return originalValue; + } + internal void InvokeDeferredSyncCallbacks() { foreach (Action hook in deferredSyncVarHooks) @@ -983,6 +995,23 @@ public void GeneratedSyncVarDeserialize_Hook(ref T field, Action OnChan } } + else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawn) + { + T baseline = GetClientInitialBaseline(previous, ref originalValue, ref originalValueSet); + if (!SyncVarEqual(baseline, ref field)) + { + if (!NetworkClient.isSpawnFinished) + { + T capturedPrevious = baseline; + T capturedNew = field; + deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); + } + else + { + OnChanged(baseline, field); + } + } + } else if (!SyncVarEqual(previous, ref field)) { if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) @@ -1048,6 +1077,23 @@ public void GeneratedSyncVarDeserialize_GameObject_Hook(ref GameObject field, Ac } } + else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawn) + { + GameObject baseline = GetClientInitialBaseline(previousGameObject, ref originalValue, ref originalValueSet); + if (!SyncVarEqual(baseline, ref field)) + { + if (!NetworkClient.isSpawnFinished) + { + GameObject capturedPrevious = baseline; + GameObject capturedNew = field; + deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); + } + else + { + OnChanged(baseline, field); + } + } + } else if (!SyncVarEqual(previousNetId, ref netIdField)) { if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) @@ -1113,6 +1159,23 @@ public void GeneratedSyncVarDeserialize_NetworkIdentity_Hook(ref NetworkIdentity } } + else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawn) + { + NetworkIdentity baseline = GetClientInitialBaseline(previousIdentity, ref originalValue, ref originalValueSet); + if (!SyncVarEqual(baseline, ref field)) + { + if (!NetworkClient.isSpawnFinished) + { + NetworkIdentity capturedPrevious = baseline; + NetworkIdentity capturedNew = field; + deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); + } + else + { + OnChanged(baseline, field); + } + } + } else if (!SyncVarEqual(previousNetId, ref netIdField)) { if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) @@ -1180,6 +1243,23 @@ public void GeneratedSyncVarDeserialize_NetworkBehaviour_Hook(ref T field, Ac } } + else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawn) + { + T baseline = GetClientInitialBaseline(previousBehaviour, ref originalValue, ref originalValueSet); + if (!SyncVarEqual(baseline, ref field)) + { + if (!NetworkClient.isSpawnFinished) + { + T capturedPrevious = baseline; + T capturedNew = field; + deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); + } + else + { + OnChanged(baseline, field); + } + } + } else if (!SyncVarEqual(previousNetId, ref netIdField)) { if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index 00027acc6a1..26d14858e89 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -1212,7 +1212,15 @@ internal static void ApplySpawnPayload(NetworkIdentity identity, SpawnMessage me { using (NetworkReaderPooled payloadReader = NetworkReaderPool.Get(message.payload)) { - identity.DeserializeClient(payloadReader, true); + identity.clientInitialSpawn = true; + try + { + identity.DeserializeClient(payloadReader, true); + } + finally + { + identity.clientInitialSpawn = false; + } } } diff --git a/Assets/Mirror/Core/NetworkIdentity.cs b/Assets/Mirror/Core/NetworkIdentity.cs index 9ffe04d0a97..d8467d75979 100644 --- a/Assets/Mirror/Core/NetworkIdentity.cs +++ b/Assets/Mirror/Core/NetworkIdentity.cs @@ -123,6 +123,11 @@ public sealed class NetworkIdentity : MonoBehaviour // only set temporarily during OnHostClientSpawn deserialization. internal bool hostInitialSpawn; + // flag to indicate a remote client is deserializing an initial spawn. + // scene objects may be re-used across hide/show cycles, so hook logic + // needs to treat each re-observation like a fresh first observation. + internal bool clientInitialSpawn; + /// The set of network connections (players) that can see this object. public readonly Dictionary observers = new Dictionary(); diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_ClientSceneReobserveTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_ClientSceneReobserveTest.cs new file mode 100644 index 00000000000..521c0acf00f --- /dev/null +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_ClientSceneReobserveTest.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +namespace Mirror.Tests.SyncVars +{ + class ClientSceneReobserveHookBehaviour : NetworkBehaviour + { + [SyncVar(hook = nameof(OnValueChanged))] + public int value = 42; + + public readonly List<(int oldValue, int newValue)> hookValues = new List<(int oldValue, int newValue)>(); + + void OnValueChanged(int oldValue, int newValue) => hookValues.Add((oldValue, newValue)); + } + + public class SyncVarAttributeHook_ClientSceneReobserveTest : MirrorEditModeTest + { + NetworkConnectionToClient connectionToClient; + + [SetUp] + public override void SetUp() + { + base.SetUp(); + NetworkServer.Listen(1); + ConnectClientBlockingAuthenticatedAndReady(out connectionToClient); + } + + [Test] + public void SceneObject_ReplaysHookFromOriginalBaselineWhenObservedAgain() + { + CreateNetworkedAndSpawn( + out _, out NetworkIdentity serverIdentity, out ClientSceneReobserveHookBehaviour serverBehaviour, + out GameObject clientGO, out _, out ClientSceneReobserveHookBehaviour clientBehaviour); + + serverBehaviour.value = 100; + ProcessMessages(); + Assert.That(clientBehaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + + NetworkServer.HideForConnection(serverIdentity, connectionToClient); + ProcessMessages(); + + Assert.That(clientGO.activeSelf, Is.False); + Assert.That(NetworkClient.spawned.ContainsKey(serverIdentity.netId), Is.False); + + NetworkServer.ShowForConnection(serverIdentity, connectionToClient); + ProcessMessages(); + + Assert.That(clientGO.activeSelf, Is.True); + Assert.That(NetworkClient.spawned.ContainsKey(serverIdentity.netId), Is.True); + Assert.That(clientBehaviour.hookValues, Is.EqualTo(new[] { (42, 100), (42, 100) })); + } + } +} diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_ClientSceneReobserveTest.cs.meta b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_ClientSceneReobserveTest.cs.meta new file mode 100644 index 00000000000..089c93abdd7 --- /dev/null +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_ClientSceneReobserveTest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2c087029f12a4b23b761f4f12b6b0053 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From b9f5b1d091793c3a5f49b6241eaba7295935dcce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:02:38 +0000 Subject: [PATCH 27/48] Clarify client initial spawn hook state --- Assets/Mirror/Core/NetworkBehaviour.cs | 11 +++++++---- Assets/Mirror/Core/NetworkClient.cs | 4 ++-- Assets/Mirror/Core/NetworkIdentity.cs | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index 21b2a85d332..d4e42bd2e58 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -197,6 +197,9 @@ void CaptureHostBaseline(T previous, ref T originalValue, ref bool originalVa [MethodImpl(MethodImplOptions.AggressiveInlining)] T GetClientInitialBaseline(T previous, ref T originalValue, ref bool originalValueSet) { + // Scene objects are re-used across hide/show cycles on remote clients. + // Preserve the very first local baseline so each initial spawn replay + // behaves like a fresh first observation again. if (!originalValueSet) { originalValue = previous; @@ -995,7 +998,7 @@ public void GeneratedSyncVarDeserialize_Hook(ref T field, Action OnChan } } - else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawn) + else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawnActive) { T baseline = GetClientInitialBaseline(previous, ref originalValue, ref originalValueSet); if (!SyncVarEqual(baseline, ref field)) @@ -1077,7 +1080,7 @@ public void GeneratedSyncVarDeserialize_GameObject_Hook(ref GameObject field, Ac } } - else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawn) + else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawnActive) { GameObject baseline = GetClientInitialBaseline(previousGameObject, ref originalValue, ref originalValueSet); if (!SyncVarEqual(baseline, ref field)) @@ -1159,7 +1162,7 @@ public void GeneratedSyncVarDeserialize_NetworkIdentity_Hook(ref NetworkIdentity } } - else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawn) + else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawnActive) { NetworkIdentity baseline = GetClientInitialBaseline(previousIdentity, ref originalValue, ref originalValueSet); if (!SyncVarEqual(baseline, ref field)) @@ -1243,7 +1246,7 @@ public void GeneratedSyncVarDeserialize_NetworkBehaviour_Hook(ref T field, Ac } } - else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawn) + else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawnActive) { T baseline = GetClientInitialBaseline(previousBehaviour, ref originalValue, ref originalValueSet); if (!SyncVarEqual(baseline, ref field)) diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index 26d14858e89..8e9294a9800 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -1212,14 +1212,14 @@ internal static void ApplySpawnPayload(NetworkIdentity identity, SpawnMessage me { using (NetworkReaderPooled payloadReader = NetworkReaderPool.Get(message.payload)) { - identity.clientInitialSpawn = true; + identity.clientInitialSpawnActive = true; try { identity.DeserializeClient(payloadReader, true); } finally { - identity.clientInitialSpawn = false; + identity.clientInitialSpawnActive = false; } } } diff --git a/Assets/Mirror/Core/NetworkIdentity.cs b/Assets/Mirror/Core/NetworkIdentity.cs index d8467d75979..e36174ce7a3 100644 --- a/Assets/Mirror/Core/NetworkIdentity.cs +++ b/Assets/Mirror/Core/NetworkIdentity.cs @@ -126,7 +126,7 @@ public sealed class NetworkIdentity : MonoBehaviour // flag to indicate a remote client is deserializing an initial spawn. // scene objects may be re-used across hide/show cycles, so hook logic // needs to treat each re-observation like a fresh first observation. - internal bool clientInitialSpawn; + internal bool clientInitialSpawnActive; /// The set of network connections (players) that can see this object. public readonly Dictionary observers = From 278f94e23bb0cd06fca564cdc983ccaeb52d58ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:04:41 +0000 Subject: [PATCH 28/48] Deduplicate SyncVar hook replay helpers --- Assets/Mirror/Core/NetworkBehaviour.cs | 197 ++++++------------------- 1 file changed, 42 insertions(+), 155 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index d4e42bd2e58..f7a0b4e2eed 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -183,23 +183,11 @@ internal bool ShouldCaptureHostBaseline() => !IsHostClientObserved(); [MethodImpl(MethodImplOptions.AggressiveInlining)] - void CaptureHostBaseline(T previous, ref T originalValue, ref bool originalValueSet) - { - if (originalValueSet) - return; - - // Re-observation should replay the same hook oldValue as a first - // observation again, even after the host has already spawned/seen it. - originalValue = previous; - originalValueSet = true; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - T GetClientInitialBaseline(T previous, ref T originalValue, ref bool originalValueSet) + T EnsureInitialHookBaseline(T previous, ref T originalValue, ref bool originalValueSet) { // Scene objects are re-used across hide/show cycles on remote clients. - // Preserve the very first local baseline so each initial spawn replay - // behaves like a fresh first observation again. + // Preserve the very first local baseline so each initial spawn replay, + // including host re-observation, behaves like a fresh first observation again. if (!originalValueSet) { originalValue = previous; @@ -209,6 +197,21 @@ T GetClientInitialBaseline(T previous, ref T originalValue, ref bool original return originalValue; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + void InvokeSyncVarHookAction(Action onChanged, T previous, T current) + { + if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) + { + T capturedPrevious = previous; + T capturedNew = current; + deferredSyncVarHooks.Add(() => onChanged(capturedPrevious, capturedNew)); + } + else + { + onChanged(previous, current); + } + } + internal void InvokeDeferredSyncCallbacks() { foreach (Action hook in deferredSyncVarHooks) @@ -656,13 +659,13 @@ public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, if (IsHostClientObserved()) { if (!originalValueSet) - CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } else if (ShouldCaptureHostBaseline() && !originalValueSet) - CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); } } } @@ -702,13 +705,13 @@ public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObj if (IsHostClientObserved()) { if (!originalValueSet) - CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } else if (ShouldCaptureHostBaseline() && !originalValueSet) - CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); } } } @@ -748,13 +751,13 @@ public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, r if (IsHostClientObserved()) { if (!originalValueSet) - CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } else if (ShouldCaptureHostBaseline() && !originalValueSet) - CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); } } } @@ -796,13 +799,13 @@ public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field if (IsHostClientObserved()) { if (!originalValueSet) - CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } else if (ShouldCaptureHostBaseline() && !originalValueSet) - CaptureHostBaseline(previous, ref originalValue, ref originalValueSet); + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); } } } @@ -951,16 +954,7 @@ public void GeneratedSyncVarDeserialize(ref T field, Action OnChanged, bool hostInitialSpawnInHostMode = NetworkServer.activeHost && netIdentity.hostInitialSpawn; if (changed || hostInitialSpawnInHostMode) { - if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) - { - T capturedPrevious = previous; - T capturedNew = field; - deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); - } - else - { - OnChanged(previous, field); - } + InvokeSyncVarHookAction(OnChanged, previous, field); } } } @@ -1000,33 +994,13 @@ public void GeneratedSyncVarDeserialize_Hook(ref T field, Action OnChan } else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawnActive) { - T baseline = GetClientInitialBaseline(previous, ref originalValue, ref originalValueSet); + T baseline = EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); if (!SyncVarEqual(baseline, ref field)) - { - if (!NetworkClient.isSpawnFinished) - { - T capturedPrevious = baseline; - T capturedNew = field; - deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); - } - else - { - OnChanged(baseline, field); - } - } + InvokeSyncVarHookAction(OnChanged, baseline, field); } else if (!SyncVarEqual(previous, ref field)) { - if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) - { - T capturedPrevious = previous; - T capturedNew = field; - deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); - } - else - { - OnChanged(previous, field); - } + InvokeSyncVarHookAction(OnChanged, previous, field); } } } @@ -1044,16 +1018,7 @@ public void GeneratedSyncVarDeserialize_GameObject(ref GameObject field, Action< bool hostInitialSpawnInHostMode = NetworkServer.activeHost && netIdentity.hostInitialSpawn; if (changed || hostInitialSpawnInHostMode) { - if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) - { - GameObject capturedPrevious = previousGameObject; - GameObject capturedNew = field; - deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); - } - else - { - OnChanged(previousGameObject, field); - } + InvokeSyncVarHookAction(OnChanged, previousGameObject, field); } } } @@ -1082,33 +1047,13 @@ public void GeneratedSyncVarDeserialize_GameObject_Hook(ref GameObject field, Ac } else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawnActive) { - GameObject baseline = GetClientInitialBaseline(previousGameObject, ref originalValue, ref originalValueSet); + GameObject baseline = EnsureInitialHookBaseline(previousGameObject, ref originalValue, ref originalValueSet); if (!SyncVarEqual(baseline, ref field)) - { - if (!NetworkClient.isSpawnFinished) - { - GameObject capturedPrevious = baseline; - GameObject capturedNew = field; - deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); - } - else - { - OnChanged(baseline, field); - } - } + InvokeSyncVarHookAction(OnChanged, baseline, field); } else if (!SyncVarEqual(previousNetId, ref netIdField)) { - if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) - { - GameObject capturedPrevious = previousGameObject; - GameObject capturedNew = field; - deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); - } - else - { - OnChanged(previousGameObject, field); - } + InvokeSyncVarHookAction(OnChanged, previousGameObject, field); } } } @@ -1126,16 +1071,7 @@ public void GeneratedSyncVarDeserialize_NetworkIdentity(ref NetworkIdentity fiel bool hostInitialSpawnInHostMode = NetworkServer.activeHost && netIdentity.hostInitialSpawn; if (changed || hostInitialSpawnInHostMode) { - if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) - { - NetworkIdentity capturedPrevious = previousIdentity; - NetworkIdentity capturedNew = field; - deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); - } - else - { - OnChanged(previousIdentity, field); - } + InvokeSyncVarHookAction(OnChanged, previousIdentity, field); } } } @@ -1164,33 +1100,13 @@ public void GeneratedSyncVarDeserialize_NetworkIdentity_Hook(ref NetworkIdentity } else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawnActive) { - NetworkIdentity baseline = GetClientInitialBaseline(previousIdentity, ref originalValue, ref originalValueSet); + NetworkIdentity baseline = EnsureInitialHookBaseline(previousIdentity, ref originalValue, ref originalValueSet); if (!SyncVarEqual(baseline, ref field)) - { - if (!NetworkClient.isSpawnFinished) - { - NetworkIdentity capturedPrevious = baseline; - NetworkIdentity capturedNew = field; - deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); - } - else - { - OnChanged(baseline, field); - } - } + InvokeSyncVarHookAction(OnChanged, baseline, field); } else if (!SyncVarEqual(previousNetId, ref netIdField)) { - if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) - { - NetworkIdentity capturedPrevious = previousIdentity; - NetworkIdentity capturedNew = field; - deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); - } - else - { - OnChanged(previousIdentity, field); - } + InvokeSyncVarHookAction(OnChanged, previousIdentity, field); } } } @@ -1209,16 +1125,7 @@ public void GeneratedSyncVarDeserialize_NetworkBehaviour(ref T field, Action< bool hostInitialSpawnInHostMode = NetworkServer.activeHost && netIdentity.hostInitialSpawn; if (changed || hostInitialSpawnInHostMode) { - if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) - { - T capturedPrevious = previousBehaviour; - T capturedNew = field; - deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); - } - else - { - OnChanged(previousBehaviour, field); - } + InvokeSyncVarHookAction(OnChanged, previousBehaviour, field); } } } @@ -1248,33 +1155,13 @@ public void GeneratedSyncVarDeserialize_NetworkBehaviour_Hook(ref T field, Ac } else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawnActive) { - T baseline = GetClientInitialBaseline(previousBehaviour, ref originalValue, ref originalValueSet); + T baseline = EnsureInitialHookBaseline(previousBehaviour, ref originalValue, ref originalValueSet); if (!SyncVarEqual(baseline, ref field)) - { - if (!NetworkClient.isSpawnFinished) - { - T capturedPrevious = baseline; - T capturedNew = field; - deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); - } - else - { - OnChanged(baseline, field); - } - } + InvokeSyncVarHookAction(OnChanged, baseline, field); } else if (!SyncVarEqual(previousNetId, ref netIdField)) { - if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) - { - T capturedPrevious = previousBehaviour; - T capturedNew = field; - deferredSyncVarHooks.Add(() => OnChanged(capturedPrevious, capturedNew)); - } - else - { - OnChanged(previousBehaviour, field); - } + InvokeSyncVarHookAction(OnChanged, previousBehaviour, field); } } } From 1c6fdc15f20ef2acd03e299a259ec2ae274dae71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:17:24 +0000 Subject: [PATCH 29/48] Fix sync collection scene re-observation replay --- Assets/Mirror/Core/NetworkBehaviour.cs | 8 +++++ Assets/Mirror/Core/NetworkIdentity.cs | 3 ++ Assets/Mirror/Core/SyncDictionary.cs | 9 ++++++ Assets/Mirror/Core/SyncList.cs | 11 +++++++ Assets/Mirror/Core/SyncObject.cs | 3 ++ Assets/Mirror/Core/SyncSet.cs | 8 +++++ .../SyncDictionaryActionTest_HostMode.cs | 32 +++++++++++++++++++ .../SyncListActionTest_HostMode.cs | 32 +++++++++++++++++++ .../SyncSetActionTest_HostMode.cs | 32 +++++++++++++++++++ 9 files changed, 138 insertions(+) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index f7a0b4e2eed..66ca90573d0 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -1550,6 +1550,14 @@ internal void ResetSyncObjects() } } + internal void ResetSyncObjectCallbacks() + { + foreach (SyncObject syncObject in syncObjects) + { + syncObject.ResetCallbacks(); + } + } + /// Like Start(), but only called on server and host. public virtual void OnStartServer() {} diff --git a/Assets/Mirror/Core/NetworkIdentity.cs b/Assets/Mirror/Core/NetworkIdentity.cs index e36174ce7a3..c3ff10adb68 100644 --- a/Assets/Mirror/Core/NetworkIdentity.cs +++ b/Assets/Mirror/Core/NetworkIdentity.cs @@ -1679,6 +1679,9 @@ internal void ResetState() ClearObservers(); + foreach (NetworkBehaviour comp in NetworkBehaviours) + comp.ResetSyncObjectCallbacks(); + // clear local player if it was the local player, // THEN reset isLocalPlayer AFTERWARDS if (isLocalPlayer) diff --git a/Assets/Mirror/Core/SyncDictionary.cs b/Assets/Mirror/Core/SyncDictionary.cs index 7fd64dd0924..d955a8b377d 100644 --- a/Assets/Mirror/Core/SyncDictionary.cs +++ b/Assets/Mirror/Core/SyncDictionary.cs @@ -278,6 +278,15 @@ public override void Reset() objects.Clear(); } + public override void ResetCallbacks() + { + OnAdd = null; + OnSet = null; + OnRemove = null; + OnClear = null; + OnChange = null; + } + public TValue this[TKey i] { get => objects[i]; diff --git a/Assets/Mirror/Core/SyncList.cs b/Assets/Mirror/Core/SyncList.cs index 041abeb5b79..6b864e7538e 100644 --- a/Assets/Mirror/Core/SyncList.cs +++ b/Assets/Mirror/Core/SyncList.cs @@ -97,6 +97,17 @@ public override void Reset() objects.Clear(); } + public override void ResetCallbacks() + { + OnAdd = null; + OnInsert = null; + OnSet = null; + OnRemove = null; + OnClear = null; + OnChange = null; + Callback = null; + } + void AddOperation(Operation op, int itemIndex, T oldItem, T newItem, bool checkAccess, bool shouldApplyChanges) { if (checkAccess && IsReadOnly) diff --git a/Assets/Mirror/Core/SyncObject.cs b/Assets/Mirror/Core/SyncObject.cs index e8554ef6c7f..087d508e58e 100644 --- a/Assets/Mirror/Core/SyncObject.cs +++ b/Assets/Mirror/Core/SyncObject.cs @@ -53,6 +53,9 @@ public abstract class SyncObject /// Queues host-mode first-observation Add replay actions without mutating server state. public virtual void QueueHostVisibilityReplay() {} + /// Clears any registered callbacks so reused scene objects behave like a fresh client observation. + public virtual void ResetCallbacks() {} + /// Resets the SyncObject so that it can be re-used public abstract void Reset(); } diff --git a/Assets/Mirror/Core/SyncSet.cs b/Assets/Mirror/Core/SyncSet.cs index 3c50d66b416..4a3a87d116b 100644 --- a/Assets/Mirror/Core/SyncSet.cs +++ b/Assets/Mirror/Core/SyncSet.cs @@ -66,6 +66,14 @@ public override void Reset() objects.Clear(); } + public override void ResetCallbacks() + { + OnAdd = null; + OnRemove = null; + OnClear = null; + OnChange = null; + } + // throw away all the changes // this should be called after a successful sync public override void ClearChanges() => changes.Clear(); diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncDictionaryActionTest_HostMode.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncDictionaryActionTest_HostMode.cs index fc1412e2ad2..3d3c63fb217 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncDictionaryActionTest_HostMode.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncDictionaryActionTest_HostMode.cs @@ -104,6 +104,38 @@ public void ClientServer_ActionsRespectAOI() Assert.That(clientComp.actionsReceived.Count, Is.EqualTo(3), "Total of 3 Actions invoked"); Assert.That(clientComp.actionsReceived[2], Is.EqualTo("Add:key3:third")); + + // move client player back out of range + serverPlayer.transform.position = Vector3.right * (aoi.visRange + 1); + NetworkServer.RebuildObservers(serverComp.netIdentity, false); + ProcessMessages(); + + Assert.That(clientGO.activeSelf, Is.False); + Assert.That(NetworkClient.spawned.ContainsKey(serverIdentity.netId), Is.False); + + // add item while hidden + serverComp.dictionary.Add("key4", "fourth"); + ProcessMessages(); + + Assert.That(clientComp.actionsReceived.Count, Is.EqualTo(3), "Hidden changes should not fire Actions"); + + // move back into range and ensure the full current state replays as Add actions again + serverPlayer.transform.position = Vector3.zero; + NetworkServer.RebuildObservers(serverComp.netIdentity, false); + ProcessMessages(); + + Assert.That(clientGO.activeSelf, Is.True); + Assert.That(NetworkClient.spawned.ContainsKey(serverIdentity.netId), Is.True); + Assert.That(clientComp.actionsReceived, Is.EqualTo(new[] + { + "Add:key1:first", + "Add:key2:second", + "Add:key3:third", + "Add:key1:first", + "Add:key2:second", + "Add:key3:third", + "Add:key4:fourth" + })); } } diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncListActionTest_HostMode.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncListActionTest_HostMode.cs index 95006213742..7825022a564 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncListActionTest_HostMode.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncListActionTest_HostMode.cs @@ -105,6 +105,38 @@ public void ClientServer_ActionsRespectAOI() Assert.That(clientComp.actionsReceived.Count, Is.EqualTo(3), "Total of 3 Actions invoked"); Assert.That(clientComp.actionsReceived[2], Is.EqualTo("Add:third")); + + // move client player back out of range + serverPlayer.transform.position = Vector3.right * (aoi.visRange + 1); + NetworkServer.RebuildObservers(serverComp.netIdentity, false); + ProcessMessages(); + + Assert.That(clientGO.activeSelf, Is.False); + Assert.That(NetworkClient.spawned.ContainsKey(serverIdentity.netId), Is.False); + + // add item while hidden + serverComp.list.Add("fourth"); + ProcessMessages(); + + Assert.That(clientComp.actionsReceived.Count, Is.EqualTo(3), "Hidden changes should not fire Actions"); + + // move back into range and ensure the full current state replays as Add actions again + serverPlayer.transform.position = Vector3.zero; + NetworkServer.RebuildObservers(serverComp.netIdentity, false); + ProcessMessages(); + + Assert.That(clientGO.activeSelf, Is.True); + Assert.That(NetworkClient.spawned.ContainsKey(serverIdentity.netId), Is.True); + Assert.That(clientComp.actionsReceived, Is.EqualTo(new[] + { + "Add:first", + "Add:second", + "Add:third", + "Add:first", + "Add:second", + "Add:third", + "Add:fourth" + })); } } diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncSetActionTest_HostMode.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncSetActionTest_HostMode.cs index 37325787d74..8de7b24cb52 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncSetActionTest_HostMode.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncSetActionTest_HostMode.cs @@ -104,6 +104,38 @@ public void ClientServer_ActionsRespectAOI() Assert.That(clientComp.actionsReceived.Count, Is.EqualTo(3), "Total of 3 Actions invoked"); Assert.That(clientComp.actionsReceived[2], Is.EqualTo("Add:third")); + + // move client player back out of range + serverPlayer.transform.position = Vector3.right * (aoi.visRange + 1); + NetworkServer.RebuildObservers(serverComp.netIdentity, false); + ProcessMessages(); + + Assert.That(clientGO.activeSelf, Is.False); + Assert.That(NetworkClient.spawned.ContainsKey(serverIdentity.netId), Is.False); + + // add item while hidden + serverComp.set.Add("fourth"); + ProcessMessages(); + + Assert.That(clientComp.actionsReceived.Count, Is.EqualTo(3), "Hidden changes should not fire Actions"); + + // move back into range and ensure the full current state replays as Add actions again + serverPlayer.transform.position = Vector3.zero; + NetworkServer.RebuildObservers(serverComp.netIdentity, false); + ProcessMessages(); + + Assert.That(clientGO.activeSelf, Is.True); + Assert.That(NetworkClient.spawned.ContainsKey(serverIdentity.netId), Is.True); + Assert.That(clientComp.actionsReceived, Is.EqualTo(new[] + { + "Add:first", + "Add:second", + "Add:third", + "Add:first", + "Add:second", + "Add:third", + "Add:fourth" + })); } } From 8bd96291621a11e2d9c3d0c75e182c9cd955a8e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:18:47 +0000 Subject: [PATCH 30/48] Document sync object callback reset --- Assets/Mirror/Core/SyncObject.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Assets/Mirror/Core/SyncObject.cs b/Assets/Mirror/Core/SyncObject.cs index 087d508e58e..b50c4028687 100644 --- a/Assets/Mirror/Core/SyncObject.cs +++ b/Assets/Mirror/Core/SyncObject.cs @@ -53,7 +53,11 @@ public abstract class SyncObject /// Queues host-mode first-observation Add replay actions without mutating server state. public virtual void QueueHostVisibilityReplay() {} - /// Clears any registered callbacks so reused scene objects behave like a fresh client observation. + /// + /// Clears any registered callbacks when a client object is unspawned. + /// Reused scene objects keep the same SyncObject instance, so they need + /// this reset to re-register handlers cleanly on the next observation. + /// public virtual void ResetCallbacks() {} /// Resets the SyncObject so that it can be re-used From 11b2504e04de5570030fd2d65b193de044772a6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:44:51 +0000 Subject: [PATCH 31/48] Fix client scene object re-show registration --- Assets/Mirror/Core/NetworkClient.cs | 6 +++-- .../NetworkClientTests_DestroyObjects.cs | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index 8e9294a9800..a70932d9728 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -1984,6 +1984,8 @@ static void DestroyObject(uint netId) // Debug.Log($"NetworkClient.OnObjDestroy netId: {netId}"); if (spawned.TryGetValue(netId, out NetworkIdentity identity) && identity != null) { + ulong sceneId = identity.sceneId; + if (identity.isLocalPlayer) identity.OnStopLocalPlayer(); @@ -1996,7 +1998,7 @@ static void DestroyObject(uint netId) identity.ResetState(); } // otherwise fall back to default Destroy - else if (identity.sceneId == 0) + else if (sceneId == 0) { // don't call reset before destroy so that values are still set in OnDestroy GameObject.Destroy(identity.gameObject); @@ -2005,9 +2007,9 @@ static void DestroyObject(uint netId) else { identity.gameObject.SetActive(false); - spawnableObjects[identity.sceneId] = identity; // reset for scene objects identity.ResetState(); + spawnableObjects[sceneId] = identity; } // remove from dictionary no matter how it is unspawned diff --git a/Assets/Mirror/Tests/Editor/NetworkClient/NetworkClientTests_DestroyObjects.cs b/Assets/Mirror/Tests/Editor/NetworkClient/NetworkClientTests_DestroyObjects.cs index d60759a0f94..45b52114b9d 100644 --- a/Assets/Mirror/Tests/Editor/NetworkClient/NetworkClientTests_DestroyObjects.cs +++ b/Assets/Mirror/Tests/Editor/NetworkClient/NetworkClientTests_DestroyObjects.cs @@ -48,6 +48,30 @@ public void OnObjectDestroy_SceneObjectIsDisabledAndReturnedToSpawnableObjects() Assert.That(NetworkClient.spawnableObjects.ContainsKey(sceneId), Is.True); } + [Test] + public void OnObjectHide_SceneObjectCanBeShownAgain() + { + CreateNetworked(out _, out NetworkIdentity identity); + const uint netId = 103; + const ulong sceneId = 56; + identity.netId = netId; + identity.sceneId = sceneId; + identity.gameObject.SetActive(true); + NetworkClient.spawned[netId] = identity; + NetworkClient.isSpawnFinished = true; + + NetworkClient.OnObjectHide(new ObjectHideMessage { netId = netId }); + + Assert.That(NetworkClient.spawnableObjects.TryGetValue(sceneId, out NetworkIdentity spawnableIdentity), Is.True); + Assert.That(spawnableIdentity, Is.SameAs(identity)); + + NetworkClient.OnSpawn(new SpawnMessage { netId = netId, sceneId = sceneId }); + + Assert.That(identity.gameObject.activeSelf, Is.True); + Assert.That(NetworkClient.spawned.TryGetValue(netId, out NetworkIdentity respawnedIdentity), Is.True); + Assert.That(respawnedIdentity, Is.SameAs(identity)); + } + [Test] public void OnObjectDestroy_CallsUnspawnHandlerWhenRegistered() { From 27312b8b78c0531b291625d92b191eb8e0335c82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:46:55 +0000 Subject: [PATCH 32/48] Preserve scene object respawn registration --- Assets/Mirror/Core/NetworkClient.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index a70932d9728..239114d6457 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -2007,6 +2007,7 @@ static void DestroyObject(uint netId) else { identity.gameObject.SetActive(false); + spawnableObjects[sceneId] = identity; // reset for scene objects identity.ResetState(); spawnableObjects[sceneId] = identity; From ec51fa0fffa79481f063c7b816b0aa098c29f765 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:48:41 +0000 Subject: [PATCH 33/48] Clean up scene object respawn fix --- Assets/Mirror/Core/NetworkClient.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index 239114d6457..a70932d9728 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -2007,7 +2007,6 @@ static void DestroyObject(uint netId) else { identity.gameObject.SetActive(false); - spawnableObjects[sceneId] = identity; // reset for scene objects identity.ResetState(); spawnableObjects[sceneId] = identity; From ab050a225269ed02f1253ad946ddd5dafa63aaa8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:27:00 +0000 Subject: [PATCH 34/48] Restore test access to object hide handler --- Assets/Mirror/Core/NetworkClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index a70932d9728..2a233e7bcff 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -1608,7 +1608,7 @@ static void OnRPCMessage(RpcMessage message) // Rpcs often can't be applied if interest management unspawned them } - static void OnObjectHide(ObjectHideMessage message) => DestroyObject(message.netId); + internal static void OnObjectHide(ObjectHideMessage message) => DestroyObject(message.netId); internal static void OnObjectDestroy(ObjectDestroyMessage message) => DestroyObject(message.netId); From 41148b8440879c0ca48ac85ee71b7a9807d9d076 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:33:45 +0000 Subject: [PATCH 35/48] Fix remote client scene-object reobserve hook gating --- Assets/Mirror/Core/NetworkBehaviour.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index 66ca90573d0..b8441c56490 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -200,7 +200,7 @@ T EnsureInitialHookBaseline(T previous, ref T originalValue, ref bool origina [MethodImpl(MethodImplOptions.AggressiveInlining)] void InvokeSyncVarHookAction(Action onChanged, T previous, T current) { - if (NetworkClient.active && !NetworkServer.active && !NetworkClient.isSpawnFinished) + if (NetworkClient.active && !NetworkClient.activeHost && !NetworkClient.isSpawnFinished) { T capturedPrevious = previous; T capturedNew = current; @@ -992,7 +992,7 @@ public void GeneratedSyncVarDeserialize_Hook(ref T field, Action OnChan } } - else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawnActive) + else if (NetworkClient.active && !NetworkClient.activeHost && netIdentity.clientInitialSpawnActive) { T baseline = EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); if (!SyncVarEqual(baseline, ref field)) @@ -1045,7 +1045,7 @@ public void GeneratedSyncVarDeserialize_GameObject_Hook(ref GameObject field, Ac } } - else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawnActive) + else if (NetworkClient.active && !NetworkClient.activeHost && netIdentity.clientInitialSpawnActive) { GameObject baseline = EnsureInitialHookBaseline(previousGameObject, ref originalValue, ref originalValueSet); if (!SyncVarEqual(baseline, ref field)) @@ -1098,7 +1098,7 @@ public void GeneratedSyncVarDeserialize_NetworkIdentity_Hook(ref NetworkIdentity } } - else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawnActive) + else if (NetworkClient.active && !NetworkClient.activeHost && netIdentity.clientInitialSpawnActive) { NetworkIdentity baseline = EnsureInitialHookBaseline(previousIdentity, ref originalValue, ref originalValueSet); if (!SyncVarEqual(baseline, ref field)) @@ -1153,7 +1153,7 @@ public void GeneratedSyncVarDeserialize_NetworkBehaviour_Hook(ref T field, Ac } } - else if (NetworkClient.active && !NetworkServer.active && netIdentity.clientInitialSpawnActive) + else if (NetworkClient.active && !NetworkClient.activeHost && netIdentity.clientInitialSpawnActive) { T baseline = EnsureInitialHookBaseline(previousBehaviour, ref originalValue, ref originalValueSet); if (!SyncVarEqual(baseline, ref field)) From ae5c2fd68fba68b463b04ac431815c50a47391f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:31:42 +0000 Subject: [PATCH 36/48] fix: scope syncobject callback reset to client scene reuse --- Assets/Mirror/Core/NetworkClient.cs | 2 ++ Assets/Mirror/Core/NetworkIdentity.cs | 9 ++++-- .../NetworkClientTests_DestroyObjects.cs | 20 +++++++++++++ .../Editor/NetworkServer/NetworkServerTest.cs | 28 +++++++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index 2a233e7bcff..3fbfa9781e0 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -1957,6 +1957,7 @@ public static void DestroyAllClientObjects() // they always stay in the scene, we don't destroy them. if (identity.sceneId != 0) { + identity.ResetSyncObjectCallbacks(); identity.ResetState(); identity.gameObject.SetActive(false); } @@ -2008,6 +2009,7 @@ static void DestroyObject(uint netId) { identity.gameObject.SetActive(false); // reset for scene objects + identity.ResetSyncObjectCallbacks(); identity.ResetState(); spawnableObjects[sceneId] = identity; } diff --git a/Assets/Mirror/Core/NetworkIdentity.cs b/Assets/Mirror/Core/NetworkIdentity.cs index c3ff10adb68..aa0c6f2cf46 100644 --- a/Assets/Mirror/Core/NetworkIdentity.cs +++ b/Assets/Mirror/Core/NetworkIdentity.cs @@ -1679,9 +1679,6 @@ internal void ResetState() ClearObservers(); - foreach (NetworkBehaviour comp in NetworkBehaviours) - comp.ResetSyncObjectCallbacks(); - // clear local player if it was the local player, // THEN reset isLocalPlayer AFTERWARDS if (isLocalPlayer) @@ -1697,6 +1694,12 @@ internal void ResetState() isLocalPlayer = false; } + internal void ResetSyncObjectCallbacks() + { + foreach (NetworkBehaviour comp in NetworkBehaviours) + comp.ResetSyncObjectCallbacks(); + } + bool hadAuthority; internal void NotifyAuthority() { diff --git a/Assets/Mirror/Tests/Editor/NetworkClient/NetworkClientTests_DestroyObjects.cs b/Assets/Mirror/Tests/Editor/NetworkClient/NetworkClientTests_DestroyObjects.cs index 45b52114b9d..eb7247e1b17 100644 --- a/Assets/Mirror/Tests/Editor/NetworkClient/NetworkClientTests_DestroyObjects.cs +++ b/Assets/Mirror/Tests/Editor/NetworkClient/NetworkClientTests_DestroyObjects.cs @@ -3,6 +3,11 @@ namespace Mirror.Tests.NetworkClients { + class NetworkClientDestroyObjectSyncListBehaviour : NetworkBehaviour + { + public readonly SyncList list = new SyncList(); + } + public class NetworkClientTests_DestroyObjects : MirrorEditModeTest { [SetUp] @@ -72,6 +77,21 @@ public void OnObjectHide_SceneObjectCanBeShownAgain() Assert.That(respawnedIdentity, Is.SameAs(identity)); } + [Test] + public void OnObjectHide_SceneObjectClearsSyncObjectCallbacks() + { + CreateNetworked(out _, out NetworkIdentity identity, out NetworkClientDestroyObjectSyncListBehaviour behaviour); + const uint netId = 104; + identity.netId = netId; + identity.sceneId = 57; + behaviour.list.OnAdd += _ => {}; + NetworkClient.spawned[netId] = identity; + + NetworkClient.OnObjectHide(new ObjectHideMessage { netId = netId }); + + Assert.That(behaviour.list.OnAdd, Is.Null); + } + [Test] public void OnObjectDestroy_CallsUnspawnHandlerWhenRegistered() { diff --git a/Assets/Mirror/Tests/Editor/NetworkServer/NetworkServerTest.cs b/Assets/Mirror/Tests/Editor/NetworkServer/NetworkServerTest.cs index b3610420d6a..0252d934b1b 100644 --- a/Assets/Mirror/Tests/Editor/NetworkServer/NetworkServerTest.cs +++ b/Assets/Mirror/Tests/Editor/NetworkServer/NetworkServerTest.cs @@ -61,6 +61,11 @@ public static void RpcGenerated(NetworkBehaviour comp, NetworkReader reader, Net } } + public class SyncListCallbackNetworkBehaviour : NetworkBehaviour + { + public readonly SyncList list = new SyncList(); + } + [TestFixture] public class NetworkServerTest : MirrorEditModeTest { @@ -1136,6 +1141,29 @@ public void UnSpawn() Assert.That(identity.netId, Is.Zero); } + [Test] + public void UnSpawn_PreservesSyncObjectCallbacks() + { + NetworkServer.Listen(1); + + CreateNetworked(out GameObject go, out NetworkIdentity identity, out SyncListCallbackNetworkBehaviour behaviour); + identity.sceneId = 43; + + int addCalls = 0; + behaviour.list.OnAdd += _ => ++addCalls; + + NetworkServer.Spawn(go); + NetworkServer.UnSpawn(go); + + Assert.That(behaviour.list.OnAdd, Is.Not.Null); + + go.SetActive(true); + NetworkServer.Spawn(go); + behaviour.list.Add("value"); + + Assert.That(addCalls, Is.EqualTo(1)); + } + [Test] public void UnSpawnAndClearAuthority() { From 8cd84317c0f99fd03c3e8bad8e0f441f16a3d0d5 Mon Sep 17 00:00:00 2001 From: MrGadget <9826063+MrGadget1024@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:00:04 -0400 Subject: [PATCH 37/48] Changed Basic Example for testing --- .../Examples/Basic/Scripts/BasicNetManager.cs | 13 +++++++++++++ Assets/Mirror/Examples/Basic/Scripts/Player.cs | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs b/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs index 508c15f8482..d4eefe833db 100644 --- a/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs +++ b/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs @@ -1,3 +1,4 @@ +using System.Collections; using UnityEngine; namespace Mirror.Examples.Basic @@ -12,6 +13,18 @@ public class BasicNetManager : NetworkManager /// Connection from client. public override void OnServerAddPlayer(NetworkConnectionToClient conn) { + if (conn is LocalConnectionToClient) + { + StartCoroutine(DelayedAddPlayer(conn)); + return; + } + base.OnServerAddPlayer(conn); + Player.ResetPlayerNumbers(); + } + + IEnumerator DelayedAddPlayer (NetworkConnectionToClient conn) + { + yield return new WaitForSeconds(5f); base.OnServerAddPlayer(conn); Player.ResetPlayerNumbers(); } diff --git a/Assets/Mirror/Examples/Basic/Scripts/Player.cs b/Assets/Mirror/Examples/Basic/Scripts/Player.cs index c095be4d1c1..0522acfb8ce 100644 --- a/Assets/Mirror/Examples/Basic/Scripts/Player.cs +++ b/Assets/Mirror/Examples/Basic/Scripts/Player.cs @@ -45,18 +45,21 @@ public class Player : NetworkBehaviour // This is called by the hook of playerNumber SyncVar above void PlayerNumberChanged(byte _, byte newPlayerNumber) { + Debug.Log($"PlayerNumberChanged: {newPlayerNumber} {isServer}", gameObject); OnPlayerNumberChanged?.Invoke(newPlayerNumber); } // This is called by the hook of playerColor SyncVar above void PlayerColorChanged(Color32 _, Color32 newPlayerColor) { + Debug.Log($"PlayerColorChanged: {newPlayerColor} {isServer}", gameObject); OnPlayerColorChanged?.Invoke(newPlayerColor); } // This is called by the hook of playerData SyncVar above void PlayerDataChanged(ushort _, ushort newPlayerData) { + Debug.Log($"PlayerDataChanged: {newPlayerData} {isServer}", gameObject); OnPlayerDataChanged?.Invoke(newPlayerData); } @@ -93,7 +96,7 @@ internal static void ResetPlayerNumbers() { byte playerNumber = 0; foreach (Player player in playersList) - player.playerNumber = playerNumber++; + player.playerNumber = ++playerNumber; } // This only runs on the server, called from OnStartServer via InvokeRepeating From 4d1d1cc49875a3d791626a5aac59ce97c9afca1e Mon Sep 17 00:00:00 2001 From: MrGadget <9826063+MrGadget1024@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:08:46 -0400 Subject: [PATCH 38/48] Changed Basic Example for testing --- .../Mirror/Examples/Basic/Scripts/BasicNetManager.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs b/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs index d4eefe833db..757bbedd3d8 100644 --- a/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs +++ b/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs @@ -13,11 +13,11 @@ public class BasicNetManager : NetworkManager /// Connection from client. public override void OnServerAddPlayer(NetworkConnectionToClient conn) { - if (conn is LocalConnectionToClient) - { - StartCoroutine(DelayedAddPlayer(conn)); - return; - } + //if (conn is LocalConnectionToClient) + //{ + // StartCoroutine(DelayedAddPlayer(conn)); + // return; + //} base.OnServerAddPlayer(conn); Player.ResetPlayerNumbers(); } From c444f4e4d1faee4efb6f31cc71f06b27bc7d70cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:16:58 +0000 Subject: [PATCH 39/48] Fix host visibility replay double firing --- Assets/Mirror/Core/NetworkBehaviour.cs | 52 +++++++++++++++---- Assets/Mirror/Core/NetworkClient.cs | 3 ++ Assets/Mirror/Core/SyncDictionary.cs | 1 + Assets/Mirror/Core/SyncList.cs | 1 + Assets/Mirror/Core/SyncSet.cs | 1 + ...SyncCollectionActionTest_HostVisibility.cs | 23 ++++++++ ...SyncVarAttributeHook_HostVisibilityTest.cs | 23 ++++++++ 7 files changed, 93 insertions(+), 11 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index b8441c56490..7911510b98e 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -171,6 +171,7 @@ public bool authority // Queue for deferred SyncCollection Actions during initial spawn / host visibility changes. // Actions are queued while the object isn't visible to the local client and invoked once it is. internal readonly List deferredSyncCollectionActions = new List(); + internal bool hostVisibilityReplayPending; internal bool IsHostClientObserved() => NetworkServer.activeHost && @@ -182,6 +183,8 @@ internal bool ShouldCaptureHostBaseline() => NetworkServer.activeHost && !IsHostClientObserved(); + internal void MarkHostVisibilityReplayPending() => hostVisibilityReplayPending = true; + [MethodImpl(MethodImplOptions.AggressiveInlining)] T EnsureInitialHookBaseline(T previous, ref T originalValue, ref bool originalValueSet) { @@ -229,10 +232,17 @@ protected virtual void InvokeSyncVarHostVisibilityHooks() {} internal void InvokeHostVisibilityDeferredCallbacks() { - for (int i = 0; i < syncObjects.Count; ++i) - syncObjects[i].QueueHostVisibilityReplay(); + bool replayPending = hostVisibilityReplayPending; + hostVisibilityReplayPending = false; + + if (replayPending) + { + for (int i = 0; i < syncObjects.Count; ++i) + syncObjects[i].QueueHostVisibilityReplay(); + + InvokeSyncVarHostVisibilityHooks(); + } - InvokeSyncVarHostVisibilityHooks(); InvokeDeferredSyncCallbacks(); } @@ -664,8 +674,12 @@ public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (ShouldCaptureHostBaseline() && !originalValueSet) - EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + else if (ShouldCaptureHostBaseline()) + { + if (!originalValueSet) + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + MarkHostVisibilityReplayPending(); + } } } } @@ -710,8 +724,12 @@ public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObj OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (ShouldCaptureHostBaseline() && !originalValueSet) - EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + else if (ShouldCaptureHostBaseline()) + { + if (!originalValueSet) + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + MarkHostVisibilityReplayPending(); + } } } } @@ -756,8 +774,12 @@ public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, r OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (ShouldCaptureHostBaseline() && !originalValueSet) - EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + else if (ShouldCaptureHostBaseline()) + { + if (!originalValueSet) + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + MarkHostVisibilityReplayPending(); + } } } } @@ -804,8 +826,12 @@ public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); } - else if (ShouldCaptureHostBaseline() && !originalValueSet) - EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + else if (ShouldCaptureHostBaseline()) + { + if (!originalValueSet) + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + MarkHostVisibilityReplayPending(); + } } } } @@ -1548,6 +1574,10 @@ internal void ResetSyncObjects() { syncObject.Reset(); } + + deferredSyncVarHooks.Clear(); + deferredSyncCollectionActions.Clear(); + hostVisibilityReplayPending = false; } internal void ResetSyncObjectCallbacks() diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index 3fbfa9781e0..b976d35d6b9 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -1424,6 +1424,9 @@ static void OnHostClientObjectHide(ObjectHideMessage message) if (spawned.TryGetValue(message.netId, out NetworkIdentity identity) && identity != null) { + foreach (NetworkBehaviour component in identity.NetworkBehaviours) + component.MarkHostVisibilityReplayPending(); + if (aoi != null) aoi.SetHostVisibility(identity, false); } diff --git a/Assets/Mirror/Core/SyncDictionary.cs b/Assets/Mirror/Core/SyncDictionary.cs index d955a8b377d..09887cbbc36 100644 --- a/Assets/Mirror/Core/SyncDictionary.cs +++ b/Assets/Mirror/Core/SyncDictionary.cs @@ -392,6 +392,7 @@ void AddOperation(Operation op, TKey key, TValue item, TValue oldItem, bool chec networkBehaviour.syncDirection == SyncDirection.ServerToClient && !networkBehaviour.IsHostClientObserved()) { + networkBehaviour.MarkHostVisibilityReplayPending(); return; } diff --git a/Assets/Mirror/Core/SyncList.cs b/Assets/Mirror/Core/SyncList.cs index 6b864e7538e..eb5723dbd67 100644 --- a/Assets/Mirror/Core/SyncList.cs +++ b/Assets/Mirror/Core/SyncList.cs @@ -137,6 +137,7 @@ void AddOperation(Operation op, int itemIndex, T oldItem, T newItem, bool checkA networkBehaviour.syncDirection == SyncDirection.ServerToClient && !networkBehaviour.IsHostClientObserved()) { + networkBehaviour.MarkHostVisibilityReplayPending(); return; } diff --git a/Assets/Mirror/Core/SyncSet.cs b/Assets/Mirror/Core/SyncSet.cs index 4a3a87d116b..9b81c2f5359 100644 --- a/Assets/Mirror/Core/SyncSet.cs +++ b/Assets/Mirror/Core/SyncSet.cs @@ -126,6 +126,7 @@ void AddOperation(Operation op, T oldItem, T newItem, bool checkAccess, bool sho networkBehaviour.syncDirection == SyncDirection.ServerToClient && !networkBehaviour.IsHostClientObserved()) { + networkBehaviour.MarkHostVisibilityReplayPending(); return; } diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs index dd6cb0a14b7..8680ad9f4dd 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -69,6 +69,14 @@ void AddLocalPlayerWithoutProcessing(Vector3 position) NetworkServer.AddPlayerForConnection(NetworkServer.localConnection, player); } + NetworkIdentity AddLocalPlayerWithoutProcessing(Vector3 position, out HostVisibilitySyncListBehaviour behaviour) + { + CreateNetworked(out GameObject player, out NetworkIdentity identity, out behaviour); + player.transform.position = position; + NetworkServer.AddPlayerForConnection(NetworkServer.localConnection, player); + return identity; + } + void RebuildLocalObserver(NetworkIdentity identity, Vector3 localPlayerPosition) { NetworkClient.localPlayer.transform.position = localPlayerPosition; @@ -228,6 +236,21 @@ public void SyncList_HostVisibilityReplayPreservesPendingRemoteChanges() Assert.That(GetDeltaChangeCount(behaviour.list), Is.EqualTo(1)); } + [Test] + public void SyncList_DoesNotDoubleReplayWhenHostPlayerChangesBeforeSpawnProcessed() + { + NetworkIdentity identity = AddLocalPlayerWithoutProcessing(Vector3.zero, out HostVisibilitySyncListBehaviour behaviour); + behaviour.Register(); + + behaviour.list.Add("first"); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + + ProcessMessages(); + + Assert.That(NetworkClient.localPlayer, Is.EqualTo(identity)); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + } + [Test] public void SyncDictionary_ActionsDeferAgainAfterLeavingAoi() { diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs index 57ccd1791c8..d7ae7ca3ebc 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -64,6 +64,15 @@ void AddLocalPlayer(Vector3 position) Assert.That(NetworkClient.localPlayer, Is.EqualTo(identity)); } + NetworkIdentity AddLocalPlayerWithoutProcessing(Vector3 position, out HostVisibilityHookBehaviour behaviour) + { + CreateNetworked(out GameObject player, out NetworkIdentity identity, out behaviour); + player.transform.position = position; + NetworkServer.AddPlayerForConnection(NetworkServer.localConnection, player); + Assert.That(NetworkClient.localPlayer, Is.EqualTo(identity)); + return identity; + } + void RebuildLocalObserver(NetworkIdentity identity, Vector3 localPlayerPosition) { NetworkClient.localPlayer.transform.position = localPlayerPosition; @@ -180,5 +189,19 @@ public void Hook_DoesNotReplayHiddenChangesAfterLeavingAOI() AssertObserved(identity, true); Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 200) })); } + + [Test] + public void Hook_DoesNotDoubleFireWhenHostPlayerChangesBeforeSpawnProcessed() + { + NetworkIdentity identity = AddLocalPlayerWithoutProcessing(Vector3.zero, out HostVisibilityHookBehaviour behaviour); + + behaviour.value = 100; + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + + ProcessMessages(); + + Assert.That(NetworkClient.localPlayer, Is.EqualTo(identity)); + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + } } } From e8cef59728af182149444e79b4e63f02a20ebe9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:19:22 +0000 Subject: [PATCH 40/48] Clarify host visibility test helper names --- .../SyncCollectionActionTest_HostVisibility.cs | 12 ++++++------ .../SyncVarAttributeHook_HostVisibilityTest.cs | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs index 8680ad9f4dd..1dd8dc44d06 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -62,14 +62,14 @@ void AddLocalPlayer(Vector3 position) ProcessMessages(); } - void AddLocalPlayerWithoutProcessing(Vector3 position) + void AddLocalPlayerWithoutProcessingMessages(Vector3 position) { CreateNetworked(out GameObject player, out _); player.transform.position = position; NetworkServer.AddPlayerForConnection(NetworkServer.localConnection, player); } - NetworkIdentity AddLocalPlayerWithoutProcessing(Vector3 position, out HostVisibilitySyncListBehaviour behaviour) + NetworkIdentity AddLocalPlayerWithoutProcessingMessages(Vector3 position, out HostVisibilitySyncListBehaviour behaviour) { CreateNetworked(out GameObject player, out NetworkIdentity identity, out behaviour); player.transform.position = position; @@ -229,7 +229,7 @@ public void SyncList_HostVisibilityReplayPreservesPendingRemoteChanges() behaviour.list.Add("first"); Assert.That(GetDeltaChangeCount(behaviour.list), Is.EqualTo(1)); - AddLocalPlayerWithoutProcessing(Vector3.zero); + AddLocalPlayerWithoutProcessingMessages(Vector3.zero); AssertObserved(identity, true); InvokeHostVisibilityDeferredCallbacks(identity); Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); @@ -239,7 +239,7 @@ public void SyncList_HostVisibilityReplayPreservesPendingRemoteChanges() [Test] public void SyncList_DoesNotDoubleReplayWhenHostPlayerChangesBeforeSpawnProcessed() { - NetworkIdentity identity = AddLocalPlayerWithoutProcessing(Vector3.zero, out HostVisibilitySyncListBehaviour behaviour); + NetworkIdentity identity = AddLocalPlayerWithoutProcessingMessages(Vector3.zero, out HostVisibilitySyncListBehaviour behaviour); behaviour.Register(); behaviour.list.Add("first"); @@ -297,7 +297,7 @@ public void SyncDictionary_HostVisibilityReplayPreservesPendingRemoteChanges() behaviour.dictionary.Add("key1", "first"); Assert.That(GetDeltaChangeCount(behaviour.dictionary), Is.EqualTo(1)); - AddLocalPlayerWithoutProcessing(Vector3.zero); + AddLocalPlayerWithoutProcessingMessages(Vector3.zero); AssertObserved(identity, true); InvokeHostVisibilityDeferredCallbacks(identity); Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first" })); @@ -350,7 +350,7 @@ public void SyncSet_HostVisibilityReplayPreservesPendingRemoteChanges() behaviour.set.Add("first"); Assert.That(GetDeltaChangeCount(behaviour.set), Is.EqualTo(1)); - AddLocalPlayerWithoutProcessing(Vector3.zero); + AddLocalPlayerWithoutProcessingMessages(Vector3.zero); AssertObserved(identity, true); InvokeHostVisibilityDeferredCallbacks(identity); Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs index d7ae7ca3ebc..054e6ddf305 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -64,7 +64,7 @@ void AddLocalPlayer(Vector3 position) Assert.That(NetworkClient.localPlayer, Is.EqualTo(identity)); } - NetworkIdentity AddLocalPlayerWithoutProcessing(Vector3 position, out HostVisibilityHookBehaviour behaviour) + NetworkIdentity AddLocalPlayerWithoutProcessingMessages(Vector3 position, out HostVisibilityHookBehaviour behaviour) { CreateNetworked(out GameObject player, out NetworkIdentity identity, out behaviour); player.transform.position = position; @@ -193,7 +193,7 @@ public void Hook_DoesNotReplayHiddenChangesAfterLeavingAOI() [Test] public void Hook_DoesNotDoubleFireWhenHostPlayerChangesBeforeSpawnProcessed() { - NetworkIdentity identity = AddLocalPlayerWithoutProcessing(Vector3.zero, out HostVisibilityHookBehaviour behaviour); + NetworkIdentity identity = AddLocalPlayerWithoutProcessingMessages(Vector3.zero, out HostVisibilityHookBehaviour behaviour); behaviour.value = 100; Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); From be2f07cc400fe076a478a4c7456afd261eda1b70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:56:28 +0000 Subject: [PATCH 41/48] Fix host syncvar replay dedupe --- Assets/Mirror/Core/NetworkBehaviour.cs | 51 ++++++++++++------- Assets/Mirror/Core/NetworkClient.cs | 3 ++ .../Processors/NetworkBehaviourProcessor.cs | 39 ++++++++++++++ .../Processors/SyncVarAttributeProcessor.cs | 14 +++++ ...SyncVarAttributeHook_HostVisibilityTest.cs | 36 +++++++++++++ 5 files changed, 126 insertions(+), 17 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index 7911510b98e..6326f795592 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -185,6 +185,13 @@ internal bool ShouldCaptureHostBaseline() => internal void MarkHostVisibilityReplayPending() => hostVisibilityReplayPending = true; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + void MarkSyncVarHostVisibilityReplayPending(ref bool hostVisibilityPending) + { + hostVisibilityPending = true; + hostVisibilityReplayPending = true; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] T EnsureInitialHookBaseline(T previous, ref T originalValue, ref bool originalValueSet) { @@ -229,6 +236,7 @@ internal void InvokeDeferredSyncCallbacks() } protected virtual void InvokeSyncVarHostVisibilityHooks() {} + protected internal virtual void MarkAllSyncVarHostVisibilityReplayPending() {} internal void InvokeHostVisibilityDeferredCallbacks() { @@ -657,7 +665,7 @@ public void GeneratedSyncVarSetter(T value, ref T field, ulong dirtyBit, Acti } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, Action OnChanged, ref T originalValue, ref bool originalValueSet) + public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, Action OnChanged, ref T originalValue, ref bool originalValueSet, ref bool hostVisibilityPending) { if (!SyncVarEqual(value, ref field)) { @@ -670,6 +678,7 @@ public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, { if (!originalValueSet) EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + hostVisibilityPending = false; SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); @@ -678,7 +687,7 @@ public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, { if (!originalValueSet) EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); - MarkHostVisibilityReplayPending(); + MarkSyncVarHostVisibilityReplayPending(ref hostVisibilityPending); } } } @@ -707,7 +716,7 @@ public void GeneratedSyncVarSetter_GameObject(GameObject value, ref GameObject f } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObject field, ulong dirtyBit, Action OnChanged, ref uint netIdField, ref GameObject originalValue, ref bool originalValueSet) + public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObject field, ulong dirtyBit, Action OnChanged, ref uint netIdField, ref GameObject originalValue, ref bool originalValueSet, ref bool hostVisibilityPending) { if (!SyncVarGameObjectEqual(value, netIdField)) { @@ -720,6 +729,7 @@ public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObj { if (!originalValueSet) EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + hostVisibilityPending = false; SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); @@ -728,7 +738,7 @@ public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObj { if (!originalValueSet) EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); - MarkHostVisibilityReplayPending(); + MarkSyncVarHostVisibilityReplayPending(ref hostVisibilityPending); } } } @@ -757,7 +767,7 @@ public void GeneratedSyncVarSetter_NetworkIdentity(NetworkIdentity value, ref Ne } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, ref NetworkIdentity field, ulong dirtyBit, Action OnChanged, ref uint netIdField, ref NetworkIdentity originalValue, ref bool originalValueSet) + public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, ref NetworkIdentity field, ulong dirtyBit, Action OnChanged, ref uint netIdField, ref NetworkIdentity originalValue, ref bool originalValueSet, ref bool hostVisibilityPending) { if (!SyncVarNetworkIdentityEqual(value, netIdField)) { @@ -770,6 +780,7 @@ public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, r { if (!originalValueSet) EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + hostVisibilityPending = false; SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); @@ -778,7 +789,7 @@ public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, r { if (!originalValueSet) EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); - MarkHostVisibilityReplayPending(); + MarkSyncVarHostVisibilityReplayPending(ref hostVisibilityPending); } } } @@ -808,7 +819,7 @@ public void GeneratedSyncVarSetter_NetworkBehaviour(T value, ref T field, ulo } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field, ulong dirtyBit, Action OnChanged, ref NetworkBehaviourSyncVar netIdField, ref T originalValue, ref bool originalValueSet) + public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field, ulong dirtyBit, Action OnChanged, ref NetworkBehaviourSyncVar netIdField, ref T originalValue, ref bool originalValueSet, ref bool hostVisibilityPending) where T : NetworkBehaviour { if (!SyncVarNetworkBehaviourEqual(value, netIdField)) @@ -822,6 +833,7 @@ public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field { if (!originalValueSet) EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + hostVisibilityPending = false; SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); @@ -830,7 +842,7 @@ public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field { if (!originalValueSet) EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); - MarkHostVisibilityReplayPending(); + MarkSyncVarHostVisibilityReplayPending(ref hostVisibilityPending); } } } @@ -985,16 +997,21 @@ public void GeneratedSyncVarDeserialize(ref T field, Action OnChanged, } } - public void GeneratedSyncVarHostVisibilityHook(ref T field, Action OnChanged, ulong dirtyBit, ref T originalValue, ref bool originalValueSet) + public void GeneratedSyncVarHostVisibilityHook(ref T field, Action OnChanged, ulong dirtyBit, ref T originalValue, ref bool originalValueSet, ref bool hostVisibilityPending) { - if (OnChanged != null && - originalValueSet && - !GetSyncVarHookGuard(dirtyBit) && - !SyncVarEqual(originalValue, ref field)) - { - SetSyncVarHookGuard(dirtyBit, true); - OnChanged(originalValue, field); - SetSyncVarHookGuard(dirtyBit, false); + if (hostVisibilityPending) + { + hostVisibilityPending = false; + + if (OnChanged != null && + originalValueSet && + !GetSyncVarHookGuard(dirtyBit) && + !SyncVarEqual(originalValue, ref field)) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(originalValue, field); + SetSyncVarHookGuard(dirtyBit, false); + } } } diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index b976d35d6b9..31eebd0eec9 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -1425,7 +1425,10 @@ static void OnHostClientObjectHide(ObjectHideMessage message) identity != null) { foreach (NetworkBehaviour component in identity.NetworkBehaviours) + { + component.MarkAllSyncVarHostVisibilityReplayPending(); component.MarkHostVisibilityReplayPending(); + } if (aoi != null) aoi.SetHostVisibility(identity, false); diff --git a/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs b/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs index 7da650ed695..0279130cdf4 100644 --- a/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs +++ b/Assets/Mirror/Editor/Weaver/Processors/NetworkBehaviourProcessor.cs @@ -100,6 +100,7 @@ public bool Process(ref bool WeavingFailed) GenerateDeSerialization(ref WeavingFailed); GenerateHostVisibilityHookInvocation(ref WeavingFailed); + GenerateHostVisibilityPendingSetter(); return true; } @@ -845,6 +846,8 @@ void GenerateHostVisibilityHookInvocation(ref bool WeavingFailed) worker.Emit(OpCodes.Ldflda, hookData.originalValueField); worker.Emit(OpCodes.Ldarg_0); worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.hostVisibilityPendingField); MethodReference generic = weaverTypes.generatedSyncVarHostVisibilityHook.MakeGeneric(assembly.MainModule, syncVar.FieldType); worker.Emit(OpCodes.Call, generic); @@ -857,6 +860,42 @@ void GenerateHostVisibilityHookInvocation(ref bool WeavingFailed) netBehaviourSubclass.Methods.Add(method); } + void GenerateHostVisibilityPendingSetter() + { + const string MethodName = "MarkAllSyncVarHostVisibilityReplayPending"; + if (netBehaviourSubclass.GetMethod(MethodName) != null) + return; + + if (syncVarHookDelegates.Count == 0) + return; + + MethodDefinition method = new MethodDefinition(MethodName, + MethodAttributes.FamORAssem | MethodAttributes.Virtual | MethodAttributes.HideBySig, + weaverTypes.Import(typeof(void))); + + ILProcessor worker = method.Body.GetILProcessor(); + + MethodReference baseMethod = Resolvers.TryResolveMethodInParents(netBehaviourSubclass.BaseType, assembly, MethodName); + if (baseMethod != null) + { + worker.Append(worker.Create(OpCodes.Ldarg_0)); + worker.Append(worker.Create(OpCodes.Call, baseMethod)); + } + + foreach (FieldDefinition syncVar in syncVars) + { + if (syncVarHookDelegates.TryGetValue(syncVar, out SyncVarAttributeProcessor.SyncVarHookData hookData)) + { + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldc_I4_1); + worker.Emit(OpCodes.Stfld, hookData.hostVisibilityPendingField); + } + } + + worker.Emit(OpCodes.Ret); + netBehaviourSubclass.Methods.Add(method); + } + public static bool ReadArguments(MethodDefinition method, Readers readers, Logger Log, ILProcessor worker, RemoteCallType callType, ref bool WeavingFailed) { // read each argument diff --git a/Assets/Mirror/Editor/Weaver/Processors/SyncVarAttributeProcessor.cs b/Assets/Mirror/Editor/Weaver/Processors/SyncVarAttributeProcessor.cs index ef77216522d..c4f572fe539 100644 --- a/Assets/Mirror/Editor/Weaver/Processors/SyncVarAttributeProcessor.cs +++ b/Assets/Mirror/Editor/Weaver/Processors/SyncVarAttributeProcessor.cs @@ -16,6 +16,7 @@ public struct SyncVarHookData public FieldDefinition hookDelegateField; public FieldDefinition originalValueField; public FieldDefinition originalValueSetField; + public FieldDefinition hostVisibilityPendingField; public MethodDefinition hookMethod; } @@ -69,6 +70,9 @@ public FieldDefinition CreateOriginalValueFieldDefinition(FieldDefinition syncVa public FieldDefinition CreateOriginalValueSetFieldDefinition(FieldDefinition syncVarField) => new FieldDefinition($"_Mirror_SyncVarHookOriginalSet_{syncVarField.Name}", FieldAttributes.Private, weaverTypes.Import()); + public FieldDefinition CreateHostVisibilityPendingFieldDefinition(FieldDefinition syncVarField) => + new FieldDefinition($"_Mirror_SyncVarHookHostVisibilityPending_{syncVarField.Name}", FieldAttributes.Private, weaverTypes.Import()); + // push hook from GetHookMethod() onto the stack as a new Action. // allows for reuse without handling static/virtual cases every time. // perf warning: it is recommended to use this method only when generating IL to create a new Action() in order to store it into a field @@ -365,6 +369,8 @@ public MethodDefinition GenerateSyncVarSetter(TypeDefinition td, FieldDefinition worker.Emit(OpCodes.Ldflda, hookData.originalValueField); worker.Emit(OpCodes.Ldarg_0); worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.hostVisibilityPendingField); worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarSetter_GameObject_Hook); } else @@ -384,6 +390,8 @@ public MethodDefinition GenerateSyncVarSetter(TypeDefinition td, FieldDefinition worker.Emit(OpCodes.Ldflda, hookData.originalValueField); worker.Emit(OpCodes.Ldarg_0); worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.hostVisibilityPendingField); worker.Emit(OpCodes.Call, weaverTypes.generatedSyncVarSetter_NetworkIdentity_Hook); } else @@ -408,6 +416,8 @@ public MethodDefinition GenerateSyncVarSetter(TypeDefinition td, FieldDefinition worker.Emit(OpCodes.Ldflda, hookData.originalValueField); worker.Emit(OpCodes.Ldarg_0); worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.hostVisibilityPendingField); } worker.Emit(OpCodes.Call, getFunc); @@ -422,6 +432,8 @@ public MethodDefinition GenerateSyncVarSetter(TypeDefinition td, FieldDefinition worker.Emit(OpCodes.Ldflda, hookData.originalValueField); worker.Emit(OpCodes.Ldarg_0); worker.Emit(OpCodes.Ldflda, hookData.originalValueSetField); + worker.Emit(OpCodes.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.hostVisibilityPendingField); } worker.Emit(OpCodes.Call, generic); @@ -473,6 +485,7 @@ public void ProcessSyncVar(TypeDefinition td, FieldDefinition fd, Dictionary hookValues.Add((oldValue, newValue)); } + class HostVisibilityMultiHookBehaviour : NetworkBehaviour + { + [SyncVar(hook = nameof(OnFirstChanged))] + public int first = 10; + + [SyncVar(hook = nameof(OnSecondChanged))] + public int second = 20; + + public readonly List hookCalls = new List(); + + void OnFirstChanged(int oldValue, int newValue) => hookCalls.Add($"first:{oldValue}->{newValue}"); + void OnSecondChanged(int oldValue, int newValue) => hookCalls.Add($"second:{oldValue}->{newValue}"); + } + public class SyncVarAttributeHook_HostVisibilityTest : MirrorTest { DistanceInterestManagement aoi; @@ -203,5 +217,27 @@ public void Hook_DoesNotDoubleFireWhenHostPlayerChangesBeforeSpawnProcessed() Assert.That(NetworkClient.localPlayer, Is.EqualTo(identity)); Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); } + + [Test] + public void Hook_DoesNotReplayImmediatelyObservedSyncVarBecauseAnotherHookIsPending() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilityMultiHookBehaviour behaviour); + go.transform.position = Vector3.zero; + NetworkServer.Spawn(go); + ProcessMessages(); + + behaviour.first = 100; + Assert.That(behaviour.hookCalls, Is.Empty); + + AddLocalPlayerWithoutProcessingMessages(Vector3.zero, out HostVisibilityHookBehaviour _); + AssertObserved(identity, true); + + behaviour.second = 200; + Assert.That(behaviour.hookCalls, Is.EqualTo(new[] { "second:20->200" })); + + ProcessMessages(); + + Assert.That(behaviour.hookCalls, Is.EqualTo(new[] { "second:20->200", "first:10->100" })); + } } } From 956a0f65882589b3470fc8045887ca071e258bc9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:15:52 +0000 Subject: [PATCH 42/48] fix: avoid cross-collection host replay duplicates --- Assets/Mirror/Core/NetworkBehaviour.cs | 12 +++++- Assets/Mirror/Core/NetworkClient.cs | 2 +- Assets/Mirror/Core/SyncDictionary.cs | 2 +- Assets/Mirror/Core/SyncList.cs | 2 +- Assets/Mirror/Core/SyncObject.cs | 16 ++++++++ Assets/Mirror/Core/SyncSet.cs | 2 +- ...SyncCollectionActionTest_HostVisibility.cs | 39 +++++++++++++++++++ 7 files changed, 70 insertions(+), 5 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index 6326f795592..3be5bf72011 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -185,6 +185,12 @@ internal bool ShouldCaptureHostBaseline() => internal void MarkHostVisibilityReplayPending() => hostVisibilityReplayPending = true; + internal void MarkAllSyncObjectHostVisibilityReplayPending() + { + for (int i = 0; i < syncObjects.Count; ++i) + syncObjects[i].MarkHostVisibilityReplayPending(); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] void MarkSyncVarHostVisibilityReplayPending(ref bool hostVisibilityPending) { @@ -246,7 +252,10 @@ internal void InvokeHostVisibilityDeferredCallbacks() if (replayPending) { for (int i = 0; i < syncObjects.Count; ++i) - syncObjects[i].QueueHostVisibilityReplay(); + { + if (syncObjects[i].ConsumeHostVisibilityReplayPending()) + syncObjects[i].QueueHostVisibilityReplay(); + } InvokeSyncVarHostVisibilityHooks(); } @@ -1590,6 +1599,7 @@ internal void ResetSyncObjects() foreach (SyncObject syncObject in syncObjects) { syncObject.Reset(); + syncObject.ClearHostVisibilityReplayPending(); } deferredSyncVarHooks.Clear(); diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index 31eebd0eec9..8dfdcba2adb 100644 --- a/Assets/Mirror/Core/NetworkClient.cs +++ b/Assets/Mirror/Core/NetworkClient.cs @@ -1427,7 +1427,7 @@ static void OnHostClientObjectHide(ObjectHideMessage message) foreach (NetworkBehaviour component in identity.NetworkBehaviours) { component.MarkAllSyncVarHostVisibilityReplayPending(); - component.MarkHostVisibilityReplayPending(); + component.MarkAllSyncObjectHostVisibilityReplayPending(); } if (aoi != null) diff --git a/Assets/Mirror/Core/SyncDictionary.cs b/Assets/Mirror/Core/SyncDictionary.cs index 09887cbbc36..d1d78b87eca 100644 --- a/Assets/Mirror/Core/SyncDictionary.cs +++ b/Assets/Mirror/Core/SyncDictionary.cs @@ -392,7 +392,7 @@ void AddOperation(Operation op, TKey key, TValue item, TValue oldItem, bool chec networkBehaviour.syncDirection == SyncDirection.ServerToClient && !networkBehaviour.IsHostClientObserved()) { - networkBehaviour.MarkHostVisibilityReplayPending(); + MarkHostVisibilityReplayPending(); return; } diff --git a/Assets/Mirror/Core/SyncList.cs b/Assets/Mirror/Core/SyncList.cs index eb5723dbd67..e7cf452b8a6 100644 --- a/Assets/Mirror/Core/SyncList.cs +++ b/Assets/Mirror/Core/SyncList.cs @@ -137,7 +137,7 @@ void AddOperation(Operation op, int itemIndex, T oldItem, T newItem, bool checkA networkBehaviour.syncDirection == SyncDirection.ServerToClient && !networkBehaviour.IsHostClientObserved()) { - networkBehaviour.MarkHostVisibilityReplayPending(); + MarkHostVisibilityReplayPending(); return; } diff --git a/Assets/Mirror/Core/SyncObject.cs b/Assets/Mirror/Core/SyncObject.cs index b50c4028687..ff30123475c 100644 --- a/Assets/Mirror/Core/SyncObject.cs +++ b/Assets/Mirror/Core/SyncObject.cs @@ -14,6 +14,7 @@ public abstract class SyncObject { // Back-reference to owning NetworkBehaviour for accessing deferred queues internal NetworkBehaviour networkBehaviour; + internal bool hostVisibilityReplayPending; /// Used internally to set owner NetworkBehaviour's dirty mask bit when changed. public Action OnDirty; @@ -53,6 +54,21 @@ public abstract class SyncObject /// Queues host-mode first-observation Add replay actions without mutating server state. public virtual void QueueHostVisibilityReplay() {} + internal void MarkHostVisibilityReplayPending() + { + hostVisibilityReplayPending = true; + networkBehaviour.MarkHostVisibilityReplayPending(); + } + + internal bool ConsumeHostVisibilityReplayPending() + { + bool pending = hostVisibilityReplayPending; + hostVisibilityReplayPending = false; + return pending; + } + + internal void ClearHostVisibilityReplayPending() => hostVisibilityReplayPending = false; + /// /// Clears any registered callbacks when a client object is unspawned. /// Reused scene objects keep the same SyncObject instance, so they need diff --git a/Assets/Mirror/Core/SyncSet.cs b/Assets/Mirror/Core/SyncSet.cs index 9b81c2f5359..69b07ff6192 100644 --- a/Assets/Mirror/Core/SyncSet.cs +++ b/Assets/Mirror/Core/SyncSet.cs @@ -126,7 +126,7 @@ void AddOperation(Operation op, T oldItem, T newItem, bool checkAccess, bool sho networkBehaviour.syncDirection == SyncDirection.ServerToClient && !networkBehaviour.IsHostClientObserved()) { - networkBehaviour.MarkHostVisibilityReplayPending(); + MarkHostVisibilityReplayPending(); return; } diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs index 1dd8dc44d06..2959a321680 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -28,6 +28,21 @@ class HostVisibilitySyncSetBehaviour : NetworkBehaviour public void Register() => set.OnAdd += item => actions.Add($"Add:{item}"); } + class HostVisibilityMultiCollectionBehaviour : NetworkBehaviour + { + public readonly SyncList list = new SyncList(); + public readonly SyncDictionary dictionary = new SyncDictionary(); + public readonly SyncHashSet set = new SyncHashSet(); + public readonly List actions = new List(); + + public void Register() + { + list.OnAdd += index => actions.Add($"List:Add:{list[index]}"); + dictionary.OnAdd += key => actions.Add($"Dictionary:Add:{key}:{dictionary[key]}"); + set.OnAdd += item => actions.Add($"Set:Add:{item}"); + } + } + public class SyncCollectionActionTest_HostVisibility : MirrorTest { DistanceInterestManagement aoi; @@ -251,6 +266,30 @@ public void SyncList_DoesNotDoubleReplayWhenHostPlayerChangesBeforeSpawnProcesse Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); } + [Test] + public void SyncCollections_DoNotReplayImmediatelyObservedActionsBecauseAnotherCollectionIsPending() + { + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilityMultiCollectionBehaviour behaviour); + go.transform.position = Vector3.zero; + behaviour.Register(); + NetworkServer.Spawn(go); + ProcessMessages(); + + behaviour.list.Add("hidden"); + Assert.That(behaviour.actions, Is.Empty); + + AddLocalPlayerWithoutProcessingMessages(Vector3.zero); + AssertObserved(identity, true); + + behaviour.dictionary.Add("key", "visible"); + behaviour.set.Add("visible"); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Dictionary:Add:key:visible", "Set:Add:visible" })); + + ProcessMessages(); + + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Dictionary:Add:key:visible", "Set:Add:visible", "List:Add:hidden" })); + } + [Test] public void SyncDictionary_ActionsDeferAgainAfterLeavingAoi() { From d83f910c1292d2e15a6b86380b71ad8458d85c33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:52:06 +0000 Subject: [PATCH 43/48] Fix host AOI syncvar replay gating --- Assets/Mirror/Core/NetworkBehaviour.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index 3be5bf72011..1c825b96c32 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -242,7 +242,7 @@ internal void InvokeDeferredSyncCallbacks() } protected virtual void InvokeSyncVarHostVisibilityHooks() {} - protected internal virtual void MarkAllSyncVarHostVisibilityReplayPending() {} + protected internal virtual void MarkAllSyncVarHostVisibilityReplayPending() => MarkHostVisibilityReplayPending(); internal void InvokeHostVisibilityDeferredCallbacks() { From d41bf4c1b877c821d2cd89265db17daf7560f8db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:38:59 +0000 Subject: [PATCH 44/48] Fix host replay transition gaps --- Assets/Mirror/Core/NetworkBehaviour.cs | 13 ++++++ Assets/Mirror/Core/SyncDictionary.cs | 13 ++++-- Assets/Mirror/Core/SyncList.cs | 13 ++++-- Assets/Mirror/Core/SyncSet.cs | 13 ++++-- ...SyncCollectionActionTest_HostVisibility.cs | 45 +++++++++++++++++++ ...SyncVarAttributeHook_HostVisibilityTest.cs | 43 ++++++++++++++++++ 6 files changed, 128 insertions(+), 12 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index 1c825b96c32..26d87e23cf0 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -246,6 +246,11 @@ protected virtual void InvokeSyncVarHostVisibilityHooks() {} internal void InvokeHostVisibilityDeferredCallbacks() { + if (NetworkServer.activeHost && + syncDirection == SyncDirection.ServerToClient && + !IsHostClientObserved()) + return; + bool replayPending = hostVisibilityReplayPending; hostVisibilityReplayPending = false; @@ -687,6 +692,8 @@ public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, { if (!originalValueSet) EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + if (hostVisibilityPending) + return; hostVisibilityPending = false; SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); @@ -738,6 +745,8 @@ public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObj { if (!originalValueSet) EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + if (hostVisibilityPending) + return; hostVisibilityPending = false; SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); @@ -789,6 +798,8 @@ public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, r { if (!originalValueSet) EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + if (hostVisibilityPending) + return; hostVisibilityPending = false; SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); @@ -842,6 +853,8 @@ public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field { if (!originalValueSet) EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + if (hostVisibilityPending) + return; hostVisibilityPending = false; SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); diff --git a/Assets/Mirror/Core/SyncDictionary.cs b/Assets/Mirror/Core/SyncDictionary.cs index d1d78b87eca..966897cde53 100644 --- a/Assets/Mirror/Core/SyncDictionary.cs +++ b/Assets/Mirror/Core/SyncDictionary.cs @@ -389,11 +389,16 @@ void AddOperation(Operation op, TKey key, TValue item, TValue oldItem, bool chec if (shouldFireActions) { if (NetworkServer.activeHost && - networkBehaviour.syncDirection == SyncDirection.ServerToClient && - !networkBehaviour.IsHostClientObserved()) + networkBehaviour.syncDirection == SyncDirection.ServerToClient) { - MarkHostVisibilityReplayPending(); - return; + if (!networkBehaviour.IsHostClientObserved()) + { + MarkHostVisibilityReplayPending(); + return; + } + + if (hostVisibilityReplayPending) + return; } // Defer Actions during initial spawn on pure client to eliminate diff --git a/Assets/Mirror/Core/SyncList.cs b/Assets/Mirror/Core/SyncList.cs index e7cf452b8a6..0aeb1b73b21 100644 --- a/Assets/Mirror/Core/SyncList.cs +++ b/Assets/Mirror/Core/SyncList.cs @@ -134,11 +134,16 @@ void AddOperation(Operation op, int itemIndex, T oldItem, T newItem, bool checkA if (shouldFireActions) { if (NetworkServer.activeHost && - networkBehaviour.syncDirection == SyncDirection.ServerToClient && - !networkBehaviour.IsHostClientObserved()) + networkBehaviour.syncDirection == SyncDirection.ServerToClient) { - MarkHostVisibilityReplayPending(); - return; + if (!networkBehaviour.IsHostClientObserved()) + { + MarkHostVisibilityReplayPending(); + return; + } + + if (hostVisibilityReplayPending) + return; } // Defer Actions during initial spawn on pure client to eliminate diff --git a/Assets/Mirror/Core/SyncSet.cs b/Assets/Mirror/Core/SyncSet.cs index 69b07ff6192..5841e035b20 100644 --- a/Assets/Mirror/Core/SyncSet.cs +++ b/Assets/Mirror/Core/SyncSet.cs @@ -123,11 +123,16 @@ void AddOperation(Operation op, T oldItem, T newItem, bool checkAccess, bool sho if (shouldFireActions) { if (NetworkServer.activeHost && - networkBehaviour.syncDirection == SyncDirection.ServerToClient && - !networkBehaviour.IsHostClientObserved()) + networkBehaviour.syncDirection == SyncDirection.ServerToClient) { - MarkHostVisibilityReplayPending(); - return; + if (!networkBehaviour.IsHostClientObserved()) + { + MarkHostVisibilityReplayPending(); + return; + } + + if (hostVisibilityReplayPending) + return; } // Defer Actions during initial spawn on pure client to eliminate diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs index 2959a321680..594cbe01378 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -266,6 +266,51 @@ public void SyncList_DoesNotDoubleReplayWhenHostPlayerChangesBeforeSpawnProcesse Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); } + [Test] + public void SyncList_StaysDeferredWhenSameCollectionChangesAgainBeforeHostSpawnProcessed() + { + CreateNetworked(out GameObject player, out NetworkIdentity identity, out HostVisibilitySyncListBehaviour behaviour); + player.transform.position = Vector3.zero; + behaviour.Register(); + + behaviour.list.Add("first"); + Assert.That(behaviour.actions, Is.Empty); + + NetworkServer.AddPlayerForConnection(NetworkServer.localConnection, player); + Assert.That(NetworkClient.localPlayer, Is.EqualTo(identity)); + + behaviour.list.Add("second"); + Assert.That(behaviour.actions, Is.Empty); + + ProcessMessages(); + + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first", "Add:second" })); + } + + [Test] + public void SyncList_DoesNotReplayBeforeHostLocalPlayerExists() + { + NetworkServer.aoi = null; + NetworkClient.aoi = null; + + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilitySyncListBehaviour behaviour); + go.transform.position = Vector3.zero; + behaviour.Register(); + NetworkServer.Spawn(go); + + behaviour.list.Add("first"); + Assert.That(behaviour.actions, Is.Empty); + + ProcessMessages(); + + Assert.That(NetworkClient.localPlayer, Is.Null); + Assert.That(NetworkServer.localConnection.observing.Contains(identity), Is.True); + Assert.That(behaviour.actions, Is.Empty); + + AddLocalPlayer(Vector3.zero); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + } + [Test] public void SyncCollections_DoNotReplayImmediatelyObservedActionsBecauseAnotherCollectionIsPending() { diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs index 7f9f7579c10..d6ca4ea1db3 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -218,6 +218,49 @@ public void Hook_DoesNotDoubleFireWhenHostPlayerChangesBeforeSpawnProcessed() Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); } + [Test] + public void Hook_StaysDeferredWhenSameSyncVarChangesAgainBeforeHostSpawnProcessed() + { + CreateNetworked(out GameObject player, out NetworkIdentity identity, out HostVisibilityHookBehaviour behaviour); + player.transform.position = Vector3.zero; + + behaviour.value = 50; + Assert.That(behaviour.hookValues, Is.Empty); + + NetworkServer.AddPlayerForConnection(NetworkServer.localConnection, player); + Assert.That(NetworkClient.localPlayer, Is.EqualTo(identity)); + + behaviour.value = 100; + Assert.That(behaviour.hookValues, Is.Empty); + + ProcessMessages(); + + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + } + + [Test] + public void Hook_DoesNotReplayBeforeHostLocalPlayerExists() + { + NetworkServer.aoi = null; + NetworkClient.aoi = null; + + CreateNetworked(out GameObject go, out NetworkIdentity identity, out HostVisibilityHookBehaviour behaviour); + go.transform.position = Vector3.zero; + NetworkServer.Spawn(go); + + behaviour.value = 100; + Assert.That(behaviour.hookValues, Is.Empty); + + ProcessMessages(); + + Assert.That(NetworkClient.localPlayer, Is.Null); + Assert.That(NetworkServer.localConnection.observing.Contains(identity), Is.True); + Assert.That(behaviour.hookValues, Is.Empty); + + AddLocalPlayer(Vector3.zero); + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + } + [Test] public void Hook_DoesNotReplayImmediatelyObservedSyncVarBecauseAnotherHookIsPending() { From 0a75f0933cc08bd0bdb8e4dd3d6b33c145654e78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:40:59 +0000 Subject: [PATCH 45/48] Clarify deferred host syncvar replay --- Assets/Mirror/Core/NetworkBehaviour.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index 26d87e23cf0..2ca0cc85c09 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -694,7 +694,6 @@ public void GeneratedSyncVarSetter_Hook(T value, ref T field, ulong dirtyBit, EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); if (hostVisibilityPending) return; - hostVisibilityPending = false; SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); @@ -747,7 +746,6 @@ public void GeneratedSyncVarSetter_GameObject_Hook(GameObject value, ref GameObj EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); if (hostVisibilityPending) return; - hostVisibilityPending = false; SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); @@ -800,7 +798,6 @@ public void GeneratedSyncVarSetter_NetworkIdentity_Hook(NetworkIdentity value, r EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); if (hostVisibilityPending) return; - hostVisibilityPending = false; SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); @@ -855,7 +852,6 @@ public void GeneratedSyncVarSetter_NetworkBehaviour_Hook(T value, ref T field EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); if (hostVisibilityPending) return; - hostVisibilityPending = false; SetSyncVarHookGuard(dirtyBit, true); OnChanged(previous, value); SetSyncVarHookGuard(dirtyBit, false); From 1fab2fcde77290c94ced4d5776ee35761844dab0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:01:53 +0000 Subject: [PATCH 46/48] fix host replay after runtime unspawn respawn --- Assets/Mirror/Core/NetworkServer.cs | 6 +++ ...SyncCollectionActionTest_HostVisibility.cs | 28 +++++++++++++ ...SyncVarAttributeHook_HostVisibilityTest.cs | 40 +++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/Assets/Mirror/Core/NetworkServer.cs b/Assets/Mirror/Core/NetworkServer.cs index e35dca6431b..694db11d1ed 100644 --- a/Assets/Mirror/Core/NetworkServer.cs +++ b/Assets/Mirror/Core/NetworkServer.cs @@ -1884,6 +1884,12 @@ static void UnSpawnInternal(GameObject obj, bool resetState) // in host mode, call OnStopClient/OnStopLocalPlayer manually if (NetworkClient.active && activeHost) { + foreach (NetworkBehaviour component in identity.NetworkBehaviours) + { + component.MarkAllSyncVarHostVisibilityReplayPending(); + component.MarkAllSyncObjectHostVisibilityReplayPending(); + } + // fix: #3962 custom unspawn handler for this prefab (for prefab pools etc.) NetworkClient.InvokeUnSpawnHandler(identity.assetId, identity.gameObject); diff --git a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs index 594cbe01378..5e8138d9858 100644 --- a/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -335,6 +335,34 @@ public void SyncCollections_DoNotReplayImmediatelyObservedActionsBecauseAnotherC Assert.That(behaviour.actions, Is.EqualTo(new[] { "Dictionary:Add:key:visible", "Set:Add:visible", "List:Add:hidden" })); } + [Test] + public void SyncCollections_ReplayWhenHostRespawnsRuntimeObject() + { + NetworkServer.aoi = null; + NetworkClient.aoi = null; + + AddLocalPlayer(Vector3.zero); + + CreateNetworked(out GameObject go, out _, out HostVisibilityMultiCollectionBehaviour behaviour); + behaviour.Register(); + behaviour.list.Add("first"); + behaviour.dictionary.Add("key", "value"); + behaviour.set.Add("first"); + + NetworkServer.Spawn(go); + ProcessMessages(); + + Assert.That(behaviour.actions, Is.EqualTo(new[] { "List:Add:first", "Dictionary:Add:key:value", "Set:Add:first" })); + + behaviour.actions.Clear(); + + NetworkServer.UnSpawn(go); + NetworkServer.Spawn(go); + ProcessMessages(); + + Assert.That(behaviour.actions, Is.EqualTo(new[] { "List:Add:first", "Dictionary:Add:key:value", "Set:Add:first" })); + } + [Test] public void SyncDictionary_ActionsDeferAgainAfterLeavingAoi() { diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs index d6ca4ea1db3..28f9c221412 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -43,6 +43,22 @@ class HostVisibilityMultiHookBehaviour : NetworkBehaviour void OnSecondChanged(int oldValue, int newValue) => hookCalls.Add($"second:{oldValue}->{newValue}"); } + class HostRespawnHookBehaviour : NetworkBehaviour + { + [SyncVar(hook = nameof(OnValueChanged))] + public int value = 42; + + public readonly List<(int oldValue, int newValue)> hookValues = new List<(int oldValue, int newValue)>(); + + public override void OnStartServer() + { + if (value == 42) + value = 100; + } + + void OnValueChanged(int oldValue, int newValue) => hookValues.Add((oldValue, newValue)); + } + public class SyncVarAttributeHook_HostVisibilityTest : MirrorTest { DistanceInterestManagement aoi; @@ -282,5 +298,29 @@ public void Hook_DoesNotReplayImmediatelyObservedSyncVarBecauseAnotherHookIsPend Assert.That(behaviour.hookCalls, Is.EqualTo(new[] { "second:20->200", "first:10->100" })); } + + [Test] + public void Hook_ReplaysWhenHostRespawnsRuntimeObject() + { + NetworkServer.aoi = null; + NetworkClient.aoi = null; + + AddLocalPlayer(Vector3.zero); + + CreateNetworked(out GameObject go, out _, out HostRespawnHookBehaviour behaviour); + + NetworkServer.Spawn(go); + ProcessMessages(); + + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + + behaviour.hookValues.Clear(); + + NetworkServer.UnSpawn(go); + NetworkServer.Spawn(go); + ProcessMessages(); + + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + } } } From 310425db332166e34cdbb7c50a7af42f5b36dd57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:03:46 +0000 Subject: [PATCH 47/48] test clarify host respawn replay regression coverage --- .../SyncVarAttributeHook_HostVisibilityTest.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs index 28f9c221412..247a1a2df41 100644 --- a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -45,15 +45,18 @@ class HostVisibilityMultiHookBehaviour : NetworkBehaviour class HostRespawnHookBehaviour : NetworkBehaviour { + internal const int InitialValue = 42; + internal const int SpawnValue = 100; + [SyncVar(hook = nameof(OnValueChanged))] - public int value = 42; + public int syncedValue = InitialValue; public readonly List<(int oldValue, int newValue)> hookValues = new List<(int oldValue, int newValue)>(); public override void OnStartServer() { - if (value == 42) - value = 100; + if (syncedValue == InitialValue) + syncedValue = SpawnValue; } void OnValueChanged(int oldValue, int newValue) => hookValues.Add((oldValue, newValue)); @@ -312,7 +315,7 @@ public void Hook_ReplaysWhenHostRespawnsRuntimeObject() NetworkServer.Spawn(go); ProcessMessages(); - Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (HostRespawnHookBehaviour.InitialValue, HostRespawnHookBehaviour.SpawnValue) })); behaviour.hookValues.Clear(); @@ -320,7 +323,7 @@ public void Hook_ReplaysWhenHostRespawnsRuntimeObject() NetworkServer.Spawn(go); ProcessMessages(); - Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 100) })); + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (HostRespawnHookBehaviour.InitialValue, HostRespawnHookBehaviour.SpawnValue) })); } } } From 2ce684a3b62b799174b9f10a367fe53b9d2f6f26 Mon Sep 17 00:00:00 2001 From: MrGadget <9826063+MrGadget1024@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:02:25 -0400 Subject: [PATCH 48/48] Cleanup from testing --- .../Examples/Basic/Scripts/BasicNetManager.cs | 12 ------------ .../Mirror/Examples/Basic/Scripts/Player.cs | 6 +++--- .../Examples/PlayerTest/PlayerTestScene.unity | 19 +------------------ .../Examples/_Common/Scripts/RandomColor.cs | 2 +- 4 files changed, 5 insertions(+), 34 deletions(-) diff --git a/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs b/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs index 757bbedd3d8..03751378afb 100644 --- a/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs +++ b/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs @@ -13,18 +13,6 @@ public class BasicNetManager : NetworkManager /// Connection from client. public override void OnServerAddPlayer(NetworkConnectionToClient conn) { - //if (conn is LocalConnectionToClient) - //{ - // StartCoroutine(DelayedAddPlayer(conn)); - // return; - //} - base.OnServerAddPlayer(conn); - Player.ResetPlayerNumbers(); - } - - IEnumerator DelayedAddPlayer (NetworkConnectionToClient conn) - { - yield return new WaitForSeconds(5f); base.OnServerAddPlayer(conn); Player.ResetPlayerNumbers(); } diff --git a/Assets/Mirror/Examples/Basic/Scripts/Player.cs b/Assets/Mirror/Examples/Basic/Scripts/Player.cs index 0522acfb8ce..eb61096f86a 100644 --- a/Assets/Mirror/Examples/Basic/Scripts/Player.cs +++ b/Assets/Mirror/Examples/Basic/Scripts/Player.cs @@ -45,21 +45,21 @@ public class Player : NetworkBehaviour // This is called by the hook of playerNumber SyncVar above void PlayerNumberChanged(byte _, byte newPlayerNumber) { - Debug.Log($"PlayerNumberChanged: {newPlayerNumber} {isServer}", gameObject); + //Debug.Log($"PlayerNumberChanged: {newPlayerNumber} {isServer}", gameObject); OnPlayerNumberChanged?.Invoke(newPlayerNumber); } // This is called by the hook of playerColor SyncVar above void PlayerColorChanged(Color32 _, Color32 newPlayerColor) { - Debug.Log($"PlayerColorChanged: {newPlayerColor} {isServer}", gameObject); + //Debug.Log($"PlayerColorChanged: {newPlayerColor} {isServer}", gameObject); OnPlayerColorChanged?.Invoke(newPlayerColor); } // This is called by the hook of playerData SyncVar above void PlayerDataChanged(ushort _, ushort newPlayerData) { - Debug.Log($"PlayerDataChanged: {newPlayerData} {isServer}", gameObject); + //Debug.Log($"PlayerDataChanged: {newPlayerData} {isServer}", gameObject); OnPlayerDataChanged?.Invoke(newPlayerData); } diff --git a/Assets/Mirror/Examples/PlayerTest/PlayerTestScene.unity b/Assets/Mirror/Examples/PlayerTest/PlayerTestScene.unity index 48b15f8db19..d3a169a45b4 100644 --- a/Assets/Mirror/Examples/PlayerTest/PlayerTestScene.unity +++ b/Assets/Mirror/Examples/PlayerTest/PlayerTestScene.unity @@ -414,7 +414,7 @@ Transform: m_GameObject: {fileID: 856494733} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: -325.63, y: 12.57, z: 1.38} + m_LocalPosition: {x: -325.78217, y: 11.999975, z: 4.128914} m_LocalScale: {x: 4, y: 4, z: 4} m_ConstrainProportionsScale: 0 m_Children: [] @@ -832,7 +832,6 @@ GameObject: - component: {fileID: 2126814506} - component: {fileID: 2126814504} - component: {fileID: 2126814508} - - component: {fileID: 2126814509} m_Layer: 0 m_Name: Network m_TagString: Untagged @@ -959,22 +958,6 @@ MonoBehaviour: m_EditorClassIdentifier: showGUI: 1 showLog: 0 ---- !u!114 &2126814509 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2126814503} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 8f60becab051427fbdd3c8ac9ab4712b, type: 3} - m_Name: - m_EditorClassIdentifier: - visRange: 10 - minMoveDistance: 0.1 - rebuildInterval: 1 - staticRebuildInterval: 10 --- !u!1660057539 &9223372036854775807 SceneRoots: m_ObjectHideFlags: 0 diff --git a/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs b/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs index 35f1195e1fc..410ab464f4c 100644 --- a/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs +++ b/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs @@ -17,7 +17,7 @@ void SetColor(Color32 oldColor, Color32 newColor) { if (cachedMaterial == null) cachedMaterial = GetComponentInChildren().material; cachedMaterial.color = newColor; - Debug.Log($"SetColor: {oldColor} -> {newColor} for {gameObject.name}", gameObject); + //Debug.Log($"SetColor: {oldColor} -> {newColor} for {gameObject.name}", gameObject); } public override void OnStartServer()