Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
5923887
Initial plan
Copilot Jul 2, 2026
1ef0fb7
Implement host sync visibility deferral
Copilot Jul 2, 2026
4a4a3ce
Polish host sync deferral changes
Copilot Jul 2, 2026
ebfa5a4
Fix host visibility helper access
Copilot Jul 2, 2026
485ce90
fix deferred SyncDictionary action capture
Copilot Jul 5, 2026
7ee058b
fix host-mode SyncVar test expectations
Copilot Jul 6, 2026
efbc424
Flush deferred host sync callbacks after local player setup
Copilot Jul 6, 2026
52f22a7
Document observed callback snapshot copy
Copilot Jul 6, 2026
1a04d7d
Extend host visibility AOI tests
Copilot Jul 11, 2026
c67aab3
Fix host AOI SyncVar replay semantics
Copilot Jul 11, 2026
8b90c52
Polish host SyncVar baseline helper
Copilot Jul 11, 2026
99cf1b5
Rename AOI visibility test
Copilot Jul 11, 2026
fbe9a60
Fix host AOI re-observation SyncVar baseline
Copilot Jul 11, 2026
fbb1dc2
Fix host AOI re-observation test assertions
Copilot Jul 11, 2026
bdc5c68
Fix host sync collection re-observation replay
Copilot Jul 11, 2026
fb57062
fix: suppress hidden host sync collection actions
Copilot Jul 11, 2026
a93d909
Add missing meta files
MrGadget1024 Jul 11, 2026
744bc2f
Add Debug.Log to RandomColor
MrGadget1024 Jul 11, 2026
793c700
fix: replay host SyncVar hooks on re-observe
Copilot Jul 11, 2026
3410ecd
refactor: clarify host hook baseline capture
Copilot Jul 11, 2026
3aa87ca
fix: avoid host replay clearing sync collection deltas
Copilot Jul 12, 2026
f99c312
test: isolate host visibility replay delta assertions
Copilot Jul 12, 2026
c79d45a
test: clarify host visibility replay helper
Copilot Jul 12, 2026
1f3b2ea
Add Distance IM to PlayerTest Scene
MrGadget1024 Jul 12, 2026
7d5ef90
Add logging to RandomColor
MrGadget1024 Jul 12, 2026
3e2e7d0
Fix scene object SyncVar hook replay
Copilot Jul 12, 2026
b9f5b1d
Clarify client initial spawn hook state
Copilot Jul 12, 2026
278f94e
Deduplicate SyncVar hook replay helpers
Copilot Jul 12, 2026
1c6fdc1
Fix sync collection scene re-observation replay
Copilot Jul 12, 2026
8bd9629
Document sync object callback reset
Copilot Jul 12, 2026
11b2504
Fix client scene object re-show registration
Copilot Jul 12, 2026
27312b8
Preserve scene object respawn registration
Copilot Jul 12, 2026
ec51fa0
Clean up scene object respawn fix
Copilot Jul 12, 2026
ab050a2
Restore test access to object hide handler
Copilot Jul 12, 2026
41148b8
Fix remote client scene-object reobserve hook gating
Copilot Jul 12, 2026
ae5c2fd
fix: scope syncobject callback reset to client scene reuse
Copilot Jul 13, 2026
8cd8431
Changed Basic Example for testing
MrGadget1024 Jul 13, 2026
4d1d1cc
Changed Basic Example for testing
MrGadget1024 Jul 13, 2026
c444f4e
Fix host visibility replay double firing
Copilot Jul 13, 2026
e8cef59
Clarify host visibility test helper names
Copilot Jul 13, 2026
be2f07c
Fix host syncvar replay dedupe
Copilot Jul 13, 2026
956a0f6
fix: avoid cross-collection host replay duplicates
Copilot Jul 13, 2026
d83f910
Fix host AOI syncvar replay gating
Copilot Jul 14, 2026
d41bf4c
Fix host replay transition gaps
Copilot Jul 14, 2026
0a75f09
Clarify deferred host syncvar replay
Copilot Jul 14, 2026
1fab2fc
fix host replay after runtime unspawn respawn
Copilot Jul 15, 2026
310425d
test clarify host respawn replay regression coverage
Copilot Jul 15, 2026
2ce684a
Cleanup from testing
MrGadget1024 Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
614 changes: 388 additions & 226 deletions Assets/Mirror/Core/NetworkBehaviour.cs

Large diffs are not rendered by default.

92 changes: 61 additions & 31 deletions Assets/Mirror/Core/NetworkClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<NetworkIdentity> observed = new List<NetworkIdentity>(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;
}
}
}

/// <summary>Sends AddPlayer message to the server, indicating that we want to join the world.</summary>
Expand Down Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();

Expand All @@ -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);
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions Assets/Mirror/Core/NetworkIdentity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>The set of network connections (players) that can see this object.</summary>
public readonly Dictionary<int, NetworkConnectionToClient> observers =
new Dictionary<int, NetworkConnectionToClient>();
Expand Down Expand Up @@ -1689,6 +1694,12 @@ internal void ResetState()
isLocalPlayer = false;
}

internal void ResetSyncObjectCallbacks()
{
foreach (NetworkBehaviour comp in NetworkBehaviours)
comp.ResetSyncObjectCallbacks();
}

bool hadAuthority;
internal void NotifyAuthority()
{
Expand Down
6 changes: 6 additions & 0 deletions Assets/Mirror/Core/NetworkServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
47 changes: 40 additions & 7 deletions Assets/Mirror/Core/SyncDictionary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TKey, TValue> 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();
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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.
Expand Down
49 changes: 42 additions & 7 deletions Assets/Mirror/Core/SyncList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading