diff --git a/Assets/Mirror/Core/NetworkBehaviour.cs b/Assets/Mirror/Core/NetworkBehaviour.cs index 1f885f0176b..2ca0cc85c09 100644 --- a/Assets/Mirror/Core/NetworkBehaviour.cs +++ b/Assets/Mirror/Core/NetworkBehaviour.cs @@ -164,14 +164,109 @@ 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(); + internal bool hostVisibilityReplayPending; + + internal bool IsHostClientObserved() => + NetworkServer.activeHost && + NetworkServer.localConnection != null && + NetworkClient.localPlayer != null && + netIdentity.observers.ContainsKey(NetworkServer.localConnection.connectionId); + + internal bool ShouldCaptureHostBaseline() => + NetworkServer.activeHost && + !IsHostClientObserved(); + + 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) + { + hostVisibilityPending = true; + hostVisibilityReplayPending = true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + 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, + // including host re-observation, behaves like a fresh first observation again. + if (!originalValueSet) + { + originalValue = previous; + originalValueSet = true; + } + + return originalValue; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + void InvokeSyncVarHookAction(Action onChanged, T previous, T current) + { + if (NetworkClient.active && !NetworkClient.activeHost && !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) + hook?.Invoke(); + + deferredSyncVarHooks.Clear(); + + foreach (Action action in deferredSyncCollectionActions) + action?.Invoke(); + + deferredSyncCollectionActions.Clear(); + } + + protected virtual void InvokeSyncVarHostVisibilityHooks() {} + protected internal virtual void MarkAllSyncVarHostVisibilityReplayPending() => MarkHostVisibilityReplayPending(); + + internal void InvokeHostVisibilityDeferredCallbacks() + { + if (NetworkServer.activeHost && + syncDirection == SyncDirection.ServerToClient && + !IsHostClientObserved()) + return; + + bool replayPending = hostVisibilityReplayPending; + hostVisibilityReplayPending = false; + + if (replayPending) + { + for (int i = 0; i < syncObjects.Count; ++i) + { + if (syncObjects[i].ConsumeHostVisibilityReplayPending()) + syncObjects[i].QueueHostVisibilityReplay(); + } + + InvokeSyncVarHostVisibilityHooks(); + } + + InvokeDeferredSyncCallbacks(); + } protected virtual void OnValidate() { @@ -571,15 +666,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 +678,36 @@ 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, ref bool hostVisibilityPending) + { + if (!SyncVarEqual(value, ref field)) + { + T previous = field; + SetSyncVar(value, ref field, dirtyBit); + + if (OnChanged != null && NetworkServer.activeHost && !GetSyncVarHookGuard(dirtyBit)) + { + if (IsHostClientObserved()) + { + if (!originalValueSet) + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + if (hostVisibilityPending) + return; + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(previous, value); + SetSyncVarHookGuard(dirtyBit, false); + } + else if (ShouldCaptureHostBaseline()) + { + if (!originalValueSet) + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + MarkSyncVarHostVisibilityReplayPending(ref hostVisibilityPending); + } + } + } + } + // GameObject needs custom handling for persistence via netId. // has one extra parameter. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -600,15 +718,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 +730,36 @@ 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, ref bool hostVisibilityPending) + { + if (!SyncVarGameObjectEqual(value, netIdField)) + { + GameObject previous = field; + SetSyncVarGameObject(value, ref field, dirtyBit, ref netIdField); + + if (OnChanged != null && NetworkServer.activeHost && !GetSyncVarHookGuard(dirtyBit)) + { + if (IsHostClientObserved()) + { + if (!originalValueSet) + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + if (hostVisibilityPending) + return; + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(previous, value); + SetSyncVarHookGuard(dirtyBit, false); + } + else if (ShouldCaptureHostBaseline()) + { + if (!originalValueSet) + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + MarkSyncVarHostVisibilityReplayPending(ref hostVisibilityPending); + } + } + } + } + // NetworkIdentity needs custom handling for persistence via netId. // has one extra parameter. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -629,15 +770,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 +782,36 @@ 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, ref bool hostVisibilityPending) + { + if (!SyncVarNetworkIdentityEqual(value, netIdField)) + { + NetworkIdentity previous = field; + SetSyncVarNetworkIdentity(value, ref field, dirtyBit, ref netIdField); + + if (OnChanged != null && NetworkServer.activeHost && !GetSyncVarHookGuard(dirtyBit)) + { + if (IsHostClientObserved()) + { + if (!originalValueSet) + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + if (hostVisibilityPending) + return; + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(previous, value); + SetSyncVarHookGuard(dirtyBit, false); + } + else if (ShouldCaptureHostBaseline()) + { + if (!originalValueSet) + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + MarkSyncVarHostVisibilityReplayPending(ref hostVisibilityPending); + } + } + } + } + // NetworkBehaviour needs custom handling for persistence via netId. // has one extra parameter. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -659,15 +823,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 +835,37 @@ 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, ref bool hostVisibilityPending) + 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()) + { + if (!originalValueSet) + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + if (hostVisibilityPending) + return; + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(previous, value); + SetSyncVarHookGuard(dirtyBit, false); + } + else if (ShouldCaptureHostBaseline()) + { + if (!originalValueSet) + EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + MarkSyncVarHostVisibilityReplayPending(ref hostVisibilityPending); + } + } + } + } + // helper function for [SyncVar] GameObjects. // needs to be public so that tests & NetworkBehaviours from other // assemblies both find it @@ -810,271 +998,232 @@ 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 + InvokeSyncVarHookAction(OnChanged, previous, field); + } + } + } + + public void GeneratedSyncVarHostVisibilityHook(ref T field, Action OnChanged, ulong dirtyBit, ref T originalValue, ref bool originalValueSet, ref bool hostVisibilityPending) + { + if (hostVisibilityPending) + { + hostVisibilityPending = false; + + 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; + field = value; + + if (OnChanged != null) + { + if (NetworkServer.activeHost && netIdentity.hostInitialSpawn) + { + if (originalValueSet) { - // Normal: invoke immediately (host mode, server, or after spawn finished) - OnChanged(previous, field); + if (!SyncVarEqual(originalValue, ref field)) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(originalValue, field); + SetSyncVarHookGuard(dirtyBit, false); + } + } } + else if (NetworkClient.active && !NetworkClient.activeHost && netIdentity.clientInitialSpawnActive) + { + T baseline = EnsureInitialHookBaseline(previous, ref originalValue, ref originalValueSet); + if (!SyncVarEqual(baseline, ref field)) + InvokeSyncVarHookAction(OnChanged, baseline, field); + } + else if (!SyncVarEqual(previous, ref field)) + { + InvokeSyncVarHookAction(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_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); 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 + InvokeSyncVarHookAction(OnChanged, previousGameObject, field); + } + } + } + + 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) { - OnChanged(previousGameObject, field); + if (!SyncVarEqual(originalValue, ref field)) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(originalValue, field); + SetSyncVarHookGuard(dirtyBit, false); + } + } } + else if (NetworkClient.active && !NetworkClient.activeHost && netIdentity.clientInitialSpawnActive) + { + GameObject baseline = EnsureInitialHookBaseline(previousGameObject, ref originalValue, ref originalValueSet); + if (!SyncVarEqual(baseline, ref field)) + InvokeSyncVarHookAction(OnChanged, baseline, field); + } + else if (!SyncVarEqual(previousNetId, ref netIdField)) + { + InvokeSyncVarHookAction(OnChanged, previousGameObject, 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; - // 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_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); 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 + InvokeSyncVarHookAction(OnChanged, previousIdentity, field); + } + } + } + + 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) { - OnChanged(previousIdentity, field); + if (!SyncVarEqual(originalValue, ref field)) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(originalValue, field); + SetSyncVarHookGuard(dirtyBit, false); + } + } } + else if (NetworkClient.active && !NetworkClient.activeHost && netIdentity.clientInitialSpawnActive) + { + NetworkIdentity baseline = EnsureInitialHookBaseline(previousIdentity, ref originalValue, ref originalValueSet); + if (!SyncVarEqual(baseline, ref field)) + InvokeSyncVarHookAction(OnChanged, baseline, field); + } + else if (!SyncVarEqual(previousNetId, ref netIdField)) + { + InvokeSyncVarHookAction(OnChanged, previousIdentity, 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) - // { - // 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_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); 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 + InvokeSyncVarHookAction(OnChanged, previousBehaviour, field); + } + } + } + + 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) { - OnChanged(previousBehaviour, field); + if (!SyncVarEqual(originalValue, ref field)) + { + SetSyncVarHookGuard(dirtyBit, true); + OnChanged(originalValue, field); + SetSyncVarHookGuard(dirtyBit, false); + } + } } + else if (NetworkClient.active && !NetworkClient.activeHost && netIdentity.clientInitialSpawnActive) + { + T baseline = EnsureInitialHookBaseline(previousBehaviour, ref originalValue, ref originalValueSet); + if (!SyncVarEqual(baseline, ref field)) + InvokeSyncVarHookAction(OnChanged, baseline, field); + } + else if (!SyncVarEqual(previousNetId, ref netIdField)) + { + InvokeSyncVarHookAction(OnChanged, previousBehaviour, field); + } } } @@ -1459,6 +1608,19 @@ internal void ResetSyncObjects() foreach (SyncObject syncObject in syncObjects) { syncObject.Reset(); + syncObject.ClearHostVisibilityReplayPending(); + } + + deferredSyncVarHooks.Clear(); + deferredSyncCollectionActions.Clear(); + hostVisibilityReplayPending = false; + } + + internal void ResetSyncObjectCallbacks() + { + foreach (SyncObject syncObject in syncObjects) + { + syncObject.ResetCallbacks(); } } diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs index fc77d7941ad..8dfdcba2adb 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,34 @@ 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; + + // Hooks may change visibility/ownership, so iterate a snapshot. + List observed = new List(NetworkServer.localConnection.observing); + foreach (NetworkIdentity identity in observed) + { + if (identity == null || !spawned.ContainsKey(identity.netId)) + continue; + + identity.hostInitialSpawn = true; + try + { + foreach (NetworkBehaviour comp in identity.NetworkBehaviours) + comp.InvokeHostVisibilityDeferredCallbacks(); + } + finally + { + identity.hostInitialSpawn = false; + } + } } /// Sends AddPlayer message to the server, indicating that we want to join the world. @@ -1182,7 +1212,15 @@ internal static void ApplySpawnPayload(NetworkIdentity identity, SpawnMessage me { using (NetworkReaderPooled payloadReader = NetworkReaderPool.Get(message.payload)) { - identity.DeserializeClient(payloadReader, true); + identity.clientInitialSpawnActive = true; + try + { + identity.DeserializeClient(payloadReader, true); + } + finally + { + identity.clientInitialSpawnActive = false; + } } } @@ -1368,18 +1406,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); } @@ -1397,6 +1424,12 @@ static void OnHostClientObjectHide(ObjectHideMessage message) if (spawned.TryGetValue(message.netId, out NetworkIdentity identity) && identity != null) { + foreach (NetworkBehaviour component in identity.NetworkBehaviours) + { + component.MarkAllSyncVarHostVisibilityReplayPending(); + component.MarkAllSyncObjectHostVisibilityReplayPending(); + } + if (aoi != null) aoi.SetHostVisibility(identity, false); } @@ -1421,28 +1454,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; - // Invoke callbacks after deserializing + // Invoke callbacks after host-visible state is ready. InvokeIdentityCallbacks(identity); } } @@ -1588,7 +1614,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); @@ -1937,6 +1963,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); } @@ -1964,6 +1991,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(); @@ -1976,7 +2005,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); @@ -1985,9 +2014,10 @@ static void DestroyObject(uint netId) else { identity.gameObject.SetActive(false); - spawnableObjects[identity.sceneId] = identity; // reset for scene objects + identity.ResetSyncObjectCallbacks(); identity.ResetState(); + spawnableObjects[sceneId] = identity; } // remove from dictionary no matter how it is unspawned diff --git a/Assets/Mirror/Core/NetworkIdentity.cs b/Assets/Mirror/Core/NetworkIdentity.cs index 9ffe04d0a97..aa0c6f2cf46 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 clientInitialSpawnActive; + /// The set of network connections (players) that can see this object. public readonly Dictionary observers = new Dictionary(); @@ -1689,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/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/Core/SyncDictionary.cs b/Assets/Mirror/Core/SyncDictionary.cs index f9555715beb..966897cde53 100644 --- a/Assets/Mirror/Core/SyncDictionary.cs +++ b/Assets/Mirror/Core/SyncDictionary.cs @@ -131,12 +131,30 @@ public override void OnDeserializeAll(NetworkReader reader) objects.Add(key, obj); } + QueueHostVisibilityReplay(); + // We will need to skip all these changes // the next time the list is synchronized // because they have already been applied 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(); @@ -260,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]; @@ -359,15 +386,21 @@ 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) + { + if (!networkBehaviour.IsHostClientObserved()) + { + MarkHostVisibilityReplayPending(); + return; + } + + if (hostVisibilityReplayPending) + 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 eec88f13e86..0aeb1b73b21 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) @@ -120,15 +131,21 @@ 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) + { + if (!networkBehaviour.IsHostClientObserved()) + { + MarkHostVisibilityReplayPending(); + return; + } + + if (hostVisibilityReplayPending) + 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. @@ -247,12 +264,30 @@ public override void OnDeserializeAll(NetworkReader reader) objects.Add(obj); } + QueueHostVisibilityReplay(); + // We will need to skip all these changes // the next time the list is synchronized // because they have already been applied 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..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; @@ -50,6 +51,31 @@ 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() {} + + 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 + /// 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 public abstract void Reset(); } diff --git a/Assets/Mirror/Core/SyncSet.cs b/Assets/Mirror/Core/SyncSet.cs index a098e1164d8..5841e035b20 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(); @@ -112,15 +120,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) + { + if (!networkBehaviour.IsHostClientObserved()) + { + MarkHostVisibilityReplayPending(); + return; + } + + if (hostVisibilityReplayPending) + 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. @@ -216,12 +230,29 @@ public override void OnDeserializeAll(NetworkReader reader) objects.Add(obj); } + QueueHostVisibilityReplay(); + // We will need to skip all these changes // the next time the list is synchronized // because they have already been applied 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 47f1b940487..0279130cdf4 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(); @@ -99,6 +99,8 @@ public bool Process(ref bool WeavingFailed) } GenerateDeSerialization(ref WeavingFailed); + GenerateHostVisibilityHookInvocation(ref WeavingFailed); + GenerateHostVisibilityPendingSetter(); return true; } @@ -353,11 +355,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 +578,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 +598,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 +613,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 +622,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 +647,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 +675,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 +709,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 +765,11 @@ void GenerateDeSerialization(ref bool WeavingFailed) serWorker.Append(serWorker.Create(OpCodes.Ldarg_2)); serWorker.Append(serWorker.Create(OpCodes.Brfalse, initialStateLabel)); + int syncVarBitOffset = syncVarAccessLists.GetSyncVarStart(netBehaviourSubclass.BaseType.FullName); foreach (FieldDefinition syncVar in syncVars) { - DeserializeField(syncVar, serWorker, ref WeavingFailed); + DeserializeField(syncVar, serWorker, 1L << syncVarBitOffset, ref WeavingFailed); + syncVarBitOffset += 1; } serWorker.Append(serWorker.Create(OpCodes.Ret)); @@ -743,7 +795,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; @@ -757,6 +809,93 @@ 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); + 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); + } + + dirtyBit += 1; + } + + worker.Emit(OpCodes.Ret); + 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 76e3ac7ed5c..c4f572fe539 100644 --- a/Assets/Mirror/Editor/Weaver/Processors/SyncVarAttributeProcessor.cs +++ b/Assets/Mirror/Editor/Weaver/Processors/SyncVarAttributeProcessor.cs @@ -11,6 +11,15 @@ 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 FieldDefinition hostVisibilityPendingField; + public MethodDefinition hookMethod; + } + // ulong = 64 bytes const int SyncVarLimit = 64; @@ -55,6 +64,15 @@ 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()); + + 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 @@ -261,7 +279,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 +341,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 +362,42 @@ 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.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.hostVisibilityPendingField); + 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.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.hostVisibilityPendingField); + 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 +407,35 @@ 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.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.hostVisibilityPendingField); + } + 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.Ldarg_0); + worker.Emit(OpCodes.Ldflda, hookData.hostVisibilityPendingField); + } + worker.Emit(OpCodes.Call, generic); } @@ -387,7 +449,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 +477,19 @@ 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 +574,12 @@ 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); + td.Fields.Add(entry.hostVisibilityPendingField); } // include parent class syncvars diff --git a/Assets/Mirror/Editor/Weaver/WeaverTypes.cs b/Assets/Mirror/Editor/Weaver/WeaverTypes.cs index 27c3b7ba304..bbb46b1173b 100644 --- a/Assets/Mirror/Editor/Weaver/WeaverTypes.cs +++ b/Assets/Mirror/Editor/Weaver/WeaverTypes.cs @@ -36,13 +36,22 @@ 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 generatedSyncVarHostVisibilityHook; 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 +112,23 @@ 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); + 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); 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/Examples/Basic/Scripts/BasicNetManager.cs b/Assets/Mirror/Examples/Basic/Scripts/BasicNetManager.cs index 508c15f8482..03751378afb 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 diff --git a/Assets/Mirror/Examples/Basic/Scripts/Player.cs b/Assets/Mirror/Examples/Basic/Scripts/Player.cs index c095be4d1c1..eb61096f86a 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 diff --git a/Assets/Mirror/Examples/PlayerTest/PlayerTestScene.unity b/Assets/Mirror/Examples/PlayerTest/PlayerTestScene.unity index 40845735073..d3a169a45b4 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.78217, y: 11.999975, z: 4.128914} 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 @@ -882,20 +902,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 diff --git a/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs b/Assets/Mirror/Examples/_Common/Scripts/RandomColor.cs index 3763e386bed..410ab464f4c 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 { @@ -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() diff --git a/Assets/Mirror/Tests/Editor/NetworkClient/NetworkClientTests_DestroyObjects.cs b/Assets/Mirror/Tests/Editor/NetworkClient/NetworkClientTests_DestroyObjects.cs index d60759a0f94..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] @@ -48,6 +53,45 @@ 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 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() { 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..5e8138d9858 --- /dev/null +++ b/Assets/Mirror/Tests/Editor/SyncCollections/SyncCollectionActionTest_HostVisibility.cs @@ -0,0 +1,472 @@ +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}"); + } + + 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; + + [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(); + } + + void AddLocalPlayerWithoutProcessingMessages(Vector3 position) + { + CreateNetworked(out GameObject player, out _); + player.transform.position = position; + NetworkServer.AddPlayerForConnection(NetworkServer.localConnection, player); + } + + NetworkIdentity AddLocalPlayerWithoutProcessingMessages(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; + NetworkServer.RebuildObservers(identity, false); + ProcessMessages(); + } + + static void InvokeHostVisibilityDeferredCallbacks(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)); + 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() + { + 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" })); + } + + [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.actions.Clear(); + + behaviour.list.Add("second"); + Assert.That(behaviour.actions, Is.Empty); + + RebuildLocalObserver(identity, Vector3.zero); + AssertObserved(identity, true); + 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)); + + AddLocalPlayerWithoutProcessingMessages(Vector3.zero); + AssertObserved(identity, true); + InvokeHostVisibilityDeferredCallbacks(identity); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + Assert.That(GetDeltaChangeCount(behaviour.list), Is.EqualTo(1)); + } + + [Test] + public void SyncList_DoesNotDoubleReplayWhenHostPlayerChangesBeforeSpawnProcessed() + { + NetworkIdentity identity = AddLocalPlayerWithoutProcessingMessages(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 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() + { + 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 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() + { + 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.actions.Clear(); + + behaviour.dictionary.Add("key2", "second"); + 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" })); + } + + [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)); + + AddLocalPlayerWithoutProcessingMessages(Vector3.zero); + AssertObserved(identity, true); + InvokeHostVisibilityDeferredCallbacks(identity); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:key1:first" })); + Assert.That(GetDeltaChangeCount(behaviour.dictionary), Is.EqualTo(1)); + } + + [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.actions.Clear(); + + behaviour.set.Add("second"); + Assert.That(behaviour.actions, Is.Empty); + + RebuildLocalObserver(identity, Vector3.zero); + 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)); + + AddLocalPlayerWithoutProcessingMessages(Vector3.zero); + AssertObserved(identity, true); + InvokeHostVisibilityDeferredCallbacks(identity); + Assert.That(behaviour.actions, Is.EqualTo(new[] { "Add:first" })); + Assert.That(GetDeltaChangeCount(behaviour.set), Is.EqualTo(1)); + } + } +} 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/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" + })); } } 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: 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/SyncVarAttributeHook_HostVisibilityTest.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs new file mode 100644 index 00000000000..247a1a2df41 --- /dev/null +++ b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarAttributeHook_HostVisibilityTest.cs @@ -0,0 +1,329 @@ +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)); + } + + 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}"); + } + + class HostRespawnHookBehaviour : NetworkBehaviour + { + internal const int InitialValue = 42; + internal const int SpawnValue = 100; + + [SyncVar(hook = nameof(OnValueChanged))] + public int syncedValue = InitialValue; + + public readonly List<(int oldValue, int newValue)> hookValues = new List<(int oldValue, int newValue)>(); + + public override void OnStartServer() + { + if (syncedValue == InitialValue) + syncedValue = SpawnValue; + } + + void OnValueChanged(int oldValue, int 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)); + } + + NetworkIdentity AddLocalPlayerWithoutProcessingMessages(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; + 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() + { + 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)); + } + + [Test] + public void Hook_DoesNotReplayHiddenChangesAfterLeavingAOI() + { + 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.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; + + Assert.That(behaviour.hookValues, Is.Empty); + + RebuildLocalObserver(identity, Vector3.zero); + AssertObserved(identity, true); + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (42, 200) })); + } + + [Test] + public void Hook_DoesNotDoubleFireWhenHostPlayerChangesBeforeSpawnProcessed() + { + NetworkIdentity identity = AddLocalPlayerWithoutProcessingMessages(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) })); + } + + [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() + { + 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" })); + } + + [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[] { (HostRespawnHookBehaviour.InitialValue, HostRespawnHookBehaviour.SpawnValue) })); + + behaviour.hookValues.Clear(); + + NetworkServer.UnSpawn(go); + NetworkServer.Spawn(go); + ProcessMessages(); + + Assert.That(behaviour.hookValues, Is.EqualTo(new[] { (HostRespawnHookBehaviour.InitialValue, HostRespawnHookBehaviour.SpawnValue) })); + } + } +} 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: diff --git a/Assets/Mirror/Tests/Editor/SyncVars/SyncVarHookDeferralTests.cs b/Assets/Mirror/Tests/Editor/SyncVars/SyncVarHookDeferralTests.cs index a83a3701fe7..7010e54af7c 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,19 @@ 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, 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)); // All targets should be accessible (host mode, everything is local) Assert.That(comp1.targetWasInSpawnedWhenHookFired, Is.True, @@ -144,13 +148,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]