diff --git a/docs/GHOST_MECH_ANALYSIS.md b/docs/GHOST_MECH_ANALYSIS.md index 7bd825e..b698cca 100644 --- a/docs/GHOST_MECH_ANALYSIS.md +++ b/docs/GHOST_MECH_ANALYSIS.md @@ -1,7 +1,32 @@ # The GHOST MECH / zombie-wreck failure — field analysis 2026-07-29 -**Status:** mechanism established [T1/T2]; the missing link (why the drop-zone handshake fails) is -still open. Field session: 2026-07-29 night, Steam MP, 5 players, build 4.11.642. Raw logs and the +## ✅ FIXED 2026-07-30 — two defects that were masking each other +**1. The death latch never released on failure.** The binary's `FUN_004c012c` tail is +`Post(...)` → `*(this+0x290) = 0` → `*(this+0x258) = 0`. We had the `Post` and the +`suppressConsole` and were **missing the middle instruction**, so `deathPending` cleared only on +SUCCESS paths. One failed respawn latched the pilot for the rest of the mission — every later death +hit the dedup and was SWALLOWED, so the cycle could never restart. That is what made a transient +hiccup a PERMANENT ghost, and it matches the field signature exactly (8 cycles, 6 stranded, none +recovering). Fixed in `btplayer.cpp` (death-handler tail). +**2. `VehicleDeadMessage` was dispatched TWICE per death.** `BTPostKillScore` (`btplayer.cpp:2263`) +sent a second one "to credit a death" — but that message is the RESPAWN-CYCLE TRIGGER, not a +scoreboard increment, and the tally is already credited by the handler's `++deathTally` (`:538`). +Both fire inside the same death transition (`mech4.cpp:2006` and `:2110`), so they were always +paired. Removed. +**They hid each other:** the duplicate made the latch look necessary, and the latch made the +duplicate invisible — every `SWALLOWED` warning in the field logs was simply the latch deduping our +own duplicate (8 of 8 deaths, a 100 % base rate, which is why it correlated with nothing). Fixing +either alone makes things visibly worse, which is why earlier passes at #57/#55 kept adding +clear-sites instead of finding the root. The binary broke the tie. + +**Verified:** solo **17 consecutive death/respawn cycles**, every one `START`→`RESET`, 0 swallowed / +0 mismatch / 0 crash (pre-fix this strands permanently after cycle 1). Two-node MP over a real +network path with cross-machine drop-zone replies: A 11 cycles, B 12, 0 swallowed / 0 mismatch / +0 discarded / 0 crash. Bench: `scratchpad/night6/mp_ghost.sh`, `BT_SELF_DAMAGE_REPEAT=1`. + +**Status of the analysis below:** the mechanism write-up that led to the fix. The instrumentation +(`[dz]`, `[dzreq]`, `[ghost]`) stays in — it is what will prove the fix in the field, and the +cross-machine dependency in §4 is still a real fragility even with the latch fixed. Field session: 2026-07-29 night, Steam MP, 5 players, build 4.11.642. Raw logs and the verbatim agent findings are in `scratchpad/night6/` (uncommitted — they contain machine names and Steam identities). Tracker: #81 (zombie wreck), #57 (deathPending latch), #45 (tally replication). diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index dede7e5..3c6f123 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -641,6 +641,38 @@ void } // else: out of lives -> the +10s mission-review post (id 0x18). Deferred. + // + // RELEASE THE DEATH LATCH (#81, the GHOST MECH fix -- 2026-07-30). + // + // The binary clears it right here, between the re-post and suppressConsole: + // FUN_004c012c's tail is Post(...) ; *(this+0x290) = 0 ; *(this+0x258) = 0 + // (part_013.c:10519-10523). We had the Post and the suppressConsole and + // were missing the middle instruction, so `deathPending` -- which we DO set + // (:505, and the dedup gate that reads it IS authentic; the binary's own + // @004c05c4 does `mov edx,[ebx+0x290]; test edx,edx; jne ret`) -- was only + // ever cleared on the SUCCESS paths. One failed respawn therefore latched + // the pilot dead for the rest of the mission: every later death hit the + // dedup and was SWALLOWED, so the cycle could never restart. That is what + // turned a transient respawn hiccup into a PERMANENT ghost (dead, + // un-Reset, still driveable, a burning wreck on every peer that sinks out + // of the world after ~18 s and can never be drawn again). + // + // Field signature it explains exactly: 2026-07-29, 8 death cycles, 6 + // stranded, NONE of them ever recovering. + // + // Binary evidence that the latch must not persist: `+0x290` is written in + // exactly THREE places in the whole of BTL4OPT.EXE (file offsets 0x0b75fb, + // 0x0bffe3, 0x0c0a05) and ALL THREE store a zeroed register (`xor` on the + // preceding instruction); there is no write of 1 -- or of any non-zero + // value, in any instruction form -- anywhere in the executable. So in 1995 + // the gate exists but can never block. [T1] + // + // Ordering matters and is preserved: the re-entrant death that arrives + // while the death EFFECTS are being dispatched still lands while the latch + // is up, so the "one death, one cycle" dedup is untouched. + // + deathPending = 0; // this+0x290 (binary: FUN_004c012c tail) + suppressConsole = 0; // this+0x258 } @@ -2222,17 +2254,31 @@ void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP deat (int)(k != 0 && ((Mech *)k)->GetPlayerLink() != 0)); } - // MP DEATH: credit a death to the VICTIM's own player. NULL for the solo - // BT_SPAWN_ENEMY dummy (GetPlayerLink()==0) -> skipped, so DEATHS stays 0 in - // solo (authentic -- DEATHS only lands on a real pilot in multiplayer). - BTPlayer *victim_player = (BTPlayer *)((Mech *)victim)->GetPlayerLink(); - if (victim_player != 0) - { - Player::VehicleDeadMessage dead( - Player::VehicleDeadMessageID, // 0x13 - sizeof(Player::VehicleDeadMessage)); - victim_player->Dispatch(&dead); - } + // MP DEATH: the victim's death tally. + // + // REMOVED 2026-07-30 (#81): this used to dispatch a second + // `Player::VehicleDeadMessage` to the victim's own player "to credit a + // death". That message is the RESPAWN-CYCLE TRIGGER, not a scoreboard + // increment, and the victim already receives one from the mech's death + // transition (mech4.cpp:2110) -- which is the hardened, authentic notify + // (it carries the #55 NULL-playerLink fallback). Both fire inside the SAME + // death transition (BTPostKillScore is called at mech4.cpp:2006, the notify + // at :2110), so they were always paired: every death dispatched the message + // TWICE. The tally itself is credited by the handler's `++deathTally` + // (:538) on the first one, so this dispatch never added anything. + // + // It was invisible because the `deathPending` latch silently deduped it -- + // that is what EVERY "death ... SWALLOWED" warning in the field logs + // actually was (8 of 8 deaths, a 100% base rate, which is exactly why it + // correlated with nothing). Once the latch was released to match the + // binary (see the death handler's tail), the duplicate stopped being + // masked and started a SECOND death cycle: deathCount double-incremented, + // the first cycle's re-post went stale and tripped the drop-zone + // `*** MISMATCH ***`, and one respawn burned two cycles. Caught on the + // first solo bench of the latch fix. + // + // The kill credit above is unaffected -- it dispatches a ScoreMessage, the + // correct message for a scoreboard change. } diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 02d60ea..dd39268 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -5667,6 +5667,15 @@ void if ((Entity *)this == application->GetViewpointEntity() && IsMechDestroyed()) s_sdSpent = 1; + // BT_SELF_DAMAGE_REPEAT=1: re-arm once we are alive again, so the bench + // can drive MULTIPLE death/respawn cycles. Needed to regression-test + // the #81 death-latch fix -- the whole point of that fix is that the + // SECOND death starts a fresh cycle instead of being swallowed forever, + // which a one-death harness can never exercise. + if (s_sdSpent && getenv("BT_SELF_DAMAGE_REPEAT") + && (Entity *)this == application->GetViewpointEntity() + && !IsMechDestroyed()) + s_sdSpent = 0; if ((Entity *)this == application->GetViewpointEntity() && getenv("BT_SELF_DAMAGE") && !s_sdSpent && !IsMechDestroyed() && damageZoneCount > 0) diff --git a/scratchpad/night6/mp_ghost.sh b/scratchpad/night6/mp_ghost.sh index e60a1ad..cf4c283 100644 --- a/scratchpad/night6/mp_ghost.sh +++ b/scratchpad/night6/mp_ghost.sh @@ -14,11 +14,11 @@ sed 's/^experience=expert/experience=novice/' MP.EGG > MPG.EGG # B first (listener), then A -- same order as the working mp_skate.sh BT_LOG=mp_b.log BT_AFFINITY=0x2 BT_MP_LOG=1 BT_SCORE_LOG=1 BT_DEATH_LOG=1 \ - BT_SELF_DAMAGE=60 BT_SELF_DAMAGE_DELAY=40 \ + BT_SELF_DAMAGE=60 BT_SELF_DAMAGE_DELAY=40 BT_SELF_DAMAGE_REPEAT=1 \ ../build/Release/btl4.exe -egg MPG.EGG -net 1601 & sleep 2 BT_LOG=mp_a.log BT_AFFINITY=0x1 BT_MP_LOG=1 BT_SCORE_LOG=1 BT_DEATH_LOG=1 \ - BT_SELF_DAMAGE=60 BT_SELF_DAMAGE_DELAY=55 \ + BT_SELF_DAMAGE=60 BT_SELF_DAMAGE_DELAY=55 BT_SELF_DAMAGE_REPEAT=1 \ ../build/Release/btl4.exe -egg MPG.EGG -net 1501 & sleep 4 python ../tools/btconsole.py MPG.EGG 127.0.0.1:1501 127.0.0.1:1601 &