#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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
This commit is contained in:
Joe DiPrima
2026-07-31 10:19:24 -05:00
co-authored by Claude Fable 5
parent ba4af90d6b
commit ac7c46b87a
5 changed files with 74 additions and 0 deletions
+5
View File
@@ -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);
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
+7
View File
@@ -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
+3
View File
@@ -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);
+35
View File
@@ -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);
//###########################################################################
+24
View File
@@ -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 ==="