#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
+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);
//###########################################################################