From 0dbdad17f3125973ef3b217154db643afd7ac30e Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Thu, 30 Jul 2026 01:08:01 -0500 Subject: [PATCH] #81 ghost: light the SILENT DISCARD -- the only path a respawn can vanish on Walked the respawn handshake end to end and measured the assumptions. Two eliminations and one find: ELIMINATED: the engine hunt re-posts to itself every 2s forever while the mech is dead (PLAYER.cpp:325-327; its only state-based exit never fires, see below), and the DropZone re-grants the SAME slot to a repeat request from the same requester+deathCount even while busy (DROPZONE.cpp:182-194). So neither a transient "no slot" nor a single lost reply can strand a respawn -- the request keeps being re-sent and re-granted. A permanent strand needs a permanent cause. THE FIND: BTPlayer::DropZoneReplyMessageHandler ends if (!playerVehicle) ... else if (deathCount == message->deathCount) ... else { return; } and that last branch was a BARE RETURN WITH NO LOG. The drop zone grants a spot, replies, the numbers disagree, the reply is dropped, deathPending stays latched, the mech is never Reset -> permanent ghost, zero evidence. That is why 6 of 8 field cycles stranded silently. Now always-on, printing the DIRECTION of the mismatch: msgDeath < ours = genuinely stale (dropping is right); msgDeath > ours = our counter is behind and we just threw away a LIVE respawn -> points straight at #45 (the death tally does not replicate). Deliberately NOT auto-recovered: guessing would be a stand-in, and the wrong guess resets a mech that is still alive. LANDMINE DOCUMENTED IN CODE: Set_Alarm_Level is an empty stub (btstubs.cpp:87), so the death path's Set_Alarm_Level(this+0x2c,1) and the reply's (+0x2c,2) are no-ops. Their values decode against Player's enum as DropZoneAcquiredState(1) and VehicleTranslocatedState(2), which makes "these should obviously be SetSimulationState() calls" both attractive and CATASTROPHIC: the engine hunt is gated on GetSimulationState() != DropZoneAcquiredState, so setting 1 on death would stop AssignDropZone ever being dispatched and ghost EVERY pilot. Measured our simulationState at 0x24 (not 0x2c) and the write leaves it 0; in the binary +0x2c is the Simulation-base alarm (the field the mech side calls graphicAlarm, @0x4ac126 "owner alarm+0x2C -> level 9"), which our layout models only on Mech. Verified solo: a healthy death+respawn logs the grant and the RESET and emits ZERO discard/gate-off lines (no false positives). Co-Authored-By: Claude Fable 5 --- docs/GHOST_MECH_ANALYSIS.md | 45 ++++++++++++++++++++ game/reconstructed/btplayer.cpp | 75 +++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/docs/GHOST_MECH_ANALYSIS.md b/docs/GHOST_MECH_ANALYSIS.md index f4b815e..1e07497 100644 --- a/docs/GHOST_MECH_ANALYSIS.md +++ b/docs/GHOST_MECH_ANALYSIS.md @@ -56,6 +56,51 @@ this behaviour is authoritative for what we compile, *not* for what the pods did unrecovered gap. Recovering it (byte-scan + windowed disasm, the technique that recovered `FUN_004a6344`) is the open task. +## 2026-07-30 dig: the respawn handshake, walked end to end +Re-read the whole chain (death → +5 s re-post → engine hunt → DropZone grant → reply → Reset) and +**measured** the parts that were assumptions. Results, in order of importance: + +**1. The engine hunt RETRIES EVERY 2 SECONDS, forever, while the mech is dead** [T0] +(`PLAYER.cpp:325-327` posts the message back to itself unconditionally; its only state-based exit is +`GetSimulationState() == DropZoneAcquiredState`, which never happens — see 3). The DropZone also has +a resend net: a repeat request from the same requester + same `deathCount` re-grants the **same** +slot even while it is busy (`DROPZONE.cpp:182-194`). **So "a transient no-slot" and "one lost reply" +are both ELIMINATED as causes** — the request keeps being re-sent and re-granted every 2 s. A +permanent strand needs a permanent reason. + +**2. THE SILENT DISCARD — the one remaining unlit path, now instrumented** [T1] +`BTPlayer::DropZoneReplyMessageHandler` is `if (!playerVehicle) … else if (deathCount == +message->deathCount) … else { return; }`. That final branch was a **bare return with no logging**: +the drop zone granted a spot and replied, the numbers disagreed, and the reply was dropped — +`deathPending` stays latched, the mech is never `Reset`, and the pilot is a permanent ghost **with +zero trace in the log**. That is exactly why 6 of 8 field cycles stranded leaving no evidence. Now +always-on (`[ghost] DROP-ZONE REPLY DISCARDED …`) and it prints the **direction** of the mismatch, +which decides the fix: `msgDeath < ours` = genuinely stale (dropping is correct); `msgDeath > ours` = +**our counter is behind and we threw away a live respawn** — which points straight at #45 (the death +tally does not replicate correctly). Deliberately NOT "recovered" yet: guessing the direction would +be a stand-in, and the wrong guess respawns a mech that is still alive. + +**3. ⚠ LANDMINE — do not convert the two `Set_Alarm_Level` raw writes to `SetSimulationState()`.** +`Set_Alarm_Level` is an **empty stub** (`btstubs.cpp:87`), so the death path's +`Set_Alarm_Level(this+0x2c, 1)` and the reply path's `(this+0x2c, 2)` are **no-ops** today. Their +values decode perfectly against `Player`'s enum (`PLAYER.h:273-279`) as `DropZoneAcquiredState`(1) +and `VehicleTranslocatedState`(2) — `DropZoneAcquiredState = Entity::StateCount`, and `Entity` adds +no states, so it is **1**. That makes "obviously these should be `SetSimulationState` calls" a very +attractive and **catastrophic** fix: the engine hunt is gated on `GetSimulationState() != +DropZoneAcquiredState`, so setting state 1 on death would stop `AssignDropZone` from ever being +dispatched and make **every** pilot ghost permanently. +Measured: our `BTPlayer::simulationState` is at **0x24**, not 0x2c, and the write leaves it at 0 +(`[ghost] death raw-write(+0x2c,1): simState 0 -> 0 (hunt gate still open)`). In the binary `+0x2c` +is the **Simulation-base alarm** — the same field the mech side calls `graphicAlarm` (`@0x4ac126`: +"owner alarm+0x2C -> level 9"). Our layout models that only as a `Mech` member, so a `BTPlayer` has +nowhere to put it; the stub is harmless until we recover what reads a *Player's* alarm in the binary +(likely a cockpit/HUD respawn indicator). [T1 offsets, T3 purpose] + +**Revised theory of the ghost:** not drop-zone starvation (see below) and not a lost reply (see 1), +but a **`deathCount` mismatch between the player and the reply**, silently discarding a live +respawn — MP-specific in a way solo structurally cannot reproduce (solo respawn works every time; +6 of 8 MP cycles stranded), and directly coupled to the known #45 replication defect. + ## What the instrumentation already killed `slots=8` (measured, `[dz] POOL`). An 8-slot pool **cannot** be cooldown-starved by 5 players (at most 5 slots on cooldown at once), so the shared `dropzone=one` in `tools/eggmodel.py:42` is **not** diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index 3fde367..dede7e5 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -429,6 +429,15 @@ void static int s_forDeath = -999; if (s_forDeath != message->deathCount) { s_forDeath = message->deathCount; s_tries = 0; } ++s_tries; + // #81: the OTHER gate. The engine hunt (PLAYER.cpp:229) requires + // BOTH message->deathCount == deathCount AND simState != + // DropZoneAcquiredState. Report both, so a stranded respawn names + // its own cause instead of being inferred. + if (GetSimulationState() == (unsigned)DropZoneAcquiredState) + DEBUG_STREAM << "[ghost] hunt GATED OFF: simState=" + << (int)GetSimulationState() << " == DropZoneAcquiredState" + << " -- the engine will not dispatch AssignDropZone, so no" + << " reply, no RESET, permanent ghost\n" << std::flush; if (s_tries <= 3 || (s_tries % 5) == 0) DEBUG_STREAM << "[dzreq] player " << BTMatchHostOf(GetEntityID()) << ":" << (int)GetEntityID() << " asking for a drop zone" @@ -578,6 +587,25 @@ void DEBUG_STREAM << "[respawn] BUG (Gitea #59): the death path left this " "player's watchers DELAYED -- ExecuteWatchers is dead for it now\n" << std::flush; + // ⚠ LANDMINE, MEASURED 2026-07-30 (#81 dig). `Set_Alarm_Level` is an empty + // STUB (btstubs.cpp:87), so this write and its partner in + // DropZoneReplyMessageHandler (+0x2c, 2) are NO-OPS today. + // + // DO NOT "fix" them by calling SetSimulationState(). The values 1 and 2 + // decode against Player's enum as DropZoneAcquiredState and + // VehicleTranslocatedState (PLAYER.h:273-279), which makes that conversion + // look obviously right -- and it would BREAK RESPAWN ENTIRELY: the engine's + // respawn hunt is gated on `GetSimulationState() != DropZoneAcquiredState` + // (PLAYER.cpp:231), so setting state 1 on death means AssignDropZone is + // never dispatched for that death and EVERY pilot ghosts permanently. + // + // +0x2c is not simulationState (ours is at 0x24, measured). In the binary + // it is the Simulation-base ALARM indicator -- the same field the mech side + // calls `graphicAlarm` (@0x4ac126 sets "owner alarm+0x2C -> level 9"). Our + // layout models that only as a Mech member, so a BTPlayer has nowhere to + // put it; the stub is therefore harmless until we find what reads a + // PLAYER's alarm in the binary (unrecovered; likely a cockpit/HUD respawn + // indicator). [T1 offsets, T3 purpose] Set_Alarm_Level((char *)this + 0x2c, 1); // FUN_0041bbd8(this+0x2c, 1) // @@ -1438,6 +1466,36 @@ void } else // stale / old message { + // + // #81 GHOST: THIS IS THE LAST PLACE A RESPAWN CAN VANISH WITHOUT A + // TRACE. The drop zone granted us a spot and dispatched the reply, but + // its deathCount does not match ours -- so the reply is dropped, the + // mech is never Reset, `deathPending` stays latched, and the pilot is a + // permanent GHOST: dead, un-reset, still simulated and driveable, and a + // burning wreck on every peer (which then sinks after ~18s and can + // never be drawn again, btl4vid.cpp:1277). Until now this branch was a + // bare `return` with no log at all, which is exactly why 6 of 8 field + // death cycles stranded on 2026-07-29 leaving no evidence. + // + // ALWAYS ON, and it prints the DIRECTION of the mismatch, which decides + // the fix: msgDeath < ours means the reply is genuinely stale (a + // superseded death) and dropping it is correct; msgDeath > ours means + // OUR counter is behind (a missed increment / a replicated write -- cf. + // #45, the death tally does not replicate correctly) and the reply is + // for a live death we should have honoured. Do not "recover" here + // until the field logs say which way it goes -- guessing would be a + // stand-in, and the wrong guess re-spawns a mech that is still alive. + // + DEBUG_STREAM << "[ghost] DROP-ZONE REPLY DISCARDED for player " + << BTMatchHostOf(GetEntityID()) << ":" << (int)GetEntityID() + << " -- msgDeath=" << message->deathCount + << " ourDeathCount=" << deathCount + << (message->deathCount < deathCount + ? " (reply is STALE -- superseded death)" + : " *** OUR COUNTER IS BEHIND -- this was a live respawn and we just threw it away ***") + << " deathPending=" << deathPending + << (deathPending != 0 ? " <== LATCHED: this pilot is now a GHOST" : "") + << "\n" << std::flush; return; } @@ -1543,6 +1601,23 @@ BTPlayer::BTPlayer( << " deathPending@0x" << (int)offsetof(BTPlayer, deathPending) << std::dec << " <-- the raw write targets byte 0x284\n" << std::flush; + // #81 GHOST DIG: the death path does Set_Alarm_Level(this+0x2c, 1) + // and the drop-zone reply does (this+0x2c, 2). Decoded against + // Player's enum those are DropZoneAcquiredState(1) and + // VehicleTranslocatedState(2) -- i.e. the BINARY's +0x2c is the + // Player's simulationState. The engine's respawn hunt is gated on + // `GetSimulationState() != DropZoneAcquiredState` + // (engine/MUNGA/PLAYER.cpp:229), so if that raw byte really is our + // simulationState, EVERY death marks "drop zone already acquired" + // and the hunt can never run -> permanent ghost. Print where our + // compiled layout actually puts it. + DEBUG_STREAM << "[layout] simulationState@0x" << std::hex + << (int)((char *)&simulationState - (char *)this) + << std::dec << " (raw death write targets 0x2c;" + << " DropZoneAcquiredState=" << (int)DropZoneAcquiredState + << " VehicleTranslocatedState=" << (int)VehicleTranslocatedState + << ") liveState=" << (int)GetSimulationState() << "\n" + << std::flush; } }