diff --git a/AdvancedCoop/CoopAdvancedHardening.cs b/AdvancedCoop/CoopAdvancedHardening.cs
index 0ee587a..3463b11 100644
--- a/AdvancedCoop/CoopAdvancedHardening.cs
+++ b/AdvancedCoop/CoopAdvancedHardening.cs
@@ -20,7 +20,8 @@
namespace DeadCellsMultiplayerMod.AdvancedCoop;
///
-/// Stability and progression layer built on top of the original multiplayer base.
+/// Lobby heartbeat and permanent unlock progression layer on top of the multiplayer base.
+/// This is not enemy/mob sync — combat entities are owned by MobsSynchronization.
/// It deliberately avoids constructing fake heroes/items during HeroInit.
///
public sealed class CoopAdvancedHardening :
diff --git a/FakeDeath/FakeDeath.Anchors.cs b/FakeDeath/FakeDeath.Anchors.cs
new file mode 100644
index 0000000..d32fd36
--- /dev/null
+++ b/FakeDeath/FakeDeath.Anchors.cs
@@ -0,0 +1,560 @@
+using System;
+using System.Diagnostics;
+using dc.en;
+using dc.tool.atk;
+using DeadCellsMultiplayerMod.Ghost.GhostBase;
+using ModCore.Modules;
+
+namespace DeadCellsMultiplayerMod
+{
+ public partial class ModEntry
+ {
+ // Keep enough grounded history to recover a corpse even after a long pit fall or a
+ // several-second trap sequence. The old 18-sample buffer only covered about two seconds,
+ // which was too short for many off-map deaths.
+ private const int SafeReviveAnchorHistorySize = 96;
+ private const double SafeReviveAnchorSampleSeconds = 0.12;
+ private const double SafeReviveAnchorPreferredAgeSeconds = 0.75;
+ private const double SafeReviveAnchorMaxAgeSeconds = 12.0;
+ private const double EnvironmentalDamageContextSeconds = 2.5;
+ // A sample recorded immediately before or during contact with spikes/lava is not safe.
+ // Quarantine new samples briefly after environmental damage and remove the recent tail.
+ private const double SafeAnchorEnvironmentalQuarantineSeconds = 2.5;
+ private const double SafeAnchorEnvironmentalPurgeSeconds = 2.25;
+ // Downed bodies should never remain at the lethal coordinate. Prefer an older point far
+ // enough away to be outside even a wide spike strip, then fall back to the oldest valid
+ // same-room anchor rather than the death position.
+ private const double HazardRecoveryPreferredAgeSeconds = 1.25;
+ private const double HazardRecoveryFallbackAgeSeconds = 2.25;
+ private const double HazardRecoveryMinDistancePx = 144.0;
+ private const double HazardRecoveryMinDistanceSq = HazardRecoveryMinDistancePx * HazardRecoveryMinDistancePx;
+
+ private readonly struct SafeReviveAnchorSample
+ {
+ public readonly double X;
+ public readonly double Y;
+ public readonly long Ticks;
+ public readonly string LevelId;
+
+ public SafeReviveAnchorSample(double x, double y, long ticks, string levelId)
+ {
+ X = x;
+ Y = y;
+ Ticks = ticks;
+ LevelId = levelId ?? string.Empty;
+ }
+ }
+
+ private readonly SafeReviveAnchorSample[] _safeReviveAnchors = new SafeReviveAnchorSample[SafeReviveAnchorHistorySize];
+ private int _safeReviveAnchorCount;
+ private int _safeReviveAnchorWriteIndex;
+ private long _nextSafeReviveAnchorSampleTicks;
+ private string _safeReviveAnchorLevelId = string.Empty;
+ private bool _hasSafeAnchorMotionProbe;
+ private double _lastSafeAnchorMotionProbeY;
+ private string _safeAnchorMotionProbeLevelId = string.Empty;
+ private bool _localDownedUsesRecoveryAnchor;
+ private bool _lastLocalDamageWasEnvironmental;
+ private long _lastLocalDamageContextTicks;
+
+
+ private void RecordLocalDamageContext(AttackData? attack)
+ {
+ var now = Stopwatch.GetTimestamp();
+ _lastLocalDamageContextTicks = now;
+ _lastLocalDamageWasEnvironmental = IsEnvironmentalDamageSource(attack);
+ if (_lastLocalDamageWasEnvironmental)
+ InvalidateRecentSafeReviveAnchors(now, SafeAnchorEnvironmentalPurgeSeconds);
+ }
+
+ private void InvalidateRecentSafeReviveAnchors(long now, double seconds)
+ {
+ if (_safeReviveAnchorCount <= 0 || seconds <= 0.0)
+ return;
+
+ var cutoffTicks = now - (long)(Stopwatch.Frequency * seconds);
+ for (var i = 0; i < _safeReviveAnchors.Length; i++)
+ {
+ var sample = _safeReviveAnchors[i];
+ if (sample.Ticks > 0 && sample.Ticks >= cutoffTicks)
+ _safeReviveAnchors[i] = default;
+ }
+ }
+
+ private static bool IsEnvironmentalDamageSource(AttackData? attack)
+ {
+ if (attack == null)
+ return true;
+
+ dc.Entity? source = null;
+ try { source = attack.source; } catch { }
+ if (source == null)
+ return true;
+ if (source is Mob || source is Hero || source is GhostKing)
+ return false;
+
+ try
+ {
+ var name = source.GetType().Name ?? string.Empty;
+ if (name.IndexOf("Bullet", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ name.IndexOf("Projectile", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ name.IndexOf("Arrow", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ name.IndexOf("Shot", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return false;
+ }
+ }
+ catch
+ {
+ }
+
+ return true;
+ }
+
+ private void UpdateLocalSafeReviveAnchor(Hero? hero)
+ {
+ if (hero == null || _localFakeDead || _localDeathConversionInProgress)
+ return;
+
+ try
+ {
+ if (hero.destroyed || hero.life <= 0 || hero._level == null || hero.spr == null)
+ return;
+ if (hero.isOutOfGame)
+ return;
+ }
+ catch
+ {
+ return;
+ }
+
+ var level = GetCurrentLevelId();
+ if (string.IsNullOrWhiteSpace(level))
+ return;
+ if (!string.Equals(_safeReviveAnchorLevelId, level, StringComparison.Ordinal))
+ ResetSafeReviveAnchorHistory(level);
+
+ var now = Stopwatch.GetTimestamp();
+ if (_lastLocalDamageWasEnvironmental && _lastLocalDamageContextTicks > 0 &&
+ now - _lastLocalDamageContextTicks <
+ (long)(Stopwatch.Frequency * SafeAnchorEnvironmentalQuarantineSeconds))
+ {
+ return;
+ }
+
+ if (_nextSafeReviveAnchorSampleTicks != 0 && now < _nextSafeReviveAnchorSampleTicks)
+ return;
+ _nextSafeReviveAnchorSampleTicks = now +
+ (long)(Stopwatch.Frequency * SafeReviveAnchorSampleSeconds);
+
+ try
+ {
+ var verticalMotion = Math.Abs(hero.dy) + Math.Abs(hero.bdy);
+ if (!double.IsFinite(verticalMotion) || verticalMotion > 0.45)
+ return;
+ }
+ catch
+ {
+ }
+
+ double x;
+ double y;
+ if (!TryGetHeroLogicalPixelPosition(hero, out x, out y))
+ {
+ try
+ {
+ x = hero.get_targetSprPosX();
+ y = hero.get_targetSprPosY();
+ }
+ catch
+ {
+ try
+ {
+ x = hero.spr?.x ?? 0.0;
+ y = hero.spr?.y ?? 0.0;
+ }
+ catch
+ {
+ return;
+ }
+ }
+ }
+
+ if (!TryProjectHeroPositionToSafeGround(hero, x, y, out var safeX, out var safeY))
+ return;
+
+ // Do not certify positions while a platform/elevator is carrying the hero vertically.
+ // Hero.dy can remain near zero on moving platforms, so compare logical floor Y across
+ // samples as a second independent stability check. The first stable probe after any
+ // vertical movement is only observed; the following stable probe becomes an anchor.
+ if (!_hasSafeAnchorMotionProbe ||
+ !string.Equals(_safeAnchorMotionProbeLevelId, level, StringComparison.Ordinal))
+ {
+ _hasSafeAnchorMotionProbe = true;
+ _lastSafeAnchorMotionProbeY = safeY;
+ _safeAnchorMotionProbeLevelId = level;
+ return;
+ }
+
+ var floorDeltaY = Math.Abs(safeY - _lastSafeAnchorMotionProbeY);
+ _lastSafeAnchorMotionProbeY = safeY;
+ if (!double.IsFinite(floorDeltaY) || floorDeltaY > 2.0)
+ return;
+
+ _safeReviveAnchors[_safeReviveAnchorWriteIndex] =
+ new SafeReviveAnchorSample(safeX, safeY, now, level);
+ _safeReviveAnchorWriteIndex = (_safeReviveAnchorWriteIndex + 1) % SafeReviveAnchorHistorySize;
+ if (_safeReviveAnchorCount < SafeReviveAnchorHistorySize)
+ _safeReviveAnchorCount++;
+ }
+
+ private void ResetSafeReviveAnchorHistory(string levelId)
+ {
+ Array.Clear(_safeReviveAnchors, 0, _safeReviveAnchors.Length);
+ _safeReviveAnchorCount = 0;
+ _safeReviveAnchorWriteIndex = 0;
+ _nextSafeReviveAnchorSampleTicks = 0;
+ _safeReviveAnchorLevelId = levelId ?? string.Empty;
+ _hasSafeAnchorMotionProbe = false;
+ _lastSafeAnchorMotionProbeY = 0.0;
+ _safeAnchorMotionProbeLevelId = levelId ?? string.Empty;
+ }
+
+ private bool TryGetSafeReviveAnchor(string levelId, long now, bool preferOlderSample, out double x, out double y)
+ {
+ x = 0.0;
+ y = 0.0;
+ if (_safeReviveAnchorCount <= 0 || string.IsNullOrWhiteSpace(levelId))
+ return false;
+
+ SafeReviveAnchorSample? newestValid = null;
+ for (var i = 0; i < _safeReviveAnchorCount; i++)
+ {
+ var index = (_safeReviveAnchorWriteIndex - 1 - i + SafeReviveAnchorHistorySize) %
+ SafeReviveAnchorHistorySize;
+ var sample = _safeReviveAnchors[index];
+ if (sample.Ticks <= 0 ||
+ !string.Equals(sample.LevelId, levelId, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var ageSeconds = (now - sample.Ticks) / (double)Stopwatch.Frequency;
+ if (ageSeconds < 0.0 || ageSeconds > SafeReviveAnchorMaxAgeSeconds)
+ continue;
+
+ newestValid ??= sample;
+ if (!preferOlderSample || ageSeconds >= SafeReviveAnchorPreferredAgeSeconds)
+ {
+ x = sample.X;
+ y = sample.Y;
+ return true;
+ }
+ }
+
+ if (newestValid.HasValue)
+ {
+ x = newestValid.Value.X;
+ y = newestValid.Value.Y;
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool TryGetHazardRecoveryAnchor(
+ string levelId,
+ long now,
+ double deathX,
+ double deathY,
+ out double x,
+ out double y)
+ {
+ x = 0.0;
+ y = 0.0;
+ if (_safeReviveAnchorCount <= 0 || string.IsNullOrWhiteSpace(levelId))
+ return false;
+
+ SafeReviveAnchorSample? agedFallback = null;
+ SafeReviveAnchorSample? oldestValid = null;
+ var hasFiniteDeathPosition = double.IsFinite(deathX) && double.IsFinite(deathY);
+
+ // Search from newest to oldest. Prefer a point that is both old enough to pre-date the
+ // hazard contact and far enough away that it is unlikely to still be inside the same
+ // spike bed, pit edge, lava strip, or trap volume.
+ for (var i = 0; i < _safeReviveAnchorCount; i++)
+ {
+ var index = (_safeReviveAnchorWriteIndex - 1 - i + SafeReviveAnchorHistorySize) %
+ SafeReviveAnchorHistorySize;
+ var sample = _safeReviveAnchors[index];
+ if (sample.Ticks <= 0 ||
+ !string.Equals(sample.LevelId, levelId, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var ageSeconds = (now - sample.Ticks) / (double)Stopwatch.Frequency;
+ if (ageSeconds < 0.0 || ageSeconds > SafeReviveAnchorMaxAgeSeconds)
+ continue;
+
+ oldestValid = sample;
+ if (!agedFallback.HasValue && ageSeconds >= HazardRecoveryFallbackAgeSeconds)
+ agedFallback = sample;
+
+ var separatedFromDeath = true;
+ if (hasFiniteDeathPosition)
+ {
+ var dx = sample.X - deathX;
+ var dy = sample.Y - deathY;
+ separatedFromDeath = dx * dx + dy * dy >= HazardRecoveryMinDistanceSq;
+ }
+
+ if (ageSeconds >= HazardRecoveryPreferredAgeSeconds && separatedFromDeath)
+ {
+ x = sample.X;
+ y = sample.Y;
+ return true;
+ }
+ }
+
+ // A player can die while almost stationary on a trap, so distance may not produce a
+ // candidate. In that case use an older grounded sample rather than leaving the corpse
+ // in the hazard. The final fallback is the oldest still-valid same-room sample.
+ var fallback = oldestValid ?? agedFallback;
+ if (!fallback.HasValue)
+ return false;
+
+ x = fallback.Value.X;
+ y = fallback.Value.Y;
+ return true;
+ }
+
+ private bool TryGetLivingTeammateSafeAnchor(Hero hero, out double x, out double y)
+ {
+ x = 0.0;
+ y = 0.0;
+ if (hero == null)
+ return false;
+
+ var net = _net;
+ var localId = net?.id ?? 0;
+ for (var i = 0; i < clients.Length; i++)
+ {
+ var client = clients[i];
+ if (client == null)
+ continue;
+
+ try
+ {
+ if (client.destroyed || client._level == null || client.spr == null)
+ continue;
+ }
+ catch
+ {
+ continue;
+ }
+
+ var remoteId = clientIds[i];
+ if (remoteId <= 0 || (localId > 0 && remoteId == localId) || IsRemotePlayerDowned(remoteId))
+ continue;
+
+ double remoteX;
+ double remoteY;
+ if (!TryGetGhostLogicalPixelPosition(client, out remoteX, out remoteY))
+ {
+ try
+ {
+ remoteX = client.get_targetSprPosX();
+ remoteY = client.get_targetSprPosY();
+ }
+ catch
+ {
+ try
+ {
+ remoteX = client.spr?.x ?? 0.0;
+ remoteY = client.spr?.y ?? 0.0;
+ }
+ catch
+ {
+ continue;
+ }
+ }
+ }
+
+ if (double.IsFinite(remoteX) && double.IsFinite(remoteY))
+ {
+ x = remoteX;
+ y = remoteY;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TryGetHeroLogicalPixelPosition(Hero hero, out double x, out double y)
+ {
+ x = 0.0;
+ y = 0.0;
+ if (hero == null)
+ return false;
+
+ try
+ {
+ x = (hero.cx + hero.xr) * 24.0;
+ y = (hero.cy + hero.yr) * 24.0;
+ return double.IsFinite(x) && double.IsFinite(y);
+ }
+ catch
+ {
+ x = 0.0;
+ y = 0.0;
+ return false;
+ }
+ }
+
+ private static bool TryGetGhostLogicalPixelPosition(GhostKing king, out double x, out double y)
+ {
+ x = 0.0;
+ y = 0.0;
+ if (king == null)
+ return false;
+
+ try
+ {
+ x = (king.cx + king.xr) * 24.0;
+ y = (king.cy + king.yr) * 24.0;
+ return double.IsFinite(x) && double.IsFinite(y);
+ }
+ catch
+ {
+ x = 0.0;
+ y = 0.0;
+ return false;
+ }
+ }
+
+ // Do not call LevelMap.getGroundYr from the managed per-frame update path. On the current
+ // DCCM/GameProxy combination that native bridge can receive the wrong HashLink receiver and
+ // terminate the game with "Can't cast tool.CPoint to level.LevelMap". Safe anchors are
+ // therefore selected only from finite, alive, same-level positions with low vertical motion.
+ // The history itself provides the ground/reachability guarantee without touching LevelMap.
+ private static bool TryProjectHeroPositionToSafeGround(Hero hero, double x, double y, out double safeX, out double safeY)
+ {
+ safeX = x;
+ safeY = y;
+ if (hero == null || !double.IsFinite(x) || !double.IsFinite(y))
+ return false;
+
+ try
+ {
+ if (hero.destroyed || hero._level == null || hero.spr == null || hero.isOutOfGame)
+ return false;
+
+ var verticalMotion = Math.Abs(hero.dy) + Math.Abs(hero.bdy);
+ if (!double.IsFinite(verticalMotion) || verticalMotion > 0.45)
+ return false;
+ }
+ catch
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private bool IsUnsafeLocalDeathPosition(Hero hero, double x, double y)
+ {
+ if (hero == null || !double.IsFinite(x) || !double.IsFinite(y))
+ return true;
+
+ try
+ {
+ if (hero.isOutOfGame)
+ return true;
+ }
+ catch
+ {
+ }
+
+ try
+ {
+ var verticalMotion = Math.Abs(hero.dy) + Math.Abs(hero.bdy);
+ if (double.IsFinite(verticalMotion) && verticalMotion > 0.65)
+ return true;
+ }
+ catch
+ {
+ }
+
+ // Finite, in-game positions with low vertical motion are usable. Environmental
+ // damage still selects an older history sample in ResolveLocalDownedAnchor.
+ return false;
+ }
+
+ private void ResolveLocalDownedAnchor(Hero hero, double deathX, double deathY, out double downedX, out double downedY)
+ {
+ downedX = deathX;
+ downedY = deathY;
+ _localDownedUsesRecoveryAnchor = false;
+
+ var now = Stopwatch.GetTimestamp();
+ var level = GetCurrentLevelId();
+ var recentEnvironmentalDamage = _lastLocalDamageWasEnvironmental &&
+ _lastLocalDamageContextTicks > 0 &&
+ now - _lastLocalDamageContextTicks <=
+ (long)(Stopwatch.Frequency * EnvironmentalDamageContextSeconds);
+ var unsafePosition = IsUnsafeLocalDeathPosition(hero, deathX, deathY);
+
+ // Always prefer a confirmed earlier anchor. This is deliberately not limited to
+ // deaths that were correctly classified as environmental: some spike/pit kill paths
+ // bypass onDamage and only reach Hero.kill/onDie, which previously left the body at
+ // the lethal coordinate. The history selection requires age and separation first,
+ // then uses the oldest valid same-room sample as a guaranteed reachable fallback.
+ if (TryGetHazardRecoveryAnchor(level, now, deathX, deathY, out var safeX, out var safeY))
+ {
+ downedX = safeX;
+ downedY = safeY;
+ _localDownedUsesRecoveryAnchor = true;
+ Logger.Information(
+ "[NetMod][ReviveAnchor] selected prior safe downed anchor environmental={Environmental} unsafe={Unsafe} deathX={DeathX:0.0} deathY={DeathY:0.0} safeX={SafeX:0.0} safeY={SafeY:0.0}",
+ recentEnvironmentalDamage,
+ unsafePosition,
+ deathX,
+ deathY,
+ downedX,
+ downedY);
+ }
+ else if (TryGetLivingTeammateSafeAnchor(hero, out safeX, out safeY))
+ {
+ // Very early room deaths can occur before the local history contains a sample.
+ // A living teammate is then the only known reachable in-room location.
+ downedX = safeX;
+ downedY = safeY;
+ _localDownedUsesRecoveryAnchor = true;
+ Logger.Information(
+ "[NetMod][ReviveAnchor] used living teammate fallback safeX={SafeX:0.0} safeY={SafeY:0.0}",
+ downedX,
+ downedY);
+ }
+ else if (!recentEnvironmentalDamage &&
+ !unsafePosition &&
+ TryProjectHeroPositionToSafeGround(hero, deathX, deathY, out var groundX, out var groundY))
+ {
+ // Last-resort only: no history and no living teammate. Keep a normal non-hazard
+ // death where it occurred rather than manufacturing an unverified coordinate.
+ downedX = groundX;
+ downedY = groundY;
+ }
+ else if (TryGetSafeReviveAnchor(level, now, preferOlderSample: true, out safeX, out safeY))
+ {
+ downedX = safeX;
+ downedY = safeY;
+ _localDownedUsesRecoveryAnchor = true;
+ }
+
+ _lastLocalDamageWasEnvironmental = false;
+ _lastLocalDamageContextTicks = 0;
+ }
+ }
+}
diff --git a/FakeDeath/FakeDeath.Cine.cs b/FakeDeath/FakeDeath.Cine.cs
new file mode 100644
index 0000000..daa9bd3
--- /dev/null
+++ b/FakeDeath/FakeDeath.Cine.cs
@@ -0,0 +1,501 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using dc.en;
+using dc.cine;
+using DeadCellsMultiplayerMod.Ghost.GhostBase;
+using ModCore.Modules;
+
+namespace DeadCellsMultiplayerMod
+{
+ public partial class ModEntry
+ {
+ // Cursed/environmental death hooks can run from inside attack/cooldown iteration. Constructing
+ // DeadBase or GameOver re-entrantly from that stack can leave HashLink in a permanent main-loop
+ // stall. Defer corpse-cinematic creation until a later normal frame and serialize flow updates.
+ private const double LocalDeadCineCreateDelaySeconds = 0.12;
+ private long _localDeadCineCreateAfterTicks;
+ private bool _hasLocalDownedAnchor;
+ private double _localDownedAnchorX;
+ private double _localDownedAnchorY;
+ private const double DownedCorpseMaxDriftPx = 96.0;
+ private const double DownedCorpseMaxDriftSq = DownedCorpseMaxDriftPx * DownedCorpseMaxDriftPx;
+ private readonly HashSet _scratchActiveCorpseIds = new();
+ private readonly List _scratchStaleCorpseIds = new();
+ // Environmental deaths can temporarily publish an invalid room marker or put the remote
+ // GhostKing into the engine's out-of-game state. Keep a brief revive grace period so the
+ // normal snapshot stream can reattach the same remote shell without requiring a sublevel
+ // round-trip to rebuild it.
+ private readonly Dictionary _remoteReviveVisibilityGraceUntilTicks = new();
+ private const double RemoteReviveVisibilityGraceSeconds = 3.0;
+
+
+ private static bool IsVanillaHeroDeathCineActive()
+ {
+ try
+ {
+ var cine = dc.pr.Game.Class.ME?.curCine;
+ return cine is HeroDeath ||
+ cine is HeroDeathBase ||
+ cine is HeroDeathContinue ||
+ cine is HeroDeathRespawn ||
+ cine is HeroDeathDLCP;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private bool ShouldSuppressVanillaHeroDeathCinematic(Hero? lostBody)
+ {
+ return _netRole != NetRole.None &&
+ _net != null &&
+ _net.IsAlive &&
+ me != null &&
+ lostBody != null &&
+ ReferenceEquals(lostBody, me);
+ }
+
+ private bool SuppressVanillaHeroDeathCinematic(Hero? lostBody, dc.GameCinematic? cine)
+ {
+ if (!ShouldSuppressVanillaHeroDeathCinematic(lostBody))
+ return false;
+
+ if (!_localFakeDead && lostBody != null && _net != null)
+ EnterLocalFakeDeath(lostBody, _net);
+
+ try
+ {
+ var game = dc.pr.Game.Class.ME;
+ if (game != null && cine != null && ReferenceEquals(game.curCine, cine))
+ game.curCine = null;
+ }
+ catch
+ {
+ }
+
+ // Constructor hooks run before the vanilla cinematic object is initialized. Calling
+ // destroy/disposeImmediately on that half-constructed object can tear down unrelated
+ // hero state. Simply skip the constructor; startDeathCine/kill are already redirected.
+ return true;
+ }
+
+ private void Hook__HeroDeath__constructor__(Hook__HeroDeath.orig___constructor__ orig, HeroDeath e, Hero lostBody, bool fromMob)
+ {
+ if (SuppressVanillaHeroDeathCinematic(lostBody, e))
+ return;
+
+ orig(e, lostBody, fromMob);
+ }
+
+ private void Hook__HeroDeathBase__constructor__(Hook__HeroDeathBase.orig___constructor__ orig, HeroDeathBase e, Hero lostBody, bool mob)
+ {
+ if (SuppressVanillaHeroDeathCinematic(lostBody, e))
+ return;
+
+ orig(e, lostBody, mob);
+ }
+
+ private void Hook__HeroDeathContinue__constructor__(Hook__HeroDeathContinue.orig___constructor__ orig, HeroDeathContinue e, Hero lostBody, bool keepBody)
+ {
+ if (SuppressVanillaHeroDeathCinematic(lostBody, e))
+ return;
+
+ orig(e, lostBody, keepBody);
+ }
+
+ private void Hook__HeroDeathRespawn__constructor__(Hook__HeroDeathRespawn.orig___constructor__ orig, HeroDeathRespawn e, Hero lostBody)
+ {
+ if (SuppressVanillaHeroDeathCinematic(lostBody, e))
+ return;
+
+ orig(e, lostBody);
+ }
+
+ private void Hook__HeroDeathDLCP__constructor__(Hook__HeroDeathDLCP.orig___constructor__ orig, HeroDeathDLCP e, Hero lostBody, bool fromMob)
+ {
+ if (SuppressVanillaHeroDeathCinematic(lostBody, e))
+ return;
+
+ orig(e, lostBody, fromMob);
+ }
+
+ private void ApplyRemoteDownedGhostPositions(NetNode net)
+ {
+ if (net == null)
+ return;
+
+ if (_remoteDowned.Count == 0)
+ {
+ DisposeAllRemoteDownedCines();
+ for (int i = 0; i < clients.Length; i++)
+ {
+ var client = clients[i];
+ if (client != null)
+ {
+ try { client._targetable = true; } catch { }
+ }
+ }
+ return;
+ }
+
+ var localId = net.id;
+ var localLevelId = GetCurrentLevelId();
+ _scratchActiveCorpseIds.Clear();
+ foreach (var state in _remoteDowned.Values)
+ {
+ if (state == null || state.UserId <= 0)
+ continue;
+ if (!TryGetClientIndex(localId, state.UserId, out var index))
+ {
+ DisposeRemoteDownedCine(state.UserId);
+ continue;
+ }
+
+ if (!string.IsNullOrEmpty(localLevelId) &&
+ !string.IsNullOrEmpty(state.LevelId) &&
+ !string.Equals(state.LevelId, localLevelId, StringComparison.Ordinal))
+ {
+ DisposeRemoteDownedCine(state.UserId);
+ continue;
+ }
+
+ var client = clients[index];
+ if (client == null)
+ {
+ // A fall/lava snapshot can dispose the remote shell because its temporary room
+ // marker no longer matches. Recreate it immediately for the authoritative
+ // same-level downed body instead of waiting for a sublevel transition.
+ client = EnsureClientKingSlot(index);
+ }
+ if (client == null)
+ {
+ DisposeRemoteDownedCine(state.UserId);
+ continue;
+ }
+ CancelPendingClientDispose(index);
+
+ _scratchActiveCorpseIds.Add(state.UserId);
+
+ // Never create a second corpse cinematic once the local player is also downed.
+ // If this remote corpse already existed (the local player died second), keep and
+ // update that single cinematic; otherwise the brief all-down state needs no new one.
+ RemoteDownedCorpse? cine = null;
+ if (_remoteDownedCines.TryGetValue(state.UserId, out var existingCine) && existingCine != null)
+ cine = existingCine;
+ else if (!_localFakeDead)
+ cine = EnsureRemoteDownedCine(state, client);
+
+ if (cine != null)
+ {
+ try
+ {
+ cine.UpdateTarget(
+ state.X,
+ state.Y,
+ client.dir,
+ state.HasHeadPosition ? state.HeadX : null,
+ state.HasHeadPosition ? state.HeadY : null,
+ state.HasHeadAnim ? state.HeadAnim : null);
+ }
+ catch { DisposeRemoteDownedCine(state.UserId); }
+ }
+
+ try { client._targetable = false; } catch { }
+ try { client.setPosPixel(state.X, state.Y - DownedGhostBodyYOffsetPx); } catch { }
+
+ rLastX[index] = state.X;
+ rLastY[index] = state.Y - DownedGhostBodyYOffsetPx;
+ }
+
+ if (_remoteDownedCines.Count > 0)
+ {
+ _scratchStaleCorpseIds.Clear();
+ foreach (var pair in _remoteDownedCines)
+ {
+ if (!_scratchActiveCorpseIds.Contains(pair.Key))
+ _scratchStaleCorpseIds.Add(pair.Key);
+ }
+
+ for (int i = 0; i < _scratchStaleCorpseIds.Count; i++)
+ DisposeRemoteDownedCine(_scratchStaleCorpseIds[i]);
+ }
+ }
+
+ private bool IsRemoteDownedVisibleInCurrentLevel(int userId, string? localLevelId)
+ {
+ if (userId <= 0 || !_remoteDowned.TryGetValue(userId, out var state) || state == null)
+ return false;
+
+ if (string.IsNullOrWhiteSpace(localLevelId) || string.IsNullOrWhiteSpace(state.LevelId))
+ return true;
+
+ return string.Equals(localLevelId, state.LevelId, StringComparison.Ordinal);
+ }
+
+ private bool IsRemoteReviveVisibilityGraceActive(int userId)
+ {
+ if (userId <= 0 || !_remoteReviveVisibilityGraceUntilTicks.TryGetValue(userId, out var untilTicks))
+ return false;
+
+ if (Stopwatch.GetTimestamp() < untilTicks)
+ return true;
+
+ _remoteReviveVisibilityGraceUntilTicks.Remove(userId);
+ return false;
+ }
+
+ private void BeginRemoteReviveVisibilityRecovery(
+ int userId,
+ int slot,
+ GhostKing? client,
+ double x,
+ double y)
+ {
+ if (userId <= 0)
+ return;
+
+ _remoteReviveVisibilityGraceUntilTicks[userId] = Stopwatch.GetTimestamp() +
+ (long)(Stopwatch.Frequency * RemoteReviveVisibilityGraceSeconds);
+ _remoteLastDoorMarkers.Remove(userId);
+
+ if (slot < 0 || slot >= clients.Length)
+ return;
+
+ CancelPendingClientDispose(slot);
+ clientLastDownedOffsets[slot] = false;
+
+ if (client == null)
+ return;
+
+ var unusable = false;
+ try { unusable = client.destroyed; } catch { }
+ try
+ {
+ if (!unusable && me?._level != null && client._level != null &&
+ !ReferenceEquals(client._level, me._level))
+ {
+ unusable = true;
+ }
+ }
+ catch
+ {
+ }
+ try
+ {
+ if (!unusable && client.spr == null)
+ unusable = true;
+ }
+ catch
+ {
+ }
+
+ if (unusable)
+ {
+ DisposeClientSlot(slot, clearIdentity: false);
+ return;
+ }
+
+ RestoreRemoteKingRenderAfterRevive(slot, client, x, y, "down-state-up");
+ }
+
+ private void RestoreRemoteKingRenderAfterRevive(
+ int slot,
+ GhostKing client,
+ double x,
+ double y,
+ string reason)
+ {
+ if (client == null || slot < 0 || slot >= clients.Length)
+ return;
+
+ try
+ {
+ if (double.IsFinite(x) && double.IsFinite(y))
+ {
+ client.setPosPixel(x, y);
+ rLastX[slot] = x;
+ rLastY[slot] = y;
+ }
+ }
+ catch
+ {
+ }
+
+ var wasOutOfGame = false;
+ try { wasOutOfGame = client.isOutOfGame; } catch { }
+ try { client.lastOutOfGame = false; } catch { }
+ try { client.isOutOfGame = false; } catch { }
+ try { client.isOnScreen = true; } catch { }
+ try
+ {
+ if (client.onScreenRecent < 1200.0)
+ client.onScreenRecent = 1200.0;
+ }
+ catch { }
+ if (wasOutOfGame)
+ {
+ try { client.onOutOfGameChange(); } catch { }
+ }
+ try { client.visible = true; } catch { }
+ try { client.spr?.set_visible(true); } catch { }
+ try { client._targetable = true; } catch { }
+
+ try { EnsureGhostKingRenderSafe(client, "remote-revive:" + reason, detachForTransition: false); } catch { }
+
+ if (clientHeads[slot] == null || client.head == null)
+ ScheduleGhostHeadRecreate(slot, immediate: true);
+ MarkGhostHeadDirty(slot, immediate: true);
+ }
+
+ private RemoteDownedCorpse? EnsureRemoteDownedCine(RemoteDownedState state, GhostKing client)
+ {
+ if (state == null || client == null || me == null)
+ return null;
+
+ if (_remoteDownedCines.TryGetValue(state.UserId, out var existing))
+ {
+ if (existing != null)
+ return existing;
+
+ _remoteDownedCines.Remove(state.UserId);
+ }
+
+ try
+ {
+ var previousCine = dc.pr.Game.Class.ME?.curCine;
+ var created = new RemoteDownedCorpse(me, client, state.X, state.Y, client.dir, previousCine);
+ _remoteDownedCines[state.UserId] = created;
+ return created;
+ }
+ catch
+ {
+ _remoteDownedCines.Remove(state.UserId);
+ return null;
+ }
+ }
+
+ private void DisposeRemoteDownedCine(int userId)
+ {
+ if (!_remoteDownedCines.TryGetValue(userId, out var cine) || cine == null)
+ return;
+
+ _remoteDownedCines.Remove(userId);
+ try { cine.destroy(); } catch { }
+ try { cine.disposeImmediately(); } catch { }
+ }
+
+ private void DisposeAllRemoteDownedCines()
+ {
+ if (_remoteDownedCines.Count == 0)
+ return;
+
+ _scratchStaleCorpseIds.Clear();
+ foreach (var id in _remoteDownedCines.Keys)
+ _scratchStaleCorpseIds.Add(id);
+
+ for (int i = 0; i < _scratchStaleCorpseIds.Count; i++)
+ DisposeRemoteDownedCine(_scratchStaleCorpseIds[i]);
+ }
+
+ private void ShowReviveHintFor(int userId)
+ {
+ if (_remoteDownedCines.Count == 0)
+ return;
+
+ foreach (var pair in _remoteDownedCines)
+ {
+ var cine = pair.Value;
+ if (cine == null)
+ continue;
+
+ try
+ {
+ if (pair.Key == userId)
+ cine.SetInteractionLabel(Localize(ReviveHintText));
+ else
+ cine.SetInteractionLabel(null);
+ }
+ catch
+ {
+ }
+ }
+ }
+
+ private void ClearReviveHints()
+ {
+ if (_remoteDownedCines.Count == 0)
+ return;
+
+ foreach (var cine in _remoteDownedCines.Values)
+ {
+ if (cine == null)
+ continue;
+ try { cine.SetInteractionLabel(null); } catch { }
+ }
+ }
+
+ private void StartLocalDeadCine(Hero hero)
+ {
+ if (hero == null)
+ return;
+
+ if (_localDeadCine != null)
+ return;
+
+ try
+ {
+ _localDeadCine = new DeadBase(hero, ModEntry.GetPrimaryClient());
+ }
+ catch
+ {
+ _localDeadCine = null;
+ }
+ }
+
+ private void StopLocalDeadCine()
+ {
+ var cine = _localDeadCine;
+ _localDeadCine = null;
+ if (cine == null)
+ return;
+
+ try { cine.destroy(); } catch { }
+ try { cine.disposeImmediately(); } catch { }
+ }
+
+ private bool TryUpdateDownedPositionFromCorpse(double corpseX, double corpseY)
+ {
+ // Co-op corpses are pinned to the authoritative revive point. Never let a one-frame
+ // corpse physics step move the gameplay anchor downward or through floor tiles.
+ if (_localFakeDead && ShouldAnchorLocalDownedCorpse())
+ return false;
+
+ if (!double.IsFinite(corpseX) || !double.IsFinite(corpseY))
+ return false;
+
+ if (!_hasLocalDownedAnchor)
+ {
+ _localDownedAnchorX = _localDownedX;
+ _localDownedAnchorY = _localDownedY;
+ _hasLocalDownedAnchor = true;
+ }
+
+ var dx = corpseX - _localDownedAnchorX;
+ var dy = corpseY - _localDownedAnchorY;
+ var distSq = dx * dx + dy * dy;
+ if (distSq > DownedCorpseMaxDriftSq)
+ return false;
+
+ _localDownedX = corpseX;
+ _localDownedY = corpseY;
+ _localHeldX = _localDownedX;
+ _localHeldY = _localDownedY;
+ _localDownedAnchorX = corpseX;
+ _localDownedAnchorY = corpseY;
+ return true;
+ }
+
+ }
+}
diff --git a/FakeDeath/FakeDeath.cs b/FakeDeath/FakeDeath.cs
index 4f28cc9..0966b12 100644
--- a/FakeDeath/FakeDeath.cs
+++ b/FakeDeath/FakeDeath.cs
@@ -6,7 +6,6 @@
using dc.tool.atk;
using dc.tool.mainSkills;
using dc.ui;
-using dc.cine;
using DeadCellsMultiplayerMod.Ghost.GhostBase;
using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI;
using ModCore.Modules;
@@ -21,76 +20,11 @@ public partial class ModEntry
private bool _allDownedRestartQueued;
private long _allDownedRestartAtTicks;
private const double AllDownedGameOverDelaySeconds = 0.35;
- // Cursed/environmental death hooks can run from inside attack/cooldown iteration. Constructing
- // DeadBase or GameOver re-entrantly from that stack can leave HashLink in a permanent main-loop
- // stall. Defer corpse-cinematic creation until a later normal frame and serialize flow updates.
- private const double LocalDeadCineCreateDelaySeconds = 0.12;
- private long _localDeadCineCreateAfterTicks;
private bool _fakeDeathFlowInProgress;
- private bool _hasLocalDownedAnchor;
- private double _localDownedAnchorX;
- private double _localDownedAnchorY;
- private const double DownedCorpseMaxDriftPx = 96.0;
- private const double DownedCorpseMaxDriftSq = DownedCorpseMaxDriftPx * DownedCorpseMaxDriftPx;
- // Keep enough grounded history to recover a corpse even after a long pit fall or a
- // several-second trap sequence. The old 18-sample buffer only covered about two seconds,
- // which was too short for many off-map deaths.
- private const int SafeReviveAnchorHistorySize = 96;
- private const double SafeReviveAnchorSampleSeconds = 0.12;
- private const double SafeReviveAnchorPreferredAgeSeconds = 0.75;
- private const double SafeReviveAnchorMaxAgeSeconds = 12.0;
- private const double EnvironmentalDamageContextSeconds = 2.5;
- // A sample recorded immediately before or during contact with spikes/lava is not safe.
- // Quarantine new samples briefly after environmental damage and remove the recent tail.
- private const double SafeAnchorEnvironmentalQuarantineSeconds = 2.5;
- private const double SafeAnchorEnvironmentalPurgeSeconds = 2.25;
- // Downed bodies should never remain at the lethal coordinate. Prefer an older point far
- // enough away to be outside even a wide spike strip, then fall back to the oldest valid
- // same-room anchor rather than the death position.
- private const double HazardRecoveryPreferredAgeSeconds = 1.25;
- private const double HazardRecoveryFallbackAgeSeconds = 2.25;
- private const double HazardRecoveryMinDistancePx = 144.0;
- private const double HazardRecoveryMinDistanceSq = HazardRecoveryMinDistancePx * HazardRecoveryMinDistancePx;
-
- private readonly struct SafeReviveAnchorSample
- {
- public readonly double X;
- public readonly double Y;
- public readonly long Ticks;
- public readonly string LevelId;
-
- public SafeReviveAnchorSample(double x, double y, long ticks, string levelId)
- {
- X = x;
- Y = y;
- Ticks = ticks;
- LevelId = levelId ?? string.Empty;
- }
- }
-
- private readonly SafeReviveAnchorSample[] _safeReviveAnchors = new SafeReviveAnchorSample[SafeReviveAnchorHistorySize];
- private int _safeReviveAnchorCount;
- private int _safeReviveAnchorWriteIndex;
- private long _nextSafeReviveAnchorSampleTicks;
- private string _safeReviveAnchorLevelId = string.Empty;
- private bool _hasSafeAnchorMotionProbe;
- private double _lastSafeAnchorMotionProbeY;
- private string _safeAnchorMotionProbeLevelId = string.Empty;
- private bool _localDownedUsesRecoveryAnchor;
private bool _localDownedGravityCaptured;
private bool _localDownedOriginalHasGravity = true;
- private bool _lastLocalDamageWasEnvironmental;
- private long _lastLocalDamageContextTicks;
private readonly HashSet _scratchRemoteActiveIds = new();
- private readonly HashSet _scratchActiveCorpseIds = new();
private readonly List _scratchStaleRemoteIds = new();
- private readonly List _scratchStaleCorpseIds = new();
- // Environmental deaths can temporarily publish an invalid room marker or put the remote
- // GhostKing into the engine's out-of-game state. Keep a brief revive grace period so the
- // normal snapshot stream can reattach the same remote shell without requiring a sublevel
- // round-trip to rebuild it.
- private readonly Dictionary _remoteReviveVisibilityGraceUntilTicks = new();
- private const double RemoteReviveVisibilityGraceSeconds = 3.0;
private void Hook_Hero_onHeroDie(Hook_Hero.orig_onHeroDie orig, Hero self)
{
@@ -265,505 +199,6 @@ private void AbortLocalDiveStateForFakeDeath(Hero? hero, string reason)
// map from a death hook risks a delayed HashLink null/type crash in the main loop.
}
- private void RecordLocalDamageContext(AttackData? attack)
- {
- var now = Stopwatch.GetTimestamp();
- _lastLocalDamageContextTicks = now;
- _lastLocalDamageWasEnvironmental = IsEnvironmentalDamageSource(attack);
- if (_lastLocalDamageWasEnvironmental)
- InvalidateRecentSafeReviveAnchors(now, SafeAnchorEnvironmentalPurgeSeconds);
- }
-
- private void InvalidateRecentSafeReviveAnchors(long now, double seconds)
- {
- if (_safeReviveAnchorCount <= 0 || seconds <= 0.0)
- return;
-
- var cutoffTicks = now - (long)(Stopwatch.Frequency * seconds);
- for (var i = 0; i < _safeReviveAnchors.Length; i++)
- {
- var sample = _safeReviveAnchors[i];
- if (sample.Ticks > 0 && sample.Ticks >= cutoffTicks)
- _safeReviveAnchors[i] = default;
- }
- }
-
- private static bool IsEnvironmentalDamageSource(AttackData? attack)
- {
- if (attack == null)
- return true;
-
- dc.Entity? source = null;
- try { source = attack.source; } catch { }
- if (source == null)
- return true;
- if (source is Mob || source is Hero || source is GhostKing)
- return false;
-
- try
- {
- var name = source.GetType().Name ?? string.Empty;
- if (name.IndexOf("Bullet", StringComparison.OrdinalIgnoreCase) >= 0 ||
- name.IndexOf("Projectile", StringComparison.OrdinalIgnoreCase) >= 0 ||
- name.IndexOf("Arrow", StringComparison.OrdinalIgnoreCase) >= 0 ||
- name.IndexOf("Shot", StringComparison.OrdinalIgnoreCase) >= 0)
- {
- return false;
- }
- }
- catch
- {
- }
-
- return true;
- }
-
- private void UpdateLocalSafeReviveAnchor(Hero? hero)
- {
- if (hero == null || _localFakeDead || _localDeathConversionInProgress)
- return;
-
- try
- {
- if (hero.destroyed || hero.life <= 0 || hero._level == null || hero.spr == null)
- return;
- if (hero.isOutOfGame)
- return;
- }
- catch
- {
- return;
- }
-
- var level = GetCurrentLevelId();
- if (string.IsNullOrWhiteSpace(level))
- return;
- if (!string.Equals(_safeReviveAnchorLevelId, level, StringComparison.Ordinal))
- ResetSafeReviveAnchorHistory(level);
-
- var now = Stopwatch.GetTimestamp();
- if (_lastLocalDamageWasEnvironmental && _lastLocalDamageContextTicks > 0 &&
- now - _lastLocalDamageContextTicks <
- (long)(Stopwatch.Frequency * SafeAnchorEnvironmentalQuarantineSeconds))
- {
- return;
- }
-
- if (_nextSafeReviveAnchorSampleTicks != 0 && now < _nextSafeReviveAnchorSampleTicks)
- return;
- _nextSafeReviveAnchorSampleTicks = now +
- (long)(Stopwatch.Frequency * SafeReviveAnchorSampleSeconds);
-
- try
- {
- var verticalMotion = Math.Abs(hero.dy) + Math.Abs(hero.bdy);
- if (!double.IsFinite(verticalMotion) || verticalMotion > 0.45)
- return;
- }
- catch
- {
- }
-
- double x;
- double y;
- if (!TryGetHeroLogicalPixelPosition(hero, out x, out y))
- {
- try
- {
- x = hero.get_targetSprPosX();
- y = hero.get_targetSprPosY();
- }
- catch
- {
- try
- {
- x = hero.spr?.x ?? 0.0;
- y = hero.spr?.y ?? 0.0;
- }
- catch
- {
- return;
- }
- }
- }
-
- if (!TryProjectHeroPositionToSafeGround(hero, x, y, out var safeX, out var safeY))
- return;
-
- // Do not certify positions while a platform/elevator is carrying the hero vertically.
- // Hero.dy can remain near zero on moving platforms, so compare logical floor Y across
- // samples as a second independent stability check. The first stable probe after any
- // vertical movement is only observed; the following stable probe becomes an anchor.
- if (!_hasSafeAnchorMotionProbe ||
- !string.Equals(_safeAnchorMotionProbeLevelId, level, StringComparison.Ordinal))
- {
- _hasSafeAnchorMotionProbe = true;
- _lastSafeAnchorMotionProbeY = safeY;
- _safeAnchorMotionProbeLevelId = level;
- return;
- }
-
- var floorDeltaY = Math.Abs(safeY - _lastSafeAnchorMotionProbeY);
- _lastSafeAnchorMotionProbeY = safeY;
- if (!double.IsFinite(floorDeltaY) || floorDeltaY > 2.0)
- return;
-
- _safeReviveAnchors[_safeReviveAnchorWriteIndex] =
- new SafeReviveAnchorSample(safeX, safeY, now, level);
- _safeReviveAnchorWriteIndex = (_safeReviveAnchorWriteIndex + 1) % SafeReviveAnchorHistorySize;
- if (_safeReviveAnchorCount < SafeReviveAnchorHistorySize)
- _safeReviveAnchorCount++;
- }
-
- private void ResetSafeReviveAnchorHistory(string levelId)
- {
- Array.Clear(_safeReviveAnchors, 0, _safeReviveAnchors.Length);
- _safeReviveAnchorCount = 0;
- _safeReviveAnchorWriteIndex = 0;
- _nextSafeReviveAnchorSampleTicks = 0;
- _safeReviveAnchorLevelId = levelId ?? string.Empty;
- _hasSafeAnchorMotionProbe = false;
- _lastSafeAnchorMotionProbeY = 0.0;
- _safeAnchorMotionProbeLevelId = levelId ?? string.Empty;
- }
-
- private bool TryGetSafeReviveAnchor(string levelId, long now, bool preferOlderSample, out double x, out double y)
- {
- x = 0.0;
- y = 0.0;
- if (_safeReviveAnchorCount <= 0 || string.IsNullOrWhiteSpace(levelId))
- return false;
-
- SafeReviveAnchorSample? newestValid = null;
- for (var i = 0; i < _safeReviveAnchorCount; i++)
- {
- var index = (_safeReviveAnchorWriteIndex - 1 - i + SafeReviveAnchorHistorySize) %
- SafeReviveAnchorHistorySize;
- var sample = _safeReviveAnchors[index];
- if (sample.Ticks <= 0 ||
- !string.Equals(sample.LevelId, levelId, StringComparison.Ordinal))
- {
- continue;
- }
-
- var ageSeconds = (now - sample.Ticks) / (double)Stopwatch.Frequency;
- if (ageSeconds < 0.0 || ageSeconds > SafeReviveAnchorMaxAgeSeconds)
- continue;
-
- newestValid ??= sample;
- if (!preferOlderSample || ageSeconds >= SafeReviveAnchorPreferredAgeSeconds)
- {
- x = sample.X;
- y = sample.Y;
- return true;
- }
- }
-
- if (newestValid.HasValue)
- {
- x = newestValid.Value.X;
- y = newestValid.Value.Y;
- return true;
- }
-
- return false;
- }
-
- private bool TryGetHazardRecoveryAnchor(
- string levelId,
- long now,
- double deathX,
- double deathY,
- out double x,
- out double y)
- {
- x = 0.0;
- y = 0.0;
- if (_safeReviveAnchorCount <= 0 || string.IsNullOrWhiteSpace(levelId))
- return false;
-
- SafeReviveAnchorSample? agedFallback = null;
- SafeReviveAnchorSample? oldestValid = null;
- var hasFiniteDeathPosition = double.IsFinite(deathX) && double.IsFinite(deathY);
-
- // Search from newest to oldest. Prefer a point that is both old enough to pre-date the
- // hazard contact and far enough away that it is unlikely to still be inside the same
- // spike bed, pit edge, lava strip, or trap volume.
- for (var i = 0; i < _safeReviveAnchorCount; i++)
- {
- var index = (_safeReviveAnchorWriteIndex - 1 - i + SafeReviveAnchorHistorySize) %
- SafeReviveAnchorHistorySize;
- var sample = _safeReviveAnchors[index];
- if (sample.Ticks <= 0 ||
- !string.Equals(sample.LevelId, levelId, StringComparison.Ordinal))
- {
- continue;
- }
-
- var ageSeconds = (now - sample.Ticks) / (double)Stopwatch.Frequency;
- if (ageSeconds < 0.0 || ageSeconds > SafeReviveAnchorMaxAgeSeconds)
- continue;
-
- oldestValid = sample;
- if (!agedFallback.HasValue && ageSeconds >= HazardRecoveryFallbackAgeSeconds)
- agedFallback = sample;
-
- var separatedFromDeath = true;
- if (hasFiniteDeathPosition)
- {
- var dx = sample.X - deathX;
- var dy = sample.Y - deathY;
- separatedFromDeath = dx * dx + dy * dy >= HazardRecoveryMinDistanceSq;
- }
-
- if (ageSeconds >= HazardRecoveryPreferredAgeSeconds && separatedFromDeath)
- {
- x = sample.X;
- y = sample.Y;
- return true;
- }
- }
-
- // A player can die while almost stationary on a trap, so distance may not produce a
- // candidate. In that case use an older grounded sample rather than leaving the corpse
- // in the hazard. The final fallback is the oldest still-valid same-room sample.
- var fallback = oldestValid ?? agedFallback;
- if (!fallback.HasValue)
- return false;
-
- x = fallback.Value.X;
- y = fallback.Value.Y;
- return true;
- }
-
- private bool TryGetLivingTeammateSafeAnchor(Hero hero, out double x, out double y)
- {
- x = 0.0;
- y = 0.0;
- if (hero == null)
- return false;
-
- var net = _net;
- var localId = net?.id ?? 0;
- for (var i = 0; i < clients.Length; i++)
- {
- var client = clients[i];
- if (client == null)
- continue;
-
- try
- {
- if (client.destroyed || client._level == null || client.spr == null)
- continue;
- }
- catch
- {
- continue;
- }
-
- var remoteId = clientIds[i];
- if (remoteId <= 0 || (localId > 0 && remoteId == localId) || IsRemotePlayerDowned(remoteId))
- continue;
-
- double remoteX;
- double remoteY;
- if (!TryGetGhostLogicalPixelPosition(client, out remoteX, out remoteY))
- {
- try
- {
- remoteX = client.get_targetSprPosX();
- remoteY = client.get_targetSprPosY();
- }
- catch
- {
- try
- {
- remoteX = client.spr?.x ?? 0.0;
- remoteY = client.spr?.y ?? 0.0;
- }
- catch
- {
- continue;
- }
- }
- }
-
- if (double.IsFinite(remoteX) && double.IsFinite(remoteY))
- {
- x = remoteX;
- y = remoteY;
- return true;
- }
- }
-
- return false;
- }
-
- private static bool TryGetHeroLogicalPixelPosition(Hero hero, out double x, out double y)
- {
- x = 0.0;
- y = 0.0;
- if (hero == null)
- return false;
-
- try
- {
- x = (hero.cx + hero.xr) * 24.0;
- y = (hero.cy + hero.yr) * 24.0;
- return double.IsFinite(x) && double.IsFinite(y);
- }
- catch
- {
- x = 0.0;
- y = 0.0;
- return false;
- }
- }
-
- private static bool TryGetGhostLogicalPixelPosition(GhostKing king, out double x, out double y)
- {
- x = 0.0;
- y = 0.0;
- if (king == null)
- return false;
-
- try
- {
- x = (king.cx + king.xr) * 24.0;
- y = (king.cy + king.yr) * 24.0;
- return double.IsFinite(x) && double.IsFinite(y);
- }
- catch
- {
- x = 0.0;
- y = 0.0;
- return false;
- }
- }
-
- // Do not call LevelMap.getGroundYr from the managed per-frame update path. On the current
- // DCCM/GameProxy combination that native bridge can receive the wrong HashLink receiver and
- // terminate the game with "Can't cast tool.CPoint to level.LevelMap". Safe anchors are
- // therefore selected only from finite, alive, same-level positions with low vertical motion.
- // The history itself provides the ground/reachability guarantee without touching LevelMap.
- private static bool TryProjectHeroPositionToSafeGround(Hero hero, double x, double y, out double safeX, out double safeY)
- {
- safeX = x;
- safeY = y;
- if (hero == null || !double.IsFinite(x) || !double.IsFinite(y))
- return false;
-
- try
- {
- if (hero.destroyed || hero._level == null || hero.spr == null || hero.isOutOfGame)
- return false;
-
- var verticalMotion = Math.Abs(hero.dy) + Math.Abs(hero.bdy);
- if (!double.IsFinite(verticalMotion) || verticalMotion > 0.45)
- return false;
- }
- catch
- {
- return false;
- }
-
- return true;
- }
-
- private bool IsUnsafeLocalDeathPosition(Hero hero, double x, double y)
- {
- if (hero == null || !double.IsFinite(x) || !double.IsFinite(y))
- return true;
-
- try
- {
- if (hero.isOutOfGame)
- return true;
- }
- catch
- {
- }
-
- try
- {
- var verticalMotion = Math.Abs(hero.dy) + Math.Abs(hero.bdy);
- if (double.IsFinite(verticalMotion) && verticalMotion > 0.65)
- return true;
- }
- catch
- {
- }
-
- // Finite, in-game positions with low vertical motion are usable. Environmental
- // damage still selects an older history sample in ResolveLocalDownedAnchor.
- return false;
- }
-
- private void ResolveLocalDownedAnchor(Hero hero, double deathX, double deathY, out double downedX, out double downedY)
- {
- downedX = deathX;
- downedY = deathY;
- _localDownedUsesRecoveryAnchor = false;
-
- var now = Stopwatch.GetTimestamp();
- var level = GetCurrentLevelId();
- var recentEnvironmentalDamage = _lastLocalDamageWasEnvironmental &&
- _lastLocalDamageContextTicks > 0 &&
- now - _lastLocalDamageContextTicks <=
- (long)(Stopwatch.Frequency * EnvironmentalDamageContextSeconds);
- var unsafePosition = IsUnsafeLocalDeathPosition(hero, deathX, deathY);
-
- // Always prefer a confirmed earlier anchor. This is deliberately not limited to
- // deaths that were correctly classified as environmental: some spike/pit kill paths
- // bypass onDamage and only reach Hero.kill/onDie, which previously left the body at
- // the lethal coordinate. The history selection requires age and separation first,
- // then uses the oldest valid same-room sample as a guaranteed reachable fallback.
- if (TryGetHazardRecoveryAnchor(level, now, deathX, deathY, out var safeX, out var safeY))
- {
- downedX = safeX;
- downedY = safeY;
- _localDownedUsesRecoveryAnchor = true;
- Logger.Information(
- "[NetMod][ReviveAnchor] selected prior safe downed anchor environmental={Environmental} unsafe={Unsafe} deathX={DeathX:0.0} deathY={DeathY:0.0} safeX={SafeX:0.0} safeY={SafeY:0.0}",
- recentEnvironmentalDamage,
- unsafePosition,
- deathX,
- deathY,
- downedX,
- downedY);
- }
- else if (TryGetLivingTeammateSafeAnchor(hero, out safeX, out safeY))
- {
- // Very early room deaths can occur before the local history contains a sample.
- // A living teammate is then the only known reachable in-room location.
- downedX = safeX;
- downedY = safeY;
- _localDownedUsesRecoveryAnchor = true;
- Logger.Information(
- "[NetMod][ReviveAnchor] used living teammate fallback safeX={SafeX:0.0} safeY={SafeY:0.0}",
- downedX,
- downedY);
- }
- else if (!recentEnvironmentalDamage &&
- !unsafePosition &&
- TryProjectHeroPositionToSafeGround(hero, deathX, deathY, out var groundX, out var groundY))
- {
- // Last-resort only: no history and no living teammate. Keep a normal non-hazard
- // death where it occurred rather than manufacturing an unverified coordinate.
- downedX = groundX;
- downedY = groundY;
- }
- else if (TryGetSafeReviveAnchor(level, now, preferOlderSample: true, out safeX, out safeY))
- {
- downedX = safeX;
- downedY = safeY;
- _localDownedUsesRecoveryAnchor = true;
- }
-
- _lastLocalDamageWasEnvironmental = false;
- _lastLocalDamageContextTicks = 0;
- }
-
private bool CanConvertLocalHeroDeathToFakeDeath(Hero? self, NetNode? net)
{
if (_netRole == NetRole.None || net == null || !net.IsAlive)
@@ -773,137 +208,46 @@ private bool CanConvertLocalHeroDeathToFakeDeath(Hero? self, NetNode? net)
try
{
- if (self.destroyed || self._level == null || self.spr == null)
- return false;
- if (self.maxLife <= 0)
- return false;
- }
- catch
- {
- return false;
- }
-
- return true;
- }
-
- private bool ShouldEnterFakeDeathFromEarlyDeathHook(Hero self, NetNode net)
- {
- if (self == null || net == null)
- return false;
- if (_localFakeDead)
- return false;
- if (me == null || !ReferenceEquals(self, me))
- return false;
-
- // Guard against spawn/initialization lifecycle where kill/onDie may fire transiently.
- try
- {
- if (self._level == null || self.spr == null)
- return false;
- if (self.maxLife <= 0)
- return false;
- if (self.life > 0)
- return false;
- }
- catch
- {
- return false;
- }
-
- return true;
- }
-
- private static bool IsVanillaHeroDeathCineActive()
- {
- try
- {
- var cine = dc.pr.Game.Class.ME?.curCine;
- return cine is HeroDeath ||
- cine is HeroDeathBase ||
- cine is HeroDeathContinue ||
- cine is HeroDeathRespawn ||
- cine is HeroDeathDLCP;
+ if (self.destroyed || self._level == null || self.spr == null)
+ return false;
+ if (self.maxLife <= 0)
+ return false;
}
catch
{
return false;
}
- }
- private bool ShouldSuppressVanillaHeroDeathCinematic(Hero? lostBody)
- {
- return _netRole != NetRole.None &&
- _net != null &&
- _net.IsAlive &&
- me != null &&
- lostBody != null &&
- ReferenceEquals(lostBody, me);
+ return true;
}
- private bool SuppressVanillaHeroDeathCinematic(Hero? lostBody, dc.GameCinematic? cine)
+ private bool ShouldEnterFakeDeathFromEarlyDeathHook(Hero self, NetNode net)
{
- if (!ShouldSuppressVanillaHeroDeathCinematic(lostBody))
+ if (self == null || net == null)
+ return false;
+ if (_localFakeDead)
+ return false;
+ if (me == null || !ReferenceEquals(self, me))
return false;
- if (!_localFakeDead && lostBody != null && _net != null)
- EnterLocalFakeDeath(lostBody, _net);
-
+ // Guard against spawn/initialization lifecycle where kill/onDie may fire transiently.
try
{
- var game = dc.pr.Game.Class.ME;
- if (game != null && cine != null && ReferenceEquals(game.curCine, cine))
- game.curCine = null;
+ if (self._level == null || self.spr == null)
+ return false;
+ if (self.maxLife <= 0)
+ return false;
+ if (self.life > 0)
+ return false;
}
catch
{
+ return false;
}
- // Constructor hooks run before the vanilla cinematic object is initialized. Calling
- // destroy/disposeImmediately on that half-constructed object can tear down unrelated
- // hero state. Simply skip the constructor; startDeathCine/kill are already redirected.
return true;
}
- private void Hook__HeroDeath__constructor__(Hook__HeroDeath.orig___constructor__ orig, HeroDeath e, Hero lostBody, bool fromMob)
- {
- if (SuppressVanillaHeroDeathCinematic(lostBody, e))
- return;
-
- orig(e, lostBody, fromMob);
- }
-
- private void Hook__HeroDeathBase__constructor__(Hook__HeroDeathBase.orig___constructor__ orig, HeroDeathBase e, Hero lostBody, bool mob)
- {
- if (SuppressVanillaHeroDeathCinematic(lostBody, e))
- return;
-
- orig(e, lostBody, mob);
- }
-
- private void Hook__HeroDeathContinue__constructor__(Hook__HeroDeathContinue.orig___constructor__ orig, HeroDeathContinue e, Hero lostBody, bool keepBody)
- {
- if (SuppressVanillaHeroDeathCinematic(lostBody, e))
- return;
-
- orig(e, lostBody, keepBody);
- }
-
- private void Hook__HeroDeathRespawn__constructor__(Hook__HeroDeathRespawn.orig___constructor__ orig, HeroDeathRespawn e, Hero lostBody)
- {
- if (SuppressVanillaHeroDeathCinematic(lostBody, e))
- return;
-
- orig(e, lostBody);
- }
-
- private void Hook__HeroDeathDLCP__constructor__(Hook__HeroDeathDLCP.orig___constructor__ orig, HeroDeathDLCP e, Hero lostBody, bool fromMob)
- {
- if (SuppressVanillaHeroDeathCinematic(lostBody, e))
- return;
-
- orig(e, lostBody, fromMob);
- }
-
private void Hook_Hero_startDeathCine(Hook_Hero.orig_startDeathCine orig, Hero self)
{
if (IsDebugImmortalLocalHero(self))
@@ -1239,284 +583,6 @@ private void PruneRemoteDownedStates(NetNode net)
global::DeadCellsMultiplayerMod.Mobs.MobsSynchronization.MobsSynchronization.NotifyPlayerCombatStateChanged("remote-down-pruned");
}
- private void ApplyRemoteDownedGhostPositions(NetNode net)
- {
- if (net == null)
- return;
-
- if (_remoteDowned.Count == 0)
- {
- DisposeAllRemoteDownedCines();
- for (int i = 0; i < clients.Length; i++)
- {
- var client = clients[i];
- if (client != null)
- {
- try { client._targetable = true; } catch { }
- }
- }
- return;
- }
-
- var localId = net.id;
- var localLevelId = GetCurrentLevelId();
- _scratchActiveCorpseIds.Clear();
- foreach (var state in _remoteDowned.Values)
- {
- if (state == null || state.UserId <= 0)
- continue;
- if (!TryGetClientIndex(localId, state.UserId, out var index))
- {
- DisposeRemoteDownedCine(state.UserId);
- continue;
- }
-
- if (!string.IsNullOrEmpty(localLevelId) &&
- !string.IsNullOrEmpty(state.LevelId) &&
- !string.Equals(state.LevelId, localLevelId, StringComparison.Ordinal))
- {
- DisposeRemoteDownedCine(state.UserId);
- continue;
- }
-
- var client = clients[index];
- if (client == null)
- {
- // A fall/lava snapshot can dispose the remote shell because its temporary room
- // marker no longer matches. Recreate it immediately for the authoritative
- // same-level downed body instead of waiting for a sublevel transition.
- client = EnsureClientKingSlot(index);
- }
- if (client == null)
- {
- DisposeRemoteDownedCine(state.UserId);
- continue;
- }
- CancelPendingClientDispose(index);
-
- _scratchActiveCorpseIds.Add(state.UserId);
-
- // Never create a second corpse cinematic once the local player is also downed.
- // If this remote corpse already existed (the local player died second), keep and
- // update that single cinematic; otherwise the brief all-down state needs no new one.
- RemoteDownedCorpse? cine = null;
- if (_remoteDownedCines.TryGetValue(state.UserId, out var existingCine) && existingCine != null)
- cine = existingCine;
- else if (!_localFakeDead)
- cine = EnsureRemoteDownedCine(state, client);
-
- if (cine != null)
- {
- try
- {
- cine.UpdateTarget(
- state.X,
- state.Y,
- client.dir,
- state.HasHeadPosition ? state.HeadX : null,
- state.HasHeadPosition ? state.HeadY : null,
- state.HasHeadAnim ? state.HeadAnim : null);
- }
- catch { DisposeRemoteDownedCine(state.UserId); }
- }
-
- try { client._targetable = false; } catch { }
- try { client.setPosPixel(state.X, state.Y - DownedGhostBodyYOffsetPx); } catch { }
-
- rLastX[index] = state.X;
- rLastY[index] = state.Y - DownedGhostBodyYOffsetPx;
- }
-
- if (_remoteDownedCines.Count > 0)
- {
- _scratchStaleCorpseIds.Clear();
- foreach (var pair in _remoteDownedCines)
- {
- if (!_scratchActiveCorpseIds.Contains(pair.Key))
- _scratchStaleCorpseIds.Add(pair.Key);
- }
-
- for (int i = 0; i < _scratchStaleCorpseIds.Count; i++)
- DisposeRemoteDownedCine(_scratchStaleCorpseIds[i]);
- }
- }
-
- private bool IsRemoteDownedVisibleInCurrentLevel(int userId, string? localLevelId)
- {
- if (userId <= 0 || !_remoteDowned.TryGetValue(userId, out var state) || state == null)
- return false;
-
- if (string.IsNullOrWhiteSpace(localLevelId) || string.IsNullOrWhiteSpace(state.LevelId))
- return true;
-
- return string.Equals(localLevelId, state.LevelId, StringComparison.Ordinal);
- }
-
- private bool IsRemoteReviveVisibilityGraceActive(int userId)
- {
- if (userId <= 0 || !_remoteReviveVisibilityGraceUntilTicks.TryGetValue(userId, out var untilTicks))
- return false;
-
- if (Stopwatch.GetTimestamp() < untilTicks)
- return true;
-
- _remoteReviveVisibilityGraceUntilTicks.Remove(userId);
- return false;
- }
-
- private void BeginRemoteReviveVisibilityRecovery(
- int userId,
- int slot,
- GhostKing? client,
- double x,
- double y)
- {
- if (userId <= 0)
- return;
-
- _remoteReviveVisibilityGraceUntilTicks[userId] = Stopwatch.GetTimestamp() +
- (long)(Stopwatch.Frequency * RemoteReviveVisibilityGraceSeconds);
- _remoteLastDoorMarkers.Remove(userId);
-
- if (slot < 0 || slot >= clients.Length)
- return;
-
- CancelPendingClientDispose(slot);
- clientLastDownedOffsets[slot] = false;
-
- if (client == null)
- return;
-
- var unusable = false;
- try { unusable = client.destroyed; } catch { }
- try
- {
- if (!unusable && me?._level != null && client._level != null &&
- !ReferenceEquals(client._level, me._level))
- {
- unusable = true;
- }
- }
- catch
- {
- }
- try
- {
- if (!unusable && client.spr == null)
- unusable = true;
- }
- catch
- {
- }
-
- if (unusable)
- {
- DisposeClientSlot(slot, clearIdentity: false);
- return;
- }
-
- RestoreRemoteKingRenderAfterRevive(slot, client, x, y, "down-state-up");
- }
-
- private void RestoreRemoteKingRenderAfterRevive(
- int slot,
- GhostKing client,
- double x,
- double y,
- string reason)
- {
- if (client == null || slot < 0 || slot >= clients.Length)
- return;
-
- try
- {
- if (double.IsFinite(x) && double.IsFinite(y))
- {
- client.setPosPixel(x, y);
- rLastX[slot] = x;
- rLastY[slot] = y;
- }
- }
- catch
- {
- }
-
- var wasOutOfGame = false;
- try { wasOutOfGame = client.isOutOfGame; } catch { }
- try { client.lastOutOfGame = false; } catch { }
- try { client.isOutOfGame = false; } catch { }
- try { client.isOnScreen = true; } catch { }
- try
- {
- if (client.onScreenRecent < 1200.0)
- client.onScreenRecent = 1200.0;
- }
- catch { }
- if (wasOutOfGame)
- {
- try { client.onOutOfGameChange(); } catch { }
- }
- try { client.visible = true; } catch { }
- try { client.spr?.set_visible(true); } catch { }
- try { client._targetable = true; } catch { }
-
- try { EnsureGhostKingRenderSafe(client, "remote-revive:" + reason, detachForTransition: false); } catch { }
-
- if (clientHeads[slot] == null || client.head == null)
- ScheduleGhostHeadRecreate(slot, immediate: true);
- MarkGhostHeadDirty(slot, immediate: true);
- }
-
- private RemoteDownedCorpse? EnsureRemoteDownedCine(RemoteDownedState state, GhostKing client)
- {
- if (state == null || client == null || me == null)
- return null;
-
- if (_remoteDownedCines.TryGetValue(state.UserId, out var existing))
- {
- if (existing != null)
- return existing;
-
- _remoteDownedCines.Remove(state.UserId);
- }
-
- try
- {
- var previousCine = dc.pr.Game.Class.ME?.curCine;
- var created = new RemoteDownedCorpse(me, client, state.X, state.Y, client.dir, previousCine);
- _remoteDownedCines[state.UserId] = created;
- return created;
- }
- catch
- {
- _remoteDownedCines.Remove(state.UserId);
- return null;
- }
- }
-
- private void DisposeRemoteDownedCine(int userId)
- {
- if (!_remoteDownedCines.TryGetValue(userId, out var cine) || cine == null)
- return;
-
- _remoteDownedCines.Remove(userId);
- try { cine.destroy(); } catch { }
- try { cine.disposeImmediately(); } catch { }
- }
-
- private void DisposeAllRemoteDownedCines()
- {
- if (_remoteDownedCines.Count == 0)
- return;
-
- _scratchStaleCorpseIds.Clear();
- foreach (var id in _remoteDownedCines.Keys)
- _scratchStaleCorpseIds.Add(id);
-
- for (int i = 0; i < _scratchStaleCorpseIds.Count; i++)
- DisposeRemoteDownedCine(_scratchStaleCorpseIds[i]);
- }
-
private bool HasAliveRemoteTeammate(NetNode net)
{
var localId = net.id;
@@ -2026,43 +1092,6 @@ private void ResetReviveHold()
_reviveHoldStartedTicks = 0;
}
- private void ShowReviveHintFor(int userId)
- {
- if (_remoteDownedCines.Count == 0)
- return;
-
- foreach (var pair in _remoteDownedCines)
- {
- var cine = pair.Value;
- if (cine == null)
- continue;
-
- try
- {
- if (pair.Key == userId)
- cine.SetInteractionLabel(Localize(ReviveHintText));
- else
- cine.SetInteractionLabel(null);
- }
- catch
- {
- }
- }
- }
-
- private void ClearReviveHints()
- {
- if (_remoteDownedCines.Count == 0)
- return;
-
- foreach (var cine in _remoteDownedCines.Values)
- {
- if (cine == null)
- continue;
- try { cine.SetInteractionLabel(null); } catch { }
- }
- }
-
private bool TryConsumeOneFlask(Hero hero)
{
if (hero == null)
@@ -2158,35 +1187,6 @@ private string GetCurrentLevelId()
return string.Empty;
}
- private void StartLocalDeadCine(Hero hero)
- {
- if (hero == null)
- return;
-
- if (_localDeadCine != null)
- return;
-
- try
- {
- _localDeadCine = new DeadBase(hero, ModEntry.GetPrimaryClient());
- }
- catch
- {
- _localDeadCine = null;
- }
- }
-
- private void StopLocalDeadCine()
- {
- var cine = _localDeadCine;
- _localDeadCine = null;
- if (cine == null)
- return;
-
- try { cine.destroy(); } catch { }
- try { cine.disposeImmediately(); } catch { }
- }
-
private void ResetFakeDeathState(
bool unlockLocalHero,
bool sendNetworkUpState,
@@ -2365,38 +1365,6 @@ private void ResetAllDownedGameOverState()
_allDownedRestartAtTicks = 0;
}
- private bool TryUpdateDownedPositionFromCorpse(double corpseX, double corpseY)
- {
- // Co-op corpses are pinned to the authoritative revive point. Never let a one-frame
- // corpse physics step move the gameplay anchor downward or through floor tiles.
- if (_localFakeDead && ShouldAnchorLocalDownedCorpse())
- return false;
-
- if (!double.IsFinite(corpseX) || !double.IsFinite(corpseY))
- return false;
-
- if (!_hasLocalDownedAnchor)
- {
- _localDownedAnchorX = _localDownedX;
- _localDownedAnchorY = _localDownedY;
- _hasLocalDownedAnchor = true;
- }
-
- var dx = corpseX - _localDownedAnchorX;
- var dy = corpseY - _localDownedAnchorY;
- var distSq = dx * dx + dy * dy;
- if (distSq > DownedCorpseMaxDriftSq)
- return false;
-
- _localDownedX = corpseX;
- _localDownedY = corpseY;
- _localHeldX = _localDownedX;
- _localHeldY = _localDownedY;
- _localDownedAnchorX = corpseX;
- _localDownedAnchorY = corpseY;
- return true;
- }
-
private static void EnsureHeroVisibilityAfterRoomChange(Hero? hero)
{
if (hero == null)
@@ -2448,3 +1416,4 @@ internal static void ResetDownedPlayersForRestart()
}
}
}
+
diff --git a/GameDataSync/GameDataSync.LevelGraph.cs b/GameDataSync/GameDataSync.LevelGraph.cs
new file mode 100644
index 0000000..2feb730
--- /dev/null
+++ b/GameDataSync/GameDataSync.LevelGraph.cs
@@ -0,0 +1,417 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+using dc.level;
+using ModCore.Utilities;
+using Rand = dc.libs.Rand;
+
+namespace DeadCellsMultiplayerMod
+{
+ internal partial class GameDataSync
+ {
+ private static readonly object _levelGraphLock = new();
+ private static readonly Dictionary _remoteLevelGraphs = new(StringComparer.Ordinal);
+ private static long _nextLevelGraphSequence;
+ private static long _lastReceivedLevelGraphSequence;
+ private const int MaxRemoteLevelGraphPayloadChars = 1_000_000;
+ private const int MaxRemoteLevelGraphNodes = 4096;
+ private const int MaxCachedRemoteLevelGraphs = 8;
+ private const int RemoteLevelGraphTtlMs = 60_000;
+
+ private sealed class LevelGraphSync
+ {
+ public int V { get; set; } = 1;
+ public long Seq { get; set; }
+ public string LevelId { get; set; } = string.Empty;
+ [JsonIgnore]
+ public long ReceivedAtTick { get; set; }
+ public string? RootUid { get; set; }
+ public int ZLinkId { get; set; }
+ public double? PostGraphRandSeed { get; set; }
+ public List Nodes { get; set; } = new();
+ }
+
+ private sealed class LevelGraphNodeSync
+ {
+ public string Uid { get; set; } = string.Empty;
+ public string? ParentUid { get; set; }
+ public string? SubTeleportUid { get; set; }
+ public bool IsZRoot { get; set; }
+ public string RType { get; set; } = string.Empty;
+ public int Group { get; set; }
+ public int Id { get; set; }
+ public int Flags { get; set; }
+ public string? ForcedTemplateId { get; set; }
+ public string? ExitLevel { get; set; }
+ public string? ExitName { get; set; }
+ public int? ExitColor { get; set; }
+ public int ChildPriority { get; set; }
+ public int X { get; set; }
+ public int Y { get; set; }
+ public int SpawnDistance { get; set; }
+ public double FillerWeight { get; set; }
+ public int? ParentLinkConstraint { get; set; }
+ public List? ChildrenUids { get; set; }
+ public List? ZChildrenUids { get; set; }
+ public List? Npcs { get; set; }
+ public List? ZLinks { get; set; }
+ public LevelGraphGenDataSync? GenData { get; set; }
+ }
+
+ private sealed class LevelGraphZLinkSync
+ {
+ public int Id { get; set; }
+ public string DestUid { get; set; } = string.Empty;
+ public string? DoorId { get; set; }
+ public int? ContentClue { get; set; }
+ }
+
+ private sealed class LevelGraphGenDataSync
+ {
+ public string? SpecificBiome { get; set; }
+ public bool? ZDoorLock { get; set; }
+ public bool? ForcePauseTimer { get; set; }
+ public bool? ShouldBeFlipped { get; set; }
+ public int? GenSubTeleportTo { get; set; }
+ public LevelGraphZDoorTypeSync? ZDoorType { get; set; }
+ }
+
+ private sealed class LevelGraphZDoorTypeSync
+ {
+ public int RawIndex { get; set; }
+ public int? IntParam0 { get; set; }
+ public double? DoubleParam0 { get; set; }
+ }
+
+ public static void ReceiveLevelGraph(string payload)
+ {
+ if (string.IsNullOrWhiteSpace(payload))
+ return;
+ if (payload.Length > MaxRemoteLevelGraphPayloadChars)
+ {
+ _log?.Warning("[NetMod] Rejected oversized level graph payload ({Length} chars)", payload.Length);
+ return;
+ }
+
+ try
+ {
+ var graph = JsonSerializer.Deserialize(payload);
+ if (graph == null || string.IsNullOrWhiteSpace(graph.LevelId))
+ return;
+ if (graph.LevelId.Length > 128 || graph.Nodes == null || graph.Nodes.Count == 0 || graph.Nodes.Count > MaxRemoteLevelGraphNodes)
+ {
+ _log?.Warning(
+ "[NetMod] Rejected invalid level graph level={LevelId} nodes={Count}",
+ graph.LevelId,
+ graph.Nodes?.Count ?? -1);
+ return;
+ }
+
+ graph.ReceivedAtTick = Environment.TickCount64;
+ lock (_levelGraphLock)
+ {
+ PruneRemoteLevelGraphsLocked(graph.ReceivedAtTick);
+ if (graph.Seq > 0 && graph.Seq <= _lastReceivedLevelGraphSequence)
+ {
+ _log?.Debug(
+ "[NetMod] Ignored stale level graph seq={Seq} last={Last} level={LevelId}",
+ graph.Seq,
+ _lastReceivedLevelGraphSequence,
+ graph.LevelId);
+ return;
+ }
+
+ if (graph.Seq > 0)
+ _lastReceivedLevelGraphSequence = graph.Seq;
+ _remoteLevelGraphs[graph.LevelId] = graph;
+ TrimRemoteLevelGraphCacheLocked();
+ Monitor.PulseAll(_levelGraphLock);
+ }
+
+ _log?.Information(
+ "[NetMod] Received level graph for {LevelId} ({Count} nodes, seq={Seq})",
+ graph.LevelId,
+ graph.Nodes.Count,
+ graph.Seq);
+
+ // Lobby auto-start waits on HasPendingRemoteLevelGraph; re-arm if seed/exec
+ // already arrived before this LGRAPH.
+ GameMenu.NotifyClientLaunchPrerequisiteProgress();
+
+ var reason = LevelReloadReason.GraphUpdated;
+ // Graph and boss-rune packets can arrive in either order. Fold a pending boss-rune
+ // change into the same coalesced reload so only one reloadAfterBossRuneModif runs.
+ if (HasPendingBossRuneReloadForLevel(graph.LevelId))
+ reason |= LevelReloadReason.BossRuneChanged;
+ ScheduleLevelReload(graph.LevelId, reason, payload);
+ }
+ catch (Exception ex)
+ {
+ _log?.Warning("[NetMod] Failed to parse level graph sync: {Message}", ex.Message);
+ }
+ }
+
+ private static void PruneRemoteLevelGraphsLocked(long now)
+ {
+ if (_remoteLevelGraphs.Count == 0)
+ return;
+
+ List? expired = null;
+ foreach (var pair in _remoteLevelGraphs)
+ {
+ var receivedAt = pair.Value?.ReceivedAtTick ?? 0;
+ if (receivedAt > 0 && now - receivedAt > RemoteLevelGraphTtlMs)
+ {
+ expired ??= new List();
+ expired.Add(pair.Key);
+ }
+ }
+
+ if (expired == null)
+ return;
+ foreach (var key in expired)
+ _remoteLevelGraphs.Remove(key);
+ }
+
+ private static void TrimRemoteLevelGraphCacheLocked()
+ {
+ while (_remoteLevelGraphs.Count > MaxCachedRemoteLevelGraphs)
+ {
+ string? oldestKey = null;
+ long oldestTick = long.MaxValue;
+ foreach (var pair in _remoteLevelGraphs)
+ {
+ var tick = pair.Value?.ReceivedAtTick ?? 0;
+ if (oldestKey == null || tick < oldestTick)
+ {
+ oldestKey = pair.Key;
+ oldestTick = tick;
+ }
+ }
+
+ if (oldestKey == null)
+ break;
+ _remoteLevelGraphs.Remove(oldestKey);
+ }
+ }
+
+ internal static bool HasPendingRemoteLevelGraph(string? levelId)
+ {
+ if (string.IsNullOrWhiteSpace(levelId))
+ return false;
+
+ lock (_levelGraphLock)
+ {
+ return _remoteLevelGraphs.ContainsKey(levelId);
+ }
+ }
+
+ private static bool HasRemoteLevelGraphCached(string levelId)
+ {
+ if (string.IsNullOrWhiteSpace(levelId))
+ return false;
+
+ lock (_levelGraphLock)
+ {
+ return _remoteLevelGraphs.ContainsKey(levelId);
+ }
+ }
+
+ public static void SendLevelGraph(string levelId, RoomNode? root, LevelStruct? graph, Rand? rng, NetNode? net)
+ {
+ if (net == null || !net.IsAlive)
+ {
+ _log?.Information("[NetMod] Skip level graph send for {LevelId}: net unavailable", levelId);
+ return;
+ }
+
+ if (graph == null || string.IsNullOrWhiteSpace(levelId))
+ {
+ _log?.Warning("[NetMod] Skip level graph send: invalid graph/levelId (level={LevelId})", levelId);
+ return;
+ }
+
+ try
+ {
+ var sync = CaptureLevelGraph(levelId, graph);
+ if (sync == null)
+ {
+ _log?.Warning("[NetMod] CaptureLevelGraph returned null for {LevelId} (allLen={AllLen})", levelId, graph.all?.length ?? -1);
+ return;
+ }
+
+ if (sync.Nodes.Count == 0)
+ {
+ _log?.Warning("[NetMod] Captured empty level graph for {LevelId} (allLen={AllLen})", levelId, graph.all?.length ?? -1);
+ return;
+ }
+
+ try
+ {
+ sync.RootUid = root?.uid?.ToString();
+ }
+ catch
+ {
+ }
+
+ try
+ {
+ if (rng != null)
+ sync.PostGraphRandSeed = rng.seed;
+ }
+ catch
+ {
+ }
+
+ sync.Seq = Interlocked.Increment(ref _nextLevelGraphSequence);
+ var json = JsonSerializer.Serialize(sync);
+ net.SendLevelGraph(levelId, json);
+ _log?.Information("[NetNode] Sent level graph for {LevelId} ({Count} nodes, seq={Seq}, postRand={PostRand})",
+ levelId,
+ sync.Nodes.Count,
+ sync.Seq,
+ sync.PostGraphRandSeed.HasValue ? sync.PostGraphRandSeed.Value.ToString(CultureInfo.InvariantCulture) : "n/a");
+ }
+ catch (Exception ex)
+ {
+ _log?.Warning("[NetMod] Failed to send level graph for {LevelId}: {Message}", levelId, ex.Message);
+ }
+ }
+
+ public static bool TryApplyRemoteLevelGraph(string levelId, LevelStruct? graph, Rand? rng, int timeoutMs, out RoomNode? appliedRoot, out string reason)
+ {
+ reason = string.Empty;
+ appliedRoot = null;
+ if (graph == null || string.IsNullOrWhiteSpace(levelId))
+ {
+ reason = "invalid arguments";
+ return false;
+ }
+
+ if (!TryWaitGetRemoteLevelGraph(levelId, timeoutMs, out var remoteGraph))
+ {
+ reason = "remote graph not received";
+ return false;
+ }
+
+ if (remoteGraph == null || remoteGraph.Nodes == null || remoteGraph.Nodes.Count == 0)
+ {
+ reason = "remote graph payload empty";
+ return false;
+ }
+
+ var applied = ApplyLevelGraph(graph, remoteGraph, out appliedRoot, out reason);
+ if (applied && rng != null && remoteGraph.PostGraphRandSeed.HasValue)
+ {
+ try
+ {
+ rng.seed = remoteGraph.PostGraphRandSeed.Value;
+ }
+ catch (Exception ex)
+ {
+ applied = false;
+ reason = "failed to apply post-graph rand seed: " + ex.Message;
+ }
+ }
+
+ ConsumeRemoteLevelGraph(levelId);
+ if (!applied)
+ return false;
+
+ // FTL / HL cast correlation: pair with host "Sent level graph" and client combat/restart logs.
+ // Repro surface: client PrisonStart (or any level) with remote graph, host_restart, then dive/combat.
+ try
+ {
+ var rootUid = remoteGraph.RootUid ?? "?";
+ try
+ {
+ if (appliedRoot != null)
+ rootUid = appliedRoot.uid?.ToString() ?? rootUid;
+ }
+ catch
+ {
+ }
+
+ _log?.Information(
+ "[NetMod] Remote level graph applied (FTL correlation) levelId={LevelId} nodes={Count} rootUid={RootUid} postRandSeed={PostRand} postRandApplied={PostRandApplied}",
+ levelId,
+ remoteGraph.Nodes.Count,
+ rootUid,
+ remoteGraph.PostGraphRandSeed.HasValue
+ ? remoteGraph.PostGraphRandSeed.Value.ToString(CultureInfo.InvariantCulture)
+ : "n/a",
+ rng != null && remoteGraph.PostGraphRandSeed.HasValue);
+ }
+ catch
+ {
+ }
+
+ return true;
+ }
+
+ private static bool TryWaitGetRemoteLevelGraph(string levelId, int timeoutMs, out LevelGraphSync? graph)
+ {
+ graph = null;
+ if (string.IsNullOrWhiteSpace(levelId))
+ return false;
+
+ var deadline = Environment.TickCount64 + Math.Max(0, timeoutMs);
+ lock (_levelGraphLock)
+ {
+ while (true)
+ {
+ var now = Environment.TickCount64;
+ PruneRemoteLevelGraphsLocked(now);
+ if (_remoteLevelGraphs.TryGetValue(levelId, out var found))
+ {
+ graph = found;
+ return true;
+ }
+
+ var remaining = deadline - now;
+ if (timeoutMs <= 0 || remaining <= 0)
+ return false;
+
+ // LGRAPH is parsed by the network fast path, so waiting on the graph lock is
+ // enough. Processing arbitrary main-thread actions from inside LevelGen caused
+ // re-entrant level/dispose/ghost work while the graph was only half built.
+ Monitor.Wait(_levelGraphLock, (int)Math.Min(remaining, 250));
+ }
+ }
+ }
+
+ private static void ConsumeRemoteLevelGraph(string levelId)
+ {
+ if (string.IsNullOrWhiteSpace(levelId))
+ return;
+
+ lock (_levelGraphLock)
+ {
+ _remoteLevelGraphs.Remove(levelId);
+ }
+ }
+
+ internal static void ResetTransientNetworkState()
+ {
+ lock (_levelSeedLock)
+ {
+ _remoteLevelId = null;
+ _remoteLevelSeed = null;
+ }
+
+ lock (_levelGraphLock)
+ {
+ _remoteLevelGraphs.Clear();
+ _lastReceivedLevelGraphSequence = 0;
+ Monitor.PulseAll(_levelGraphLock);
+ }
+ Interlocked.Exchange(ref _nextLevelGraphSequence, 0);
+
+ ResetLevelReloadState();
+ ClearPendingBossRuneReloadState();
+ }
+ }
+}
diff --git a/GameDataSync/GameDataSync.Rest.cs b/GameDataSync/GameDataSync.LevelGraphCapture.cs
similarity index 57%
rename from GameDataSync/GameDataSync.Rest.cs
rename to GameDataSync/GameDataSync.LevelGraphCapture.cs
index fed4ec8..045241e 100644
--- a/GameDataSync/GameDataSync.Rest.cs
+++ b/GameDataSync/GameDataSync.LevelGraphCapture.cs
@@ -1,25 +1,15 @@
-using DeadCellsMultiplayerMod.Interface.ModuleInitializing;
-using ModCore.Events;
using dc;
using dc.haxe.ds;
using dc.level;
-using dc.pr;
-using dc.tool;
using Hashlink.Virtuals;
using HaxeProxy.Runtime;
using ModCore.Utilities;
using System;
using System.Collections.Generic;
using System.Globalization;
-using System.Runtime.InteropServices;
using System.Reflection;
-using System.Text;
-using System.Text.Json;
-using System.Threading;
using dc.haxe;
-using dc.haxe.io;
using dc.hl.types;
-using Rand = dc.libs.Rand;
namespace DeadCellsMultiplayerMod
@@ -27,703 +17,6 @@ namespace DeadCellsMultiplayerMod
internal partial class GameDataSync
{
- private static void CopyStoryVisitedLoreRoomsToSet(dynamic? map, HashSet target)
- {
- target.Clear();
- if (map == null)
- return;
-
- try
- {
- var keys = map.keys.Invoke();
- while (keys.hasNext.Invoke())
- {
- var keyObj = keys.next.Invoke();
- if (keyObj == null)
- continue;
-
- var key = keyObj.ToString();
- if (string.IsNullOrWhiteSpace(key))
- continue;
-
- var raw = map.get.Invoke(keyObj);
- var visited = raw is bool b ? b : ToInt(raw) != 0;
- if (visited)
- target.Add(key);
- }
- }
- catch
- {
- }
- }
-
- private static void CopyStoryPlannedLoresToList(ArrayBytes_Int? source, List target)
- {
- target.Clear();
- if (source == null)
- return;
-
- var seen = new HashSet();
- for (var i = 0; i < source.length; i++)
- {
- int planned;
- try
- {
- planned = ToInt(source.getDyn(i));
- }
- catch
- {
- continue;
- }
-
- if (seen.Add(planned))
- target.Add(planned);
- }
- }
-
- private static StringMap BuildCountersMap(Dictionary values)
- {
- var map = new StringMap();
- foreach (var kv in values)
- map.set(kv.Key.AsHaxeString(), kv.Value);
- return map;
- }
-
- private static EnumValueMap BuildNpcProgressMap(Dictionary values)
- {
- var map = new EnumValueMap();
- foreach (var kv in values)
- {
- var npcId = CreateNpcIdFromIndex(kv.Key);
- if (npcId == null)
- continue;
-
- map.set(npcId, kv.Value);
- }
- return map;
- }
-
- private static void ApplyStoryStringIntMap(dynamic? map, Dictionary values)
- {
- if (map == null)
- return;
-
- try
- {
- var keysToRemove = new List