From ac7c46b87a2e5f7f14e9a023b9d75e395d810d8e Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Fri, 31 Jul 2026 10:19:24 -0500 Subject: [PATCH] #93: fix the end-of-round PerformAndWatch crash -- the STATIC projectile pool held dangling entity pointers across round teardown Root cause (disasm-pinned on the shipped 4.11.659 exe): RajelAran's crash (call to 0x65676769 = ASCII 'igge', EBP-walk return btl4+0x2b91a) is the tgt->Dispatch vtable call in BTUpdateProjectiles' NON-MECH impact branch (mech4.cpp:1598; the site matches the disasm literally -- the 0x12/0x64 TakeDamageMessage ctor, the EntityID::Null ternary, the getenv gate after). The trap: the mech branch is guarded by BTIsRegisteredMech(tgt) -- but at round teardown a destroyed mech is DEREGISTERED, so a round still in flight (the pool is a static array that outlives the round; missiles are slow, #84) holding it as p.target now FAILS the mech check and falls into the !BTIsRegisteredMech branch, which treats the freed mech as a cultural icon and dispatches into freed memory. The liveness check itself routed the dangling pointer into the unguarded branch. Freed heap reused by a string -> vtable slot +0x10 read 'igge' -> call 0x65676769. (The EBP walker explains the stack shape: the faulting call pushed its return address on ESP, but the walk reads [EBP+4] = PerformAndWatch's frame.) Fix -- scrub at the source of truth: - BTProjectilesDropEntity(e): every pool entry drops a dying entity from p.target/p.shooter; called from ~Mech and ~CulturalIcon (the only free paths for targetable entities). Flight + impact code is already null-guarded on both fields. - BTProjectilesClearAll(): kills all rounds at RunMissions exit (cross- mission hygiene for the static pool). Verified: 6 short-mission cycles (45s missions with a spawned dummy + autofire, mission expiring mid-combat) -- 0 exceptions, 6/6 clean 'RunMissions returned'. The original crash was a heap-reuse race with no deterministic repro; the scrub eliminates the dangling-pointer class by construction. (Bench note: blind autofire never launches missiles -- the launcher needs a target lock -- so the exact in-flight race was not re-created; the non-regression + the pinned mechanism carry the verdict.) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J --- engine/MUNGA/CULTURAL.cpp | 5 +++++ game/btl4main.cpp | 7 +++++++ game/reconstructed/mech.cpp | 3 +++ game/reconstructed/mech4.cpp | 35 ++++++++++++++++++++++++++++++++ scratchpad/night7/mp_teardown.sh | 24 ++++++++++++++++++++++ 5 files changed, 74 insertions(+) create mode 100644 scratchpad/night7/mp_teardown.sh diff --git a/engine/MUNGA/CULTURAL.cpp b/engine/MUNGA/CULTURAL.cpp index 4a53421..8517d32 100644 --- a/engine/MUNGA/CULTURAL.cpp +++ b/engine/MUNGA/CULTURAL.cpp @@ -450,6 +450,11 @@ CulturalIcon::~CulturalIcon() break; } } + // #93: an icon can be a projectile's picked target -- scrub the static + // projectile pool so a round in flight never dispatches into a freed icon + // (same teardown race as the mech case; see mech4.cpp BTProjectilesDropEntity). + extern void BTProjectilesDropEntity(void *e); + BTProjectilesDropEntity(this); } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // diff --git a/game/btl4main.cpp b/game/btl4main.cpp index 664a1b9..cdf783c 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -1539,6 +1539,13 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine app_manager->RunMissions(); std::cout << "[boot] RunMissions returned (mission loop exited)." << std::endl << std::flush; + // #93: the projectile pool is static -- kill any rounds still in flight so + // nothing dangles across the mission boundary (the per-entity dtor scrub + // covers the in-mission teardown race; this covers everything else). + { + extern void BTProjectilesClearAll(void); + BTProjectilesClearAll(); + } // MATCHLOG AUTO-UPLOAD (2026-07-22): in relay mode, send this peer's // match forensic log back to the operator's relay (saved under the diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index ad69197..5815696 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -2151,6 +2151,9 @@ Mech::~Mech() extern void BTDeregisterMech(Entity *m); // task #46 live-mech registry BTDeregisterMech((Entity *)this); + extern void BTProjectilesDropEntity(void *e); // #93: scrub the STATIC projectile + BTProjectilesDropEntity(this); // pool -- a round in flight must not + // outlive its target/shooter pointer extern void BTUnstashClipState(const Mech *m); // task #59 clip-set registry BTUnstashClipState(this); diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index b70570f..8c97e24 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -847,6 +847,41 @@ struct BTProjectile { }; static BTProjectile gProjectiles[64]; +//########################################################################### +// Projectile-pool entity scrub (#93 -- Rajel's end-of-round crash). The pool +// is STATIC and outlives entities: at round teardown a destroyed mech is +// DEREGISTERED, so a round still in flight holding it as p.target fails +// BTIsRegisteredMech and falls into the NON-MECH impact branch -- which +// happily Dispatch()es into the freed object (its vtable slot read reused +// string bytes: the 0x65676769 "igge" call target, symbolized + disasm-pinned +// to the tgt->Dispatch at the icon branch). The liveness check itself ROUTED +// the dangling pointer into the unguarded branch. Fix at the source of +// truth: every entity scrubs itself out of the pool as it dies (~Mech and +// ~CulturalIcon call this), and mission exit clears the pool outright. +//########################################################################### +void BTProjectilesDropEntity(void *e) +{ + for (int i = 0; i < 64; ++i) + { + BTProjectile &p = gProjectiles[i]; + if (!p.active) + continue; + if (p.target == (Entity *)e) + p.target = 0; // flies on unguided; impact hits nothing + if (p.shooter == (Entity *)e) + { + p.shooter = 0; // null-guarded at every use + p.weaponSubsys = -1; + } + } +} + +void BTProjectilesClearAll(void) +{ + for (int i = 0; i < 64; ++i) + gProjectiles[i].active = 0; +} + extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, float); //########################################################################### diff --git a/scratchpad/night7/mp_teardown.sh b/scratchpad/night7/mp_teardown.sh new file mode 100644 index 0000000..8b9f484 --- /dev/null +++ b/scratchpad/night7/mp_teardown.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# #93 bench: mission teardown with missiles IN FLIGHT (3 cycles). +set -x +cd /c/git/bt411/content || exit 1 +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2 +for i in 1 2 3; do + rm -f td_$i.log + ( export BT_PLATFORM=glass BT_START_INSIDE=1 BT_DEV_GAUGES=1 \ + BT_LOG=td_$i.log BT_AFFINITY=0x03 \ + BT_GOTO=enemy BT_GOTO_STOP=100 BT_AUTOFIRE=1 BT_PROJ_LOG=1 BT_SPAWN_ENEMY=1 + ../build/Release/btl4.exe -egg SHORTMSN.EGG & + wait )& + BGPID=$! + # mission length 45s + load ~20s + menu relaunch; give it 100s then kill the relaunch + sleep 100 + taskkill //F //IM btl4.exe > /dev/null 2>&1 + sleep 2 + echo "=== cycle $i ===" + grep -c "crash" td_$i.log + grep -c "RunMissions returned" td_$i.log + grep -c "projectile" td_$i.log +done +echo "=== DONE ==="