From a357dc4265a8a90a5d4e8eb0021b8c594b1a7b3e Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Thu, 30 Jul 2026 08:49:27 -0500 Subject: [PATCH] #81 GHOST MECH FIXED: release the death latch + stop the duplicate VehicleDead Two defects that were masking each other, both now fixed and benched. 1) THE LATCH NEVER RELEASED ON FAILURE. The binary's FUN_004c012c 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 cleared only on SUCCESS paths. One failed respawn latched the pilot for the whole mission: every later death hit the dedup and was SWALLOWED, so the cycle could never restart -- a transient hiccup became a PERMANENT ghost (dead, un-Reset, still driveable, a burning wreck on every peer that sinks after ~18s and can never be drawn again). Binary evidence: +0x290 is written in exactly THREE places in all of BTL4OPT.EXE (0x0b75fb, 0x0bffe3, 0x0c0a05) and all three store a ZEROED register; there is no write of 1 -- or any non-zero, in any instruction form -- anywhere. The dedup gate itself IS authentic (@004c05c4 does mov edx,[ebx+0x290]; test edx,edx; jne ret), so it is kept. 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 (BTPostKillScore at mech4.cpp:2006, the hardened authentic notify at :2110), so they were always paired. Removed; the kill credit above it is untouched (it correctly uses a ScoreMessage). THEY HID EACH OTHER: the duplicate made the latch look necessary, and the latch made the duplicate invisible. Every "death ... SWALLOWED" warning in the field logs was just the latch deduping our own duplicate -- 8 of 8 deaths, a 100% base rate, which is exactly why it correlated with nothing when tested. Fixing either alone makes things visibly worse (the first bench of fix 1 alone produced a DOUBLE cycle: deathCount double-incremented, cycle 1's re-post gone stale and tripping the drop-zone MISMATCH). That 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. MP : two nodes over a real network path with cross-machine drop-zone replies (each node's request is answered by the OTHER machine) -- A 11 cycles, B 12, 0 swallowed / 0 mismatch / 0 discarded / 0 crash. harness: BT_SELF_DAMAGE_REPEAT=1 re-arms the self-damage bench after respawn so multiple cycles can be driven (a one-death harness can never exercise this fix). scratchpad/night6/mp_ghost.sh. Co-Authored-By: Claude Fable 5 --- docs/GHOST_MECH_ANALYSIS.md | 29 +++++++++++++- game/reconstructed/btplayer.cpp | 68 +++++++++++++++++++++++++++------ game/reconstructed/mech4.cpp | 9 +++++ scratchpad/night6/mp_ghost.sh | 4 +- 4 files changed, 95 insertions(+), 15 deletions(-) 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 &