From 367c54eb825702423452bca5b02f27f949493c9a Mon Sep 17 00:00:00 2001 From: Alexey Date: Sun, 20 Sep 2026 14:08:42 +0700 Subject: [PATCH 1/7] fix authoritative elite mob deaths --- Mobs/MonsterSynchronization.Attacks.cs | 29 ++++++++++++ ...nsterSynchronization.AuthoritativeDeath.cs | 10 +++-- Mobs/MonsterSynchronization.ClientReceive.cs | 22 ++++++++++ Mobs/MonsterSynchronization.cs | 31 +++++++++++++ PortableCore/MobLifecycleLedger.cs | 44 +++++++++++++++++++ .../LaunchProtocolReplayTests.cs | 20 +++++++++ UI/ConnectionUI/LobbyHeadSkin.cs | 13 ++++-- UI/ConnectionUI/TitleMenuHooks.cs | 2 +- 8 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 PortableCore/MobLifecycleLedger.cs diff --git a/Mobs/MonsterSynchronization.Attacks.cs b/Mobs/MonsterSynchronization.Attacks.cs index b61cd7b5..ee31d70a 100644 --- a/Mobs/MonsterSynchronization.Attacks.cs +++ b/Mobs/MonsterSynchronization.Attacks.cs @@ -1685,6 +1685,35 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) if (!reserved) { + // NetId is the host-owned identity. Elite rune mobs can replace their native + // wrapper (and therefore their type/class signature) during promotion, phase + // setup, or the final death transition. Rejecting that packet invalidates the + // only binding we have and turns the next host life=0/MOBDIE into a permanent + // client ghost. The resolver already validated the generation, level and + // forward/reverse mapping, so preserve the binding and refresh the cached type. + // Do not use position here: a teleport/elite transform may legitimately move + // the same authoritative mob between packets. + if (IsStateRebindCandidateLocked(mappedMob) && + MappingOwnsSyncIdLocked(mappedMob, state.Index)) + { + if (!string.IsNullOrWhiteSpace(state.Type)) + hostMobTypeBySyncId[state.Index] = state.Type; + + MobSyncTrace.LogFallbackMatchResolved( + state.Life <= 0 + ? "state_type_mismatch_accepted_by_authoritative_id" + : "state_type_changed_preserved_authoritative_id", + state.Index, + state.Type ?? string.Empty, + state.X, + state.Y, + candidateCount: 1, + rebound: false); + if (bossEntityId > 0) + RememberClientBossEntityIdLocked(mappedMob, bossEntityId); + return mappedMob; + } + InvalidateTrackedSyncCacheLocked(state.Index, "state_type_mismatch"); MobSyncTrace.LogIncomingMappingMismatch( "state", diff --git a/Mobs/MonsterSynchronization.AuthoritativeDeath.cs b/Mobs/MonsterSynchronization.AuthoritativeDeath.cs index c83b78a1..a795d928 100644 --- a/Mobs/MonsterSynchronization.AuthoritativeDeath.cs +++ b/Mobs/MonsterSynchronization.AuthoritativeDeath.cs @@ -152,19 +152,19 @@ private static void FlushPendingClientAuthoritativeDeaths() if (!IsClient(LobbySession.NetRef)) return; - var pending = new List(); var frame = GetCurrentFrame(null); lock (Sync) { if (s_pendingCulledMobDeaths.Count == 0) return; - pending.AddRange(s_pendingCulledMobDeaths); + s_pendingCulledMobDeathsScratch.Clear(); + s_pendingCulledMobDeathsScratch.AddRange(s_pendingCulledMobDeaths); } - for (int i = 0; i < pending.Count; i++) + for (int i = 0; i < s_pendingCulledMobDeathsScratch.Count; i++) { - var mob = pending[i]; + var mob = s_pendingCulledMobDeathsScratch[i]; if (mob == null) continue; @@ -205,6 +205,8 @@ private static void FlushPendingClientAuthoritativeDeaths() if (RunAuthoritativeClientDeathNow(mob)) ClearPendingClientAuthoritativeDeath(mob); } + + s_pendingCulledMobDeathsScratch.Clear(); } private static void ClearPendingClientAuthoritativeDeath(Mob mob) diff --git a/Mobs/MonsterSynchronization.ClientReceive.cs b/Mobs/MonsterSynchronization.ClientReceive.cs index e12d04c9..6a08bc33 100644 --- a/Mobs/MonsterSynchronization.ClientReceive.cs +++ b/Mobs/MonsterSynchronization.ClientReceive.cs @@ -276,6 +276,8 @@ private static void ApplyIncomingHostMobStates(IReadOnlyList 0) + s_clientMobLifecycle.MarkActive(state.Generation, state.Index); s_usedTrackedMobsScratch.Add(mob); var incomingDir = NormalizeDir(state.Dir); @@ -2944,6 +2946,26 @@ private static bool MobHitRegistryStillTrustworthyLocked(Mob mob, NetNode.MobHit return mapped; } + // MOBDIE is keyed by the host-owned NetId. An elite may have changed its + // native wrapper/type signature before the death packet arrives, and an + // elite teleport makes position corroboration unreliable. The mapping has + // already passed generation/level validation in ResolveMobBySyncIdLocked; + // preserve it and finish vanilla onDie instead of stranding the replica at + // zero HP. + if (IsStateRebindCandidateLocked(mapped) && + MappingOwnsSyncIdLocked(mapped, die.MobIndex)) + { + MobSyncTrace.LogFallbackMatchResolved( + "death_type_mismatch_accepted_by_authoritative_id", + die.MobIndex, + die.Type, + die.X, + die.Y, + candidateCount: 1, + rebound: false); + return mapped; + } + InvalidateTrackedSyncCacheLocked(die.MobIndex, "death_type_mismatch"); MobSyncTrace.LogIncomingMappingMismatch( "death", diff --git a/Mobs/MonsterSynchronization.cs b/Mobs/MonsterSynchronization.cs index 62dc2fff..fb349f28 100644 --- a/Mobs/MonsterSynchronization.cs +++ b/Mobs/MonsterSynchronization.cs @@ -16,6 +16,7 @@ using DeadCellsMultiplayerMod.Ghost; using DeadCellsMultiplayerMod.Interface.ModuleInitializing; using DeadCellsMultiplayerMod.Mobs.Bosses; +using DeadCellsMultiplayerMod.PortableCore; using DeadCellsMultiplayerMod.Tools; using Hashlink.Virtuals; using HaxeProxy.Runtime; @@ -93,6 +94,7 @@ private sealed class MobSyncAlias private static readonly List s_clientAffectAppliesScratch = new(); private static readonly List s_hostStateAppliesScratch = new(); private static readonly List s_pendingMobHitAppliesScratch = new(); + private static readonly List s_pendingCulledMobDeathsScratch = new(); private static readonly List s_mobHitMergeScratch = new(); private static readonly List clientPendingBossAttacks = new(); private static readonly List s_resolvedClientBossAttacksScratch = new(); @@ -255,6 +257,7 @@ private sealed class ClientMobTombstone // Prevent repeated typed tombstones from running a native // final boss onDie twice and duplicating rewards/cinematics. Guarded by Sync. private static readonly HashSet clientCompletedAuthoritativeBossDeaths = new(ReferenceEqualityComparer.Instance); + private static readonly MobLifecycleLedger s_clientMobLifecycle = new(); // Local client lethal damage is suppressed until the host confirms death. Track that // suppression explicitly so Hook_Mob_onDamage can still report hit|0 instead of the // temporary life=1 recovery value. @@ -1233,6 +1236,23 @@ private void Hook_Mob_postUpdate(Hook_Mob.orig_postUpdate orig, Mob self) private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) { + var lifecycleGeneration = 0; + var lifecycleNetId = -1; + var authoritativeClientDeath = false; + if (self != null && IsClient(LobbySession.NetRef) && + System.Threading.Volatile.Read(ref authoritativeClientMobDieDepth) > 0 && + TryGetMobSyncId(self, out lifecycleNetId) && + TryGetCurrentLevelIdentityToken(out lifecycleGeneration)) + { + lock (Sync) + { + if (s_clientMobLifecycle.IsDead(lifecycleGeneration, lifecycleNetId)) + return; + } + + authoritativeClientDeath = true; + } + if (ShouldSuppressClientBossDie(self)) { MarkSuppressedClientBossDie(self); @@ -1292,6 +1312,17 @@ private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) ClearSuppressedClientBossDie(self); + if (authoritativeClientDeath) + { + var stillAlive = false; + try { stillAlive = !self.destroyed && self.life > 0; } catch { } + if (!stillAlive) + { + lock (Sync) + s_clientMobLifecycle.MarkDead(lifecycleGeneration, lifecycleNetId); + } + } + // Some multi-phase bosses route a depleted phase through onDie(), then rebuild the // same encounter with positive life. That is not a victory. Keep its authoritative // mapping and immediately publish the rebuilt phase instead of sending a death packet, diff --git a/PortableCore/MobLifecycleLedger.cs b/PortableCore/MobLifecycleLedger.cs new file mode 100644 index 00000000..91c1e4d7 --- /dev/null +++ b/PortableCore/MobLifecycleLedger.cs @@ -0,0 +1,44 @@ +namespace DeadCellsMultiplayerMod.PortableCore; + +/// +/// Small allocation-free-per-operation lifecycle ledger for host-authoritative mob deaths. +/// Callers serialize access with their existing synchronization gate. +/// +internal sealed class MobLifecycleLedger +{ + private readonly Dictionary _states = new(); + + public bool IsDead(int generation, int netId) + { + return netId > 0 && + _states.TryGetValue(MakeKey(generation, netId), out var state) && + state == MobLifecycleState.Dead; + } + + public void MarkActive(int generation, int netId) + { + if (netId <= 0) + return; + + var key = MakeKey(generation, netId); + if (!_states.TryGetValue(key, out var state) || state != MobLifecycleState.Dead) + _states[key] = MobLifecycleState.Active; + } + + public void MarkDead(int generation, int netId) + { + if (netId > 0) + _states[MakeKey(generation, netId)] = MobLifecycleState.Dead; + } + + public void Clear() => _states.Clear(); + + private static long MakeKey(int generation, int netId) => + ((long)generation << 32) ^ (uint)netId; +} + +internal enum MobLifecycleState +{ + Active = 1, + Dead = 2 +} diff --git a/ProtocolReplay.Tests/LaunchProtocolReplayTests.cs b/ProtocolReplay.Tests/LaunchProtocolReplayTests.cs index 0a07df97..37c0182d 100644 --- a/ProtocolReplay.Tests/LaunchProtocolReplayTests.cs +++ b/ProtocolReplay.Tests/LaunchProtocolReplayTests.cs @@ -85,6 +85,26 @@ public void LifecycleTrackerInvalidatesOldGenerationOnStop() Assert.Equal(LifecycleState.Disposed, tracker.Snapshot.State); } + + [Fact] + public void MobLifecycleLedgerIsIdempotentPerGeneration() + { + var ledger = new MobLifecycleLedger(); + + ledger.MarkActive(10, 7); + Assert.False(ledger.IsDead(10, 7)); + + ledger.MarkDead(10, 7); + Assert.True(ledger.IsDead(10, 7)); + + // A stale active observation must not resurrect a confirmed death. + ledger.MarkActive(10, 7); + Assert.True(ledger.IsDead(10, 7)); + + // The same NetId is valid again only in a new level generation. + Assert.False(ledger.IsDead(11, 7)); + } + private static RunLaunchDescriptor CreateDescriptor() { return new RunLaunchDescriptor( diff --git a/UI/ConnectionUI/LobbyHeadSkin.cs b/UI/ConnectionUI/LobbyHeadSkin.cs index 80fbe054..8bc6e81c 100644 --- a/UI/ConnectionUI/LobbyHeadSkin.cs +++ b/UI/ConnectionUI/LobbyHeadSkin.cs @@ -41,6 +41,9 @@ public Part(string group, double offsetX, double offsetY, int? colorLight, int? private static SpriteLib? _cachedLib; private static string _cachedAtlas = string.Empty; + // The CDB proxy is exposed through dynamic Haxe members. Nullability analysis cannot + // follow those members even though every access is guarded or isolated in a try/catch. +#pragma warning disable CS8602 internal static bool TryResolve(string headId, out string atlas, out List parts, out ArrayObj? glowData, out List particleEffects) { atlas = "customHead"; @@ -59,7 +62,7 @@ internal static bool TryResolve(string headId, out string atlas, out List for (int i = 0; i < rows.array.length; i++) { var row = rows.getDyn(i); - if (!string.Equals(row.item?.ToString(), headId, StringComparison.Ordinal)) + if (row == null || !string.Equals(row.item?.ToString(), headId, StringComparison.Ordinal)) continue; try @@ -94,6 +97,9 @@ internal static bool TryResolve(string headId, out string atlas, out List for (int p = 0; p < props.length; p++) { var part = props.getDyn(p); + if (part == null) + continue; + string group = part.baseSpr?.ToString() ?? string.Empty; if (string.IsNullOrWhiteSpace(group)) continue; @@ -140,7 +146,7 @@ internal static bool TryResolve(string headId, out string atlas, out List for (int s = 0; s < states.length; s++) { var st = states.getDyn(s); - if ((int)st.state != 0) + if (st == null || (int)st.state != 0) continue; idleAnim = st.animId?.ToString(); try { idleAnimSpeed = (double)st.animSpd; } catch { } @@ -161,7 +167,7 @@ internal static bool TryResolve(string headId, out string atlas, out List { } -try + try { var fx = row.particleEffects; if (fx != null) @@ -188,6 +194,7 @@ internal static bool TryResolve(string headId, out string atlas, out List return false; } +#pragma warning restore CS8602 internal static SpriteLib? LoadAtlas(string atlas) { diff --git a/UI/ConnectionUI/TitleMenuHooks.cs b/UI/ConnectionUI/TitleMenuHooks.cs index f2d7ffc1..a969ad46 100644 --- a/UI/ConnectionUI/TitleMenuHooks.cs +++ b/UI/ConnectionUI/TitleMenuHooks.cs @@ -62,7 +62,7 @@ internal static void MainMenuHook(Hook_TitleScreen.orig_mainMenu orig, TitleScre ConnectionUI.set_visible = false; orig(self); - try { _ConnectionUI.RememberLocalHeroSkinFromUser(self?.user, "titleScreen.mainMenu"); } catch { } + try { _ConnectionUI.RememberLocalHeroSkinFromUser(self.user, "titleScreen.mainMenu"); } catch { } EnsureMainMenuMultiplayerButton(self); ProcessPendingOverlayJoinRequest(self); From a231a8b106d5990dcec44925ceab38fd19a7d69f Mon Sep 17 00:00:00 2001 From: Alexey Date: Sun, 20 Sep 2026 14:10:34 +0700 Subject: [PATCH 2/7] refactor mob synchronization lifecycle hooks --- Mobs/MonsterSynchronization.Damage.cs | 185 +++++++++ Mobs/MonsterSynchronization.DeathHooks.cs | 183 +++++++++ Mobs/MonsterSynchronization.Lifecycle.cs | 204 ++++++++++ Mobs/MonsterSynchronization.cs | 473 ---------------------- 4 files changed, 572 insertions(+), 473 deletions(-) create mode 100644 Mobs/MonsterSynchronization.Damage.cs create mode 100644 Mobs/MonsterSynchronization.DeathHooks.cs create mode 100644 Mobs/MonsterSynchronization.Lifecycle.cs diff --git a/Mobs/MonsterSynchronization.Damage.cs b/Mobs/MonsterSynchronization.Damage.cs new file mode 100644 index 00000000..1f1dcd46 --- /dev/null +++ b/Mobs/MonsterSynchronization.Damage.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using dc; +using dc.en; +using dc.h2d; +using dc.libs.heaps.slib; +using dc.libs.heaps.slib._AnimManager; +using dc.pr; +using dc.tool; +using dc.tool.atk; +using dc.tool.skill; +using DeadCellsMultiplayerMod.Ghost; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using DeadCellsMultiplayerMod.Mobs.Bosses; +using DeadCellsMultiplayerMod.PortableCore; +using DeadCellsMultiplayerMod.Tools; +using Hashlink.Virtuals; +using HaxeProxy.Runtime; +using ModCore.Events; +using ModCore.Events.Interfaces.Game; +using ModCore.Utilities; +using Serilog; + +namespace DeadCellsMultiplayerMod.Mobs.MobsSynchronization +{ + public partial class MobsSynchronization + { + private static void RunWithSuppressedMobHitSend(Action action) + { + if (action == null) + return; + + suppressMobHitSendDepth++; + try + { + action(); + } + finally + { + suppressMobHitSendDepth--; + } + } + + private void Hook_Mob_onDamage(Hook_Mob.orig_onDamage orig, Mob self, AttackData i) + { + var preDamageLife = GetMobLifeOrFallback(self, 0); + // Lethal damage runs onDie inside orig, which removes the mob from tracking before this hook resumes. + // Cache ids before orig so hit|life still sends when the mob is already untracked/destroyed. + var preSyncOk = false; + var cachedMobSyncId = -1; + if (self != null && i != null && LobbySession.NetRef != null && IsSyncMob(self)) + { + preSyncOk = TryGetMobSyncId(self, out cachedMobSyncId); + } + + // Read the attack's construction input before vanilla resolves the hit: resolution + // rewrites the per-target damage fields, and AttackData instances are recycled. + var attackIntentDamage = ReadAttackIntentDamage(i); + + orig(self, i); + + try + { + if (self == null || i == null) + return; + + var net = LobbySession.NetRef; + if (net == null) + return; + + if (!IsSyncMob(self) && !preSyncOk) + return; + + var isClient = IsClient(net); + var suppressedClientLethal = isClient && WasClientMobDeathSuppressed(self); + var tookLifeDelta = self.life < preDamageLife || suppressedClientLethal; + var becameDead = self.life <= 0 || suppressedClientLethal; + bool shouldReport = false; + if (IsHost(net)) + { + shouldReport = true; + } + else if (isClient) + { + shouldReport = IsDamageFromLocalPlayer(i); + // Fallback for damage-source edge cases: never drop a lethal hit report. + if (!shouldReport && tookLifeDelta && becameDead) + shouldReport = true; + } + + if (!shouldReport) + return; + + if (System.Threading.Volatile.Read(ref suppressMobHitSendDepth) > 0) + return; + + if (!TryGetMobSyncId(self, out var mobSyncId)) + { + if (!preSyncOk || !shouldReport) + return; + mobSyncId = cachedMobSyncId; + } + + // NetId 0 is reserved. Never report damage against it — that is the courtyard + // syncId=0 thrash vector once the real owner was gone. + if (mobSyncId <= 0) + return; + + // A locally lethal client hit temporarily restores the mob so the client does not + // run an unsanctioned death. Still report life=0 to the host; reporting the restored + // value (usually 1) was the source of elites becoming permanently unkillable. + var life = suppressedClientLethal ? 0 : GetMobLifeOrFallback(self, 0); + var damageHint = isClient && shouldReport + ? EstimateClientAttackDamageHint(attackIntentDamage, preDamageLife, life, suppressedClientLethal) + : 0.0; + var x = GetSyncX(self); + var y = GetSyncY(self); + var mobType = BuildMobStateTypeSignature(self); + + if (IsHost(net)) + { + var hx = GetWorldX(self); + var hy = GetWorldY(self); + var hitEvent = $"hit|{life.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; + if (TryGetCurrentLevelIdentityToken(out var identityToken)) + { + var update = new NetNode.MobEventUpdate(mobSyncId, hx, hy, NormalizeDir(self.dir), SingleEvent(hitEvent), mobType, identityToken); + MobSyncTrace.LogSendMobEvents(MobSyncNetRoleForTrace(net), SingleUpdate(update)); + net.SendMobEvents(SingleUpdate(update)); + } + } + + if (IsClient(net)) + { + lock (Sync) + { + if (!clientLastReportedMobLife.TryGetValue(self, out var lastLife)) + { + // First locally-confirmed hit for this tracked mob: establish baseline and + // propagate immediately when damage actually reduced life. + clientLastReportedMobLife[self] = life; + var maxLife = self.maxLife; + if (life >= maxLife && life > 0 && damageHint <= 0.0) + return; + } + else + { + // Never drop a killing blow: stale lastLife or host sync can make life look non-decreasing. + if (life >= lastLife) + { + var lethalReport = shouldReport && life <= 0 && lastLife > 0 && preDamageLife > 0; + var authoritativeProbe = shouldReport && damageHint > 0.0; + if (!lethalReport && !authoritativeProbe) + return; + } + + clientLastReportedMobLife[self] = life; + } + } + + var clientHitEvent = string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"hit|{life}|{damageHint:R}"); + if (TryGetCurrentLevelIdentityToken(out var identityToken)) + { + var clientUpdate = new NetNode.MobEventUpdate(mobSyncId, x, y, 0, SingleEvent(clientHitEvent), mobType, identityToken); + MobSyncTrace.LogSendMobEvents(MobSyncNetRoleForTrace(net), SingleUpdate(clientUpdate)); + net.SendMobEvents(SingleUpdate(clientUpdate)); + } + } + } + finally + { + TryRecoverClientSyncMobLifeAfterLocalDamage(self, preDamageLife); + TryRecoverSuppressedClientMobDie(self, preDamageLife); + TryRecoverSuppressedClientBossDie(self, preDamageLife); + } + } + + } +} diff --git a/Mobs/MonsterSynchronization.DeathHooks.cs b/Mobs/MonsterSynchronization.DeathHooks.cs new file mode 100644 index 00000000..1626c62a --- /dev/null +++ b/Mobs/MonsterSynchronization.DeathHooks.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using dc; +using dc.en; +using dc.h2d; +using dc.libs.heaps.slib; +using dc.libs.heaps.slib._AnimManager; +using dc.pr; +using dc.tool; +using dc.tool.atk; +using dc.tool.skill; +using DeadCellsMultiplayerMod.Ghost; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using DeadCellsMultiplayerMod.Mobs.Bosses; +using DeadCellsMultiplayerMod.PortableCore; +using DeadCellsMultiplayerMod.Tools; +using Hashlink.Virtuals; +using HaxeProxy.Runtime; +using ModCore.Events; +using ModCore.Events.Interfaces.Game; +using ModCore.Utilities; +using Serilog; + +namespace DeadCellsMultiplayerMod.Mobs.MobsSynchronization +{ + public partial class MobsSynchronization + { + private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) + { + var lifecycleGeneration = 0; + var lifecycleNetId = -1; + var authoritativeClientDeath = false; + if (self != null && IsClient(LobbySession.NetRef) && + System.Threading.Volatile.Read(ref authoritativeClientMobDieDepth) > 0 && + TryGetMobSyncId(self, out lifecycleNetId) && + TryGetCurrentLevelIdentityToken(out lifecycleGeneration)) + { + lock (Sync) + { + if (s_clientMobLifecycle.IsDead(lifecycleGeneration, lifecycleNetId)) + return; + } + + authoritativeClientDeath = true; + } + + if (ShouldSuppressClientBossDie(self)) + { + MarkSuppressedClientBossDie(self); + MarkSuppressedClientMobDie(self); + return; + } + + var shouldSendDie = false; + var dieSyncId = -1; + var dieX = 0.0; + var dieY = 0.0; + var dieType = string.Empty; + NetNode? dieNet = null; + var isClient = false; + var isBossDeathCandidate = false; + if (self != null && suppressMobDieSendDepth <= 0) + { + dieNet = LobbySession.NetRef; + isClient = IsClient(dieNet); + isBossDeathCandidate = BossSyncHelpers.IsBossMob(self); + + // Client is not authoritative for mob death; wait for host confirmation. The + // authoritative depth is set only while applying a host-confirmed death, allowing + // vanilla onDie to run exactly once for normal mobs, elites and bosses. + if (isClient && IsSyncMob(self) && + System.Threading.Volatile.Read(ref authoritativeClientMobDieDepth) <= 0) + { + MarkSuppressedClientMobDie(self); + try + { + if (self.life <= 0) + self.life = GetClientAuthoritativeLifeFallback(self, 1); + } + catch + { + } + + return; + } + + if (dieNet != null && + dieNet.IsAlive && + dieNet.IsHost && + TryGetMobSyncId(self, out dieSyncId)) + { + shouldSendDie = true; + dieX = GetSyncX(self); + dieY = GetSyncY(self); + dieType = BuildMobStateTypeSignature(self); + } + } + + orig(self); + + if (self == null) + return; + + ClearSuppressedClientBossDie(self); + + if (authoritativeClientDeath) + { + var stillAlive = false; + try { stillAlive = !self.destroyed && self.life > 0; } catch { } + if (!stillAlive) + { + lock (Sync) + s_clientMobLifecycle.MarkDead(lifecycleGeneration, lifecycleNetId); + } + } + + // Some multi-phase bosses route a depleted phase through onDie(), then rebuild the + // same encounter with positive life. That is not a victory. Keep its authoritative + // mapping and immediately publish the rebuilt phase instead of sending a death packet, + // removing tracking, or reviving players early. + if (shouldSendDie && isBossDeathCandidate) + { + var bossContinues = false; + try { bossContinues = !self.destroyed && self.life > 0; } catch { } + if (bossContinues) + { + QueueHostMobDirty(self, HostMobDirtyFlags.State | HostMobDirtyFlags.ForceState); + return; + } + + // Some phase transitions destroy the depleted native object outright and rebuild + // the encounter behind a new one, so "!destroyed" alone misreads them as a real + // death. If a different living Level.boss carries the same stable identity, this + // is a hand-off: publish the rebuilt phase immediately instead of a death packet. + if (TryGetHostBossPhaseSuccessor(self, out var phaseSuccessor)) + { + QueueHostMobDirty(phaseSuccessor, HostMobDirtyFlags.State | HostMobDirtyFlags.ForceState); + return; + } + } + + if (shouldSendDie && dieNet != null && dieNet.IsAlive && dieSyncId >= 0) + { + if (TryGetCurrentLevelIdentityToken(out var identityToken)) + { + lock (Sync) + { + RememberHostDeathTombstoneLocked(self, dieSyncId, dieX, dieY, identityToken); + } + + // Authoritative death: one reliable MOBDIE path (no dual MOBEVENT|die). + dieNet.SendMobDie(dieSyncId, dieX, dieY, identityToken, dieType); + } + } + + lock (Sync) + { + RemoveTrackedMobLocked(self, "on_die"); + } + } + + private static void RunWithSuppressedMobDieSend(Action action) + { + if (action == null) + return; + + suppressMobDieSendDepth++; + try + { + action(); + } + finally + { + suppressMobDieSendDepth--; + } + } + + } +} diff --git a/Mobs/MonsterSynchronization.Lifecycle.cs b/Mobs/MonsterSynchronization.Lifecycle.cs new file mode 100644 index 00000000..60886a24 --- /dev/null +++ b/Mobs/MonsterSynchronization.Lifecycle.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using dc; +using dc.en; +using dc.h2d; +using dc.libs.heaps.slib; +using dc.libs.heaps.slib._AnimManager; +using dc.pr; +using dc.tool; +using dc.tool.atk; +using dc.tool.skill; +using DeadCellsMultiplayerMod.Ghost; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using DeadCellsMultiplayerMod.Mobs.Bosses; +using DeadCellsMultiplayerMod.PortableCore; +using DeadCellsMultiplayerMod.Tools; +using Hashlink.Virtuals; +using HaxeProxy.Runtime; +using ModCore.Events; +using ModCore.Events.Interfaces.Game; +using ModCore.Utilities; +using Serilog; + +namespace DeadCellsMultiplayerMod.Mobs.MobsSynchronization +{ + public partial class MobsSynchronization + { + private static void MarkSuppressedClientMobDie(Mob? mob) + { + if (mob == null) + return; + + lock (Sync) + { + clientPendingSuppressedMobDies.Add(mob); + } + } + + private static bool WasClientMobDeathSuppressed(Mob? mob) + { + if (mob == null) + return false; + + lock (Sync) + { + return clientPendingSuppressedMobDies.Contains(mob) || + clientPendingSuppressedBossDies.Contains(mob); + } + } + + private static int GetClientAuthoritativeLifeFallback(Mob? mob, int fallbackLife) + { + var recovered = System.Math.Max(1, fallbackLife); + if (mob == null) + return recovered; + + lock (Sync) + { + if (clientMobTargets.TryGetValue(mob, out var target) && target.Life > 0) + recovered = System.Math.Max(recovered, target.Life); + } + + return recovered; + } + + private static void TryRecoverSuppressedClientMobDie(Mob? mob, int fallbackLife) + { + if (mob == null) + return; + + bool hadSuppressedDie; + lock (Sync) + { + hadSuppressedDie = clientPendingSuppressedMobDies.Remove(mob); + } + + if (!hadSuppressedDie) + return; + + try + { + if (!mob.destroyed && mob.life <= 0) + mob.life = GetClientAuthoritativeLifeFallback(mob, fallbackLife); + } + catch + { + } + + // Restore the older native handoff after a locally suppressed lethal callback. The + // regular client authority update will relock the replica when no native attack/phase + // callback remains active. + TryUnlockClientMobAiAuthority(mob); + } + + private static bool ShouldSuppressClientBossDie(Mob? mob) + { + if (mob == null || !BossSyncHelpers.IsBossMob(mob)) + return false; + + var net = LobbySession.NetRef; + if (!IsClient(net)) + return false; + if (!IsSyncMob(mob)) + return false; + + return System.Threading.Volatile.Read(ref authoritativeClientBossDieDepth) <= 0; + } + + private static void MarkSuppressedClientBossDie(Mob? mob) + { + if (mob == null) + return; + + lock (Sync) + { + clientPendingSuppressedBossDies.Add(mob); + } + } + + private static void ClearSuppressedClientBossDie(Mob? mob) + { + if (mob == null) + return; + + lock (Sync) + { + clientPendingSuppressedBossDies.Remove(mob); + } + } + + private static void TryRecoverSuppressedClientBossDie(Mob? mob, int fallbackLife) + { + if (mob == null || mob.destroyed) + return; + + var net = LobbySession.NetRef; + if (!IsClient(net)) + { + ClearSuppressedClientBossDie(mob); + return; + } + + bool hadSuppressedDie; + lock (Sync) + { + hadSuppressedDie = clientPendingSuppressedBossDies.Remove(mob); + } + + if (!hadSuppressedDie) + return; + + try + { + if (mob.life <= 0) + mob.life = System.Math.Max(1, fallbackLife); + } + catch + { + } + } + + private static void RunWithAuthoritativeClientMobDie(Mob? mob, Action action) + { + if (action == null) + return; + + var net = LobbySession.NetRef; + if (!IsClient(net) || mob == null || !IsSyncMob(mob)) + { + action(); + return; + } + + var isBoss = BossSyncHelpers.IsBossMob(mob); + authoritativeClientMobDieDepth++; + if (isBoss) + authoritativeClientBossDieDepth++; + try + { + action(); + } + finally + { + if (isBoss) + authoritativeClientBossDieDepth--; + authoritativeClientMobDieDepth--; + } + } + + /// + /// Reads the pre-target-resolution damage the attack was built from. + /// AttackUtils.createFromHero(source, baseDmg, tier) takes exactly this value, so it + /// is the only scalar the host can feed back into the native hit path without re-deriving a + /// number that was already derived once. finalDmg/inflictedDmg are produced by + /// updateDamages(attack, target) and applyHitResult: they are per-target and + /// already contain this replica's armour, resistances and invulnerability, so transmitting + /// them would let a client-side outcome dictate the authoritative result. + /// + } +} diff --git a/Mobs/MonsterSynchronization.cs b/Mobs/MonsterSynchronization.cs index fb349f28..85a2f17a 100644 --- a/Mobs/MonsterSynchronization.cs +++ b/Mobs/MonsterSynchronization.cs @@ -1234,482 +1234,9 @@ private void Hook_Mob_postUpdate(Hook_Mob.orig_postUpdate orig, Mob self) } } - private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) - { - var lifecycleGeneration = 0; - var lifecycleNetId = -1; - var authoritativeClientDeath = false; - if (self != null && IsClient(LobbySession.NetRef) && - System.Threading.Volatile.Read(ref authoritativeClientMobDieDepth) > 0 && - TryGetMobSyncId(self, out lifecycleNetId) && - TryGetCurrentLevelIdentityToken(out lifecycleGeneration)) - { - lock (Sync) - { - if (s_clientMobLifecycle.IsDead(lifecycleGeneration, lifecycleNetId)) - return; - } - - authoritativeClientDeath = true; - } - - if (ShouldSuppressClientBossDie(self)) - { - MarkSuppressedClientBossDie(self); - MarkSuppressedClientMobDie(self); - return; - } - - var shouldSendDie = false; - var dieSyncId = -1; - var dieX = 0.0; - var dieY = 0.0; - var dieType = string.Empty; - NetNode? dieNet = null; - var isClient = false; - var isBossDeathCandidate = false; - if (self != null && suppressMobDieSendDepth <= 0) - { - dieNet = LobbySession.NetRef; - isClient = IsClient(dieNet); - isBossDeathCandidate = BossSyncHelpers.IsBossMob(self); - - // Client is not authoritative for mob death; wait for host confirmation. The - // authoritative depth is set only while applying a host-confirmed death, allowing - // vanilla onDie to run exactly once for normal mobs, elites and bosses. - if (isClient && IsSyncMob(self) && - System.Threading.Volatile.Read(ref authoritativeClientMobDieDepth) <= 0) - { - MarkSuppressedClientMobDie(self); - try - { - if (self.life <= 0) - self.life = GetClientAuthoritativeLifeFallback(self, 1); - } - catch - { - } - - return; - } - - if (dieNet != null && - dieNet.IsAlive && - dieNet.IsHost && - TryGetMobSyncId(self, out dieSyncId)) - { - shouldSendDie = true; - dieX = GetSyncX(self); - dieY = GetSyncY(self); - dieType = BuildMobStateTypeSignature(self); - } - } - - orig(self); - - if (self == null) - return; - - ClearSuppressedClientBossDie(self); - - if (authoritativeClientDeath) - { - var stillAlive = false; - try { stillAlive = !self.destroyed && self.life > 0; } catch { } - if (!stillAlive) - { - lock (Sync) - s_clientMobLifecycle.MarkDead(lifecycleGeneration, lifecycleNetId); - } - } - - // Some multi-phase bosses route a depleted phase through onDie(), then rebuild the - // same encounter with positive life. That is not a victory. Keep its authoritative - // mapping and immediately publish the rebuilt phase instead of sending a death packet, - // removing tracking, or reviving players early. - if (shouldSendDie && isBossDeathCandidate) - { - var bossContinues = false; - try { bossContinues = !self.destroyed && self.life > 0; } catch { } - if (bossContinues) - { - QueueHostMobDirty(self, HostMobDirtyFlags.State | HostMobDirtyFlags.ForceState); - return; - } - - // Some phase transitions destroy the depleted native object outright and rebuild - // the encounter behind a new one, so "!destroyed" alone misreads them as a real - // death. If a different living Level.boss carries the same stable identity, this - // is a hand-off: publish the rebuilt phase immediately instead of a death packet. - if (TryGetHostBossPhaseSuccessor(self, out var phaseSuccessor)) - { - QueueHostMobDirty(phaseSuccessor, HostMobDirtyFlags.State | HostMobDirtyFlags.ForceState); - return; - } - } - - if (shouldSendDie && dieNet != null && dieNet.IsAlive && dieSyncId >= 0) - { - if (TryGetCurrentLevelIdentityToken(out var identityToken)) - { - lock (Sync) - { - RememberHostDeathTombstoneLocked(self, dieSyncId, dieX, dieY, identityToken); - } - - // Authoritative death: one reliable MOBDIE path (no dual MOBEVENT|die). - dieNet.SendMobDie(dieSyncId, dieX, dieY, identityToken, dieType); - } - } - - lock (Sync) - { - RemoveTrackedMobLocked(self, "on_die"); - } - } - - private static void RunWithSuppressedMobDieSend(Action action) - { - if (action == null) - return; - - suppressMobDieSendDepth++; - try - { - action(); - } - finally - { - suppressMobDieSendDepth--; - } - } - - private static void RunWithSuppressedMobHitSend(Action action) - { - if (action == null) - return; - - suppressMobHitSendDepth++; - try - { - action(); - } - finally - { - suppressMobHitSendDepth--; - } - } - - private void Hook_Mob_onDamage(Hook_Mob.orig_onDamage orig, Mob self, AttackData i) - { - var preDamageLife = GetMobLifeOrFallback(self, 0); - // Lethal damage runs onDie inside orig, which removes the mob from tracking before this hook resumes. - // Cache ids before orig so hit|life still sends when the mob is already untracked/destroyed. - var preSyncOk = false; - var cachedMobSyncId = -1; - if (self != null && i != null && LobbySession.NetRef != null && IsSyncMob(self)) - { - preSyncOk = TryGetMobSyncId(self, out cachedMobSyncId); - } - - // Read the attack's construction input before vanilla resolves the hit: resolution - // rewrites the per-target damage fields, and AttackData instances are recycled. - var attackIntentDamage = ReadAttackIntentDamage(i); - - orig(self, i); - - try - { - if (self == null || i == null) - return; - - var net = LobbySession.NetRef; - if (net == null) - return; - - if (!IsSyncMob(self) && !preSyncOk) - return; - - var isClient = IsClient(net); - var suppressedClientLethal = isClient && WasClientMobDeathSuppressed(self); - var tookLifeDelta = self.life < preDamageLife || suppressedClientLethal; - var becameDead = self.life <= 0 || suppressedClientLethal; - bool shouldReport = false; - if (IsHost(net)) - { - shouldReport = true; - } - else if (isClient) - { - shouldReport = IsDamageFromLocalPlayer(i); - // Fallback for damage-source edge cases: never drop a lethal hit report. - if (!shouldReport && tookLifeDelta && becameDead) - shouldReport = true; - } - - if (!shouldReport) - return; - - if (System.Threading.Volatile.Read(ref suppressMobHitSendDepth) > 0) - return; - - if (!TryGetMobSyncId(self, out var mobSyncId)) - { - if (!preSyncOk || !shouldReport) - return; - mobSyncId = cachedMobSyncId; - } - - // NetId 0 is reserved. Never report damage against it — that is the courtyard - // syncId=0 thrash vector once the real owner was gone. - if (mobSyncId <= 0) - return; - - // A locally lethal client hit temporarily restores the mob so the client does not - // run an unsanctioned death. Still report life=0 to the host; reporting the restored - // value (usually 1) was the source of elites becoming permanently unkillable. - var life = suppressedClientLethal ? 0 : GetMobLifeOrFallback(self, 0); - var damageHint = isClient && shouldReport - ? EstimateClientAttackDamageHint(attackIntentDamage, preDamageLife, life, suppressedClientLethal) - : 0.0; - var x = GetSyncX(self); - var y = GetSyncY(self); - var mobType = BuildMobStateTypeSignature(self); - - if (IsHost(net)) - { - var hx = GetWorldX(self); - var hy = GetWorldY(self); - var hitEvent = $"hit|{life.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; - if (TryGetCurrentLevelIdentityToken(out var identityToken)) - { - var update = new NetNode.MobEventUpdate(mobSyncId, hx, hy, NormalizeDir(self.dir), SingleEvent(hitEvent), mobType, identityToken); - MobSyncTrace.LogSendMobEvents(MobSyncNetRoleForTrace(net), SingleUpdate(update)); - net.SendMobEvents(SingleUpdate(update)); - } - } - - if (IsClient(net)) - { - lock (Sync) - { - if (!clientLastReportedMobLife.TryGetValue(self, out var lastLife)) - { - // First locally-confirmed hit for this tracked mob: establish baseline and - // propagate immediately when damage actually reduced life. - clientLastReportedMobLife[self] = life; - var maxLife = self.maxLife; - if (life >= maxLife && life > 0 && damageHint <= 0.0) - return; - } - else - { - // Never drop a killing blow: stale lastLife or host sync can make life look non-decreasing. - if (life >= lastLife) - { - var lethalReport = shouldReport && life <= 0 && lastLife > 0 && preDamageLife > 0; - var authoritativeProbe = shouldReport && damageHint > 0.0; - if (!lethalReport && !authoritativeProbe) - return; - } - - clientLastReportedMobLife[self] = life; - } - } - - var clientHitEvent = string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"hit|{life}|{damageHint:R}"); - if (TryGetCurrentLevelIdentityToken(out var identityToken)) - { - var clientUpdate = new NetNode.MobEventUpdate(mobSyncId, x, y, 0, SingleEvent(clientHitEvent), mobType, identityToken); - MobSyncTrace.LogSendMobEvents(MobSyncNetRoleForTrace(net), SingleUpdate(clientUpdate)); - net.SendMobEvents(SingleUpdate(clientUpdate)); - } - } - } - finally - { - TryRecoverClientSyncMobLifeAfterLocalDamage(self, preDamageLife); - TryRecoverSuppressedClientMobDie(self, preDamageLife); - TryRecoverSuppressedClientBossDie(self, preDamageLife); - } - } - private static string MobSyncNetRoleForTrace(NetNode? net) => net == null || !net.IsAlive ? "none" : (net.IsHost ? "host" : "client"); - private static void MarkSuppressedClientMobDie(Mob? mob) - { - if (mob == null) - return; - - lock (Sync) - { - clientPendingSuppressedMobDies.Add(mob); - } - } - - private static bool WasClientMobDeathSuppressed(Mob? mob) - { - if (mob == null) - return false; - - lock (Sync) - { - return clientPendingSuppressedMobDies.Contains(mob) || - clientPendingSuppressedBossDies.Contains(mob); - } - } - - private static int GetClientAuthoritativeLifeFallback(Mob? mob, int fallbackLife) - { - var recovered = System.Math.Max(1, fallbackLife); - if (mob == null) - return recovered; - - lock (Sync) - { - if (clientMobTargets.TryGetValue(mob, out var target) && target.Life > 0) - recovered = System.Math.Max(recovered, target.Life); - } - - return recovered; - } - - private static void TryRecoverSuppressedClientMobDie(Mob? mob, int fallbackLife) - { - if (mob == null) - return; - - bool hadSuppressedDie; - lock (Sync) - { - hadSuppressedDie = clientPendingSuppressedMobDies.Remove(mob); - } - - if (!hadSuppressedDie) - return; - - try - { - if (!mob.destroyed && mob.life <= 0) - mob.life = GetClientAuthoritativeLifeFallback(mob, fallbackLife); - } - catch - { - } - - // Restore the older native handoff after a locally suppressed lethal callback. The - // regular client authority update will relock the replica when no native attack/phase - // callback remains active. - TryUnlockClientMobAiAuthority(mob); - } - - private static bool ShouldSuppressClientBossDie(Mob? mob) - { - if (mob == null || !BossSyncHelpers.IsBossMob(mob)) - return false; - - var net = LobbySession.NetRef; - if (!IsClient(net)) - return false; - if (!IsSyncMob(mob)) - return false; - - return System.Threading.Volatile.Read(ref authoritativeClientBossDieDepth) <= 0; - } - - private static void MarkSuppressedClientBossDie(Mob? mob) - { - if (mob == null) - return; - - lock (Sync) - { - clientPendingSuppressedBossDies.Add(mob); - } - } - - private static void ClearSuppressedClientBossDie(Mob? mob) - { - if (mob == null) - return; - - lock (Sync) - { - clientPendingSuppressedBossDies.Remove(mob); - } - } - - private static void TryRecoverSuppressedClientBossDie(Mob? mob, int fallbackLife) - { - if (mob == null || mob.destroyed) - return; - - var net = LobbySession.NetRef; - if (!IsClient(net)) - { - ClearSuppressedClientBossDie(mob); - return; - } - - bool hadSuppressedDie; - lock (Sync) - { - hadSuppressedDie = clientPendingSuppressedBossDies.Remove(mob); - } - - if (!hadSuppressedDie) - return; - - try - { - if (mob.life <= 0) - mob.life = System.Math.Max(1, fallbackLife); - } - catch - { - } - } - - private static void RunWithAuthoritativeClientMobDie(Mob? mob, Action action) - { - if (action == null) - return; - - var net = LobbySession.NetRef; - if (!IsClient(net) || mob == null || !IsSyncMob(mob)) - { - action(); - return; - } - - var isBoss = BossSyncHelpers.IsBossMob(mob); - authoritativeClientMobDieDepth++; - if (isBoss) - authoritativeClientBossDieDepth++; - try - { - action(); - } - finally - { - if (isBoss) - authoritativeClientBossDieDepth--; - authoritativeClientMobDieDepth--; - } - } - - /// - /// Reads the pre-target-resolution damage the attack was built from. - /// AttackUtils.createFromHero(source, baseDmg, tier) takes exactly this value, so it - /// is the only scalar the host can feed back into the native hit path without re-deriving a - /// number that was already derived once. finalDmg/inflictedDmg are produced by - /// updateDamages(attack, target) and applyHitResult: they are per-target and - /// already contain this replica's armour, resistances and invulnerability, so transmitting - /// them would let a client-side outcome dictate the authoritative result. - /// private static double ReadAttackIntentDamage(AttackData? attack) { if (attack == null) From 90f5c0593818639f6dd083775be7c3fe07933c4d Mon Sep 17 00:00:00 2001 From: Alexey Date: Sun, 20 Sep 2026 14:14:30 +0700 Subject: [PATCH 3/7] split mob registry and combat receive modules --- Mobs/MonsterSynchronization.Attacks.cs | 2123 ---------------- ...sterSynchronization.ClientCombatReceive.cs | 480 ++++ Mobs/MonsterSynchronization.ClientReceive.cs | 459 ---- Mobs/MonsterSynchronization.RegistryCore.cs | 2152 +++++++++++++++++ 4 files changed, 2632 insertions(+), 2582 deletions(-) create mode 100644 Mobs/MonsterSynchronization.ClientCombatReceive.cs create mode 100644 Mobs/MonsterSynchronization.RegistryCore.cs diff --git a/Mobs/MonsterSynchronization.Attacks.cs b/Mobs/MonsterSynchronization.Attacks.cs index ee31d70a..c31709b7 100644 --- a/Mobs/MonsterSynchronization.Attacks.cs +++ b/Mobs/MonsterSynchronization.Attacks.cs @@ -127,2129 +127,6 @@ private static bool TryResolveFallbackPlayerCombatTarget(Mob? mob, Entity? curre return false; } - private static bool RebuildMobArray(Level? level) - { - var candidateIdentityToken = ComputeLevelIdentityToken(level); - var candidateEntityCount = 0; - var candidateTrackedMobs = new List(); - var role = MobSyncNetRoleForTrace(LobbySession.NetRef); - var levelId = GetLevelTraceIdSafe(level); - var levelKey = GetLevelRuntimeKey(level); - if (level?.entities != null) - { - var entities = level.entities; - candidateEntityCount = entities.length; - if (candidateIdentityToken > 0) - { - for (int i = 0; i < entities.length; i++) - { - var mob = entities.getDyn(i) as Mob; - if (mob == null || !IsSyncMob(mob)) - continue; - - candidateTrackedMobs.Add(mob); - } - } - } - - var trackedBeforeReset = 0; - var trackedAfterRebuild = 0; - var rebuildAccepted = false; - var generationAfterRebuild = 0; - var rejectionReason = string.Empty; - var currentIdentityTokenBefore = 0; - var currentIdentityReadyBefore = false; - var currentLevelKeyBefore = string.Empty; - var lastResetLevelKeyBefore = string.Empty; - var lastResetTrackedCountBefore = 0; - var lastResetIdentityTokenBefore = 0; - var lastCommittedLevelKeyBefore = string.Empty; - var lastCommittedTrackedCountBefore = 0; - var lastCommittedIdentityTokenBefore = 0; - var baselineTrackedCount = 0; - var baselineSource = string.Empty; - var lastResetReasonBefore = string.Empty; - lock (Sync) - { - trackedBeforeReset = trackedMobs.Count; - currentIdentityTokenBefore = s_levelIdentityToken; - currentIdentityReadyBefore = s_levelIdentityReady; - currentLevelKeyBefore = GetLevelRuntimeKey(currentLevel); - lastResetLevelKeyBefore = GetLastResetLevelRuntimeKeyLocked(); - lastResetTrackedCountBefore = s_lastResetTrackedCount; - lastResetIdentityTokenBefore = s_lastResetIdentityToken; - lastCommittedLevelKeyBefore = GetLastCommittedLevelRuntimeKeyLocked(); - lastCommittedTrackedCountBefore = s_lastCommittedTrackedCount; - lastCommittedIdentityTokenBefore = s_lastCommittedIdentityToken; - lastResetReasonBefore = s_lastResetReason; - if (!ShouldAcceptRebuildCandidateLocked( - level, - candidateIdentityToken, - candidateEntityCount, - candidateTrackedMobs.Count, - out rejectionReason, - out baselineTrackedCount, - out baselineSource)) - { - trackedAfterRebuild = trackedMobs.Count; - generationAfterRebuild = s_levelIdentityGeneration; - } - else - { - ResetMobTrackingLocked("rebuild_prepare"); - currentLevel = level; - // Host-owned NetIds: only the host assigns identity. Clients track unbound - // locals and bind from MOBREG / first authoritative state (type + spawn). - AssignHostNetIdsForRebuildLocked(candidateTrackedMobs); - - trackedAfterRebuild = trackedMobs.Count; - s_levelIdentityToken = candidateIdentityToken; - s_levelIdentityReady = level != null && s_levelIdentityToken > 0; - if (s_levelIdentityReady) - s_levelIdentityGeneration++; - - generationAfterRebuild = s_levelIdentityGeneration; - rebuildAccepted = true; - RememberCommittedRebuildLocked(level, candidateIdentityToken, trackedAfterRebuild); - ValidateTrackedIntegrityLocked("rebuild"); - } - } - - MobSyncTrace.LogRebuildCandidate( - role, - levelId, - levelKey, - candidateEntityCount, - candidateTrackedMobs.Count, - candidateIdentityToken, - trackedBeforeReset, - currentIdentityTokenBefore, - currentLevelKeyBefore, - lastResetLevelKeyBefore, - lastResetTrackedCountBefore, - lastResetIdentityTokenBefore, - lastCommittedLevelKeyBefore, - lastCommittedTrackedCountBefore, - lastCommittedIdentityTokenBefore, - lastResetReasonBefore); - - MobSyncTrace.LogRebuildDecision( - role, - levelId, - levelKey, - rebuildAccepted ? "accepted" : "rejected", - rejectionReason, - trackedBeforeReset, - trackedAfterRebuild, - candidateEntityCount, - candidateTrackedMobs.Count, - baselineTrackedCount, - baselineSource, - currentIdentityReadyBefore, - currentIdentityTokenBefore, - candidateIdentityToken, - currentLevelKeyBefore, - lastResetLevelKeyBefore, - lastCommittedLevelKeyBefore, - lastResetReasonBefore); - - if (!rebuildAccepted) - { - MobSyncTrace.LogRebuildRejected( - rejectionReason, - role, - levelId, - trackedBeforeReset, - candidateEntityCount, - candidateTrackedMobs.Count, - currentIdentityTokenBefore, - candidateIdentityToken); - return false; - } - - lock (Sync) - { - s_batchMobsScratch.Clear(); - s_batchMobsScratch.AddRange(trackedMobs); - } - - var minSyncId = -1; - var maxSyncId = -1; - var registryCount = 0; - lock (Sync) - { - foreach (var pair in MobToId) - { - if (pair.Value <= 0) - continue; - registryCount++; - if (minSyncId < 0 || pair.Value < minSyncId) - minSyncId = pair.Value; - if (pair.Value > maxSyncId) - maxSyncId = pair.Value; - } - } - - MobSyncTrace.LogRegistryRebuild( - role, - levelId, - trackedBeforeReset, - trackedAfterRebuild, - registryCount, - minSyncId, - maxSyncId, - nextRuntimeSyncId, - generationAfterRebuild, - s_levelIdentityToken); - MobSyncTrace.LogRebuildCommit( - role, - levelId, - levelKey, - trackedAfterRebuild, - registryCount, - generationAfterRebuild, - s_levelIdentityToken); - - lock (Sync) - { - // The client may finish level loading a few frames after the host. Queue initial - // dirty states immediately, but also force short bootstrap full-resync bursts so - // the next-level mob table cannot be missed by a single early packet/chunk. - s_hostAuthoritativeBootstrapResyncsRemaining = trackedAfterRebuild > 0 - ? HostAuthoritativeBootstrapResyncCount - : 0; - s_lastHostBossReliableKeyframeFrame = -99999.0; - s_lastHostBossReliableKeyframeToken = s_levelIdentityToken; - s_lastHostAuthoritativeFullResyncFrame = -99999.0; - s_lastHostAuthoritativeFullResyncToken = s_levelIdentityToken; - } - - ClearSyncQuiesceAfterRebuild(); - QueueHostMobRegistryAfterRebuild(); - - for (int i = 0; i < s_batchMobsScratch.Count; i++) - QueueInitialMobSync(s_batchMobsScratch[i]); - - s_batchMobsScratch.Clear(); - return true; - } - - private static bool ShouldAcceptRebuildCandidateLocked( - Level? level, - int candidateIdentityToken, - int candidateEntityCount, - int candidateTrackedCount, - out string reason, - out int baselineTrackedCount, - out string baselineSource) - { - baselineTrackedCount = 0; - baselineSource = string.Empty; - - if (level == null) - { - reason = "level_null"; - return false; - } - - if (level.entities == null) - { - reason = "entities_missing"; - return false; - } - - if (candidateIdentityToken <= 0) - { - reason = "identity_invalid"; - return false; - } - - if (trackedMobs.Count > 0 && candidateTrackedCount <= 0) - { - reason = "replace_empty"; - return false; - } - - var candidateLevelId = GetLevelTraceIdSafe(level); - var currentLevelId = GetLevelTraceIdSafe(currentLevel); - var sameIdentity = currentLevel != null && - s_levelIdentityReady && - s_levelIdentityToken > 0 && - s_levelIdentityToken == candidateIdentityToken && - string.Equals(currentLevelId, candidateLevelId, StringComparison.Ordinal); - var sameLastCommittedIdentity = s_lastCommittedIdentityToken > 0 && - s_lastCommittedIdentityToken == candidateIdentityToken && - string.Equals(s_lastCommittedLevelId, candidateLevelId, StringComparison.Ordinal); - var replacingExplicitlyDisposedSameIdentityLevel = - sameLastCommittedIdentity && - trackedMobs.Count == 0 && - !s_levelIdentityReady && - currentLevel == null && - candidateTrackedCount > 0 && - (string.Equals(s_lastResetReason, "level_dispose_before_orig", StringComparison.Ordinal) || - string.Equals(s_lastResetReason, "level_dispose_after_orig", StringComparison.Ordinal)); - - if (sameIdentity && trackedMobs.Count > 0) - { - baselineTrackedCount = trackedMobs.Count; - baselineSource = "live"; - } - else if (sameLastCommittedIdentity && - s_lastCommittedTrackedCount > 0 && - !replacingExplicitlyDisposedSameIdentityLevel) - { - baselineTrackedCount = s_lastCommittedTrackedCount; - baselineSource = "last_commit"; - } - else if (replacingExplicitlyDisposedSameIdentityLevel) - { - // Boss-cell/main-level replacement can intentionally dispose the old Level - // and rebuild the same run/level identity with a different native Level object. - // The old last-commit count is not a valid completeness baseline here; rejecting - // this first non-empty registry leaves the client at zero tracked mobs forever. - baselineTrackedCount = 0; - baselineSource = "disposed_same_identity_replacement"; - } - - if (TryGetAuthoritativeGameplayLevel(out var authoritativeLevel, out _)) - { - var candidateMatchesAuthoritative = - DoesLevelMatchIdentity(level, candidateIdentityToken, authoritativeLevel); - var currentMatchesAuthoritative = - DoesLevelMatchIdentity(currentLevel, s_levelIdentityToken, authoritativeLevel); - var lastCommittedMatchesAuthoritative = - DoesStoredIdentityMatchLevel(s_lastCommittedLevelId, s_lastCommittedIdentityToken, authoritativeLevel); - - var authoritativeBaselineTrackedCount = 0; - var authoritativeBaselineSource = string.Empty; - if (currentMatchesAuthoritative && trackedMobs.Count > 0) - { - authoritativeBaselineTrackedCount = trackedMobs.Count; - authoritativeBaselineSource = "live_authoritative"; - } - else if (lastCommittedMatchesAuthoritative && - s_lastCommittedTrackedCount > 0 && - !replacingExplicitlyDisposedSameIdentityLevel) - { - authoritativeBaselineTrackedCount = s_lastCommittedTrackedCount; - authoritativeBaselineSource = "last_commit_authoritative"; - } - - if (authoritativeBaselineTrackedCount > baselineTrackedCount) - { - baselineTrackedCount = authoritativeBaselineTrackedCount; - baselineSource = authoritativeBaselineSource; - } - - // Do not let a side/stale level replace the live gameplay combat level with an empty tracked set. - if (authoritativeBaselineTrackedCount > 0 && - !candidateMatchesAuthoritative && - candidateTrackedCount <= 0) - { - reason = "non_active_level_rebuild"; - return false; - } - } - - if (replacingExplicitlyDisposedSameIdentityLevel) - { - if (candidateEntityCount <= 0) - { - reason = "disposed_same_identity_entities_empty"; - return false; - } - - reason = "accepted_disposed_same_identity_replacement"; - return true; - } - - if (baselineTrackedCount > 0) - { - if (candidateTrackedCount <= 0) - { - reason = "same_identity_empty"; - return false; - } - - if (candidateTrackedCount < baselineTrackedCount) - { - reason = "same_identity_partial"; - return false; - } - - if (candidateEntityCount <= 0) - { - reason = "same_identity_entities_empty"; - return false; - } - } - - reason = "accepted"; - return true; - } - - private static int AddTrackedMobLocked(Mob mob) - { - if (mob == null) - return -1; - - var existingIndex = FindTrackedMobIndexLocked(mob); - if (existingIndex >= 0) - return existingIndex; - - var syncId = -1; - if (TryGetMobSyncId(mob, out syncId) && TryGetTrackedMobBySyncIdLocked(syncId, out var existingMob) && existingMob != null) - { - existingIndex = FindExactTrackedMobIndexLocked(existingMob); - if (existingIndex < 0) - { - LogRemoveAttemptLocked(existingMob, syncId, "add_tracked_stale_forward"); - IdToMob.Remove(syncId); - } - else - { - // A second HaxeProxy wrapper may refer to the same native mob. Never replace the - // canonical tracked wrapper with that transient alias: unregistering the alias - // would then remove the real mob and every later hit becomes missing_sync_id. - if (!ReferenceEquals(existingMob, mob)) - { - s_mobSyncAliases.Remove(mob); - s_mobSyncAliases.Add(mob, new MobSyncAlias - { - SyncId = syncId, - Generation = s_levelIdentityGeneration - }); - } - ValidateTrackedIntegrityLocked("track_existing"); - return existingIndex; - } - } - - // Clients must not append an unbound transient proxy wrapper. Their sync ids come - // from the level registry/host; adding a wrapper with no id bloats trackedMobs and can - // later displace the canonical entry. Hosts may allocate ids for runtime spawns. - if (syncId < 0 && LobbySession.NetRef?.IsHost != true) - return -1; - - trackedMobs.Add(mob); - var addedIndex = trackedMobs.Count - 1; - trackedMobIndices[mob] = addedIndex; - if (syncId >= 0) - { - IdToMob[syncId] = mob; - MobToId[mob] = syncId; - } - ValidateTrackedIntegrityLocked("track_add"); - return addedIndex; - } - - private static void ResetMobTrackingLocked(string reason) - { - s_lastResetReason = reason ?? string.Empty; - s_lastResetLevelRef = currentLevel == null ? null : new WeakReference(currentLevel); - s_lastResetLevelId = GetLevelTraceIdSafe(currentLevel); - s_lastResetIdentityToken = s_levelIdentityToken; - s_lastResetTrackedCount = trackedMobs.Count; - MobSyncTrace.LogTrackingReset( - s_lastResetReason, - MobSyncNetRoleForTrace(LobbySession.NetRef), - GetLevelTraceIdSafe(currentLevel), - GetLevelRuntimeKey(currentLevel), - trackedMobs.Count, - s_levelIdentityReady, - s_levelIdentityToken, - GetLastResetLevelRuntimeKeyLocked(), - s_lastResetTrackedCount, - s_lastResetIdentityToken, - GetLastCommittedLevelRuntimeKeyLocked(), - s_lastCommittedTrackedCount, - s_lastCommittedIdentityToken); - ResetMobTrackingStateLocked(); - } - - internal static void ResetForFullGameDispose(string reason) - { - lock (Sync) - { - ResetMobTrackingLocked(string.IsNullOrWhiteSpace(reason) - ? "full_game_dispose" - : reason); - - // A synchronized restart may reuse the same run/level identity token. - // Previous-level duplicate protection must not reject the first, still - // partially constructed entity pass of the new Game instance. - s_lastCommittedLevelRef = null; - s_lastCommittedLevelId = string.Empty; - s_lastCommittedIdentityToken = 0; - s_lastCommittedTrackedCount = 0; - s_lastResetLevelRef = null; - s_lastResetLevelId = string.Empty; - s_lastResetIdentityToken = 0; - s_lastResetTrackedCount = 0; - s_lastIgnoredDuplicateLevelId = string.Empty; - s_lastIgnoredDuplicateIdentityToken = 0; - s_levelIdentityGeneration = 0; - } - - try { LobbySession.NetRef?.ClearMobSyncQueues(); } catch { } - } - - private static void ResetMobTrackingStateLocked() - { - trackedMobs.Clear(); - trackedMobIndices.Clear(); - IdToMob.Clear(); - MobToId.Clear(); - s_mobSyncAliases = new ConditionalWeakTable(); - ClearHostMobStallRecoveryLocked(); - ResetPlayerCombatStateRepairLocked(); - // nextRuntimeSyncId is deliberately NOT reset here. It is the host's NetId allocator and - // must stay monotonic for the whole session: the wire generation is a pure hash of map - // id + seed, so two rebuilds of the SAME level carry the same generation and restarting - // the counter would hand id 0..N to a different set of mobs while packets addressed to - // the previous set are still in flight. Monotonic ids make that mis-binding impossible; - // a stale id simply resolves to nothing instead of resolving to the wrong enemy. - s_pendingCulledMobDeaths.Clear(); - s_pendingCulledMobDeathFirstFrame.Clear(); - clientMobTargets.Clear(); - clientCachedAttackTargetByMob.Clear(); - clientQueuedOldSkillMarkers.Clear(); - hostLastSentContactTargetUserIdByMob.Clear(); - clientLastReportedMobLife.Clear(); - clientLastSentAffectPayloadBySyncId.Clear(); - clientLastSentDrawStateBySyncId.Clear(); - clientLastAppliedHostAffectPayloadBySyncId.Clear(); - clientLastAppliedHostAffectMobBySyncId.Clear(); - hostLastAppliedClientAffectPayloadBySyncId.Clear(); - hostClientOwnedAffectIdsByMob.Clear(); - clientLastAppliedAnimPayloadByMob.Clear(); - clientLastAnimationApplyFrameByMob.Clear(); - clientLastForcedBossAnimByMob.Clear(); - s_hostBossPartWatch.Clear(); - clientActiveNetworkAttackMobs.Clear(); - clientBossSkillCallbackLeaseMobs.Clear(); - clientNetworkAttackStartFrame.Clear(); - clientAiLockedMobs.Clear(); - clientPendingSuppressedBossDies.Clear(); - clientCompletedAuthoritativeBossDeaths.Clear(); - ResetBossDeathWatchdogStateLocked(); - ResetBossIdentityStateLocked(); - clientPendingSuppressedMobDies.Clear(); - clientAuthoritativeStateSeenSyncIds.Clear(); - parsedAnimPayloadCache.Clear(); - hostMobTypeBySyncId.Clear(); - ClearHostClientInterestLocked(); - hostLastSentMobStatesBySyncId.Clear(); - clientLastAcceptedHostPositionFrameBySyncId.Clear(); - s_lastHostActiveReliableKeyframeFrame = -99999.0; - s_lastHostActiveReliableKeyframeToken = 0; - s_lastHostBossReliableKeyframeFrame = -99999.0; - s_lastHostBossReliableKeyframeToken = 0; - s_ghostHitMissBySyncId.Clear(); - s_ghostHitMissGeneration = 0; - // Sync ids are re-issued per level, so a retained rate-limit entry would suppress the - // first reconcile for an unrelated mob in the new level. - s_lastHitReconcileTicksBySyncId.Clear(); - // Boss-part despawn watch is keyed by sync id, and sync ids are re-issued per level. - // Carrying entries across a level change let a stale watch for the OLD level fire a - // despawn MOBDIE against whatever now owns that id in the NEW level. (The client's - // generation fence rejected it, so it never killed anything - but the host was emitting - // bogus deaths and the watch grew without bound.) Its reset existed and was never - // called; this is that call site. - ResetHostBossPartWatchLocked(); - // Per-encounter boss arena state must not survive into the next level/fight. - BeholderArenaSync.Reset(); - s_hostDeathTombstonesBySyncId.Clear(); - s_clientMobTombstonesBySyncId.Clear(); - s_lastHostAuthoritativeFullResyncFrame = -99999.0; - s_lastHostAuthoritativeFullResyncToken = 0; - s_hostAuthoritativeBootstrapResyncsRemaining = 0; - s_lastHostMobRegistryToken = 0; - s_lastHostMobRegistrySendFrame = -99999.0; - s_hostMobRegistryResendsRemaining = 0; - hostDetectedTargets.Clear(); - - // Scratch collections can retain destroyed Haxe proxy references across levels when an - // exception interrupts a consume/send pass. They are not game state; always drop them. - s_clientDetectedTargetsScratch.Clear(); - s_batchMobsScratch.Clear(); - s_batchSnapshotsScratch.Clear(); - s_clientAffectAppliesScratch.Clear(); - s_hostStateAppliesScratch.Clear(); - s_pendingMobHitAppliesScratch.Clear(); - s_mobHitMergeScratch.Clear(); - clientPendingBossAttacks.Clear(); - s_resolvedClientBossAttacksScratch.Clear(); - s_drawsScratch.Clear(); - s_moveSnapshotsScratch.Clear(); - s_dieVictimsScratch.Clear(); - s_dieVictimDedupScratch.Clear(); - s_usedTrackedMobsScratch.Clear(); - s_latestPacketSyncIdsScratch.Clear(); - s_ghostDespawnEchoScratch.Clear(); - s_hostDeathTombstoneScratch.Clear(); - s_hostDeathTombstoneStateScratch.Clear(); - s_hostDeathTombstoneRemoveScratch.Clear(); - s_validationSeenMobsScratch.Clear(); - s_validationSeenSyncIdsScratch.Clear(); - - clientNetworkQueuedAttackDepth = 0; - clientNetworkQueuedAttackMob = null; - clientNetworkAttackReplayDepth = 0; - clientNetworkAttackReplayMob = null; - authoritativeClientBossDieDepth = 0; - authoritativeClientMobDieDepth = 0; - suppressClientAffectDirtyDepth = 0; - suppressMobDieSendDepth = 0; - suppressMobHitSendDepth = 0; - forceExactNemesisTargetDepth = 0; - - s_trackedMobValidationPending = true; - s_syncMobTypeCache.Clear(); - ClearQueuedDirtyStateLocked(); - currentLevel = null; - s_levelIdentityReady = false; - s_levelIdentityToken = 0; - s_lastIgnoredDuplicateLevelId = string.Empty; - s_lastIgnoredDuplicateIdentityToken = 0; - } - - private static bool IsLevelIdentityReadyLocked(Level? level) - { - return DoesLevelMatchCurrentIdentityLocked(level); - } - - private static bool DoesLevelMatchCurrentIdentityLocked(Level? level) - { - if (!s_levelIdentityReady || level == null || s_levelIdentityToken <= 0) - return false; - - if (currentLevel != null && ReferenceEquals(currentLevel, level)) - return true; - - var currentLevelId = GetLevelTraceIdSafe(currentLevel); - var candidateLevelId = GetLevelTraceIdSafe(level); - if (!string.IsNullOrEmpty(currentLevelId) && - !string.IsNullOrEmpty(candidateLevelId) && - !string.Equals(currentLevelId, candidateLevelId, StringComparison.Ordinal)) - { - return false; - } - - var candidateIdentityToken = ComputeLevelIdentityToken(level); - return candidateIdentityToken > 0 && candidateIdentityToken == s_levelIdentityToken; - } - - private static bool IsIncomingMobIdentityReady() - { - lock (Sync) - { - return s_levelIdentityReady && currentLevel != null && s_levelIdentityToken > 0; - } - } - - private static void RemoveTrackedMobLocked(Mob mob, string reason) - { - if (mob == null) - return; - - s_trackedMobValidationPending = true; - var mappedSyncId = -1; - if (MobToId.TryGetValue(mob, out var ownedSyncId)) - mappedSyncId = ownedSyncId; - LogRemoveAttemptLocked(mob, mappedSyncId, reason); - var index = FindExactTrackedMobIndexLocked(mob); - if (index >= 0) - { - RemoveTrackedMobAtIndexLocked(index, reason, alreadyLogged: true); - return; - } - - // Client-only: a canonical owner that is no longer in trackedMobs can still hold a - // live host-owned mapping. Capture the tombstone before wiping it. An alias whose - // MobToId is missing, or whose IdToMob points at a different wrapper, must not wipe - // the canonical identity. Host keeps the previous alias-only cleanup. - if (mappedSyncId > 0 && IsClient(LobbySession.NetRef)) - { - var forwardIsThis = IdToMob.TryGetValue(mappedSyncId, out var forwardMob) && - ReferenceEquals(forwardMob, mob); - var forwardMissing = !IdToMob.TryGetValue(mappedSyncId, out var existingForward) || - existingForward == null; - if (forwardIsThis || forwardMissing) - { - TryCaptureClientTombstoneForMappedMobLocked(mob, reason); - IdToMob.Remove(mappedSyncId); - MobToId.Remove(mob); - } - } - - // This can be a temporary managed wrapper for a still-live canonical native mob. Remove - // only alias-local caches; never remove IdToMob/MobToId owned by the canonical wrapper. - s_mobSyncAliases.Remove(mob); - clientMobTargets.Remove(mob); - clientCachedAttackTargetByMob.Remove(mob); - clientQueuedOldSkillMarkers.Remove(mob); - hostLastSentContactTargetUserIdByMob.Remove(mob); - clientLastReportedMobLife.Remove(mob); - clientLastAppliedAnimPayloadByMob.Remove(mob); - clientLastAnimationApplyFrameByMob.Remove(mob); - clientLastForcedBossAnimByMob.Remove(mob); - clientActiveNetworkAttackMobs.Remove(mob); - clientBossSkillCallbackLeaseMobs.Remove(mob); - clientNetworkAttackStartFrame.Remove(mob); - clientAiLockedMobs.Remove(mob); - } - - private static bool ShouldRetainMobSyncIdOnTemporaryUnregisterLocked(Level? level, Mob mob) - { - if (mob == null || level == null) - return false; - if (!MobToId.ContainsKey(mob)) - return false; - - try - { - if (mob.destroyed || mob.life <= 0) - return false; - if (!DoesLevelMatchCurrentIdentityLocked(level) || !DoesLevelMatchCurrentIdentityLocked(mob._level)) - return false; - } - catch - { - return false; - } - - return true; - } - - private static void DetachTrackedMobForTemporaryUnregisterLocked(Mob mob) - { - if (mob == null || !MobToId.TryGetValue(mob, out var syncId) || syncId < 0) - return; - - // A live mob can be unregistered briefly while sleeping, teleporting, transforming, - // or entering/leaving an elite phase. Removing IdToMob here made the peer's next hit - // fail with missing_sync_id even though MobToId still claimed that the mob owned the id. - // - // Keep the complete canonical mapping and tracked-list entry until the mob is actually - // destroyed, dies, changes level, or the level registry is rebuilt. Only transient - // attack/visual caches are cleared so an interrupted skill is not replayed forever. - IdToMob[syncId] = mob; - var index = FindExactTrackedMobIndexLocked(mob); - if (index < 0) - { - trackedMobs.Add(mob); - index = trackedMobs.Count - 1; - } - trackedMobIndices[mob] = index; - - s_mobSyncAliases.Remove(mob); - clientCachedAttackTargetByMob.Remove(mob); - clientQueuedOldSkillMarkers.Remove(mob); - hostLastSentContactTargetUserIdByMob.Remove(mob); - clientActiveNetworkAttackMobs.Remove(mob); - clientBossSkillCallbackLeaseMobs.Remove(mob); - clientNetworkAttackStartFrame.Remove(mob); - clientAiLockedMobs.Remove(mob); - s_trackedMobValidationPending = true; - - MobSyncTrace.LogBindSyncId( - "temporary_unregister_retained", - syncId, - BuildMobStateTypeSignature(mob), - GetWorldX(mob), - GetWorldY(mob)); - } - - private static int FindExactTrackedMobIndexLocked(Mob mob) - { - if (mob == null) - return -1; - - if (trackedMobIndices.TryGetValue(mob, out var directIndex)) - { - if (directIndex >= 0 && directIndex < trackedMobs.Count && ReferenceEquals(trackedMobs[directIndex], mob)) - return directIndex; - trackedMobIndices.Remove(mob); - } - - for (var i = 0; i < trackedMobs.Count; i++) - { - if (ReferenceEquals(trackedMobs[i], mob)) - { - trackedMobIndices[mob] = i; - return i; - } - } - - return -1; - } - - private static void RemoveTrackedMobAtIndexLocked(int index, string reason, bool alreadyLogged = false) - { - if (index < 0 || index >= trackedMobs.Count) - return; - - s_trackedMobValidationPending = true; - var mob = trackedMobs[index]; - if (!alreadyLogged) - { - var mappedSyncId = -1; - if (mob != null && MobToId.TryGetValue(mob, out var ownedSyncId)) - mappedSyncId = ownedSyncId; - LogRemoveAttemptLocked(mob, mappedSyncId, reason); - } - TryCaptureClientTombstoneForMappedMobLocked(mob, reason); - CleanupTrackedMobCachesLocked(mob); - if (mob != null) - { - if (MobToId.Remove(mob, out var _sid)) - IdToMob.Remove(_sid); - s_mobSyncAliases.Remove(mob); - trackedMobIndices.Remove(mob); - } - - var lastIndex = trackedMobs.Count - 1; - if (index != lastIndex) - { - var movedMob = trackedMobs[lastIndex]; - trackedMobs[index] = movedMob; - if (movedMob != null) - trackedMobIndices[movedMob] = index; - } - - trackedMobs.RemoveAt(lastIndex); - ValidateTrackedIntegrityLocked("track_remove"); - } - - private static void CleanupTrackedMobCachesLocked(Mob? mob) - { - if (mob == null) - return; - - clientPendingSuppressedBossDies.Remove(mob); - clientPendingSuppressedMobDies.Remove(mob); - try - { - if (mob.destroyed) - hostClientOwnedAffectIdsByMob.Remove(mob); - } - catch - { - } - trackedMobIndices.Remove(mob); - clientMobTargets.Remove(mob); - clientCachedAttackTargetByMob.Remove(mob); - clientQueuedOldSkillMarkers.Remove(mob); - hostLastSentContactTargetUserIdByMob.Remove(mob); - clientLastReportedMobLife.Remove(mob); - clientLastAppliedAnimPayloadByMob.Remove(mob); - clientLastAnimationApplyFrameByMob.Remove(mob); - clientLastForcedBossAnimByMob.Remove(mob); - clientActiveNetworkAttackMobs.Remove(mob); - clientBossSkillCallbackLeaseMobs.Remove(mob); - clientNetworkAttackStartFrame.Remove(mob); - clientAiLockedMobs.Remove(mob); - - // Cache cleanup must only clear a mob that owns this exact managed registration. A - // transient HaxeProxy wrapper must never delete the canonical entity's sync mapping. - if (!MobToId.TryGetValue(mob, out var syncId)) - return; - - ClearPerSyncIdCachesLocked(syncId); - } - - private static void ClearPerSyncIdCachesLocked(int syncId) - { - if (syncId < 0) - return; - - IdToMob.Remove(syncId); - RemoveHostMobStallRecoveryLocked(syncId); - clientLastSentAffectPayloadBySyncId.Remove(syncId); - clientLastSentDrawStateBySyncId.Remove(syncId); - clientLastAppliedHostAffectPayloadBySyncId.Remove(syncId); - clientLastAppliedHostAffectMobBySyncId.Remove(syncId); - hostLastAppliedClientAffectPayloadBySyncId.Remove(syncId); - hostMobTypeBySyncId.Remove(syncId); - hostClientInterestUsersBySyncId.Remove(syncId); - hostLastSentMobStatesBySyncId.Remove(syncId); - hostObservedMobStatesBySyncId.Remove(syncId); - hostDirtyFlagsBySyncId.Remove(syncId); - hostDirtyQueuedSyncIds.Remove(syncId); - clientAuthoritativeStateSeenSyncIds.Remove(syncId); - clientObservedDrawStateBySyncId.Remove(syncId); - clientDirtyFlagsBySyncId.Remove(syncId); - clientDirtyQueuedSyncIds.Remove(syncId); - } - - private static int FindTrackedMobIndexLocked(Mob mob) - { - if (mob == null || trackedMobs.Count == 0) - return -1; - - if (trackedMobIndices.TryGetValue(mob, out var directIndex)) - { - if (directIndex >= 0 && directIndex < trackedMobs.Count && ReferenceEquals(trackedMobs[directIndex], mob)) - return directIndex; - - trackedMobIndices.Remove(mob); - s_trackedMobValidationPending = true; - } - - // HaxeProxy can expose another managed wrapper for the same native mob. Match it by - // current level, runtime class and near-identical position. Unlike Mob.__uid, this does - // not alias every enemy of the same class. - // - // Crowded packs used to return -1 on any ambiguity, which made TryGetMobSyncId mint a - // SECOND NetId for the same native enemy (registry_duplicate_twin / missing_sync_id). - // Prefer an already-mapped candidate; only reject when several unmapped equals collide. - var matchedIndex = -1; - var matchedSyncId = int.MaxValue; - var matchedHasId = false; - var unmappedEquals = 0; - for (int i = 0; i < trackedMobs.Count; i++) - { - var candidate = trackedMobs[i]; - if (!AreLikelySameNativeMobProxy(candidate, mob)) - continue; - - var hasId = false; - var candidateSyncId = 0; - if (candidate != null && MobToId.TryGetValue(candidate, out candidateSyncId) && candidateSyncId > 0) - hasId = true; - if (hasId) - { - if (!matchedHasId || candidateSyncId < matchedSyncId) - { - matchedIndex = i; - matchedSyncId = candidateSyncId; - matchedHasId = true; - } - continue; - } - - if (!matchedHasId) - { - unmappedEquals++; - if (matchedIndex < 0) - matchedIndex = i; - } - } - - if (matchedIndex < 0) - return -1; - - // Multiple unmapped equals and no mapped owner — refuse rather than guess. - if (!matchedHasId && unmappedEquals > 1) - return -1; - - var canonical = trackedMobs[matchedIndex]; - if (canonical != null) - trackedMobIndices[canonical] = matchedIndex; - return matchedIndex; - } - - /// - /// Host-only: if any already-mapped tracked wrapper is the same native enemy, reuse that - /// NetId instead of minting a duplicate. Caller holds . - /// - private static bool TryAttachAliasToMappedNativeProxyLocked(Mob mob, out int syncId) - { - syncId = -1; - if (mob == null) - return false; - - Mob? best = null; - var bestSyncId = int.MaxValue; - for (var i = 0; i < trackedMobs.Count; i++) - { - var candidate = trackedMobs[i]; - if (candidate == null || ReferenceEquals(candidate, mob)) - continue; - if (!MobToId.TryGetValue(candidate, out var candidateSyncId) || candidateSyncId <= 0) - continue; - if (!AreLikelySameNativeMobProxy(candidate, mob)) - continue; - if (candidateSyncId >= bestSyncId) - continue; - - best = candidate; - bestSyncId = candidateSyncId; - } - - if (best == null || bestSyncId == int.MaxValue) - return false; - - syncId = bestSyncId; - s_mobSyncAliases.Remove(mob); - s_mobSyncAliases.Add(mob, new MobSyncAlias - { - SyncId = syncId, - Generation = s_levelIdentityGeneration - }); - return true; - } - - private static bool AreLikelySameNativeMobProxy(Mob? left, Mob? right) - { - if (left == null || right == null) - return false; - if (ReferenceEquals(left, right)) - return true; - - try - { - if (!DoesLevelMatchCurrentIdentityLocked(left._level) || - !DoesLevelMatchCurrentIdentityLocked(right._level)) - { - return false; - } - if (!string.Equals( - GetMobRuntimeClassKeySafe(left), - GetMobRuntimeClassKeySafe(right), - StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - var dx = GetWorldX(left) - GetWorldX(right); - var dy = GetWorldY(left) - GetWorldY(right); - if (double.IsFinite(dx) && double.IsFinite(dy) && dx * dx + dy * dy <= PixelsPerCase * PixelsPerCase) - return true; - - // Pixel distance alone can rule out two copies of the SAME native enemy when the - // peers' coordinates drift by a fraction of a tile (spawn snapping, gravity - // landing, interpolation: 1000.0 vs 1000.1). Same-cell is the tie-breaker. - GetMobWorldCells(left, out var lcx, out var lcy); - GetMobWorldCells(right, out var rcx, out var rcy); - return lcx == rcx && lcy == rcy; - } - catch - { - return false; - } - } - - private static bool TryGetTrackedMobBySyncIdLocked(int syncId, out Mob? mob) - { - mob = null; - if (syncId < 0) - return false; - - if (!IdToMob.TryGetValue(syncId, out var mappedMob) || mappedMob == null) - { - // Repair a one-sided registry entry. Earlier temporary-unregister handling could - // leave MobToId intact while removing IdToMob, which made every later client hit - // look like a missing sync id. Recover only a unique live same-level owner. - Mob? reverseCandidate = null; - var reverseCandidates = 0; - foreach (var pair in MobToId) - { - if (pair.Value != syncId || pair.Key == null) - continue; - if (!IsStateRebindCandidateLocked(pair.Key)) - continue; - - reverseCandidate = pair.Key; - reverseCandidates++; - if (reverseCandidates > 1) - break; - } - - if (reverseCandidates == 1 && reverseCandidate != null) - { - mappedMob = reverseCandidate; - IdToMob[syncId] = mappedMob; - var repairedIndex = FindExactTrackedMobIndexLocked(mappedMob); - if (repairedIndex < 0) - { - trackedMobs.Add(mappedMob); - repairedIndex = trackedMobs.Count - 1; - } - trackedMobIndices[mappedMob] = repairedIndex; - s_trackedMobValidationPending = true; - MobSyncTrace.LogBindSyncId( - "reverse_registry_repair", - syncId, - BuildMobStateTypeSignature(mappedMob), - GetWorldX(mappedMob), - GetWorldY(mappedMob)); - } - else - { - return false; - } - } - - if (mappedMob == null) - { - MobSyncTrace.LogStaleTrackedMapping(syncId, -1, "null_mob"); - LogRemoveAttemptLocked(null, syncId, "stale_null_mob"); - IdToMob.Remove(syncId); - s_trackedMobValidationPending = true; - return false; - } - - var localIndex = FindTrackedMobIndexLocked(mappedMob); - if (localIndex < 0) - { - // An alive mob can be temporarily unregistered/re-wrapped when sleeping, teleporting - // or changing elite phase. Keep its authoritative id and reattach the live object - // instead of pruning the mapping and allocating a replacement id. - if (IsStateRebindCandidateLocked(mappedMob)) - { - trackedMobs.Add(mappedMob); - localIndex = trackedMobs.Count - 1; - trackedMobIndices[mappedMob] = localIndex; - MobToId[mappedMob] = syncId; - s_trackedMobValidationPending = true; - MobSyncTrace.LogBindSyncId( - "reattach_live_mapping", - syncId, - BuildMobStateTypeSignature(mappedMob), - GetWorldX(mappedMob), - GetWorldY(mappedMob)); - } - else - { - MobSyncTrace.LogStaleTrackedMapping(syncId, localIndex, "untracked_mob"); - LogRemoveAttemptLocked(mappedMob, syncId, "stale_untracked_mob"); - IdToMob.Remove(syncId); - s_trackedMobValidationPending = true; - return false; - } - } - - var canonicalMob = trackedMobs[localIndex]; - if (canonicalMob == null) - { - LogRemoveAttemptLocked(null, syncId, "stale_canonical_null"); - IdToMob.Remove(syncId); - s_trackedMobValidationPending = true; - return false; - } - - if (!ReferenceEquals(mappedMob, canonicalMob)) - { - // Repair a wrapper swap without changing the native mob identity or sync id. - IdToMob[syncId] = canonicalMob; - MobToId[canonicalMob] = syncId; - mappedMob = canonicalMob; - } - - if (!MobToId.TryGetValue(mappedMob, out var mappedSyncId) || mappedSyncId != syncId) - { - // Two failure modes share this branch: - // * the reverse mapping (MobToId) was lost while the forward entry survived, or - // * the same enemy was rebound to a different id — host wrapper/proxy duplication - // hands one native mob two NetIds, so IdToMob[syncId] points at a mob whose - // MobToId now names another id. - // The old remove-and-fail turned both into dropped states and lost client damage, - // which is exactly the registry_mismatch / missing_sync_id churn. Heal instead. - - // If another live tracked mob genuinely owns this id, repair the forward entry to - // point at it (the previous owner is a stale wrapper of the same native enemy). - Mob? trueOwner = null; - var ownerCount = 0; - for (var i = 0; i < trackedMobs.Count; i++) - { - var candidate = trackedMobs[i]; - if (candidate == null || ReferenceEquals(candidate, mappedMob)) - continue; - if (MobToId.TryGetValue(candidate, out var candidateId) && - candidateId == syncId && - IsStateRebindCandidateLocked(candidate)) - { - trueOwner = candidate; - ownerCount++; - if (ownerCount > 1) - break; - } - } - - if (ownerCount == 1 && trueOwner != null) - { - IdToMob[syncId] = trueOwner; - s_trackedMobValidationPending = true; - MobSyncTrace.LogBindSyncId( - "registry_mismatch_repaired", - syncId, - BuildMobStateTypeSignature(trueOwner), - GetWorldX(trueOwner), - GetWorldY(trueOwner)); - mob = trueOwner; - return true; - } - - // The forward entry points at a mob that now owns a different id — usually a - // duplicate NetId minted for a second HaxeProxy wrapper of the same native enemy. - // Keep BOTH forward ids resolving to the canonical wrapper so peer hits addressed - // to the duplicate id still land. Dropping IdToMob[dup] was the missing_sync_id - // path for legitimate high syncIds after registry_alias. - if (IsStateRebindCandidateLocked(mappedMob)) - { - if (mappedSyncId <= 0) - { - IdToMob[syncId] = mappedMob; - MobToId[mappedMob] = syncId; - } - else - { - // Canonical reverse mapping stays on mappedSyncId; duplicate syncId only - // keeps a forward alias so old packets/hits still resolve. - IdToMob[syncId] = mappedMob; - IdToMob[mappedSyncId] = mappedMob; - MobToId[mappedMob] = mappedSyncId; - } - - s_trackedMobValidationPending = true; - MobSyncTrace.LogIncomingMappingMismatch( - "registry_alias", - syncId, - BuildMobStateTypeSignature(mappedMob), - string.Empty, - mappedSyncId > 0 ? $"aliased_to:{mappedSyncId}" : "reverse_mapping_lost"); - mob = mappedMob; - return true; - } - - MobSyncTrace.LogStaleTrackedMapping( - syncId, - localIndex, - mappedSyncId == syncId ? "registry_missing" : $"registry_mismatch:{mappedSyncId}"); - LogRemoveAttemptLocked( - mappedMob, - syncId, - mappedSyncId == syncId ? "stale_registry_missing" : "stale_registry_mismatch"); - IdToMob.Remove(syncId); - s_trackedMobValidationPending = true; - return false; - } - - mob = mappedMob; - return true; - } - - private static void InvalidateTrackedSyncCacheLocked(int syncId, string reason) - { - if (syncId < 0) - return; - - if (IdToMob.TryGetValue(syncId, out var mappedMob) && mappedMob != null) - { - MobSyncTrace.LogStaleTrackedMapping(syncId, FindTrackedMobIndexLocked(mappedMob), reason); - LogRemoveAttemptLocked(mappedMob, syncId, "invalidate:" + reason); - if (MobToId.TryGetValue(mappedMob, out var reverseSyncId) && reverseSyncId == syncId) - MobToId.Remove(mappedMob); - } - else - { - LogRemoveAttemptLocked(null, syncId, "invalidate:" + reason); - } - - IdToMob.Remove(syncId); - s_trackedMobValidationPending = true; - } - - private static void ValidateTrackedIntegrityLocked(string reason) - { - if (!MobSyncTrace.AssertEnabled) - return; - - s_validationSeenMobsScratch.Clear(); - s_validationSeenSyncIdsScratch.Clear(); - - for (int i = 0; i < trackedMobs.Count; i++) - { - var mob = trackedMobs[i]; - if (mob == null) - { - MobSyncTrace.LogInvariantViolation(reason, $"null tracked mob at localIndex={i}"); - continue; - } - - if (!s_validationSeenMobsScratch.Add(mob)) - MobSyncTrace.LogInvariantViolation(reason, $"duplicate tracked mob localIndex={i} type={BuildMobStateTypeSignature(mob)}"); - - if (!trackedMobIndices.TryGetValue(mob, out var directIndex) || directIndex != i) - MobSyncTrace.LogInvariantViolation(reason, $"trackedMobIndices mismatch localIndex={i} directIndex={directIndex}"); - - if (!MobToId.TryGetValue(mob, out var syncId)) - continue; - - if (!s_validationSeenSyncIdsScratch.Add(syncId)) - MobSyncTrace.LogInvariantViolation(reason, $"duplicate syncId among tracked mobs syncId={syncId} localIndex={i}"); - - if (!IdToMob.TryGetValue(syncId, out var mappedMob) || !ReferenceEquals(mappedMob, mob)) - MobSyncTrace.LogInvariantViolation(reason, $"IdToMob mismatch syncId={syncId} localIndex={i}"); - } - - // The first pass intentionally records every tracked mob/id. Start fresh before - // validating IdToMob itself; otherwise every valid dictionary entry is reported as a - // duplicate merely because it was already seen through trackedMobs. - s_validationSeenMobsScratch.Clear(); - s_validationSeenSyncIdsScratch.Clear(); - - foreach (var pair in IdToMob) - { - var syncId = pair.Key; - var mob = pair.Value; - if (mob == null) - { - MobSyncTrace.LogInvariantViolation(reason, $"IdToMob null mob syncId={syncId}"); - continue; - } - - if (!s_validationSeenSyncIdsScratch.Add(syncId)) - MobSyncTrace.LogInvariantViolation(reason, $"duplicate syncId in IdToMob syncId={syncId}"); - if (!s_validationSeenMobsScratch.Add(mob)) - MobSyncTrace.LogInvariantViolation(reason, $"mob mapped to multiple syncIds type={BuildMobStateTypeSignature(mob)}"); - - if (trackedMobIndices.TryGetValue(mob, out var localIndex) && - (localIndex < 0 || localIndex >= trackedMobs.Count || !ReferenceEquals(trackedMobs[localIndex], mob))) - { - MobSyncTrace.LogInvariantViolation(reason, $"IdToMob tracked index drift syncId={syncId} localIndex={localIndex}"); - } - - if (!MobToId.TryGetValue(mob, out var reverseSyncId) || reverseSyncId != syncId) - MobSyncTrace.LogInvariantViolation(reason, $"IdToMob reverse lookup mismatch syncId={syncId} reverseSyncId={reverseSyncId}"); - } - } - - private static void PruneInvalidTrackedMobsLocked() - { - if (trackedMobs.Count == 0) - return; - - if (!s_trackedMobValidationPending) - return; - - s_trackedMobValidationPending = false; - - for (int i = trackedMobs.Count - 1; i >= 0; i--) - { - var mob = trackedMobs[i]; - if (mob == null) - { - RemoveTrackedMobAtIndexLocked(i, "prune_null"); - continue; - } - - var shouldRemove = false; - try - { - // Do not prune by life<=0: some bosses spawn/transition with temporary zero life - // and must stay tracked to receive authoritative host life. - shouldRemove = mob.destroyed || mob._level == null; - } - catch - { - shouldRemove = true; - } - - if (!shouldRemove) - { - try - { - var mobLevel = mob._level; - shouldRemove = !DoesLevelMatchCurrentIdentityLocked(mobLevel); - } - catch - { - shouldRemove = true; - } - } - - if (shouldRemove) - RemoveTrackedMobAtIndexLocked(i, "prune_invalid"); - } - } - - private static bool IsSyncMob(Mob? mob) - { - if (!MultiplayerSettingsStorage.EnableMobsSync) - return false; - - if (mob == null) - return false; - - try - { - if (mob.destroyed || mob._level == null) - return false; - - if (BossSyncConstants.DisableBossSyncTemporarily && BossSyncHelpers.IsBossMob(mob)) - return false; - - // Primary rule: any combat-hostile mob (including bosses) must be synced. - if (IsMobHostileToPlayers(mob)) - return true; - - return IsSyncMobByType(mob); - } - catch - { - return false; - } - } - - private static bool IsSyncMobByType(Mob mob) - { - return s_syncMobTypeCache.GetOrAdd(mob.GetType(), static (System.Type t) => - { - var typeName = t.FullName ?? t.Name; - return typeName.Contains("dc.en.boss.", StringComparison.OrdinalIgnoreCase) - || typeName.Contains(".boss.", StringComparison.OrdinalIgnoreCase) - || typeName.Contains("dc.en.mob.", StringComparison.Ordinal) - || typeName.Contains(".Mob", StringComparison.Ordinal) - || typeName.Contains(".mob.", StringComparison.Ordinal); - }); - } - - private static void EnsureMobTracked(Mob mob) - { - if (!IsSyncMob(mob)) - return; - - var shouldQueueInitialSync = false; - lock (Sync) - { - var mobLevel = mob._level; - if (!IsLevelIdentityReadyLocked(mobLevel)) - return; - - if (FindTrackedMobIndexLocked(mob) >= 0) - return; - - if (mob != null) - { - shouldQueueInitialSync = AddTrackedMobLocked(mob) >= 0; - } - } - - if (shouldQueueInitialSync && mob != null) - QueueInitialMobSync(mob); - } - - private static bool TryGetMobSyncId(Mob mob, out int syncId) - { - syncId = -1; - if (!IsSyncMob(mob)) - return false; - - lock (Sync) - { - if (MobToId.TryGetValue(mob, out syncId)) - return true; - - if (s_mobSyncAliases.TryGetValue(mob, out var alias) && - alias.Generation == s_levelIdentityGeneration && - IdToMob.TryGetValue(alias.SyncId, out var aliasedCanonical) && - aliasedCanonical != null && - FindExactTrackedMobIndexLocked(aliasedCanonical) >= 0) - { - syncId = alias.SyncId; - return true; - } - - s_mobSyncAliases.Remove(mob); - var canonicalIndex = FindTrackedMobIndexLocked(mob); - if (canonicalIndex >= 0 && canonicalIndex < trackedMobs.Count) - { - var canonicalMob = trackedMobs[canonicalIndex]; - if (canonicalMob != null && MobToId.TryGetValue(canonicalMob, out syncId)) - { - if (!ReferenceEquals(canonicalMob, mob)) - { - s_mobSyncAliases.Add(mob, new MobSyncAlias - { - SyncId = syncId, - Generation = s_levelIdentityGeneration - }); - } - return true; - } - } - - if (!IsLevelIdentityReadyLocked(mob._level)) - return false; - - // Clients never invent NetIds — host is the sole authority (native ids diverge). - if (LobbySession.NetRef?.IsHost != true) - return false; - - // Never mint a second NetId for another HaxeProxy wrapper of the same native enemy. - // Duplicate ids are what produce registry_duplicate_twin on the client and then - // missing_sync_id hits once the host drops the alias forward map. - if (TryAttachAliasToMappedNativeProxyLocked(mob, out syncId)) - return true; - - if (nextRuntimeSyncId < 1) - nextRuntimeSyncId = 1; - syncId = nextRuntimeSyncId++; - MobToId[mob] = syncId; - IdToMob[syncId] = mob; - StampHostBossNetIdLocked(mob, syncId); - // Dynamic/runtime-spawned mobs must be in the canonical tracked list immediately; - // otherwise the first dirty packet creates an IdToMob entry that is rejected as - // untracked_mob on the next dequeue. - if (FindExactTrackedMobIndexLocked(mob) < 0) - { - trackedMobs.Add(mob); - trackedMobIndices[mob] = trackedMobs.Count - 1; - } - return true; - } - } - - private static Mob? ResolveTrackedMobBySyncIdLocked(int syncId) - { - if (syncId < 0) - return null; - - var hadIdToMob = IdToMob.TryGetValue(syncId, out var preMob) && preMob != null; - var hadMobToId = preMob != null && MobToId.TryGetValue(preMob, out var preRev) && preRev == syncId; - var preDestroyed = ReadMobDestroyedSafe(preMob); - - if (TryGetTrackedMobBySyncIdLocked(syncId, out var mappedMob) && mappedMob != null) - { - if (IsClient(LobbySession.NetRef) && - IsInvalidMappedReplicaLocked(mappedMob) && - MappingOwnsSyncIdLocked(mappedMob, syncId)) - { - RemoveTrackedMobLocked(mappedMob, "mapped_but_invalid"); - LogResolveFailLocked( - syncId, - hadIdToMob, - hadMobToId, - true, - "mapped_but_invalid"); - return null; - } - - LogFocusSyncLifecycleLocked(syncId, "RESOLVE_OK", "tracked"); - return mappedMob; - } - - if (!IdToMob.TryGetValue(syncId, out var mob) || mob == null || !IsSyncMob(mob)) - { - LogResolveFailLocked( - syncId, - hadIdToMob, - hadMobToId, - preDestroyed, - !hadIdToMob ? "missing_idtomob" : (mob == null ? "forward_null" : "not_sync_mob")); - return null; - } - - try - { - if (!DoesLevelMatchCurrentIdentityLocked(mob._level)) - { - LogResolveFailLocked(syncId, true, hadMobToId, ReadMobDestroyedSafe(mob), "level_mismatch"); - return null; - } - } - catch - { - LogResolveFailLocked(syncId, true, hadMobToId, true, "level_check_threw"); - return null; - } - - if (AddTrackedMobLocked(mob) >= 0) - { - LogFocusSyncLifecycleLocked(syncId, "RESOLVE_OK", "readded"); - return mob; - } - - LogResolveFailLocked(syncId, true, hadMobToId, ReadMobDestroyedSafe(mob), "add_tracked_failed"); - return null; - } - - private static Mob? ResolveTrackedMobForIncomingStateLocked(NetNode.MobStateSnapshot state, HashSet? reservedMobs) - { - // Boss identity (bid:) folded into NetId space; still used across phase/proxy rebuilds. - var bossEntityId = BossStateSync.TryGetEntityId(state.StatePayload); - - var mappedMob = ResolveTrackedMobBySyncIdLocked(state.Index); - if (mappedMob != null) - { - var reserved = reservedMobs != null && reservedMobs.Contains(mappedMob); - if (!reserved && DoesMobMatchStateType(mappedMob, state.Type)) - { - if (bossEntityId > 0) - RememberClientBossEntityIdLocked(mappedMob, bossEntityId); - return mappedMob; - } - - if (!reserved) - { - // NetId is the host-owned identity. Elite rune mobs can replace their native - // wrapper (and therefore their type/class signature) during promotion, phase - // setup, or the final death transition. Rejecting that packet invalidates the - // only binding we have and turns the next host life=0/MOBDIE into a permanent - // client ghost. The resolver already validated the generation, level and - // forward/reverse mapping, so preserve the binding and refresh the cached type. - // Do not use position here: a teleport/elite transform may legitimately move - // the same authoritative mob between packets. - if (IsStateRebindCandidateLocked(mappedMob) && - MappingOwnsSyncIdLocked(mappedMob, state.Index)) - { - if (!string.IsNullOrWhiteSpace(state.Type)) - hostMobTypeBySyncId[state.Index] = state.Type; - - MobSyncTrace.LogFallbackMatchResolved( - state.Life <= 0 - ? "state_type_mismatch_accepted_by_authoritative_id" - : "state_type_changed_preserved_authoritative_id", - state.Index, - state.Type ?? string.Empty, - state.X, - state.Y, - candidateCount: 1, - rebound: false); - if (bossEntityId > 0) - RememberClientBossEntityIdLocked(mappedMob, bossEntityId); - return mappedMob; - } - - InvalidateTrackedSyncCacheLocked(state.Index, "state_type_mismatch"); - MobSyncTrace.LogIncomingMappingMismatch( - "state", - state.Index, - state.Type ?? string.Empty, - mappedMob != null ? BuildMobStateTypeSignature(mappedMob) : string.Empty, - "type_mismatch"); - } - } - - // Boss phase/proxy rebuild: follow learned EntityId without proximity. - if (bossEntityId > 0 && - TryResolveClientBossByEntityIdLocked(bossEntityId, state.Index, reservedMobs, out var identityBoss) && - identityBoss != null) - { - RememberClientBossEntityIdLocked(identityBoss, bossEntityId); - TryRebindTrackedMobSyncIdLocked(identityBoss, state.Index); - MobSyncTrace.LogBindSyncId( - "boss_identity_rebind", - state.Index, - state.Type ?? string.Empty, - state.X, - state.Y); - return identityBoss; - } - - // Unique authoritative boss (payload marked) when identity not yet learned. - if (TryResolveUniqueAuthoritativeBossLocked( - state.Type, - state.StatePayload, - reservedMobs, - bossEntityId, - out var authoritativeBoss, - out _) && - authoritativeBoss != null) - { - TryRebindTrackedMobSyncIdLocked(authoritativeBoss, state.Index); - if (bossEntityId > 0) - RememberClientBossEntityIdLocked(authoritativeBoss, bossEntityId); - MobSyncTrace.LogBindSyncId( - "boss_authoritative_state_repair", - state.Index, - state.Type ?? string.Empty, - state.X, - state.Y); - return authoritativeBoss; - } - - // One-shot unbound bind: type + spawn position. No ongoing proximity combat rebind. - if (TryBindUnboundMobByTypeAndSpawnLocked( - state.Index, - state.Type, - state.X, - state.Y, - reservedMobs, - out var unboundMob) && - unboundMob != null) - { - if (bossEntityId > 0) - RememberClientBossEntityIdLocked(unboundMob, bossEntityId); - MobSyncTrace.LogBindSyncId( - "state_oneshot_bind", - state.Index, - state.Type ?? string.Empty, - state.X, - state.Y); - return unboundMob; - } - - // Level.boss anchor for encounter bosses only (proximity-free, type-checked). - if (TryResolveLevelBossAnchorForStateLocked(state, reservedMobs, bossEntityId, out var anchoredBoss) && - anchoredBoss != null) - { - TryRebindTrackedMobSyncIdLocked(anchoredBoss, state.Index); - if (bossEntityId > 0) - RememberClientBossEntityIdLocked(anchoredBoss, bossEntityId); - MobSyncTrace.LogBindSyncId( - "level_boss_anchor", - state.Index, - state.Type ?? string.Empty, - state.X, - state.Y); - return anchoredBoss; - } - - // Last-resort client tombstone recovery: the host still owns this sync id and pushes - // states for it, but the local replica was removed without a confirmed death. Recreate - // the replica from the host's push data instead of dropping the state forever. - if (TryRecoverTombstonedSyncIdLocked( - state.Index, - state.Type, - state.X, - state.Y, - out var tombstoneRecovered) && - tombstoneRecovered != null) - { - if (bossEntityId > 0) - RememberClientBossEntityIdLocked(tombstoneRecovered, bossEntityId); - return tombstoneRecovered; - } - - return null; - } - - private static bool TryResolveLevelBossAnchorForStateLocked( - NetNode.MobStateSnapshot state, - HashSet? reservedMobs, - int bossEntityId, - out Mob? levelBoss) - { - levelBoss = null; - - try - { - var boss = currentLevel?.boss as Mob; - if (boss == null || boss.destroyed) - return false; - if (reservedMobs != null && reservedMobs.Contains(boss)) - return false; - if (!DoesMobMatchStateType(boss, state.Type)) - return false; - - // Identity gate: if both sides carry identities and they disagree, this state - // belongs to a different boss of the same type (duo arenas) — do not anchor. - if (bossEntityId > 0 && - s_clientEntityIdByBoss.TryGetValue(boss, out var knownId) && - knownId > 0 && - knownId != bossEntityId) - { - return false; - } - - // The anchor must have a registry home for the rebind to land in. - if (AddTrackedMobLocked(boss) < 0) - return false; - - levelBoss = boss; - return true; - } - catch - { - return false; - } - } - - private static bool TryResolveUniqueAuthoritativeBossLocked( - string? expectedType, - string? statePayload, - HashSet? reservedMobs, - int currentEntityId, - out Mob? uniqueBoss, - out int candidateCount) - { - uniqueBoss = null; - candidateCount = 0; - if (!BossStateSync.IsBossStatePayload(statePayload)) - return false; - - for (var i = 0; i < trackedMobs.Count; i++) - { - var candidate = trackedMobs[i]; - if (candidate == null || (reservedMobs != null && reservedMobs.Contains(candidate))) - continue; - if (!IsStateRebindCandidateLocked(candidate) || !BossSyncHelpers.IsBossMob(candidate)) - continue; - if (!DoesBossMatchAuthoritativeType(candidate, expectedType)) - continue; - // Phase 2: a boss already bound to a different, still-living identity is not a - // candidate for this (different) id. This keeps the fallback unambiguous when one - // boss of a duo rebuilds: only the unbound, newly rebuilt boss remains. - if (IsBossClaimedByOtherLivingEntityLocked(candidate, currentEntityId)) - continue; - - candidateCount++; - uniqueBoss = candidate; - if (candidateCount > 1) - { - uniqueBoss = null; - return false; - } - } - - return candidateCount == 1 && uniqueBoss != null; - } - - private static bool DoesBossMatchAuthoritativeType(Mob boss, string? expectedType) - { - if (boss == null || !BossSyncHelpers.IsBossMob(boss)) - return false; - if (string.IsNullOrWhiteSpace(expectedType) || DoesMobMatchStateType(boss, expectedType)) - return true; - - // A proxy class can legitimately change across a native boss phase. The stable mob - // type id is sufficient when it is present on both peers; otherwise require the class. - if (TrySplitStateTypeSignature(expectedType, out var expectedTypeId, out var expectedClass)) - { - var actualTypeId = GetMobTypeIdSafe(boss); - if (!string.IsNullOrWhiteSpace(expectedTypeId) && - !string.IsNullOrWhiteSpace(actualTypeId) && - string.Equals(expectedTypeId, actualTypeId, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - var actualClass = GetMobRuntimeClassKeySafe(boss); - return !string.IsNullOrWhiteSpace(expectedClass) && - !string.IsNullOrWhiteSpace(actualClass) && - string.Equals(expectedClass, actualClass, StringComparison.OrdinalIgnoreCase); - } - - var legacyExpected = NormalizeMobTypeKey(expectedType); - return string.Equals(legacyExpected, GetMobTypeIdSafe(boss), StringComparison.OrdinalIgnoreCase) || - string.Equals(legacyExpected, GetMobRuntimeClassKeySafe(boss), StringComparison.OrdinalIgnoreCase); - } - - private static bool IsBossRelatedEntity(string? type) - { - if (string.IsNullOrWhiteSpace(type)) - return false; - - var lowerType = type.ToLowerInvariant(); - return lowerType.Contains("tentacle") || - lowerType.Contains("claw") || - lowerType.Contains("hand") || - lowerType.Contains("eye") || - lowerType.Contains("scythe") || - lowerType.Contains("appendage") || - lowerType.Contains("proxy") || - lowerType.Contains("ttcl"); - } - - /// - /// Quantizes world coordinates to integer CELLS (floor(world / PixelsPerCase)). A mob's - /// cell is stable across host/client even when the peers' pixel coordinates drift by a - /// fraction of a tile (spawn snapping, gravity landing, interpolation: 1000.0 vs 1000.1 - /// both live in the same cell), so cells are the correct granularity for cross-peer - /// identity/position matching. The old int32-pixel quantization flipped at a 0.5px - /// boundary, which is far below the real host/client coordinate divergence. - /// - private static void QuantizeWorldPositionToCells(double x, double y, out int cx, out int cy) - { - if (!double.IsFinite(x) || !double.IsFinite(y)) - { - cx = 0; - cy = 0; - return; - } - - const double lim = int.MaxValue - 8; - cx = (int)System.Math.Clamp(System.Math.Floor(x / PixelsPerCase), -lim, lim); - cy = (int)System.Math.Clamp(System.Math.Floor(y / PixelsPerCase), -lim, lim); - } - - /// Computes a mob's integer cell from its live world position. - private static void GetMobWorldCells(Mob? mob, out int cx, out int cy) - { - if (mob == null) - { - cx = 0; - cy = 0; - return; - } - - try - { - QuantizeWorldPositionToCells(GetWorldX(mob), GetWorldY(mob), out cx, out cy); - } - catch - { - cx = 0; - cy = 0; - } - } - - private static Mob? ResolveTrackedMobForIncomingAttackLocked(NetNode.MobAttack attack) - { - var mappedMob = ResolveTrackedMobBySyncIdLocked(attack.Index); - var expectedType = attack.Type; - if (string.IsNullOrWhiteSpace(expectedType)) - hostMobTypeBySyncId.TryGetValue(attack.Index, out expectedType); - - if (mappedMob != null) - { - if (string.IsNullOrWhiteSpace(expectedType) || DoesMobMatchStateType(mappedMob, expectedType)) - return mappedMob; - - MobSyncTrace.LogIncomingMappingMismatch( - "attack", - attack.Index, - expectedType ?? string.Empty, - mappedMob != null ? BuildMobStateTypeSignature(mappedMob) : string.Empty, - "type_mismatch"); - InvalidateTrackedSyncCacheLocked(attack.Index, "attack_type_mismatch"); - } - - if (string.IsNullOrWhiteSpace(expectedType)) - return null; - - if (!string.IsNullOrWhiteSpace(attack.Type)) - hostMobTypeBySyncId[attack.Index] = attack.Type; - - if (TryResolveBossForIncomingAttackLocked( - attack, - expectedType, - out var authoritativeBoss, - out var bossCandidateCount) && - authoritativeBoss != null) - { - TryRebindTrackedMobSyncIdLocked(authoritativeBoss, attack.Index); - MobSyncTrace.LogBindSyncId( - "boss_attack_authoritative_repair", - attack.Index, - expectedType, - attack.X, - attack.Y); - return authoritativeBoss; - } - - if (bossCandidateCount > 1) - { - MobSyncTrace.LogAmbiguousMatchRejected( - "boss_attack", - attack.Index, - expectedType, - attack.X, - attack.Y, - bossCandidateCount); - } - - return null; - } - - private static bool TryResolveBossForIncomingAttackLocked( - NetNode.MobAttack attack, - string expectedType, - out Mob? resolvedBoss, - out int candidateCount) - { - resolvedBoss = null; - candidateCount = 0; - if (string.IsNullOrWhiteSpace(expectedType)) - return false; - - Mob? best = null; - var bestDistanceSq = double.MaxValue; - var secondDistanceSq = double.MaxValue; - - for (var i = 0; i < trackedMobs.Count; i++) - { - var candidate = trackedMobs[i]; - if (candidate == null || !IsStateRebindCandidateLocked(candidate) || - !BossSyncHelpers.IsBossMob(candidate) || - !DoesBossMatchAuthoritativeType(candidate, expectedType)) - { - continue; - } - - candidateCount++; - var dx = GetWorldX(candidate) - attack.X; - var dy = GetWorldY(candidate) - attack.Y; - var distanceSq = double.IsFinite(dx) && double.IsFinite(dy) - ? dx * dx + dy * dy - : double.MaxValue; - - if (distanceSq < bestDistanceSq) - { - secondDistanceSq = bestDistanceSq; - bestDistanceSq = distanceSq; - best = candidate; - } - else if (distanceSq < secondDistanceSq) - { - secondDistanceSq = distanceSq; - } - } - - if (best == null) - return false; - - if (candidateCount == 1) - { - resolvedBoss = best; - return true; - } - - var maxDistanceSq = ClientStateRebindMaxDistancePx * ClientStateRebindMaxDistancePx; - if (bestDistanceSq > maxDistanceSq || secondDistanceSq == double.MaxValue) - return false; - - // Boss parts (Giant hands/eye, Conjunctivius tentacles) cluster by identical type. - // Bind the nearest in-range candidate rather than rejecting the whole cluster on a - // small gap; the caller reserves each bound part before resolving the next, so the - // batch stays deterministic. Non-part bosses keep the strict gap. - if (IsBossRelatedEntity(expectedType)) - { - resolvedBoss = best; - return true; - } - - var bestDistance = System.Math.Sqrt(System.Math.Max(0.0, bestDistanceSq)); - var secondDistance = System.Math.Sqrt(System.Math.Max(0.0, secondDistanceSq)); - if (secondDistance - bestDistance < ClientStateRebindMinimumGapPx) - return false; - - resolvedBoss = best; - return true; - } - - private static void TryRebindTrackedMobSyncIdLocked(Mob mob, int syncId) - { - // NetId 0 is reserved / retired. Rebinding onto 0 reintroduces the syncId=0 type thrash - // seen on PrisonCourtyard (host ghost-echo + client state mismatch cascade). - if (mob == null || syncId <= 0) - return; - - if (!IsLevelIdentityReadyLocked(mob._level)) - return; - - var hadOldSyncId = MobToId.TryGetValue(mob, out var oldSyncId); - if (hadOldSyncId && oldSyncId >= 0 && oldSyncId != syncId) - { - LogRemoveAttemptLocked(mob, oldSyncId, "rebind_vacate_old_id"); - ClearPerSyncIdCachesLocked(oldSyncId); - } - - if (IdToMob.TryGetValue(syncId, out var displacedMob) && displacedMob != null && - !ReferenceEquals(displacedMob, mob)) - { - MobToId.Remove(displacedMob); - s_mobSyncAliases.Remove(displacedMob); - } - ClearPerSyncIdCachesLocked(syncId); - - if (mob != null) - { - if (MobToId.TryGetValue(mob, out var _oldId)) - IdToMob.Remove(_oldId); - IdToMob.Remove(syncId); - MobToId[mob] = syncId; - IdToMob[syncId] = mob; - s_mobSyncAliases.Remove(mob); - if (FindExactTrackedMobIndexLocked(mob) < 0) - { - trackedMobs.Add(mob); - trackedMobIndices[mob] = trackedMobs.Count - 1; - } - if (syncId >= nextRuntimeSyncId) - nextRuntimeSyncId = syncId + 1; - clientAuthoritativeStateSeenSyncIds.Add(syncId); - } - - ValidateTrackedIntegrityLocked("track_rebind"); - } - - private static bool IsStateRebindCandidateLocked(Mob? mob) - { - if (mob == null || !IsSyncMob(mob)) - return false; - - try - { - if (mob.destroyed || mob._level == null) - return false; - - if (!DoesLevelMatchCurrentIdentityLocked(mob._level)) - return false; - } - catch - { - return false; - } - - return true; - } - - private static string BuildMobStateTypeSignature(Mob mob) - { - var typeId = GetMobTypeIdSafe(mob); - var runtimeClass = GetMobRuntimeClassKeySafe(mob); - - if (!string.IsNullOrWhiteSpace(typeId) && !string.IsNullOrWhiteSpace(runtimeClass)) - { - return string.Create( - CultureInfo.InvariantCulture, - $"{typeId}|{runtimeClass}"); - } - - if (!string.IsNullOrWhiteSpace(typeId)) - return typeId; - - return runtimeClass; - } - - private static bool DoesMobMatchStateType(Mob? mob, string? stateType) - { - if (mob == null) - return false; - - if (string.IsNullOrWhiteSpace(stateType)) - return true; - - var actualType = GetMobTypeIdSafe(mob); - var actualClass = GetMobRuntimeClassKeySafe(mob); - - if (TrySplitStateTypeSignature(stateType, out var expectedType, out var expectedClass)) - { - var typeMatches = string.IsNullOrWhiteSpace(expectedType) || - (!string.IsNullOrWhiteSpace(actualType) && - string.Equals(expectedType, actualType, StringComparison.OrdinalIgnoreCase)); - - var classMatches = string.IsNullOrWhiteSpace(expectedClass) || - (!string.IsNullOrWhiteSpace(actualClass) && - string.Equals(expectedClass, actualClass, StringComparison.OrdinalIgnoreCase)); - - return typeMatches && classMatches; - } - - var legacyExpected = NormalizeMobTypeKey(stateType); - if (string.IsNullOrWhiteSpace(legacyExpected)) - return true; - - if (!string.IsNullOrWhiteSpace(actualType) && - string.Equals(legacyExpected, actualType, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (!string.IsNullOrWhiteSpace(actualClass) && - string.Equals(legacyExpected, actualClass, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - return false; - } - private static bool TryResolveSafeBossNemesisTarget(Mob? mob, Entity? requestedTarget, out Entity safeTarget) { safeTarget = null!; diff --git a/Mobs/MonsterSynchronization.ClientCombatReceive.cs b/Mobs/MonsterSynchronization.ClientCombatReceive.cs new file mode 100644 index 00000000..98c21dd9 --- /dev/null +++ b/Mobs/MonsterSynchronization.ClientCombatReceive.cs @@ -0,0 +1,480 @@ +using System.Globalization; +using dc; +using dc.en; +using dc.hl.types; +using dc.tool.atk; +using dc.tool.skill; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using DeadCellsMultiplayerMod.Mobs.Bosses; +using DeadCellsMultiplayerMod.Mobs.Levelinit; +using Hashlink.Virtuals; +using ModCore.Events; +using ModCore.Events.Interfaces.Game; +using ModCore.Utilities; +using Serilog; + +namespace DeadCellsMultiplayerMod.Mobs.MobsSynchronization +{ + public partial class MobsSynchronization + { + private static void ConsumeIncomingMobHits(NetNode net) + { + s_mobHitMergeScratch.Clear(); + if (net.TryConsumeMobHits(out var incoming) && incoming != null && incoming.Count > 0) + { + try + { + s_mobHitMergeScratch.AddRange(incoming); + } + finally + { + NetNode.ReleaseConsumedList(incoming); + } + } + + if (s_mobHitMergeScratch.Count == 0) + return; + + MobSyncTrace.LogRecvHits(net.IsHost ? "hitsOnHost" : "hitsOnClient", s_mobHitMergeScratch); + + if (IsSyncQuiescedForTransition()) + { + s_mobHitMergeScratch.Clear(); + return; + } + + ApplyIncomingMobHits(s_mobHitMergeScratch, 0, s_mobHitMergeScratch.Count, false); + } + + private static void ConsumeIncomingMobDies(NetNode net) + { + if (!net.TryConsumeMobDies(out var dies)) + return; + + try + { + MobSyncTrace.LogRecvDies(net.IsHost ? "diesOnHost" : "diesOnClient", dies); + + // Host is authoritative for mob death. Ignore remote client die packets. + if (net.IsHost) + return; + + if (IsSyncQuiescedForTransition()) + return; + + ApplyIncomingMobDies(dies); + } + finally + { + NetNode.ReleaseConsumedList(dies); + } + } + + private static void ApplyIncomingMobDies(IReadOnlyList dies) + { + if (dies == null || dies.Count == 0) + return; + + s_dieVictimsScratch.Clear(); + s_dieVictimDedupScratch.Clear(); + var rejectedGeneration = 0; + var rejectedCount = 0; + lock (Sync) + { + PruneInvalidTrackedMobsLocked(); + for (int i = 0; i < dies.Count; i++) + { + var die = dies[i]; + if (!ShouldAcceptPacketGenerationLocked(die.Generation, ref rejectedCount, ref rejectedGeneration)) + continue; + + // A host-confirmed death always wins over a dormant client tombstone: the + // identity is dead, never resurrected. Clear the tombstone so no later state/hit + // can recreate a replica for a mob the host has confirmed dead. + if (IsClient(LobbySession.NetRef) && HasClientMobTombstoneLocked(die.MobIndex)) + RemoveClientMobTombstoneLocked(die.MobIndex, "authoritative_mobdie"); + + var mob = ResolveMobFromDieLocked(die); + if (mob == null) + { + // A dropped boss death is what strands the client boss at 0/1 HP with a + // locked camera and no rewards. Buffer boss-typed packets for retry and + // bounded escalation instead of losing them. + RememberUnresolvedBossDieLocked(die); + continue; + } + + var isBoss = BossSyncHelpers.IsBossMob(mob); + if (isBoss && clientCompletedAuthoritativeBossDeaths.Contains(mob)) + continue; + var life = 0; + try + { + life = mob.life; + if (mob.destroyed) + continue; + } + catch + { + continue; + } + + // Hardening: do not ignore dead-but-not-destroyed mobs. This was a common + // source of client-side ghost elites: life already reached 0, HP bar disappeared, + // but onDie/despawn never ran locally. Let the authoritative MOBDIE packet finish cleanup. + if (!isBoss && life <= 0 && mob.destroyed) + continue; + + if (s_dieVictimDedupScratch.Add(mob)) + s_dieVictimsScratch.Add(mob); + } + } + + LogRejectedPacketGeneration("mobDie", rejectedCount, rejectedGeneration); + + s_dieVictimDedupScratch.Clear(); + + for (int i = 0; i < s_dieVictimsScratch.Count; i++) + { + var mob = s_dieVictimsScratch[i]; + if (mob == null) + continue; + + // Non-boss (client): DEFER to the mob's own update cycle instead of re-running + // onDie synchronously. The hardening that processed dead-but-not-destroyed mobs + // here re-killed mobs that had already died cleanly (host MOBDIE arriving after a + // local kill), corrupting their death state - the source of the level-transition + // render fatal. The deferred flush skips mobs that finish destroying themselves + // and only completes genuinely stuck ghosts. + if (!IsHost(LobbySession.NetRef) && !BossSyncHelpers.IsBossMob(mob) && TryDeferCulledClientMobDeath(mob)) + continue; + + TryWakeMobForForcedSimulation(mob); + try + { + RunWithAuthoritativeClientMobDie(mob, () => + { + RunWithSuppressedMobDieSend(() => + { + mob.life = 0; + mob.onDie(); + }); + }); + + if (BossSyncHelpers.IsBossMob(mob) && IsCompletedAuthoritativeBossDeath(mob)) + { + lock (Sync) + { + clientCompletedAuthoritativeBossDeaths.Add(mob); + s_clientBossAuthoritativeZeroLifeFrame.Remove(mob); + } + } + + } + catch + { + } + + } + + s_dieVictimsScratch.Clear(); + } + + private static bool IsCompletedAuthoritativeBossDeath(Mob mob) + { + if (mob == null) + return false; + + try + { + return mob.destroyed || mob.life <= 0; + } + catch + { + return true; + } + } + + private static void ApplyIncomingMobHits(IReadOnlyList hits, bool reResolveMobBySyncIdOnApply) + { + if (hits == null || hits.Count == 0) + return; + ApplyIncomingMobHits(hits, 0, hits.Count, reResolveMobBySyncIdOnApply); + } + + private static void ApplyIncomingMobHits(IReadOnlyList hits, int start, int count, bool reResolveMobBySyncIdOnApply) + { + if (hits == null || count <= 0) + return; + + var end = start + count; + if (start < 0 || end > hits.Count) + return; + + var net = LobbySession.NetRef; + var isHost = IsHost(net); + s_pendingMobHitAppliesScratch.Clear(); + var rejectedGeneration = 0; + var rejectedCount = 0; + + lock (Sync) + { + PruneInvalidTrackedMobsLocked(); + for (int i = start; i < end; i++) + { + var hit = hits[i]; + if (!ShouldAcceptPacketGenerationLocked(hit.Generation, ref rejectedCount, ref rejectedGeneration)) + continue; + + if (isHost && !IsKnownRemoteHitSenderOnHost(net, hit.UserId)) + continue; + + var mob = ResolveMobFromHitLocked(hit); + if (mob == null) + { + // This is where a client's damage is lost. The host already publishes an + // immediate reliable keyframe after every hit it DOES apply (below), so a + // client needing many times more hits than the host is this drop, not a + // missing HP broadcast. Count it so the rate is visible without per-packet spam. + MobSyncTrace.LogDamageDropped(isHost, hit.MobIndex, hit.UserId, hit.DamageHint); + continue; + } + + if (!TryGetMobLifeAndMaxSafe(mob, out var prevLife, out var maxLife)) + continue; + + var targetLife = System.Math.Clamp(hit.Hp, 0, maxLife); + var isBoss = BossSyncHelpers.IsBossMob(mob); + // A remote player's hit is an INPUT/intent, never authoritative HP. Replaying the + // reported damage through the host's native hit path preserves elite invulnerability, + // phase transitions, onDamage hooks and final death logic. The absolute HP field is + // retained only as a compatibility fallback for packets that carry no damage hint. + var replaySpecialHit = isHost && hit.DamageHint > 0.0; + if (replaySpecialHit) + targetLife = prevLife; + + // On a client, every hit in this queue came from the authoritative host, so its + // absolute result is applied even when it raises a speculative local value. On the + // host, an old/no-hint client packet can only lower HP and can never heal a mob. + if (isHost && !replaySpecialHit && targetLife >= prevLife) + { + replaySpecialHit = hit.DamageHint > 0.0 || ShouldReplayIncomingHitWithoutLifeDelta(mob); + if (!replaySpecialHit) + continue; + + targetLife = prevLife; + } + + var forceDie = targetLife <= 0 && prevLife > 0; + var syncId = -1; + TryGetMobSyncId(mob, out syncId); + MobSyncTrace.LogIncomingHitApply(syncId, hit.Hp, hit.UserId, replaySpecialHit, forceDie); + MobSyncTrace.LogDamageApplied(isHost, syncId, prevLife, targetLife, hit.DamageHint, replaySpecialHit); + s_pendingMobHitAppliesScratch.Add(new PendingMobHitApply( + mob, + hit.UserId, + prevLife, + targetLife, + maxLife, + forceDie, + syncId, + isBoss, + replaySpecialHit, + hit.DamageHint)); + } + } + + LogRejectedPacketGeneration(isHost ? "mobHitOnHost" : "mobHitOnClient", rejectedCount, rejectedGeneration); + + FlushGhostDespawnEchoes(net, isHost); + + for (int i = 0; i < s_pendingMobHitAppliesScratch.Count; i++) + { + var update = s_pendingMobHitAppliesScratch[i]; + Mob? mob; + if (reResolveMobBySyncIdOnApply && update.SyncId >= 0) + { + lock (Sync) + { + mob = ResolveMobBySyncIdLocked(update.SyncId); + } + } + else + { + mob = update.Mob; + } + + if (mob == null) + continue; + + if (isHost) + TryWakeMobForForcedSimulation(mob); + + var appliedLife = update.TargetLife; + if (update.ReplaySpecialHit) + { + // Boss phase scripts remain the one conservative exception: replaying a + // reconstructed hit in the middle of a queued boss skill can strand that script. + // Normal mobs and elites always use native host damage, even while attacking, so + // their armor/invulnerability/elite callbacks stay vanilla-authoritative. + if (isHost && update.IsBoss && HasLocalQueuedOrChargingSkill(mob)) + { + ApplyAuthoritativeLifeState(mob, update.TargetLife, update.TargetMaxLife); + appliedLife = GetMobLifeOrFallback(mob, update.TargetLife); + } + else + { + TryWakeMobForForcedSimulation(mob); + TryReplayIncomingSpecialHitReaction(mob, update.DamageHint); + appliedLife = GetMobLifeOrFallback(mob, update.TargetLife); + } + } + else if (update.ForceDie) + { + TryWakeMobForForcedSimulation(mob); + if (isHost) + { + if (update.IsBoss) + { + TryApplyHostBossFinishingHit(mob, update.TargetMaxLife); + } + else + { + try + { + if (!mob.destroyed) + { + mob.life = 0; + mob.onDie(); + } + else + { + mob.life = 0; + } + } + catch + { + } + } + + appliedLife = GetMobLifeOrFallback(mob, 0); + } + else + { + ApplyAuthoritativeLifeState(mob, 0, update.TargetMaxLife); + appliedLife = 0; + } + } + else + { + ApplyAuthoritativeLifeState(mob, update.TargetLife, update.TargetMaxLife); + appliedLife = GetMobLifeOrFallback(mob, update.TargetLife); + } + + if (isHost) + TryApplyHostMobHitCombatRefresh(mob, update.SourceUserId, update.PreviousLife, appliedLife, update.ReplaySpecialHit); + + if (isHost) + { + var mobStillPresent = false; + try { mobStillPresent = !mob.destroyed; } catch { } + if (mobStillPresent) + { + // Publish a reliable fully-typed keyframe immediately after any remote hit. + // Besides HP, this carries authoritative affects/elite phase metadata and + // repairs a stale binding without waiting for the periodic recovery pass. + QueueHostMobDirty(mob, HostMobDirtyFlags.State | HostMobDirtyFlags.ForceState); + } + } + + if (isHost && net != null && update.SyncId >= 0) + { + var destroyed = false; + try { destroyed = mob.destroyed; } catch { } + // A lethal native replay already emitted the authoritative death/tombstone. + // Do not read coordinates from a disposed proxy just to send a redundant HP=0. + if (!destroyed && TryGetCurrentLevelIdentityToken(out var identityToken)) + { + var sx = GetWorldX(mob); + var sy = GetWorldY(mob); + var dir = NormalizeDir(mob.dir); + var hitEv = $"hit|{appliedLife.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; + var mobType = BuildMobStateTypeSignature(mob); + var evUpdate = new NetNode.MobEventUpdate(update.SyncId, sx, sy, dir, SingleEvent(hitEv), mobType, identityToken); + MobSyncTrace.LogSendMobEvents(MobSyncNetRoleForTrace(net), SingleUpdate(evUpdate)); + net.SendMobEvents(SingleUpdate(evUpdate)); + } + } + } + + s_pendingMobHitAppliesScratch.Clear(); + } + + private static void TryApplyHostBossFinishingHit(Mob mob, int targetMaxLife) + { + if (mob == null) + return; + + var replayAttempted = false; + try + { + var damage = System.Math.Max(1.0, targetMaxLife * 8.0); + Hero? sourceHero = ModEntry.me ?? ModCore.Modules.Game.Instance?.HeroInstance; + try + { + if (sourceHero != null && sourceHero.destroyed) + sourceHero = null; + } + catch + { + sourceHero = null; + } + + var attackUtils = AttackUtils.Class; + var createFromHeroAndHit = attackUtils?.createFromHeroAndHit; + if (createFromHeroAndHit != null) + { + replayAttempted = true; + _ = createFromHeroAndHit(sourceHero, damage, null, mob); + if (TryFinalizeHostMobDeath(mob)) + return; + + // A boss that is alive after one valid finishing hit may have entered its next + // phase or a temporary invulnerability window. Never immediately hit it again + // or force life to zero; that skipped multi-phase vanilla boss logic. + if (GetMobLifeOrFallback(mob, 1) > 0) + return; + } + + var createFromHero = attackUtils?.createFromHero; + var hit = attackUtils?.hit; + if (!replayAttempted && createFromHero != null && hit != null) + { + var attack = createFromHero(sourceHero, damage, null); + if (attack != null) + { + replayAttempted = true; + hit(attack, mob); + if (TryFinalizeHostMobDeath(mob)) + return; + + if (GetMobLifeOrFallback(mob, 1) > 0) + return; + } + } + + if (replayAttempted || TryFinalizeHostMobDeath(mob)) + return; + + // Last resort only when this game/proxy revision exposes no valid attack helper. + mob.life = 0; + TryFinalizeHostMobDeath(mob); + } + catch (Exception ex) + { + Log.Warning(ex, "[MobsSync] Host boss finishing hit replay failed"); + } + } + + } +} diff --git a/Mobs/MonsterSynchronization.ClientReceive.cs b/Mobs/MonsterSynchronization.ClientReceive.cs index 6a08bc33..eb7f6fcb 100644 --- a/Mobs/MonsterSynchronization.ClientReceive.cs +++ b/Mobs/MonsterSynchronization.ClientReceive.cs @@ -1882,465 +1882,6 @@ private static void RegisterClientNetworkAttackExecuted(Mob mob) MarkClientNetworkAttackActive(mob); } - private static void ConsumeIncomingMobHits(NetNode net) - { - s_mobHitMergeScratch.Clear(); - if (net.TryConsumeMobHits(out var incoming) && incoming != null && incoming.Count > 0) - { - try - { - s_mobHitMergeScratch.AddRange(incoming); - } - finally - { - NetNode.ReleaseConsumedList(incoming); - } - } - - if (s_mobHitMergeScratch.Count == 0) - return; - - MobSyncTrace.LogRecvHits(net.IsHost ? "hitsOnHost" : "hitsOnClient", s_mobHitMergeScratch); - - if (IsSyncQuiescedForTransition()) - { - s_mobHitMergeScratch.Clear(); - return; - } - - ApplyIncomingMobHits(s_mobHitMergeScratch, 0, s_mobHitMergeScratch.Count, false); - } - - private static void ConsumeIncomingMobDies(NetNode net) - { - if (!net.TryConsumeMobDies(out var dies)) - return; - - try - { - MobSyncTrace.LogRecvDies(net.IsHost ? "diesOnHost" : "diesOnClient", dies); - - // Host is authoritative for mob death. Ignore remote client die packets. - if (net.IsHost) - return; - - if (IsSyncQuiescedForTransition()) - return; - - ApplyIncomingMobDies(dies); - } - finally - { - NetNode.ReleaseConsumedList(dies); - } - } - - private static void ApplyIncomingMobDies(IReadOnlyList dies) - { - if (dies == null || dies.Count == 0) - return; - - s_dieVictimsScratch.Clear(); - s_dieVictimDedupScratch.Clear(); - var rejectedGeneration = 0; - var rejectedCount = 0; - lock (Sync) - { - PruneInvalidTrackedMobsLocked(); - for (int i = 0; i < dies.Count; i++) - { - var die = dies[i]; - if (!ShouldAcceptPacketGenerationLocked(die.Generation, ref rejectedCount, ref rejectedGeneration)) - continue; - - // A host-confirmed death always wins over a dormant client tombstone: the - // identity is dead, never resurrected. Clear the tombstone so no later state/hit - // can recreate a replica for a mob the host has confirmed dead. - if (IsClient(LobbySession.NetRef) && HasClientMobTombstoneLocked(die.MobIndex)) - RemoveClientMobTombstoneLocked(die.MobIndex, "authoritative_mobdie"); - - var mob = ResolveMobFromDieLocked(die); - if (mob == null) - { - // A dropped boss death is what strands the client boss at 0/1 HP with a - // locked camera and no rewards. Buffer boss-typed packets for retry and - // bounded escalation instead of losing them. - RememberUnresolvedBossDieLocked(die); - continue; - } - - var isBoss = BossSyncHelpers.IsBossMob(mob); - if (isBoss && clientCompletedAuthoritativeBossDeaths.Contains(mob)) - continue; - var life = 0; - try - { - life = mob.life; - if (mob.destroyed) - continue; - } - catch - { - continue; - } - - // Hardening: do not ignore dead-but-not-destroyed mobs. This was a common - // source of client-side ghost elites: life already reached 0, HP bar disappeared, - // but onDie/despawn never ran locally. Let the authoritative MOBDIE packet finish cleanup. - if (!isBoss && life <= 0 && mob.destroyed) - continue; - - if (s_dieVictimDedupScratch.Add(mob)) - s_dieVictimsScratch.Add(mob); - } - } - - LogRejectedPacketGeneration("mobDie", rejectedCount, rejectedGeneration); - - s_dieVictimDedupScratch.Clear(); - - for (int i = 0; i < s_dieVictimsScratch.Count; i++) - { - var mob = s_dieVictimsScratch[i]; - if (mob == null) - continue; - - // Non-boss (client): DEFER to the mob's own update cycle instead of re-running - // onDie synchronously. The hardening that processed dead-but-not-destroyed mobs - // here re-killed mobs that had already died cleanly (host MOBDIE arriving after a - // local kill), corrupting their death state - the source of the level-transition - // render fatal. The deferred flush skips mobs that finish destroying themselves - // and only completes genuinely stuck ghosts. - if (!IsHost(LobbySession.NetRef) && !BossSyncHelpers.IsBossMob(mob) && TryDeferCulledClientMobDeath(mob)) - continue; - - TryWakeMobForForcedSimulation(mob); - try - { - RunWithAuthoritativeClientMobDie(mob, () => - { - RunWithSuppressedMobDieSend(() => - { - mob.life = 0; - mob.onDie(); - }); - }); - - if (BossSyncHelpers.IsBossMob(mob) && IsCompletedAuthoritativeBossDeath(mob)) - { - lock (Sync) - { - clientCompletedAuthoritativeBossDeaths.Add(mob); - s_clientBossAuthoritativeZeroLifeFrame.Remove(mob); - } - } - - } - catch - { - } - - } - - s_dieVictimsScratch.Clear(); - } - - private static bool IsCompletedAuthoritativeBossDeath(Mob mob) - { - if (mob == null) - return false; - - try - { - return mob.destroyed || mob.life <= 0; - } - catch - { - return true; - } - } - - private static void ApplyIncomingMobHits(IReadOnlyList hits, bool reResolveMobBySyncIdOnApply) - { - if (hits == null || hits.Count == 0) - return; - ApplyIncomingMobHits(hits, 0, hits.Count, reResolveMobBySyncIdOnApply); - } - - private static void ApplyIncomingMobHits(IReadOnlyList hits, int start, int count, bool reResolveMobBySyncIdOnApply) - { - if (hits == null || count <= 0) - return; - - var end = start + count; - if (start < 0 || end > hits.Count) - return; - - var net = LobbySession.NetRef; - var isHost = IsHost(net); - s_pendingMobHitAppliesScratch.Clear(); - var rejectedGeneration = 0; - var rejectedCount = 0; - - lock (Sync) - { - PruneInvalidTrackedMobsLocked(); - for (int i = start; i < end; i++) - { - var hit = hits[i]; - if (!ShouldAcceptPacketGenerationLocked(hit.Generation, ref rejectedCount, ref rejectedGeneration)) - continue; - - if (isHost && !IsKnownRemoteHitSenderOnHost(net, hit.UserId)) - continue; - - var mob = ResolveMobFromHitLocked(hit); - if (mob == null) - { - // This is where a client's damage is lost. The host already publishes an - // immediate reliable keyframe after every hit it DOES apply (below), so a - // client needing many times more hits than the host is this drop, not a - // missing HP broadcast. Count it so the rate is visible without per-packet spam. - MobSyncTrace.LogDamageDropped(isHost, hit.MobIndex, hit.UserId, hit.DamageHint); - continue; - } - - if (!TryGetMobLifeAndMaxSafe(mob, out var prevLife, out var maxLife)) - continue; - - var targetLife = System.Math.Clamp(hit.Hp, 0, maxLife); - var isBoss = BossSyncHelpers.IsBossMob(mob); - // A remote player's hit is an INPUT/intent, never authoritative HP. Replaying the - // reported damage through the host's native hit path preserves elite invulnerability, - // phase transitions, onDamage hooks and final death logic. The absolute HP field is - // retained only as a compatibility fallback for packets that carry no damage hint. - var replaySpecialHit = isHost && hit.DamageHint > 0.0; - if (replaySpecialHit) - targetLife = prevLife; - - // On a client, every hit in this queue came from the authoritative host, so its - // absolute result is applied even when it raises a speculative local value. On the - // host, an old/no-hint client packet can only lower HP and can never heal a mob. - if (isHost && !replaySpecialHit && targetLife >= prevLife) - { - replaySpecialHit = hit.DamageHint > 0.0 || ShouldReplayIncomingHitWithoutLifeDelta(mob); - if (!replaySpecialHit) - continue; - - targetLife = prevLife; - } - - var forceDie = targetLife <= 0 && prevLife > 0; - var syncId = -1; - TryGetMobSyncId(mob, out syncId); - MobSyncTrace.LogIncomingHitApply(syncId, hit.Hp, hit.UserId, replaySpecialHit, forceDie); - MobSyncTrace.LogDamageApplied(isHost, syncId, prevLife, targetLife, hit.DamageHint, replaySpecialHit); - s_pendingMobHitAppliesScratch.Add(new PendingMobHitApply( - mob, - hit.UserId, - prevLife, - targetLife, - maxLife, - forceDie, - syncId, - isBoss, - replaySpecialHit, - hit.DamageHint)); - } - } - - LogRejectedPacketGeneration(isHost ? "mobHitOnHost" : "mobHitOnClient", rejectedCount, rejectedGeneration); - - FlushGhostDespawnEchoes(net, isHost); - - for (int i = 0; i < s_pendingMobHitAppliesScratch.Count; i++) - { - var update = s_pendingMobHitAppliesScratch[i]; - Mob? mob; - if (reResolveMobBySyncIdOnApply && update.SyncId >= 0) - { - lock (Sync) - { - mob = ResolveMobBySyncIdLocked(update.SyncId); - } - } - else - { - mob = update.Mob; - } - - if (mob == null) - continue; - - if (isHost) - TryWakeMobForForcedSimulation(mob); - - var appliedLife = update.TargetLife; - if (update.ReplaySpecialHit) - { - // Boss phase scripts remain the one conservative exception: replaying a - // reconstructed hit in the middle of a queued boss skill can strand that script. - // Normal mobs and elites always use native host damage, even while attacking, so - // their armor/invulnerability/elite callbacks stay vanilla-authoritative. - if (isHost && update.IsBoss && HasLocalQueuedOrChargingSkill(mob)) - { - ApplyAuthoritativeLifeState(mob, update.TargetLife, update.TargetMaxLife); - appliedLife = GetMobLifeOrFallback(mob, update.TargetLife); - } - else - { - TryWakeMobForForcedSimulation(mob); - TryReplayIncomingSpecialHitReaction(mob, update.DamageHint); - appliedLife = GetMobLifeOrFallback(mob, update.TargetLife); - } - } - else if (update.ForceDie) - { - TryWakeMobForForcedSimulation(mob); - if (isHost) - { - if (update.IsBoss) - { - TryApplyHostBossFinishingHit(mob, update.TargetMaxLife); - } - else - { - try - { - if (!mob.destroyed) - { - mob.life = 0; - mob.onDie(); - } - else - { - mob.life = 0; - } - } - catch - { - } - } - - appliedLife = GetMobLifeOrFallback(mob, 0); - } - else - { - ApplyAuthoritativeLifeState(mob, 0, update.TargetMaxLife); - appliedLife = 0; - } - } - else - { - ApplyAuthoritativeLifeState(mob, update.TargetLife, update.TargetMaxLife); - appliedLife = GetMobLifeOrFallback(mob, update.TargetLife); - } - - if (isHost) - TryApplyHostMobHitCombatRefresh(mob, update.SourceUserId, update.PreviousLife, appliedLife, update.ReplaySpecialHit); - - if (isHost) - { - var mobStillPresent = false; - try { mobStillPresent = !mob.destroyed; } catch { } - if (mobStillPresent) - { - // Publish a reliable fully-typed keyframe immediately after any remote hit. - // Besides HP, this carries authoritative affects/elite phase metadata and - // repairs a stale binding without waiting for the periodic recovery pass. - QueueHostMobDirty(mob, HostMobDirtyFlags.State | HostMobDirtyFlags.ForceState); - } - } - - if (isHost && net != null && update.SyncId >= 0) - { - var destroyed = false; - try { destroyed = mob.destroyed; } catch { } - // A lethal native replay already emitted the authoritative death/tombstone. - // Do not read coordinates from a disposed proxy just to send a redundant HP=0. - if (!destroyed && TryGetCurrentLevelIdentityToken(out var identityToken)) - { - var sx = GetWorldX(mob); - var sy = GetWorldY(mob); - var dir = NormalizeDir(mob.dir); - var hitEv = $"hit|{appliedLife.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; - var mobType = BuildMobStateTypeSignature(mob); - var evUpdate = new NetNode.MobEventUpdate(update.SyncId, sx, sy, dir, SingleEvent(hitEv), mobType, identityToken); - MobSyncTrace.LogSendMobEvents(MobSyncNetRoleForTrace(net), SingleUpdate(evUpdate)); - net.SendMobEvents(SingleUpdate(evUpdate)); - } - } - } - - s_pendingMobHitAppliesScratch.Clear(); - } - - private static void TryApplyHostBossFinishingHit(Mob mob, int targetMaxLife) - { - if (mob == null) - return; - - var replayAttempted = false; - try - { - var damage = System.Math.Max(1.0, targetMaxLife * 8.0); - Hero? sourceHero = ModEntry.me ?? ModCore.Modules.Game.Instance?.HeroInstance; - try - { - if (sourceHero != null && sourceHero.destroyed) - sourceHero = null; - } - catch - { - sourceHero = null; - } - - var attackUtils = AttackUtils.Class; - var createFromHeroAndHit = attackUtils?.createFromHeroAndHit; - if (createFromHeroAndHit != null) - { - replayAttempted = true; - _ = createFromHeroAndHit(sourceHero, damage, null, mob); - if (TryFinalizeHostMobDeath(mob)) - return; - - // A boss that is alive after one valid finishing hit may have entered its next - // phase or a temporary invulnerability window. Never immediately hit it again - // or force life to zero; that skipped multi-phase vanilla boss logic. - if (GetMobLifeOrFallback(mob, 1) > 0) - return; - } - - var createFromHero = attackUtils?.createFromHero; - var hit = attackUtils?.hit; - if (!replayAttempted && createFromHero != null && hit != null) - { - var attack = createFromHero(sourceHero, damage, null); - if (attack != null) - { - replayAttempted = true; - hit(attack, mob); - if (TryFinalizeHostMobDeath(mob)) - return; - - if (GetMobLifeOrFallback(mob, 1) > 0) - return; - } - } - - if (replayAttempted || TryFinalizeHostMobDeath(mob)) - return; - - // Last resort only when this game/proxy revision exposes no valid attack helper. - mob.life = 0; - TryFinalizeHostMobDeath(mob); - } - catch (Exception ex) - { - Log.Warning(ex, "[MobsSync] Host boss finishing hit replay failed"); - } - } - private static bool TryFinalizeHostMobDeath(Mob mob) { if (mob == null) diff --git a/Mobs/MonsterSynchronization.RegistryCore.cs b/Mobs/MonsterSynchronization.RegistryCore.cs new file mode 100644 index 00000000..1fc3df23 --- /dev/null +++ b/Mobs/MonsterSynchronization.RegistryCore.cs @@ -0,0 +1,2152 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using dc; +using dc.en; +using dc.h2d; +using dc.libs.heaps.slib; +using dc.libs.heaps.slib._AnimManager; +using dc.pr; +using dc.tool.atk; +using dc.tool.skill; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using DeadCellsMultiplayerMod.Mobs.Bosses; +using Hashlink.Virtuals; +using ModCore.Events; +using ModCore.Events.Interfaces.Game; +using ModCore.Utilities; +using Serilog; + +namespace DeadCellsMultiplayerMod.Mobs.MobsSynchronization +{ + public partial class MobsSynchronization + { + private static bool RebuildMobArray(Level? level) + { + var candidateIdentityToken = ComputeLevelIdentityToken(level); + var candidateEntityCount = 0; + var candidateTrackedMobs = new List(); + var role = MobSyncNetRoleForTrace(LobbySession.NetRef); + var levelId = GetLevelTraceIdSafe(level); + var levelKey = GetLevelRuntimeKey(level); + if (level?.entities != null) + { + var entities = level.entities; + candidateEntityCount = entities.length; + if (candidateIdentityToken > 0) + { + for (int i = 0; i < entities.length; i++) + { + var mob = entities.getDyn(i) as Mob; + if (mob == null || !IsSyncMob(mob)) + continue; + + candidateTrackedMobs.Add(mob); + } + } + } + + var trackedBeforeReset = 0; + var trackedAfterRebuild = 0; + var rebuildAccepted = false; + var generationAfterRebuild = 0; + var rejectionReason = string.Empty; + var currentIdentityTokenBefore = 0; + var currentIdentityReadyBefore = false; + var currentLevelKeyBefore = string.Empty; + var lastResetLevelKeyBefore = string.Empty; + var lastResetTrackedCountBefore = 0; + var lastResetIdentityTokenBefore = 0; + var lastCommittedLevelKeyBefore = string.Empty; + var lastCommittedTrackedCountBefore = 0; + var lastCommittedIdentityTokenBefore = 0; + var baselineTrackedCount = 0; + var baselineSource = string.Empty; + var lastResetReasonBefore = string.Empty; + lock (Sync) + { + trackedBeforeReset = trackedMobs.Count; + currentIdentityTokenBefore = s_levelIdentityToken; + currentIdentityReadyBefore = s_levelIdentityReady; + currentLevelKeyBefore = GetLevelRuntimeKey(currentLevel); + lastResetLevelKeyBefore = GetLastResetLevelRuntimeKeyLocked(); + lastResetTrackedCountBefore = s_lastResetTrackedCount; + lastResetIdentityTokenBefore = s_lastResetIdentityToken; + lastCommittedLevelKeyBefore = GetLastCommittedLevelRuntimeKeyLocked(); + lastCommittedTrackedCountBefore = s_lastCommittedTrackedCount; + lastCommittedIdentityTokenBefore = s_lastCommittedIdentityToken; + lastResetReasonBefore = s_lastResetReason; + if (!ShouldAcceptRebuildCandidateLocked( + level, + candidateIdentityToken, + candidateEntityCount, + candidateTrackedMobs.Count, + out rejectionReason, + out baselineTrackedCount, + out baselineSource)) + { + trackedAfterRebuild = trackedMobs.Count; + generationAfterRebuild = s_levelIdentityGeneration; + } + else + { + ResetMobTrackingLocked("rebuild_prepare"); + currentLevel = level; + // Host-owned NetIds: only the host assigns identity. Clients track unbound + // locals and bind from MOBREG / first authoritative state (type + spawn). + AssignHostNetIdsForRebuildLocked(candidateTrackedMobs); + + trackedAfterRebuild = trackedMobs.Count; + s_levelIdentityToken = candidateIdentityToken; + s_levelIdentityReady = level != null && s_levelIdentityToken > 0; + if (s_levelIdentityReady) + s_levelIdentityGeneration++; + + generationAfterRebuild = s_levelIdentityGeneration; + rebuildAccepted = true; + RememberCommittedRebuildLocked(level, candidateIdentityToken, trackedAfterRebuild); + ValidateTrackedIntegrityLocked("rebuild"); + } + } + + MobSyncTrace.LogRebuildCandidate( + role, + levelId, + levelKey, + candidateEntityCount, + candidateTrackedMobs.Count, + candidateIdentityToken, + trackedBeforeReset, + currentIdentityTokenBefore, + currentLevelKeyBefore, + lastResetLevelKeyBefore, + lastResetTrackedCountBefore, + lastResetIdentityTokenBefore, + lastCommittedLevelKeyBefore, + lastCommittedTrackedCountBefore, + lastCommittedIdentityTokenBefore, + lastResetReasonBefore); + + MobSyncTrace.LogRebuildDecision( + role, + levelId, + levelKey, + rebuildAccepted ? "accepted" : "rejected", + rejectionReason, + trackedBeforeReset, + trackedAfterRebuild, + candidateEntityCount, + candidateTrackedMobs.Count, + baselineTrackedCount, + baselineSource, + currentIdentityReadyBefore, + currentIdentityTokenBefore, + candidateIdentityToken, + currentLevelKeyBefore, + lastResetLevelKeyBefore, + lastCommittedLevelKeyBefore, + lastResetReasonBefore); + + if (!rebuildAccepted) + { + MobSyncTrace.LogRebuildRejected( + rejectionReason, + role, + levelId, + trackedBeforeReset, + candidateEntityCount, + candidateTrackedMobs.Count, + currentIdentityTokenBefore, + candidateIdentityToken); + return false; + } + + lock (Sync) + { + s_batchMobsScratch.Clear(); + s_batchMobsScratch.AddRange(trackedMobs); + } + + var minSyncId = -1; + var maxSyncId = -1; + var registryCount = 0; + lock (Sync) + { + foreach (var pair in MobToId) + { + if (pair.Value <= 0) + continue; + registryCount++; + if (minSyncId < 0 || pair.Value < minSyncId) + minSyncId = pair.Value; + if (pair.Value > maxSyncId) + maxSyncId = pair.Value; + } + } + + MobSyncTrace.LogRegistryRebuild( + role, + levelId, + trackedBeforeReset, + trackedAfterRebuild, + registryCount, + minSyncId, + maxSyncId, + nextRuntimeSyncId, + generationAfterRebuild, + s_levelIdentityToken); + MobSyncTrace.LogRebuildCommit( + role, + levelId, + levelKey, + trackedAfterRebuild, + registryCount, + generationAfterRebuild, + s_levelIdentityToken); + + lock (Sync) + { + // The client may finish level loading a few frames after the host. Queue initial + // dirty states immediately, but also force short bootstrap full-resync bursts so + // the next-level mob table cannot be missed by a single early packet/chunk. + s_hostAuthoritativeBootstrapResyncsRemaining = trackedAfterRebuild > 0 + ? HostAuthoritativeBootstrapResyncCount + : 0; + s_lastHostBossReliableKeyframeFrame = -99999.0; + s_lastHostBossReliableKeyframeToken = s_levelIdentityToken; + s_lastHostAuthoritativeFullResyncFrame = -99999.0; + s_lastHostAuthoritativeFullResyncToken = s_levelIdentityToken; + } + + ClearSyncQuiesceAfterRebuild(); + QueueHostMobRegistryAfterRebuild(); + + for (int i = 0; i < s_batchMobsScratch.Count; i++) + QueueInitialMobSync(s_batchMobsScratch[i]); + + s_batchMobsScratch.Clear(); + return true; + } + + private static bool ShouldAcceptRebuildCandidateLocked( + Level? level, + int candidateIdentityToken, + int candidateEntityCount, + int candidateTrackedCount, + out string reason, + out int baselineTrackedCount, + out string baselineSource) + { + baselineTrackedCount = 0; + baselineSource = string.Empty; + + if (level == null) + { + reason = "level_null"; + return false; + } + + if (level.entities == null) + { + reason = "entities_missing"; + return false; + } + + if (candidateIdentityToken <= 0) + { + reason = "identity_invalid"; + return false; + } + + if (trackedMobs.Count > 0 && candidateTrackedCount <= 0) + { + reason = "replace_empty"; + return false; + } + + var candidateLevelId = GetLevelTraceIdSafe(level); + var currentLevelId = GetLevelTraceIdSafe(currentLevel); + var sameIdentity = currentLevel != null && + s_levelIdentityReady && + s_levelIdentityToken > 0 && + s_levelIdentityToken == candidateIdentityToken && + string.Equals(currentLevelId, candidateLevelId, StringComparison.Ordinal); + var sameLastCommittedIdentity = s_lastCommittedIdentityToken > 0 && + s_lastCommittedIdentityToken == candidateIdentityToken && + string.Equals(s_lastCommittedLevelId, candidateLevelId, StringComparison.Ordinal); + var replacingExplicitlyDisposedSameIdentityLevel = + sameLastCommittedIdentity && + trackedMobs.Count == 0 && + !s_levelIdentityReady && + currentLevel == null && + candidateTrackedCount > 0 && + (string.Equals(s_lastResetReason, "level_dispose_before_orig", StringComparison.Ordinal) || + string.Equals(s_lastResetReason, "level_dispose_after_orig", StringComparison.Ordinal)); + + if (sameIdentity && trackedMobs.Count > 0) + { + baselineTrackedCount = trackedMobs.Count; + baselineSource = "live"; + } + else if (sameLastCommittedIdentity && + s_lastCommittedTrackedCount > 0 && + !replacingExplicitlyDisposedSameIdentityLevel) + { + baselineTrackedCount = s_lastCommittedTrackedCount; + baselineSource = "last_commit"; + } + else if (replacingExplicitlyDisposedSameIdentityLevel) + { + // Boss-cell/main-level replacement can intentionally dispose the old Level + // and rebuild the same run/level identity with a different native Level object. + // The old last-commit count is not a valid completeness baseline here; rejecting + // this first non-empty registry leaves the client at zero tracked mobs forever. + baselineTrackedCount = 0; + baselineSource = "disposed_same_identity_replacement"; + } + + if (TryGetAuthoritativeGameplayLevel(out var authoritativeLevel, out _)) + { + var candidateMatchesAuthoritative = + DoesLevelMatchIdentity(level, candidateIdentityToken, authoritativeLevel); + var currentMatchesAuthoritative = + DoesLevelMatchIdentity(currentLevel, s_levelIdentityToken, authoritativeLevel); + var lastCommittedMatchesAuthoritative = + DoesStoredIdentityMatchLevel(s_lastCommittedLevelId, s_lastCommittedIdentityToken, authoritativeLevel); + + var authoritativeBaselineTrackedCount = 0; + var authoritativeBaselineSource = string.Empty; + if (currentMatchesAuthoritative && trackedMobs.Count > 0) + { + authoritativeBaselineTrackedCount = trackedMobs.Count; + authoritativeBaselineSource = "live_authoritative"; + } + else if (lastCommittedMatchesAuthoritative && + s_lastCommittedTrackedCount > 0 && + !replacingExplicitlyDisposedSameIdentityLevel) + { + authoritativeBaselineTrackedCount = s_lastCommittedTrackedCount; + authoritativeBaselineSource = "last_commit_authoritative"; + } + + if (authoritativeBaselineTrackedCount > baselineTrackedCount) + { + baselineTrackedCount = authoritativeBaselineTrackedCount; + baselineSource = authoritativeBaselineSource; + } + + // Do not let a side/stale level replace the live gameplay combat level with an empty tracked set. + if (authoritativeBaselineTrackedCount > 0 && + !candidateMatchesAuthoritative && + candidateTrackedCount <= 0) + { + reason = "non_active_level_rebuild"; + return false; + } + } + + if (replacingExplicitlyDisposedSameIdentityLevel) + { + if (candidateEntityCount <= 0) + { + reason = "disposed_same_identity_entities_empty"; + return false; + } + + reason = "accepted_disposed_same_identity_replacement"; + return true; + } + + if (baselineTrackedCount > 0) + { + if (candidateTrackedCount <= 0) + { + reason = "same_identity_empty"; + return false; + } + + if (candidateTrackedCount < baselineTrackedCount) + { + reason = "same_identity_partial"; + return false; + } + + if (candidateEntityCount <= 0) + { + reason = "same_identity_entities_empty"; + return false; + } + } + + reason = "accepted"; + return true; + } + + private static int AddTrackedMobLocked(Mob mob) + { + if (mob == null) + return -1; + + var existingIndex = FindTrackedMobIndexLocked(mob); + if (existingIndex >= 0) + return existingIndex; + + var syncId = -1; + if (TryGetMobSyncId(mob, out syncId) && TryGetTrackedMobBySyncIdLocked(syncId, out var existingMob) && existingMob != null) + { + existingIndex = FindExactTrackedMobIndexLocked(existingMob); + if (existingIndex < 0) + { + LogRemoveAttemptLocked(existingMob, syncId, "add_tracked_stale_forward"); + IdToMob.Remove(syncId); + } + else + { + // A second HaxeProxy wrapper may refer to the same native mob. Never replace the + // canonical tracked wrapper with that transient alias: unregistering the alias + // would then remove the real mob and every later hit becomes missing_sync_id. + if (!ReferenceEquals(existingMob, mob)) + { + s_mobSyncAliases.Remove(mob); + s_mobSyncAliases.Add(mob, new MobSyncAlias + { + SyncId = syncId, + Generation = s_levelIdentityGeneration + }); + } + ValidateTrackedIntegrityLocked("track_existing"); + return existingIndex; + } + } + + // Clients must not append an unbound transient proxy wrapper. Their sync ids come + // from the level registry/host; adding a wrapper with no id bloats trackedMobs and can + // later displace the canonical entry. Hosts may allocate ids for runtime spawns. + if (syncId < 0 && LobbySession.NetRef?.IsHost != true) + return -1; + + trackedMobs.Add(mob); + var addedIndex = trackedMobs.Count - 1; + trackedMobIndices[mob] = addedIndex; + if (syncId >= 0) + { + IdToMob[syncId] = mob; + MobToId[mob] = syncId; + } + ValidateTrackedIntegrityLocked("track_add"); + return addedIndex; + } + + private static void ResetMobTrackingLocked(string reason) + { + s_lastResetReason = reason ?? string.Empty; + s_lastResetLevelRef = currentLevel == null ? null : new WeakReference(currentLevel); + s_lastResetLevelId = GetLevelTraceIdSafe(currentLevel); + s_lastResetIdentityToken = s_levelIdentityToken; + s_lastResetTrackedCount = trackedMobs.Count; + MobSyncTrace.LogTrackingReset( + s_lastResetReason, + MobSyncNetRoleForTrace(LobbySession.NetRef), + GetLevelTraceIdSafe(currentLevel), + GetLevelRuntimeKey(currentLevel), + trackedMobs.Count, + s_levelIdentityReady, + s_levelIdentityToken, + GetLastResetLevelRuntimeKeyLocked(), + s_lastResetTrackedCount, + s_lastResetIdentityToken, + GetLastCommittedLevelRuntimeKeyLocked(), + s_lastCommittedTrackedCount, + s_lastCommittedIdentityToken); + ResetMobTrackingStateLocked(); + } + + internal static void ResetForFullGameDispose(string reason) + { + lock (Sync) + { + ResetMobTrackingLocked(string.IsNullOrWhiteSpace(reason) + ? "full_game_dispose" + : reason); + + // A synchronized restart may reuse the same run/level identity token. + // Previous-level duplicate protection must not reject the first, still + // partially constructed entity pass of the new Game instance. + s_lastCommittedLevelRef = null; + s_lastCommittedLevelId = string.Empty; + s_lastCommittedIdentityToken = 0; + s_lastCommittedTrackedCount = 0; + s_lastResetLevelRef = null; + s_lastResetLevelId = string.Empty; + s_lastResetIdentityToken = 0; + s_lastResetTrackedCount = 0; + s_lastIgnoredDuplicateLevelId = string.Empty; + s_lastIgnoredDuplicateIdentityToken = 0; + s_levelIdentityGeneration = 0; + } + + try { LobbySession.NetRef?.ClearMobSyncQueues(); } catch { } + } + + private static void ResetMobTrackingStateLocked() + { + trackedMobs.Clear(); + trackedMobIndices.Clear(); + IdToMob.Clear(); + MobToId.Clear(); + s_mobSyncAliases = new ConditionalWeakTable(); + ClearHostMobStallRecoveryLocked(); + ResetPlayerCombatStateRepairLocked(); + // nextRuntimeSyncId is deliberately NOT reset here. It is the host's NetId allocator and + // must stay monotonic for the whole session: the wire generation is a pure hash of map + // id + seed, so two rebuilds of the SAME level carry the same generation and restarting + // the counter would hand id 0..N to a different set of mobs while packets addressed to + // the previous set are still in flight. Monotonic ids make that mis-binding impossible; + // a stale id simply resolves to nothing instead of resolving to the wrong enemy. + s_pendingCulledMobDeaths.Clear(); + s_pendingCulledMobDeathFirstFrame.Clear(); + clientMobTargets.Clear(); + clientCachedAttackTargetByMob.Clear(); + clientQueuedOldSkillMarkers.Clear(); + hostLastSentContactTargetUserIdByMob.Clear(); + clientLastReportedMobLife.Clear(); + clientLastSentAffectPayloadBySyncId.Clear(); + clientLastSentDrawStateBySyncId.Clear(); + clientLastAppliedHostAffectPayloadBySyncId.Clear(); + clientLastAppliedHostAffectMobBySyncId.Clear(); + hostLastAppliedClientAffectPayloadBySyncId.Clear(); + hostClientOwnedAffectIdsByMob.Clear(); + clientLastAppliedAnimPayloadByMob.Clear(); + clientLastAnimationApplyFrameByMob.Clear(); + clientLastForcedBossAnimByMob.Clear(); + s_hostBossPartWatch.Clear(); + clientActiveNetworkAttackMobs.Clear(); + clientBossSkillCallbackLeaseMobs.Clear(); + clientNetworkAttackStartFrame.Clear(); + clientAiLockedMobs.Clear(); + clientPendingSuppressedBossDies.Clear(); + clientCompletedAuthoritativeBossDeaths.Clear(); + ResetBossDeathWatchdogStateLocked(); + ResetBossIdentityStateLocked(); + clientPendingSuppressedMobDies.Clear(); + clientAuthoritativeStateSeenSyncIds.Clear(); + parsedAnimPayloadCache.Clear(); + hostMobTypeBySyncId.Clear(); + ClearHostClientInterestLocked(); + hostLastSentMobStatesBySyncId.Clear(); + clientLastAcceptedHostPositionFrameBySyncId.Clear(); + s_lastHostActiveReliableKeyframeFrame = -99999.0; + s_lastHostActiveReliableKeyframeToken = 0; + s_lastHostBossReliableKeyframeFrame = -99999.0; + s_lastHostBossReliableKeyframeToken = 0; + s_ghostHitMissBySyncId.Clear(); + s_ghostHitMissGeneration = 0; + // Sync ids are re-issued per level, so a retained rate-limit entry would suppress the + // first reconcile for an unrelated mob in the new level. + s_lastHitReconcileTicksBySyncId.Clear(); + // Boss-part despawn watch is keyed by sync id, and sync ids are re-issued per level. + // Carrying entries across a level change let a stale watch for the OLD level fire a + // despawn MOBDIE against whatever now owns that id in the NEW level. (The client's + // generation fence rejected it, so it never killed anything - but the host was emitting + // bogus deaths and the watch grew without bound.) Its reset existed and was never + // called; this is that call site. + ResetHostBossPartWatchLocked(); + // Per-encounter boss arena state must not survive into the next level/fight. + BeholderArenaSync.Reset(); + s_hostDeathTombstonesBySyncId.Clear(); + s_clientMobTombstonesBySyncId.Clear(); + s_lastHostAuthoritativeFullResyncFrame = -99999.0; + s_lastHostAuthoritativeFullResyncToken = 0; + s_hostAuthoritativeBootstrapResyncsRemaining = 0; + s_lastHostMobRegistryToken = 0; + s_lastHostMobRegistrySendFrame = -99999.0; + s_hostMobRegistryResendsRemaining = 0; + hostDetectedTargets.Clear(); + + // Scratch collections can retain destroyed Haxe proxy references across levels when an + // exception interrupts a consume/send pass. They are not game state; always drop them. + s_clientDetectedTargetsScratch.Clear(); + s_batchMobsScratch.Clear(); + s_batchSnapshotsScratch.Clear(); + s_clientAffectAppliesScratch.Clear(); + s_hostStateAppliesScratch.Clear(); + s_pendingMobHitAppliesScratch.Clear(); + s_mobHitMergeScratch.Clear(); + clientPendingBossAttacks.Clear(); + s_resolvedClientBossAttacksScratch.Clear(); + s_drawsScratch.Clear(); + s_moveSnapshotsScratch.Clear(); + s_dieVictimsScratch.Clear(); + s_dieVictimDedupScratch.Clear(); + s_usedTrackedMobsScratch.Clear(); + s_latestPacketSyncIdsScratch.Clear(); + s_ghostDespawnEchoScratch.Clear(); + s_hostDeathTombstoneScratch.Clear(); + s_hostDeathTombstoneStateScratch.Clear(); + s_hostDeathTombstoneRemoveScratch.Clear(); + s_validationSeenMobsScratch.Clear(); + s_validationSeenSyncIdsScratch.Clear(); + + clientNetworkQueuedAttackDepth = 0; + clientNetworkQueuedAttackMob = null; + clientNetworkAttackReplayDepth = 0; + clientNetworkAttackReplayMob = null; + authoritativeClientBossDieDepth = 0; + authoritativeClientMobDieDepth = 0; + suppressClientAffectDirtyDepth = 0; + suppressMobDieSendDepth = 0; + suppressMobHitSendDepth = 0; + forceExactNemesisTargetDepth = 0; + + s_trackedMobValidationPending = true; + s_syncMobTypeCache.Clear(); + ClearQueuedDirtyStateLocked(); + currentLevel = null; + s_levelIdentityReady = false; + s_levelIdentityToken = 0; + s_lastIgnoredDuplicateLevelId = string.Empty; + s_lastIgnoredDuplicateIdentityToken = 0; + } + + private static bool IsLevelIdentityReadyLocked(Level? level) + { + return DoesLevelMatchCurrentIdentityLocked(level); + } + + private static bool DoesLevelMatchCurrentIdentityLocked(Level? level) + { + if (!s_levelIdentityReady || level == null || s_levelIdentityToken <= 0) + return false; + + if (currentLevel != null && ReferenceEquals(currentLevel, level)) + return true; + + var currentLevelId = GetLevelTraceIdSafe(currentLevel); + var candidateLevelId = GetLevelTraceIdSafe(level); + if (!string.IsNullOrEmpty(currentLevelId) && + !string.IsNullOrEmpty(candidateLevelId) && + !string.Equals(currentLevelId, candidateLevelId, StringComparison.Ordinal)) + { + return false; + } + + var candidateIdentityToken = ComputeLevelIdentityToken(level); + return candidateIdentityToken > 0 && candidateIdentityToken == s_levelIdentityToken; + } + + private static bool IsIncomingMobIdentityReady() + { + lock (Sync) + { + return s_levelIdentityReady && currentLevel != null && s_levelIdentityToken > 0; + } + } + + private static void RemoveTrackedMobLocked(Mob mob, string reason) + { + if (mob == null) + return; + + s_trackedMobValidationPending = true; + var mappedSyncId = -1; + if (MobToId.TryGetValue(mob, out var ownedSyncId)) + mappedSyncId = ownedSyncId; + LogRemoveAttemptLocked(mob, mappedSyncId, reason); + var index = FindExactTrackedMobIndexLocked(mob); + if (index >= 0) + { + RemoveTrackedMobAtIndexLocked(index, reason, alreadyLogged: true); + return; + } + + // Client-only: a canonical owner that is no longer in trackedMobs can still hold a + // live host-owned mapping. Capture the tombstone before wiping it. An alias whose + // MobToId is missing, or whose IdToMob points at a different wrapper, must not wipe + // the canonical identity. Host keeps the previous alias-only cleanup. + if (mappedSyncId > 0 && IsClient(LobbySession.NetRef)) + { + var forwardIsThis = IdToMob.TryGetValue(mappedSyncId, out var forwardMob) && + ReferenceEquals(forwardMob, mob); + var forwardMissing = !IdToMob.TryGetValue(mappedSyncId, out var existingForward) || + existingForward == null; + if (forwardIsThis || forwardMissing) + { + TryCaptureClientTombstoneForMappedMobLocked(mob, reason); + IdToMob.Remove(mappedSyncId); + MobToId.Remove(mob); + } + } + + // This can be a temporary managed wrapper for a still-live canonical native mob. Remove + // only alias-local caches; never remove IdToMob/MobToId owned by the canonical wrapper. + s_mobSyncAliases.Remove(mob); + clientMobTargets.Remove(mob); + clientCachedAttackTargetByMob.Remove(mob); + clientQueuedOldSkillMarkers.Remove(mob); + hostLastSentContactTargetUserIdByMob.Remove(mob); + clientLastReportedMobLife.Remove(mob); + clientLastAppliedAnimPayloadByMob.Remove(mob); + clientLastAnimationApplyFrameByMob.Remove(mob); + clientLastForcedBossAnimByMob.Remove(mob); + clientActiveNetworkAttackMobs.Remove(mob); + clientBossSkillCallbackLeaseMobs.Remove(mob); + clientNetworkAttackStartFrame.Remove(mob); + clientAiLockedMobs.Remove(mob); + } + + private static bool ShouldRetainMobSyncIdOnTemporaryUnregisterLocked(Level? level, Mob mob) + { + if (mob == null || level == null) + return false; + if (!MobToId.ContainsKey(mob)) + return false; + + try + { + if (mob.destroyed || mob.life <= 0) + return false; + if (!DoesLevelMatchCurrentIdentityLocked(level) || !DoesLevelMatchCurrentIdentityLocked(mob._level)) + return false; + } + catch + { + return false; + } + + return true; + } + + private static void DetachTrackedMobForTemporaryUnregisterLocked(Mob mob) + { + if (mob == null || !MobToId.TryGetValue(mob, out var syncId) || syncId < 0) + return; + + // A live mob can be unregistered briefly while sleeping, teleporting, transforming, + // or entering/leaving an elite phase. Removing IdToMob here made the peer's next hit + // fail with missing_sync_id even though MobToId still claimed that the mob owned the id. + // + // Keep the complete canonical mapping and tracked-list entry until the mob is actually + // destroyed, dies, changes level, or the level registry is rebuilt. Only transient + // attack/visual caches are cleared so an interrupted skill is not replayed forever. + IdToMob[syncId] = mob; + var index = FindExactTrackedMobIndexLocked(mob); + if (index < 0) + { + trackedMobs.Add(mob); + index = trackedMobs.Count - 1; + } + trackedMobIndices[mob] = index; + + s_mobSyncAliases.Remove(mob); + clientCachedAttackTargetByMob.Remove(mob); + clientQueuedOldSkillMarkers.Remove(mob); + hostLastSentContactTargetUserIdByMob.Remove(mob); + clientActiveNetworkAttackMobs.Remove(mob); + clientBossSkillCallbackLeaseMobs.Remove(mob); + clientNetworkAttackStartFrame.Remove(mob); + clientAiLockedMobs.Remove(mob); + s_trackedMobValidationPending = true; + + MobSyncTrace.LogBindSyncId( + "temporary_unregister_retained", + syncId, + BuildMobStateTypeSignature(mob), + GetWorldX(mob), + GetWorldY(mob)); + } + + private static int FindExactTrackedMobIndexLocked(Mob mob) + { + if (mob == null) + return -1; + + if (trackedMobIndices.TryGetValue(mob, out var directIndex)) + { + if (directIndex >= 0 && directIndex < trackedMobs.Count && ReferenceEquals(trackedMobs[directIndex], mob)) + return directIndex; + trackedMobIndices.Remove(mob); + } + + for (var i = 0; i < trackedMobs.Count; i++) + { + if (ReferenceEquals(trackedMobs[i], mob)) + { + trackedMobIndices[mob] = i; + return i; + } + } + + return -1; + } + + private static void RemoveTrackedMobAtIndexLocked(int index, string reason, bool alreadyLogged = false) + { + if (index < 0 || index >= trackedMobs.Count) + return; + + s_trackedMobValidationPending = true; + var mob = trackedMobs[index]; + if (!alreadyLogged) + { + var mappedSyncId = -1; + if (mob != null && MobToId.TryGetValue(mob, out var ownedSyncId)) + mappedSyncId = ownedSyncId; + LogRemoveAttemptLocked(mob, mappedSyncId, reason); + } + TryCaptureClientTombstoneForMappedMobLocked(mob, reason); + CleanupTrackedMobCachesLocked(mob); + if (mob != null) + { + if (MobToId.Remove(mob, out var _sid)) + IdToMob.Remove(_sid); + s_mobSyncAliases.Remove(mob); + trackedMobIndices.Remove(mob); + } + + var lastIndex = trackedMobs.Count - 1; + if (index != lastIndex) + { + var movedMob = trackedMobs[lastIndex]; + trackedMobs[index] = movedMob; + if (movedMob != null) + trackedMobIndices[movedMob] = index; + } + + trackedMobs.RemoveAt(lastIndex); + ValidateTrackedIntegrityLocked("track_remove"); + } + + private static void CleanupTrackedMobCachesLocked(Mob? mob) + { + if (mob == null) + return; + + clientPendingSuppressedBossDies.Remove(mob); + clientPendingSuppressedMobDies.Remove(mob); + try + { + if (mob.destroyed) + hostClientOwnedAffectIdsByMob.Remove(mob); + } + catch + { + } + trackedMobIndices.Remove(mob); + clientMobTargets.Remove(mob); + clientCachedAttackTargetByMob.Remove(mob); + clientQueuedOldSkillMarkers.Remove(mob); + hostLastSentContactTargetUserIdByMob.Remove(mob); + clientLastReportedMobLife.Remove(mob); + clientLastAppliedAnimPayloadByMob.Remove(mob); + clientLastAnimationApplyFrameByMob.Remove(mob); + clientLastForcedBossAnimByMob.Remove(mob); + clientActiveNetworkAttackMobs.Remove(mob); + clientBossSkillCallbackLeaseMobs.Remove(mob); + clientNetworkAttackStartFrame.Remove(mob); + clientAiLockedMobs.Remove(mob); + + // Cache cleanup must only clear a mob that owns this exact managed registration. A + // transient HaxeProxy wrapper must never delete the canonical entity's sync mapping. + if (!MobToId.TryGetValue(mob, out var syncId)) + return; + + ClearPerSyncIdCachesLocked(syncId); + } + + private static void ClearPerSyncIdCachesLocked(int syncId) + { + if (syncId < 0) + return; + + IdToMob.Remove(syncId); + RemoveHostMobStallRecoveryLocked(syncId); + clientLastSentAffectPayloadBySyncId.Remove(syncId); + clientLastSentDrawStateBySyncId.Remove(syncId); + clientLastAppliedHostAffectPayloadBySyncId.Remove(syncId); + clientLastAppliedHostAffectMobBySyncId.Remove(syncId); + hostLastAppliedClientAffectPayloadBySyncId.Remove(syncId); + hostMobTypeBySyncId.Remove(syncId); + hostClientInterestUsersBySyncId.Remove(syncId); + hostLastSentMobStatesBySyncId.Remove(syncId); + hostObservedMobStatesBySyncId.Remove(syncId); + hostDirtyFlagsBySyncId.Remove(syncId); + hostDirtyQueuedSyncIds.Remove(syncId); + clientAuthoritativeStateSeenSyncIds.Remove(syncId); + clientObservedDrawStateBySyncId.Remove(syncId); + clientDirtyFlagsBySyncId.Remove(syncId); + clientDirtyQueuedSyncIds.Remove(syncId); + } + + private static int FindTrackedMobIndexLocked(Mob mob) + { + if (mob == null || trackedMobs.Count == 0) + return -1; + + if (trackedMobIndices.TryGetValue(mob, out var directIndex)) + { + if (directIndex >= 0 && directIndex < trackedMobs.Count && ReferenceEquals(trackedMobs[directIndex], mob)) + return directIndex; + + trackedMobIndices.Remove(mob); + s_trackedMobValidationPending = true; + } + + // HaxeProxy can expose another managed wrapper for the same native mob. Match it by + // current level, runtime class and near-identical position. Unlike Mob.__uid, this does + // not alias every enemy of the same class. + // + // Crowded packs used to return -1 on any ambiguity, which made TryGetMobSyncId mint a + // SECOND NetId for the same native enemy (registry_duplicate_twin / missing_sync_id). + // Prefer an already-mapped candidate; only reject when several unmapped equals collide. + var matchedIndex = -1; + var matchedSyncId = int.MaxValue; + var matchedHasId = false; + var unmappedEquals = 0; + for (int i = 0; i < trackedMobs.Count; i++) + { + var candidate = trackedMobs[i]; + if (!AreLikelySameNativeMobProxy(candidate, mob)) + continue; + + var hasId = false; + var candidateSyncId = 0; + if (candidate != null && MobToId.TryGetValue(candidate, out candidateSyncId) && candidateSyncId > 0) + hasId = true; + if (hasId) + { + if (!matchedHasId || candidateSyncId < matchedSyncId) + { + matchedIndex = i; + matchedSyncId = candidateSyncId; + matchedHasId = true; + } + continue; + } + + if (!matchedHasId) + { + unmappedEquals++; + if (matchedIndex < 0) + matchedIndex = i; + } + } + + if (matchedIndex < 0) + return -1; + + // Multiple unmapped equals and no mapped owner — refuse rather than guess. + if (!matchedHasId && unmappedEquals > 1) + return -1; + + var canonical = trackedMobs[matchedIndex]; + if (canonical != null) + trackedMobIndices[canonical] = matchedIndex; + return matchedIndex; + } + + /// + /// Host-only: if any already-mapped tracked wrapper is the same native enemy, reuse that + /// NetId instead of minting a duplicate. Caller holds . + /// + private static bool TryAttachAliasToMappedNativeProxyLocked(Mob mob, out int syncId) + { + syncId = -1; + if (mob == null) + return false; + + Mob? best = null; + var bestSyncId = int.MaxValue; + for (var i = 0; i < trackedMobs.Count; i++) + { + var candidate = trackedMobs[i]; + if (candidate == null || ReferenceEquals(candidate, mob)) + continue; + if (!MobToId.TryGetValue(candidate, out var candidateSyncId) || candidateSyncId <= 0) + continue; + if (!AreLikelySameNativeMobProxy(candidate, mob)) + continue; + if (candidateSyncId >= bestSyncId) + continue; + + best = candidate; + bestSyncId = candidateSyncId; + } + + if (best == null || bestSyncId == int.MaxValue) + return false; + + syncId = bestSyncId; + s_mobSyncAliases.Remove(mob); + s_mobSyncAliases.Add(mob, new MobSyncAlias + { + SyncId = syncId, + Generation = s_levelIdentityGeneration + }); + return true; + } + + private static bool AreLikelySameNativeMobProxy(Mob? left, Mob? right) + { + if (left == null || right == null) + return false; + if (ReferenceEquals(left, right)) + return true; + + try + { + if (!DoesLevelMatchCurrentIdentityLocked(left._level) || + !DoesLevelMatchCurrentIdentityLocked(right._level)) + { + return false; + } + if (!string.Equals( + GetMobRuntimeClassKeySafe(left), + GetMobRuntimeClassKeySafe(right), + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var dx = GetWorldX(left) - GetWorldX(right); + var dy = GetWorldY(left) - GetWorldY(right); + if (double.IsFinite(dx) && double.IsFinite(dy) && dx * dx + dy * dy <= PixelsPerCase * PixelsPerCase) + return true; + + // Pixel distance alone can rule out two copies of the SAME native enemy when the + // peers' coordinates drift by a fraction of a tile (spawn snapping, gravity + // landing, interpolation: 1000.0 vs 1000.1). Same-cell is the tie-breaker. + GetMobWorldCells(left, out var lcx, out var lcy); + GetMobWorldCells(right, out var rcx, out var rcy); + return lcx == rcx && lcy == rcy; + } + catch + { + return false; + } + } + + private static bool TryGetTrackedMobBySyncIdLocked(int syncId, out Mob? mob) + { + mob = null; + if (syncId < 0) + return false; + + if (!IdToMob.TryGetValue(syncId, out var mappedMob) || mappedMob == null) + { + // Repair a one-sided registry entry. Earlier temporary-unregister handling could + // leave MobToId intact while removing IdToMob, which made every later client hit + // look like a missing sync id. Recover only a unique live same-level owner. + Mob? reverseCandidate = null; + var reverseCandidates = 0; + foreach (var pair in MobToId) + { + if (pair.Value != syncId || pair.Key == null) + continue; + if (!IsStateRebindCandidateLocked(pair.Key)) + continue; + + reverseCandidate = pair.Key; + reverseCandidates++; + if (reverseCandidates > 1) + break; + } + + if (reverseCandidates == 1 && reverseCandidate != null) + { + mappedMob = reverseCandidate; + IdToMob[syncId] = mappedMob; + var repairedIndex = FindExactTrackedMobIndexLocked(mappedMob); + if (repairedIndex < 0) + { + trackedMobs.Add(mappedMob); + repairedIndex = trackedMobs.Count - 1; + } + trackedMobIndices[mappedMob] = repairedIndex; + s_trackedMobValidationPending = true; + MobSyncTrace.LogBindSyncId( + "reverse_registry_repair", + syncId, + BuildMobStateTypeSignature(mappedMob), + GetWorldX(mappedMob), + GetWorldY(mappedMob)); + } + else + { + return false; + } + } + + if (mappedMob == null) + { + MobSyncTrace.LogStaleTrackedMapping(syncId, -1, "null_mob"); + LogRemoveAttemptLocked(null, syncId, "stale_null_mob"); + IdToMob.Remove(syncId); + s_trackedMobValidationPending = true; + return false; + } + + var localIndex = FindTrackedMobIndexLocked(mappedMob); + if (localIndex < 0) + { + // An alive mob can be temporarily unregistered/re-wrapped when sleeping, teleporting + // or changing elite phase. Keep its authoritative id and reattach the live object + // instead of pruning the mapping and allocating a replacement id. + if (IsStateRebindCandidateLocked(mappedMob)) + { + trackedMobs.Add(mappedMob); + localIndex = trackedMobs.Count - 1; + trackedMobIndices[mappedMob] = localIndex; + MobToId[mappedMob] = syncId; + s_trackedMobValidationPending = true; + MobSyncTrace.LogBindSyncId( + "reattach_live_mapping", + syncId, + BuildMobStateTypeSignature(mappedMob), + GetWorldX(mappedMob), + GetWorldY(mappedMob)); + } + else + { + MobSyncTrace.LogStaleTrackedMapping(syncId, localIndex, "untracked_mob"); + LogRemoveAttemptLocked(mappedMob, syncId, "stale_untracked_mob"); + IdToMob.Remove(syncId); + s_trackedMobValidationPending = true; + return false; + } + } + + var canonicalMob = trackedMobs[localIndex]; + if (canonicalMob == null) + { + LogRemoveAttemptLocked(null, syncId, "stale_canonical_null"); + IdToMob.Remove(syncId); + s_trackedMobValidationPending = true; + return false; + } + + if (!ReferenceEquals(mappedMob, canonicalMob)) + { + // Repair a wrapper swap without changing the native mob identity or sync id. + IdToMob[syncId] = canonicalMob; + MobToId[canonicalMob] = syncId; + mappedMob = canonicalMob; + } + + if (!MobToId.TryGetValue(mappedMob, out var mappedSyncId) || mappedSyncId != syncId) + { + // Two failure modes share this branch: + // * the reverse mapping (MobToId) was lost while the forward entry survived, or + // * the same enemy was rebound to a different id — host wrapper/proxy duplication + // hands one native mob two NetIds, so IdToMob[syncId] points at a mob whose + // MobToId now names another id. + // The old remove-and-fail turned both into dropped states and lost client damage, + // which is exactly the registry_mismatch / missing_sync_id churn. Heal instead. + + // If another live tracked mob genuinely owns this id, repair the forward entry to + // point at it (the previous owner is a stale wrapper of the same native enemy). + Mob? trueOwner = null; + var ownerCount = 0; + for (var i = 0; i < trackedMobs.Count; i++) + { + var candidate = trackedMobs[i]; + if (candidate == null || ReferenceEquals(candidate, mappedMob)) + continue; + if (MobToId.TryGetValue(candidate, out var candidateId) && + candidateId == syncId && + IsStateRebindCandidateLocked(candidate)) + { + trueOwner = candidate; + ownerCount++; + if (ownerCount > 1) + break; + } + } + + if (ownerCount == 1 && trueOwner != null) + { + IdToMob[syncId] = trueOwner; + s_trackedMobValidationPending = true; + MobSyncTrace.LogBindSyncId( + "registry_mismatch_repaired", + syncId, + BuildMobStateTypeSignature(trueOwner), + GetWorldX(trueOwner), + GetWorldY(trueOwner)); + mob = trueOwner; + return true; + } + + // The forward entry points at a mob that now owns a different id — usually a + // duplicate NetId minted for a second HaxeProxy wrapper of the same native enemy. + // Keep BOTH forward ids resolving to the canonical wrapper so peer hits addressed + // to the duplicate id still land. Dropping IdToMob[dup] was the missing_sync_id + // path for legitimate high syncIds after registry_alias. + if (IsStateRebindCandidateLocked(mappedMob)) + { + if (mappedSyncId <= 0) + { + IdToMob[syncId] = mappedMob; + MobToId[mappedMob] = syncId; + } + else + { + // Canonical reverse mapping stays on mappedSyncId; duplicate syncId only + // keeps a forward alias so old packets/hits still resolve. + IdToMob[syncId] = mappedMob; + IdToMob[mappedSyncId] = mappedMob; + MobToId[mappedMob] = mappedSyncId; + } + + s_trackedMobValidationPending = true; + MobSyncTrace.LogIncomingMappingMismatch( + "registry_alias", + syncId, + BuildMobStateTypeSignature(mappedMob), + string.Empty, + mappedSyncId > 0 ? $"aliased_to:{mappedSyncId}" : "reverse_mapping_lost"); + mob = mappedMob; + return true; + } + + MobSyncTrace.LogStaleTrackedMapping( + syncId, + localIndex, + mappedSyncId == syncId ? "registry_missing" : $"registry_mismatch:{mappedSyncId}"); + LogRemoveAttemptLocked( + mappedMob, + syncId, + mappedSyncId == syncId ? "stale_registry_missing" : "stale_registry_mismatch"); + IdToMob.Remove(syncId); + s_trackedMobValidationPending = true; + return false; + } + + mob = mappedMob; + return true; + } + + private static void InvalidateTrackedSyncCacheLocked(int syncId, string reason) + { + if (syncId < 0) + return; + + if (IdToMob.TryGetValue(syncId, out var mappedMob) && mappedMob != null) + { + MobSyncTrace.LogStaleTrackedMapping(syncId, FindTrackedMobIndexLocked(mappedMob), reason); + LogRemoveAttemptLocked(mappedMob, syncId, "invalidate:" + reason); + if (MobToId.TryGetValue(mappedMob, out var reverseSyncId) && reverseSyncId == syncId) + MobToId.Remove(mappedMob); + } + else + { + LogRemoveAttemptLocked(null, syncId, "invalidate:" + reason); + } + + IdToMob.Remove(syncId); + s_trackedMobValidationPending = true; + } + + private static void ValidateTrackedIntegrityLocked(string reason) + { + if (!MobSyncTrace.AssertEnabled) + return; + + s_validationSeenMobsScratch.Clear(); + s_validationSeenSyncIdsScratch.Clear(); + + for (int i = 0; i < trackedMobs.Count; i++) + { + var mob = trackedMobs[i]; + if (mob == null) + { + MobSyncTrace.LogInvariantViolation(reason, $"null tracked mob at localIndex={i}"); + continue; + } + + if (!s_validationSeenMobsScratch.Add(mob)) + MobSyncTrace.LogInvariantViolation(reason, $"duplicate tracked mob localIndex={i} type={BuildMobStateTypeSignature(mob)}"); + + if (!trackedMobIndices.TryGetValue(mob, out var directIndex) || directIndex != i) + MobSyncTrace.LogInvariantViolation(reason, $"trackedMobIndices mismatch localIndex={i} directIndex={directIndex}"); + + if (!MobToId.TryGetValue(mob, out var syncId)) + continue; + + if (!s_validationSeenSyncIdsScratch.Add(syncId)) + MobSyncTrace.LogInvariantViolation(reason, $"duplicate syncId among tracked mobs syncId={syncId} localIndex={i}"); + + if (!IdToMob.TryGetValue(syncId, out var mappedMob) || !ReferenceEquals(mappedMob, mob)) + MobSyncTrace.LogInvariantViolation(reason, $"IdToMob mismatch syncId={syncId} localIndex={i}"); + } + + // The first pass intentionally records every tracked mob/id. Start fresh before + // validating IdToMob itself; otherwise every valid dictionary entry is reported as a + // duplicate merely because it was already seen through trackedMobs. + s_validationSeenMobsScratch.Clear(); + s_validationSeenSyncIdsScratch.Clear(); + + foreach (var pair in IdToMob) + { + var syncId = pair.Key; + var mob = pair.Value; + if (mob == null) + { + MobSyncTrace.LogInvariantViolation(reason, $"IdToMob null mob syncId={syncId}"); + continue; + } + + if (!s_validationSeenSyncIdsScratch.Add(syncId)) + MobSyncTrace.LogInvariantViolation(reason, $"duplicate syncId in IdToMob syncId={syncId}"); + if (!s_validationSeenMobsScratch.Add(mob)) + MobSyncTrace.LogInvariantViolation(reason, $"mob mapped to multiple syncIds type={BuildMobStateTypeSignature(mob)}"); + + if (trackedMobIndices.TryGetValue(mob, out var localIndex) && + (localIndex < 0 || localIndex >= trackedMobs.Count || !ReferenceEquals(trackedMobs[localIndex], mob))) + { + MobSyncTrace.LogInvariantViolation(reason, $"IdToMob tracked index drift syncId={syncId} localIndex={localIndex}"); + } + + if (!MobToId.TryGetValue(mob, out var reverseSyncId) || reverseSyncId != syncId) + MobSyncTrace.LogInvariantViolation(reason, $"IdToMob reverse lookup mismatch syncId={syncId} reverseSyncId={reverseSyncId}"); + } + } + + private static void PruneInvalidTrackedMobsLocked() + { + if (trackedMobs.Count == 0) + return; + + if (!s_trackedMobValidationPending) + return; + + s_trackedMobValidationPending = false; + + for (int i = trackedMobs.Count - 1; i >= 0; i--) + { + var mob = trackedMobs[i]; + if (mob == null) + { + RemoveTrackedMobAtIndexLocked(i, "prune_null"); + continue; + } + + var shouldRemove = false; + try + { + // Do not prune by life<=0: some bosses spawn/transition with temporary zero life + // and must stay tracked to receive authoritative host life. + shouldRemove = mob.destroyed || mob._level == null; + } + catch + { + shouldRemove = true; + } + + if (!shouldRemove) + { + try + { + var mobLevel = mob._level; + shouldRemove = !DoesLevelMatchCurrentIdentityLocked(mobLevel); + } + catch + { + shouldRemove = true; + } + } + + if (shouldRemove) + RemoveTrackedMobAtIndexLocked(i, "prune_invalid"); + } + } + + private static bool IsSyncMob(Mob? mob) + { + if (!MultiplayerSettingsStorage.EnableMobsSync) + return false; + + if (mob == null) + return false; + + try + { + if (mob.destroyed || mob._level == null) + return false; + + if (BossSyncConstants.DisableBossSyncTemporarily && BossSyncHelpers.IsBossMob(mob)) + return false; + + // Primary rule: any combat-hostile mob (including bosses) must be synced. + if (IsMobHostileToPlayers(mob)) + return true; + + return IsSyncMobByType(mob); + } + catch + { + return false; + } + } + + private static bool IsSyncMobByType(Mob mob) + { + return s_syncMobTypeCache.GetOrAdd(mob.GetType(), static (System.Type t) => + { + var typeName = t.FullName ?? t.Name; + return typeName.Contains("dc.en.boss.", StringComparison.OrdinalIgnoreCase) + || typeName.Contains(".boss.", StringComparison.OrdinalIgnoreCase) + || typeName.Contains("dc.en.mob.", StringComparison.Ordinal) + || typeName.Contains(".Mob", StringComparison.Ordinal) + || typeName.Contains(".mob.", StringComparison.Ordinal); + }); + } + + private static void EnsureMobTracked(Mob mob) + { + if (!IsSyncMob(mob)) + return; + + var shouldQueueInitialSync = false; + lock (Sync) + { + var mobLevel = mob._level; + if (!IsLevelIdentityReadyLocked(mobLevel)) + return; + + if (FindTrackedMobIndexLocked(mob) >= 0) + return; + + if (mob != null) + { + shouldQueueInitialSync = AddTrackedMobLocked(mob) >= 0; + } + } + + if (shouldQueueInitialSync && mob != null) + QueueInitialMobSync(mob); + } + + private static bool TryGetMobSyncId(Mob mob, out int syncId) + { + syncId = -1; + if (!IsSyncMob(mob)) + return false; + + lock (Sync) + { + if (MobToId.TryGetValue(mob, out syncId)) + return true; + + if (s_mobSyncAliases.TryGetValue(mob, out var alias) && + alias.Generation == s_levelIdentityGeneration && + IdToMob.TryGetValue(alias.SyncId, out var aliasedCanonical) && + aliasedCanonical != null && + FindExactTrackedMobIndexLocked(aliasedCanonical) >= 0) + { + syncId = alias.SyncId; + return true; + } + + s_mobSyncAliases.Remove(mob); + var canonicalIndex = FindTrackedMobIndexLocked(mob); + if (canonicalIndex >= 0 && canonicalIndex < trackedMobs.Count) + { + var canonicalMob = trackedMobs[canonicalIndex]; + if (canonicalMob != null && MobToId.TryGetValue(canonicalMob, out syncId)) + { + if (!ReferenceEquals(canonicalMob, mob)) + { + s_mobSyncAliases.Add(mob, new MobSyncAlias + { + SyncId = syncId, + Generation = s_levelIdentityGeneration + }); + } + return true; + } + } + + if (!IsLevelIdentityReadyLocked(mob._level)) + return false; + + // Clients never invent NetIds — host is the sole authority (native ids diverge). + if (LobbySession.NetRef?.IsHost != true) + return false; + + // Never mint a second NetId for another HaxeProxy wrapper of the same native enemy. + // Duplicate ids are what produce registry_duplicate_twin on the client and then + // missing_sync_id hits once the host drops the alias forward map. + if (TryAttachAliasToMappedNativeProxyLocked(mob, out syncId)) + return true; + + if (nextRuntimeSyncId < 1) + nextRuntimeSyncId = 1; + syncId = nextRuntimeSyncId++; + MobToId[mob] = syncId; + IdToMob[syncId] = mob; + StampHostBossNetIdLocked(mob, syncId); + // Dynamic/runtime-spawned mobs must be in the canonical tracked list immediately; + // otherwise the first dirty packet creates an IdToMob entry that is rejected as + // untracked_mob on the next dequeue. + if (FindExactTrackedMobIndexLocked(mob) < 0) + { + trackedMobs.Add(mob); + trackedMobIndices[mob] = trackedMobs.Count - 1; + } + return true; + } + } + + private static Mob? ResolveTrackedMobBySyncIdLocked(int syncId) + { + if (syncId < 0) + return null; + + var hadIdToMob = IdToMob.TryGetValue(syncId, out var preMob) && preMob != null; + var hadMobToId = preMob != null && MobToId.TryGetValue(preMob, out var preRev) && preRev == syncId; + var preDestroyed = ReadMobDestroyedSafe(preMob); + + if (TryGetTrackedMobBySyncIdLocked(syncId, out var mappedMob) && mappedMob != null) + { + if (IsClient(LobbySession.NetRef) && + IsInvalidMappedReplicaLocked(mappedMob) && + MappingOwnsSyncIdLocked(mappedMob, syncId)) + { + RemoveTrackedMobLocked(mappedMob, "mapped_but_invalid"); + LogResolveFailLocked( + syncId, + hadIdToMob, + hadMobToId, + true, + "mapped_but_invalid"); + return null; + } + + LogFocusSyncLifecycleLocked(syncId, "RESOLVE_OK", "tracked"); + return mappedMob; + } + + if (!IdToMob.TryGetValue(syncId, out var mob) || mob == null || !IsSyncMob(mob)) + { + LogResolveFailLocked( + syncId, + hadIdToMob, + hadMobToId, + preDestroyed, + !hadIdToMob ? "missing_idtomob" : (mob == null ? "forward_null" : "not_sync_mob")); + return null; + } + + try + { + if (!DoesLevelMatchCurrentIdentityLocked(mob._level)) + { + LogResolveFailLocked(syncId, true, hadMobToId, ReadMobDestroyedSafe(mob), "level_mismatch"); + return null; + } + } + catch + { + LogResolveFailLocked(syncId, true, hadMobToId, true, "level_check_threw"); + return null; + } + + if (AddTrackedMobLocked(mob) >= 0) + { + LogFocusSyncLifecycleLocked(syncId, "RESOLVE_OK", "readded"); + return mob; + } + + LogResolveFailLocked(syncId, true, hadMobToId, ReadMobDestroyedSafe(mob), "add_tracked_failed"); + return null; + } + + private static Mob? ResolveTrackedMobForIncomingStateLocked(NetNode.MobStateSnapshot state, HashSet? reservedMobs) + { + // Boss identity (bid:) folded into NetId space; still used across phase/proxy rebuilds. + var bossEntityId = BossStateSync.TryGetEntityId(state.StatePayload); + + var mappedMob = ResolveTrackedMobBySyncIdLocked(state.Index); + if (mappedMob != null) + { + var reserved = reservedMobs != null && reservedMobs.Contains(mappedMob); + if (!reserved && DoesMobMatchStateType(mappedMob, state.Type)) + { + if (bossEntityId > 0) + RememberClientBossEntityIdLocked(mappedMob, bossEntityId); + return mappedMob; + } + + if (!reserved) + { + // NetId is the host-owned identity. Elite rune mobs can replace their native + // wrapper (and therefore their type/class signature) during promotion, phase + // setup, or the final death transition. Rejecting that packet invalidates the + // only binding we have and turns the next host life=0/MOBDIE into a permanent + // client ghost. The resolver already validated the generation, level and + // forward/reverse mapping, so preserve the binding and refresh the cached type. + // Do not use position here: a teleport/elite transform may legitimately move + // the same authoritative mob between packets. + if (IsStateRebindCandidateLocked(mappedMob) && + MappingOwnsSyncIdLocked(mappedMob, state.Index)) + { + if (!string.IsNullOrWhiteSpace(state.Type)) + hostMobTypeBySyncId[state.Index] = state.Type; + + MobSyncTrace.LogFallbackMatchResolved( + state.Life <= 0 + ? "state_type_mismatch_accepted_by_authoritative_id" + : "state_type_changed_preserved_authoritative_id", + state.Index, + state.Type ?? string.Empty, + state.X, + state.Y, + candidateCount: 1, + rebound: false); + if (bossEntityId > 0) + RememberClientBossEntityIdLocked(mappedMob, bossEntityId); + return mappedMob; + } + + InvalidateTrackedSyncCacheLocked(state.Index, "state_type_mismatch"); + MobSyncTrace.LogIncomingMappingMismatch( + "state", + state.Index, + state.Type ?? string.Empty, + mappedMob != null ? BuildMobStateTypeSignature(mappedMob) : string.Empty, + "type_mismatch"); + } + } + + // Boss phase/proxy rebuild: follow learned EntityId without proximity. + if (bossEntityId > 0 && + TryResolveClientBossByEntityIdLocked(bossEntityId, state.Index, reservedMobs, out var identityBoss) && + identityBoss != null) + { + RememberClientBossEntityIdLocked(identityBoss, bossEntityId); + TryRebindTrackedMobSyncIdLocked(identityBoss, state.Index); + MobSyncTrace.LogBindSyncId( + "boss_identity_rebind", + state.Index, + state.Type ?? string.Empty, + state.X, + state.Y); + return identityBoss; + } + + // Unique authoritative boss (payload marked) when identity not yet learned. + if (TryResolveUniqueAuthoritativeBossLocked( + state.Type, + state.StatePayload, + reservedMobs, + bossEntityId, + out var authoritativeBoss, + out _) && + authoritativeBoss != null) + { + TryRebindTrackedMobSyncIdLocked(authoritativeBoss, state.Index); + if (bossEntityId > 0) + RememberClientBossEntityIdLocked(authoritativeBoss, bossEntityId); + MobSyncTrace.LogBindSyncId( + "boss_authoritative_state_repair", + state.Index, + state.Type ?? string.Empty, + state.X, + state.Y); + return authoritativeBoss; + } + + // One-shot unbound bind: type + spawn position. No ongoing proximity combat rebind. + if (TryBindUnboundMobByTypeAndSpawnLocked( + state.Index, + state.Type, + state.X, + state.Y, + reservedMobs, + out var unboundMob) && + unboundMob != null) + { + if (bossEntityId > 0) + RememberClientBossEntityIdLocked(unboundMob, bossEntityId); + MobSyncTrace.LogBindSyncId( + "state_oneshot_bind", + state.Index, + state.Type ?? string.Empty, + state.X, + state.Y); + return unboundMob; + } + + // Level.boss anchor for encounter bosses only (proximity-free, type-checked). + if (TryResolveLevelBossAnchorForStateLocked(state, reservedMobs, bossEntityId, out var anchoredBoss) && + anchoredBoss != null) + { + TryRebindTrackedMobSyncIdLocked(anchoredBoss, state.Index); + if (bossEntityId > 0) + RememberClientBossEntityIdLocked(anchoredBoss, bossEntityId); + MobSyncTrace.LogBindSyncId( + "level_boss_anchor", + state.Index, + state.Type ?? string.Empty, + state.X, + state.Y); + return anchoredBoss; + } + + // Last-resort client tombstone recovery: the host still owns this sync id and pushes + // states for it, but the local replica was removed without a confirmed death. Recreate + // the replica from the host's push data instead of dropping the state forever. + if (TryRecoverTombstonedSyncIdLocked( + state.Index, + state.Type, + state.X, + state.Y, + out var tombstoneRecovered) && + tombstoneRecovered != null) + { + if (bossEntityId > 0) + RememberClientBossEntityIdLocked(tombstoneRecovered, bossEntityId); + return tombstoneRecovered; + } + + return null; + } + + private static bool TryResolveLevelBossAnchorForStateLocked( + NetNode.MobStateSnapshot state, + HashSet? reservedMobs, + int bossEntityId, + out Mob? levelBoss) + { + levelBoss = null; + + try + { + var boss = currentLevel?.boss as Mob; + if (boss == null || boss.destroyed) + return false; + if (reservedMobs != null && reservedMobs.Contains(boss)) + return false; + if (!DoesMobMatchStateType(boss, state.Type)) + return false; + + // Identity gate: if both sides carry identities and they disagree, this state + // belongs to a different boss of the same type (duo arenas) — do not anchor. + if (bossEntityId > 0 && + s_clientEntityIdByBoss.TryGetValue(boss, out var knownId) && + knownId > 0 && + knownId != bossEntityId) + { + return false; + } + + // The anchor must have a registry home for the rebind to land in. + if (AddTrackedMobLocked(boss) < 0) + return false; + + levelBoss = boss; + return true; + } + catch + { + return false; + } + } + + private static bool TryResolveUniqueAuthoritativeBossLocked( + string? expectedType, + string? statePayload, + HashSet? reservedMobs, + int currentEntityId, + out Mob? uniqueBoss, + out int candidateCount) + { + uniqueBoss = null; + candidateCount = 0; + if (!BossStateSync.IsBossStatePayload(statePayload)) + return false; + + for (var i = 0; i < trackedMobs.Count; i++) + { + var candidate = trackedMobs[i]; + if (candidate == null || (reservedMobs != null && reservedMobs.Contains(candidate))) + continue; + if (!IsStateRebindCandidateLocked(candidate) || !BossSyncHelpers.IsBossMob(candidate)) + continue; + if (!DoesBossMatchAuthoritativeType(candidate, expectedType)) + continue; + // Phase 2: a boss already bound to a different, still-living identity is not a + // candidate for this (different) id. This keeps the fallback unambiguous when one + // boss of a duo rebuilds: only the unbound, newly rebuilt boss remains. + if (IsBossClaimedByOtherLivingEntityLocked(candidate, currentEntityId)) + continue; + + candidateCount++; + uniqueBoss = candidate; + if (candidateCount > 1) + { + uniqueBoss = null; + return false; + } + } + + return candidateCount == 1 && uniqueBoss != null; + } + + private static bool DoesBossMatchAuthoritativeType(Mob boss, string? expectedType) + { + if (boss == null || !BossSyncHelpers.IsBossMob(boss)) + return false; + if (string.IsNullOrWhiteSpace(expectedType) || DoesMobMatchStateType(boss, expectedType)) + return true; + + // A proxy class can legitimately change across a native boss phase. The stable mob + // type id is sufficient when it is present on both peers; otherwise require the class. + if (TrySplitStateTypeSignature(expectedType, out var expectedTypeId, out var expectedClass)) + { + var actualTypeId = GetMobTypeIdSafe(boss); + if (!string.IsNullOrWhiteSpace(expectedTypeId) && + !string.IsNullOrWhiteSpace(actualTypeId) && + string.Equals(expectedTypeId, actualTypeId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var actualClass = GetMobRuntimeClassKeySafe(boss); + return !string.IsNullOrWhiteSpace(expectedClass) && + !string.IsNullOrWhiteSpace(actualClass) && + string.Equals(expectedClass, actualClass, StringComparison.OrdinalIgnoreCase); + } + + var legacyExpected = NormalizeMobTypeKey(expectedType); + return string.Equals(legacyExpected, GetMobTypeIdSafe(boss), StringComparison.OrdinalIgnoreCase) || + string.Equals(legacyExpected, GetMobRuntimeClassKeySafe(boss), StringComparison.OrdinalIgnoreCase); + } + + private static bool IsBossRelatedEntity(string? type) + { + if (string.IsNullOrWhiteSpace(type)) + return false; + + var lowerType = type.ToLowerInvariant(); + return lowerType.Contains("tentacle") || + lowerType.Contains("claw") || + lowerType.Contains("hand") || + lowerType.Contains("eye") || + lowerType.Contains("scythe") || + lowerType.Contains("appendage") || + lowerType.Contains("proxy") || + lowerType.Contains("ttcl"); + } + + /// + /// Quantizes world coordinates to integer CELLS (floor(world / PixelsPerCase)). A mob's + /// cell is stable across host/client even when the peers' pixel coordinates drift by a + /// fraction of a tile (spawn snapping, gravity landing, interpolation: 1000.0 vs 1000.1 + /// both live in the same cell), so cells are the correct granularity for cross-peer + /// identity/position matching. The old int32-pixel quantization flipped at a 0.5px + /// boundary, which is far below the real host/client coordinate divergence. + /// + private static void QuantizeWorldPositionToCells(double x, double y, out int cx, out int cy) + { + if (!double.IsFinite(x) || !double.IsFinite(y)) + { + cx = 0; + cy = 0; + return; + } + + const double lim = int.MaxValue - 8; + cx = (int)System.Math.Clamp(System.Math.Floor(x / PixelsPerCase), -lim, lim); + cy = (int)System.Math.Clamp(System.Math.Floor(y / PixelsPerCase), -lim, lim); + } + + /// Computes a mob's integer cell from its live world position. + private static void GetMobWorldCells(Mob? mob, out int cx, out int cy) + { + if (mob == null) + { + cx = 0; + cy = 0; + return; + } + + try + { + QuantizeWorldPositionToCells(GetWorldX(mob), GetWorldY(mob), out cx, out cy); + } + catch + { + cx = 0; + cy = 0; + } + } + + private static Mob? ResolveTrackedMobForIncomingAttackLocked(NetNode.MobAttack attack) + { + var mappedMob = ResolveTrackedMobBySyncIdLocked(attack.Index); + var expectedType = attack.Type; + if (string.IsNullOrWhiteSpace(expectedType)) + hostMobTypeBySyncId.TryGetValue(attack.Index, out expectedType); + + if (mappedMob != null) + { + if (string.IsNullOrWhiteSpace(expectedType) || DoesMobMatchStateType(mappedMob, expectedType)) + return mappedMob; + + MobSyncTrace.LogIncomingMappingMismatch( + "attack", + attack.Index, + expectedType ?? string.Empty, + mappedMob != null ? BuildMobStateTypeSignature(mappedMob) : string.Empty, + "type_mismatch"); + InvalidateTrackedSyncCacheLocked(attack.Index, "attack_type_mismatch"); + } + + if (string.IsNullOrWhiteSpace(expectedType)) + return null; + + if (!string.IsNullOrWhiteSpace(attack.Type)) + hostMobTypeBySyncId[attack.Index] = attack.Type; + + if (TryResolveBossForIncomingAttackLocked( + attack, + expectedType, + out var authoritativeBoss, + out var bossCandidateCount) && + authoritativeBoss != null) + { + TryRebindTrackedMobSyncIdLocked(authoritativeBoss, attack.Index); + MobSyncTrace.LogBindSyncId( + "boss_attack_authoritative_repair", + attack.Index, + expectedType, + attack.X, + attack.Y); + return authoritativeBoss; + } + + if (bossCandidateCount > 1) + { + MobSyncTrace.LogAmbiguousMatchRejected( + "boss_attack", + attack.Index, + expectedType, + attack.X, + attack.Y, + bossCandidateCount); + } + + return null; + } + + private static bool TryResolveBossForIncomingAttackLocked( + NetNode.MobAttack attack, + string expectedType, + out Mob? resolvedBoss, + out int candidateCount) + { + resolvedBoss = null; + candidateCount = 0; + if (string.IsNullOrWhiteSpace(expectedType)) + return false; + + Mob? best = null; + var bestDistanceSq = double.MaxValue; + var secondDistanceSq = double.MaxValue; + + for (var i = 0; i < trackedMobs.Count; i++) + { + var candidate = trackedMobs[i]; + if (candidate == null || !IsStateRebindCandidateLocked(candidate) || + !BossSyncHelpers.IsBossMob(candidate) || + !DoesBossMatchAuthoritativeType(candidate, expectedType)) + { + continue; + } + + candidateCount++; + var dx = GetWorldX(candidate) - attack.X; + var dy = GetWorldY(candidate) - attack.Y; + var distanceSq = double.IsFinite(dx) && double.IsFinite(dy) + ? dx * dx + dy * dy + : double.MaxValue; + + if (distanceSq < bestDistanceSq) + { + secondDistanceSq = bestDistanceSq; + bestDistanceSq = distanceSq; + best = candidate; + } + else if (distanceSq < secondDistanceSq) + { + secondDistanceSq = distanceSq; + } + } + + if (best == null) + return false; + + if (candidateCount == 1) + { + resolvedBoss = best; + return true; + } + + var maxDistanceSq = ClientStateRebindMaxDistancePx * ClientStateRebindMaxDistancePx; + if (bestDistanceSq > maxDistanceSq || secondDistanceSq == double.MaxValue) + return false; + + // Boss parts (Giant hands/eye, Conjunctivius tentacles) cluster by identical type. + // Bind the nearest in-range candidate rather than rejecting the whole cluster on a + // small gap; the caller reserves each bound part before resolving the next, so the + // batch stays deterministic. Non-part bosses keep the strict gap. + if (IsBossRelatedEntity(expectedType)) + { + resolvedBoss = best; + return true; + } + + var bestDistance = System.Math.Sqrt(System.Math.Max(0.0, bestDistanceSq)); + var secondDistance = System.Math.Sqrt(System.Math.Max(0.0, secondDistanceSq)); + if (secondDistance - bestDistance < ClientStateRebindMinimumGapPx) + return false; + + resolvedBoss = best; + return true; + } + + private static void TryRebindTrackedMobSyncIdLocked(Mob mob, int syncId) + { + // NetId 0 is reserved / retired. Rebinding onto 0 reintroduces the syncId=0 type thrash + // seen on PrisonCourtyard (host ghost-echo + client state mismatch cascade). + if (mob == null || syncId <= 0) + return; + + if (!IsLevelIdentityReadyLocked(mob._level)) + return; + + var hadOldSyncId = MobToId.TryGetValue(mob, out var oldSyncId); + if (hadOldSyncId && oldSyncId >= 0 && oldSyncId != syncId) + { + LogRemoveAttemptLocked(mob, oldSyncId, "rebind_vacate_old_id"); + ClearPerSyncIdCachesLocked(oldSyncId); + } + + if (IdToMob.TryGetValue(syncId, out var displacedMob) && displacedMob != null && + !ReferenceEquals(displacedMob, mob)) + { + MobToId.Remove(displacedMob); + s_mobSyncAliases.Remove(displacedMob); + } + ClearPerSyncIdCachesLocked(syncId); + + if (mob != null) + { + if (MobToId.TryGetValue(mob, out var _oldId)) + IdToMob.Remove(_oldId); + IdToMob.Remove(syncId); + MobToId[mob] = syncId; + IdToMob[syncId] = mob; + s_mobSyncAliases.Remove(mob); + if (FindExactTrackedMobIndexLocked(mob) < 0) + { + trackedMobs.Add(mob); + trackedMobIndices[mob] = trackedMobs.Count - 1; + } + if (syncId >= nextRuntimeSyncId) + nextRuntimeSyncId = syncId + 1; + clientAuthoritativeStateSeenSyncIds.Add(syncId); + } + + ValidateTrackedIntegrityLocked("track_rebind"); + } + + private static bool IsStateRebindCandidateLocked(Mob? mob) + { + if (mob == null || !IsSyncMob(mob)) + return false; + + try + { + if (mob.destroyed || mob._level == null) + return false; + + if (!DoesLevelMatchCurrentIdentityLocked(mob._level)) + return false; + } + catch + { + return false; + } + + return true; + } + + private static string BuildMobStateTypeSignature(Mob mob) + { + var typeId = GetMobTypeIdSafe(mob); + var runtimeClass = GetMobRuntimeClassKeySafe(mob); + + if (!string.IsNullOrWhiteSpace(typeId) && !string.IsNullOrWhiteSpace(runtimeClass)) + { + return string.Create( + CultureInfo.InvariantCulture, + $"{typeId}|{runtimeClass}"); + } + + if (!string.IsNullOrWhiteSpace(typeId)) + return typeId; + + return runtimeClass; + } + + private static bool DoesMobMatchStateType(Mob? mob, string? stateType) + { + if (mob == null) + return false; + + if (string.IsNullOrWhiteSpace(stateType)) + return true; + + var actualType = GetMobTypeIdSafe(mob); + var actualClass = GetMobRuntimeClassKeySafe(mob); + + if (TrySplitStateTypeSignature(stateType, out var expectedType, out var expectedClass)) + { + var typeMatches = string.IsNullOrWhiteSpace(expectedType) || + (!string.IsNullOrWhiteSpace(actualType) && + string.Equals(expectedType, actualType, StringComparison.OrdinalIgnoreCase)); + + var classMatches = string.IsNullOrWhiteSpace(expectedClass) || + (!string.IsNullOrWhiteSpace(actualClass) && + string.Equals(expectedClass, actualClass, StringComparison.OrdinalIgnoreCase)); + + return typeMatches && classMatches; + } + + var legacyExpected = NormalizeMobTypeKey(stateType); + if (string.IsNullOrWhiteSpace(legacyExpected)) + return true; + + if (!string.IsNullOrWhiteSpace(actualType) && + string.Equals(legacyExpected, actualType, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(actualClass) && + string.Equals(legacyExpected, actualClass, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + } +} From da8ecbdaf515d04d1c87b0a61030abfc5cb3a575 Mon Sep 17 00:00:00 2001 From: Alexey Date: Sun, 20 Sep 2026 14:28:34 +0700 Subject: [PATCH 4/7] reduce affect sync allocations --- Mobs/MonsterSynchronization.ClientReceive.cs | 23 ++++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/Mobs/MonsterSynchronization.ClientReceive.cs b/Mobs/MonsterSynchronization.ClientReceive.cs index eb7f6fcb..f5da457d 100644 --- a/Mobs/MonsterSynchronization.ClientReceive.cs +++ b/Mobs/MonsterSynchronization.ClientReceive.cs @@ -152,35 +152,40 @@ private static void ApplyClientOwnedAffectPayloadOnHost(int mobSyncId, Mob mob, return; var desired = ParseAffectStatePayload(payload); - HashSet previousOwned; + HashSet? previousOwned; lock (Sync) { - previousOwned = hostClientOwnedAffectIdsByMob.TryGetValue(mob, out var existing) - ? new HashSet(existing) - : new HashSet(); + hostClientOwnedAffectIdsByMob.TryGetValue(mob, out previousOwned); } - var nextOwned = new HashSet(); + // The common path is an unchanged or empty owned-affect set. Avoid copying two + // HashSets for every client state packet; the dictionary value is replaced, never + // mutated in place, so it is safe to inspect it outside the lock. + if (previousOwned == null || previousOwned.Count == 0) + return; + HashSet? nextOwned = null; foreach (var affectId in previousOwned) { if (desired.Contains(affectId)) - { - nextOwned.Add(affectId); continue; - } + nextOwned ??= new HashSet(previousOwned); try { mob.removeAllAffects(affectId); + nextOwned.Remove(affectId); } catch { // Keep ownership if removal failed so a later state can retry safely. - nextOwned.Add(affectId); } } + // No differences means no dictionary write and no allocation. + if (nextOwned == null) + return; + // Do not create new host affects from client presence reports. Client combat prediction // previously called setAffectS(..., 99999) here and permanently froze mobs after hits // during charge/attack (both peers). Host already applies damage via MOBHIT. From a33013fae7bd15d34c572c1bf1702443d5c5007c Mon Sep 17 00:00:00 2001 From: Alexey Date: Sun, 20 Sep 2026 14:34:10 +0700 Subject: [PATCH 5/7] throttle unchanged host state payload rebuilds --- Mobs/MonsterSynchronization.Constants.cs | 4 +++ Mobs/MonsterSynchronization.DirtyQueue.cs | 41 +++++++++++++++++++---- Mobs/MonsterSynchronization.HostSend.cs | 4 ++- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/Mobs/MonsterSynchronization.Constants.cs b/Mobs/MonsterSynchronization.Constants.cs index aa62daca..ec23017c 100644 --- a/Mobs/MonsterSynchronization.Constants.cs +++ b/Mobs/MonsterSynchronization.Constants.cs @@ -28,6 +28,10 @@ public partial class MobsSynchronization private const double MobHitMissingSyncIdRebindDistancePx = 24.0 * 48.0; /// How often the host catch-up pass covers remaining tracked mobs. private const double HostAuthoritativeFullResyncIntervalFrames = 45.0; + // A visible mob's affect/boss metadata is refreshed at a bounded cadence when no + // explicit dirty hook fired. This avoids rebuilding the same payload every frame while + // still converging phase changes within 100 ms at 30 FPS. + private const double HostStatePayloadRefreshIntervalFrames = 3.0; /// Reliable keyframe interval for mobs actively fighting or visible to either player. private const double HostActiveReliableKeyframeIntervalFrames = 8.0; /// Bosses get a tighter reliable state cadence without increasing traffic for every normal mob. diff --git a/Mobs/MonsterSynchronization.DirtyQueue.cs b/Mobs/MonsterSynchronization.DirtyQueue.cs index e6727f65..1bc79830 100644 --- a/Mobs/MonsterSynchronization.DirtyQueue.cs +++ b/Mobs/MonsterSynchronization.DirtyQueue.cs @@ -45,6 +45,7 @@ private readonly struct HostMobObservedState public readonly string MobType; public readonly string StatePayload; public readonly bool VisibleForSync; + public readonly double StatePayloadFrame; // The managed registration the state was observed from. Mob.type and the runtime class // are immutable for a mob instance, so while the same registration keeps the syncId we // reuse the previous MobType instead of rebuilding the signature every frame. @@ -60,6 +61,7 @@ public HostMobObservedState( string mobType, string statePayload, bool visibleForSync, + double statePayloadFrame, Mob mobRef) { X = x; @@ -71,6 +73,7 @@ public HostMobObservedState( MobType = mobType ?? string.Empty; StatePayload = statePayload ?? string.Empty; VisibleForSync = visibleForSync; + StatePayloadFrame = statePayloadFrame; MobRef = mobRef; } } @@ -88,6 +91,7 @@ public ClientDrawObservedState(bool isOutOfGame, bool isOnScreen) } private static readonly Dictionary hostObservedMobStatesBySyncId = new(); + private static readonly HashSet hostStatePayloadDirtySyncIds = new(); private static readonly Dictionary hostDirtyFlagsBySyncId = new(); private static readonly Queue hostDirtyMobQueue = new(); private static readonly HashSet hostDirtyQueuedSyncIds = new(); @@ -141,31 +145,43 @@ private static void ObserveHostMobForDirtyQueue(Mob mob) } var visibleForSync = IsMobOnScreenForSync(mob); + var observationFrame = GetCurrentFrame(mob); // Skip heavy state-payload builds for idle off-screen mobs. Anim still rebuilds whenever // the mob is visible so in-place attack cycles keep syncing. PrisonCourtyard (~100 // tracked) previously built BOTH payloads every postUpdate — main-thread stall, idle CPU. HostMobObservedState previous; var hasPrevious = false; + var payloadDirty = false; lock (Sync) { hasPrevious = hostObservedMobStatesBySyncId.TryGetValue(syncId, out previous); + payloadDirty = hostStatePayloadDirtySyncIds.Contains(syncId); } var needsAnim = visibleForSync; var needsStatePayload = true; + var lifeChanged = false; + var visibilityChanged = false; + var moveChanged = false; if (hasPrevious) { - var lifeChanged = life != previous.Life || maxLife != previous.MaxLife; - var visibilityChanged = visibleForSync != previous.VisibleForSync; - var moveChanged = visibleForSync && ( + lifeChanged = life != previous.Life || maxLife != previous.MaxLife; + visibilityChanged = visibleForSync != previous.VisibleForSync; + moveChanged = visibleForSync && ( !previous.VisibleForSync || !IsApproximatelyEqual(previous.X, x, MobStatePositionEpsilon) || !IsApproximatelyEqual(previous.Y, y, MobStatePositionEpsilon) || previous.Dir != dir); - // Off-screen and unchanged: reuse last state payload (keyframes still refresh). - if (!visibleForSync && !lifeChanged && !visibilityChanged && !moveChanged) + var refreshDue = observationFrame - previous.StatePayloadFrame >= HostStatePayloadRefreshIntervalFrames; + // Affect hooks mark the payload immediately. Otherwise visible metadata is rebuilt + // on a short cadence instead of once per mob per frame. Off-screen unchanged mobs + // continue to reuse the cached payload until the reliable resync path covers them. + needsStatePayload = payloadDirty || lifeChanged || visibilityChanged || + (visibleForSync && refreshDue); + + if (!visibleForSync && !lifeChanged && !visibilityChanged && !moveChanged && !payloadDirty) needsStatePayload = false; } @@ -205,14 +221,14 @@ private static void ObserveHostMobForDirtyQueue(Mob mob) if (visibleForSync) { - var moveChanged = + var snapshotMoveChanged = !previous.VisibleForSync || !IsApproximatelyEqual(previous.X, x, MobStatePositionEpsilon) || !IsApproximatelyEqual(previous.Y, y, MobStatePositionEpsilon) || previous.Dir != dir || !string.Equals(previous.AnimPayload, animPayload, StringComparison.Ordinal); - if (moveChanged) + if (snapshotMoveChanged) flags |= previous.VisibleForSync ? HostMobDirtyFlags.Move : HostMobDirtyFlags.ForceState; } else if (previous.VisibleForSync) @@ -231,8 +247,12 @@ private static void ObserveHostMobForDirtyQueue(Mob mob) mobType, statePayload, visibleForSync, + needsStatePayload ? observationFrame : previous.StatePayloadFrame, mob); + if (needsStatePayload) + hostStatePayloadDirtySyncIds.Remove(syncId); + if (flags != HostMobDirtyFlags.None) EnqueueHostMobDirtyLocked(syncId, flags); } @@ -963,6 +983,12 @@ private static void TryMarkMobAffectDirty(Entity? entity) var net = LobbySession.NetRef; if (IsHost(net)) { + if (TryGetMobSyncId(mob, out var syncId) && syncId > 0) + { + lock (Sync) + hostStatePayloadDirtySyncIds.Add(syncId); + } + QueueHostMobDirty(mob, HostMobDirtyFlags.State); return; } @@ -979,6 +1005,7 @@ private static void TryMarkMobAffectDirty(Entity? entity) private static void ClearQueuedDirtyStateLocked() { hostObservedMobStatesBySyncId.Clear(); + hostStatePayloadDirtySyncIds.Clear(); s_hostBossPartWatch.Clear(); hostDirtyFlagsBySyncId.Clear(); hostDirtyQueuedSyncIds.Clear(); diff --git a/Mobs/MonsterSynchronization.HostSend.cs b/Mobs/MonsterSynchronization.HostSend.cs index 7b1caf6b..5d4e476c 100644 --- a/Mobs/MonsterSynchronization.HostSend.cs +++ b/Mobs/MonsterSynchronization.HostSend.cs @@ -268,6 +268,7 @@ private static bool TryBuildHostMobDeltaSnapshot( var statePayload = string.Empty; HostMobObservedState observed = default; var hasObserved = false; + var payloadDirty = false; HostMobSentState previous; var hadPrevious = false; var ft = GetCurrentFrame(mob); @@ -277,10 +278,11 @@ private static bool TryBuildHostMobDeltaSnapshot( lock (Sync) { hasObserved = hostObservedMobStatesBySyncId.TryGetValue(mobSyncId, out observed); + payloadDirty = hostStatePayloadDirtySyncIds.Contains(mobSyncId); hadPrevious = hostLastSentMobStatesBySyncId.TryGetValue(mobSyncId, out previous); } - if (hasObserved) + if (hasObserved && !payloadDirty) { animPayload = prebuiltAnimPayload ?? observed.AnimPayload; mobType = observed.MobType; From 894b8d94c2fca5665a93183fd1c92651024e7a59 Mon Sep 17 00:00:00 2001 From: Alexey Date: Sun, 20 Sep 2026 14:43:33 +0700 Subject: [PATCH 6/7] cache repeated mob payload parsing and reflection --- Mobs/Bosses/BossReflection.cs | 111 ++++++++++++------- Mobs/MonsterSynchronization.ClientReceive.cs | 64 +++++++---- Mobs/MonsterSynchronization.cs | 5 + 3 files changed, 121 insertions(+), 59 deletions(-) diff --git a/Mobs/Bosses/BossReflection.cs b/Mobs/Bosses/BossReflection.cs index f176142b..d2a15b34 100644 --- a/Mobs/Bosses/BossReflection.cs +++ b/Mobs/Bosses/BossReflection.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; using System.Reflection; namespace DeadCellsMultiplayerMod.Mobs.Bosses; @@ -14,32 +16,41 @@ internal static class BossReflection private const BindingFlags Flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; + private sealed class MemberAccessorCache : ConcurrentDictionary + { + } + + private sealed class MemberAccessor + { + internal static readonly MemberAccessor Missing = new(null, null); + + internal readonly PropertyInfo? Property; + internal readonly FieldInfo? Field; + + internal MemberAccessor(PropertyInfo? property, FieldInfo? field) + { + Property = property; + Field = field; + } + } + + private static readonly ConditionalWeakTable MemberCaches = new(); + internal static object? TryReadMember(object? target, string name) { if (target == null || string.IsNullOrEmpty(name)) return null; - for (var t = target.GetType(); t != null; t = t.BaseType) + var accessor = ResolveAccessor(target.GetType(), name); + try + { + if (accessor.Property is { CanRead: true } property) + return property.GetValue(target); + if (accessor.Field != null) + return accessor.Field.GetValue(target); + } + catch { - try - { - var p = t.GetProperty(name, Flags); - if (p != null && p.CanRead) - return p.GetValue(target); - } - catch - { - } - - try - { - var f = t.GetField(name, Flags); - if (f != null) - return f.GetValue(target); - } - catch - { - } } return null; @@ -50,36 +61,58 @@ internal static bool TryWriteMember(object? target, string name, object? value) if (target == null || string.IsNullOrEmpty(name)) return false; - for (var t = target.GetType(); t != null; t = t.BaseType) + var accessor = ResolveAccessor(target.GetType(), name); + try { - try + if (accessor.Property is { CanWrite: true } property) { - var p = t.GetProperty(name, Flags); - if (p != null && p.CanWrite) - { - p.SetValue(target, CoerceValue(value, p.PropertyType)); - return true; - } + property.SetValue(target, CoerceValue(value, property.PropertyType)); + return true; } - catch + + if (accessor.Field != null) { + accessor.Field.SetValue(target, CoerceValue(value, accessor.Field.FieldType)); + return true; } + } + catch + { + } + + return false; + } - try + private static MemberAccessor ResolveAccessor(Type type, string name) + { + var cache = MemberCaches.GetOrCreateValue(type); + return cache.GetOrAdd(name, static (memberName, declaringType) => + { + for (var current = declaringType; current != null; current = current.BaseType) { - var f = t.GetField(name, Flags); - if (f != null) + try + { + var property = current.GetProperty(memberName, Flags); + if (property != null) + return new MemberAccessor(property, null); + } + catch + { + } + + try + { + var field = current.GetField(memberName, Flags); + if (field != null) + return new MemberAccessor(null, field); + } + catch { - f.SetValue(target, CoerceValue(value, f.FieldType)); - return true; } } - catch - { - } - } - return false; + return MemberAccessor.Missing; + }, type); } internal static int? TryReadInt(object? target, string name) diff --git a/Mobs/MonsterSynchronization.ClientReceive.cs b/Mobs/MonsterSynchronization.ClientReceive.cs index f5da457d..b3af1044 100644 --- a/Mobs/MonsterSynchronization.ClientReceive.cs +++ b/Mobs/MonsterSynchronization.ClientReceive.cs @@ -814,33 +814,57 @@ private static void ApplySyncedAffectPresence(Mob mob, int affectId) private static HashSet ParseAffectStatePayload(string? payload) { - var affects = new HashSet(); if (string.IsNullOrWhiteSpace(payload)) - return affects; + return new HashSet(); - var decoded = payload!; - try { decoded = Uri.UnescapeDataString(decoded); } catch { } - if (string.IsNullOrWhiteSpace(decoded)) - return affects; + // Do not retain unusually large/corrupt payloads. Normal affect signatures are short + // and repeat frequently, making this cache much cheaper than Split + HashSet per packet. + if (payload.Length <= 512) + { + lock (Sync) + { + if (s_affectPayloadParseCache.TryGetValue(payload, out var cached)) + return cached; + } + } - var parts = decoded.Split('.', StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < parts.Length; i++) + var affects = new HashSet(); + var decoded = payload; + try { decoded = Uri.UnescapeDataString(decoded); } catch { } + if (!string.IsNullOrWhiteSpace(decoded)) { - var token = parts[i]?.Trim(); - if (string.IsNullOrWhiteSpace(token)) - continue; + var parts = decoded.Split('.', StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < parts.Length; i++) + { + var token = parts[i]?.Trim(); + if (string.IsNullOrWhiteSpace(token)) + continue; - var idPart = token; - var separator = token.IndexOf(':'); - if (separator > 0) - idPart = token[..separator]; + var idPart = token; + var separator = token.IndexOf(':'); + if (separator > 0) + idPart = token[..separator]; - if (!int.TryParse(idPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id)) - continue; - if (id < 0) - continue; + if (int.TryParse(idPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id) && id >= 0) + affects.Add(id); + } + } - affects.Add(id); + if (payload.Length <= 512) + { + lock (Sync) + { + if (!s_affectPayloadParseCache.ContainsKey(payload)) + { + s_affectPayloadParseCache[payload] = affects; + s_affectPayloadParseCacheOrder.Enqueue(payload); + while (s_affectPayloadParseCacheOrder.Count > AffectPayloadParseCacheLimit) + { + var oldest = s_affectPayloadParseCacheOrder.Dequeue(); + s_affectPayloadParseCache.Remove(oldest); + } + } + } } return affects; diff --git a/Mobs/MonsterSynchronization.cs b/Mobs/MonsterSynchronization.cs index 85a2f17a..33829b02 100644 --- a/Mobs/MonsterSynchronization.cs +++ b/Mobs/MonsterSynchronization.cs @@ -65,6 +65,11 @@ private sealed class MobSyncAlias private static readonly Dictionary clientLastAppliedHostAffectPayloadBySyncId = new(); private static readonly Dictionary clientLastAppliedHostAffectMobBySyncId = new(); private static readonly Dictionary hostLastAppliedClientAffectPayloadBySyncId = new(); + // Affect payloads repeat heavily between state packets. Keep a small immutable-by-convention + // cache; callers only enumerate the returned sets and never mutate them. + private const int AffectPayloadParseCacheLimit = 256; + private static readonly Dictionary> s_affectPayloadParseCache = new(StringComparer.Ordinal); + private static readonly Queue s_affectPayloadParseCacheOrder = new(); // Affect ids that were actually created on the host from a client report. This prevents // a later client empty payload from pruning unrelated host/elite affects. private static readonly Dictionary> hostClientOwnedAffectIdsByMob = new(ReferenceEqualityComparer.Instance); From 57af5cfa16d8e5b5b11399b768f508008a127758 Mon Sep 17 00:00:00 2001 From: Alexey Date: Sun, 20 Sep 2026 14:46:01 +0700 Subject: [PATCH 7/7] bump mod version to 0.5.1 --- DeadCellsMultiplayerMod.csproj | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/DeadCellsMultiplayerMod.csproj b/DeadCellsMultiplayerMod.csproj index eb05f8ef..754f6f14 100644 --- a/DeadCellsMultiplayerMod.csproj +++ b/DeadCellsMultiplayerMod.csproj @@ -7,11 +7,11 @@ 7.0 Windows $(NoWarn);CA1416;CS0414;CS9107 - - 0.5.0 - 0.5.0 - 0.5.0 - 0.5.0-vanilla-host-authoritative-mobsync + + 0.5.1 + 0.5.1 + 0.5.1 + 0.5.1-vanilla-host-authoritative-mobsync mod DeadCellsMultiplayerMod.ModEntry