Death effects dispatch at the victim's death transition (task #42)

User report: enemy takes hits, starts smoking, then beams pass through
-- no more damage, no death. Root cause: the death-effects dispatch
(blhdead explosion chain + wreck swap + KILL score) lived in the
SHOOTER's laser fire block kill check. A missile killing blow lands
frames later in BTUpdateProjectiles -- outside that block -- and after
task #41 the boresight pick skips a dead mech, so the fire block never
ran against it again: the death chain was silently skipped, leaving an
internally-dead, smoking, standing, invulnerable mech.

Fix: the dispatch moved to the VICTIM's own once-per-death transition
(UpdateDeathState's movementMode->9 moment) -- fires exactly once for
ANY kill source (laser, missile, collision). Killer id for the
Explosion message = lastInflictingID (the task-#31 bookkeeping). The
BT_ENABLE_TEARDOWN cdb harness was removed with the old block (findings
preserved in docs/HARD_PROBLEMS.md).

Verified headless (75s soak, missile-heavy kill): destroyed ->
'blhdead' id=22 -> wreck swap 'blhdbr.bgf' -> smoke re-arm -> sink ->
buried INERT; post-death picks fall through to terrain; 0 crashes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-09 00:21:03 -05:00
co-authored by Claude Fable 5
parent e30a61a62f
commit 305c928923
2 changed files with 80 additions and 102 deletions
+72 -102
View File
@@ -1063,6 +1063,67 @@ void
DEBUG_STREAM << "[death] mech " << GetEntityID()
<< " destroyed -> subsystem shutdown + frozen wreck (IsDisabled="
<< (int)IsDisabled() << ")\n" << std::flush;
// --- DEATH EFFECTS (task #42): dispatched HERE, at the VICTIM's own
// once-per-death transition, so they fire regardless of WHAT killed it
// (laser fire block, missile impact frames later, collision damage).
// They used to live in the shooter's laser fire block, whose kill
// check a missile killing blow never reached -- and post-#41 the
// boresight pick skips a dead mech, so that block never ran against
// it again: internally dead, smoking, standing, invulnerable.
{
// KILL score (once; the ownerless dummy yields no death for us)
BTPostKillScore((Entity *)this, kShotDamage);
if ((Entity *)this == gEnemyMech)
gEnemyDestroyed = 1; // latches off damage score
// The victim's AUTHENTIC per-mech death ModelList -- 'blhdead' (RES 22;
// the burning-wreck script chain: effect 104 wreck swap + 1007 dnboom +
// 1001 ddthsmk + 3/4/5/15). Falls back to the generic explode effect.
if (gExplodeReady == 0 && application != 0
&& application->GetResourceFile() != 0)
{
ResourceDescription *exp =
application->GetResourceFile()->FindResourceDescription(
"explode", (ResourceDescription::ResourceType)1, -1);
if (exp != 0) { gExplodeRes = exp->resourceID; gExplodeReady = 1; }
else gExplodeReady = -1;
}
ResourceDescription::ResourceID dead_res = gExplodeRes;
if (application != 0 && application->GetResourceFile() != 0)
{
ResourceDescription *dr =
application->GetResourceFile()->FindResourceDescription(
"blhdead", (ResourceDescription::ResourceType)1, -1);
if (dr != 0)
{
dead_res = dr->resourceID;
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] firing authentic death list 'blhdead' id="
<< (long)dead_res << "\n" << std::flush;
}
}
if (gExplodeReady == 1 || dead_res != gExplodeRes)
{
Origin death_origin = localOrigin;
death_origin.linearPosition.y += kMuzzleHeight;
Explosion::MakeMessage death_exp(
Explosion::MakeMessageID,
sizeof(Explosion::MakeMessage),
(Entity::ClassID)RegisteredClass::ExplosionClassID,
EntityID::Null,
dead_res,
Explosion::DefaultFlags,
death_origin,
GetEntityID(), // the victim
lastInflictingID); // the killer (task #31 bookkeeping)
Explosion *boom = Explosion::Make(&death_exp);
if (boom) Register_Object(boom);
}
DEBUG_STREAM << "[damage] *** " << GetEntityID()
<< " DESTROYED (death effects dispatched from the death transition) ***\n"
<< std::flush;
}
}
void
@@ -2614,108 +2675,17 @@ void
}
}
// Death via the REAL damage model: a mech with a destroyed VITAL zone is
// dead. We read the reconstructed zone state directly (damageLevel >= 1.0
// on a vitalDamageZone). The faithful Mech::IsDisabled() reads movementMode,
// which the gait/death-transition (bypassed by the bring-up drive override)
// would set from exactly this condition. Friend access (mechdmg.hpp).
Logical dead = False;
for (int k = 0; k < gEnemyMech->damageZoneCount; ++k)
{
Mech__DamageZone *dz = ((Mech *)gEnemyMech)->Zone(k);
if (dz->vitalDamageZone && dz->damageLevel >= 1.0f) { dead = True; break; }
}
if (!gEnemyDestroyed && dead)
{
gEnemyDestroyed = 1;
// gauge scoring wave (Step 7): credit the KILL to the local player
// -> KILLS 0->1 (senderMechID = the VICTIM, so the !=our-mech branch
// fires; the ownerless dummy yields no death, DEATHS stays 0).
BTPostKillScore(gEnemyMech, kShotDamage);
// Death explosion: fire the victim's AUTHENTIC per-mech death
// ModelList -- resources 22-25 are 'blhdead'/'lokdead'/'owndead'/
// 'thrdead' (BTL4.RES); its authored video objects carry the death
// effect numbers (the burning-WRECK script chain, ExplosionScripts
// case 4 loads <mech>dbr.bgf + flames in the 1996 binary). Resolve
// "<prefix>dead" by name (prefix = the anim/model prefix); fall back
// to the generic explode resource if the mech has no dead list.
Origin death_origin = ((Mech *)gEnemyMech)->localOrigin;
death_origin.linearPosition.y += kMuzzleHeight;
ResourceDescription::ResourceID dead_res = gExplodeRes;
if (application != 0 && application->GetResourceFile() != 0)
{
ResourceDescription *dr =
application->GetResourceFile()->FindResourceDescription(
"blhdead", (ResourceDescription::ResourceType)1, -1);
if (dr != 0)
{
dead_res = dr->resourceID;
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] firing authentic death list 'blhdead' id="
<< (long)dead_res << "\n" << std::flush;
}
}
Explosion::MakeMessage death_exp(
Explosion::MakeMessageID,
sizeof(Explosion::MakeMessage),
(Entity::ClassID)RegisteredClass::ExplosionClassID,
EntityID::Null,
dead_res,
Explosion::DefaultFlags,
death_origin,
gEnemyMech->GetEntityID(),
GetEntityID());
Explosion *boom = Explosion::Make(&death_exp);
if (boom) Register_Object(boom);
// P5 ROOT-CAUSE (env-gated, DEFAULT OFF): actually tear the wreck down so
// the ~Mover collision-dtor crash reproduces under cdb. The shipped death
// stand-in below (explosion + stop-targeting) is unchanged when the flag is off.
if (getenv("BT_ENABLE_TEARDOWN"))
{
char *e = (char *)gEnemyMech;
DEBUG_STREAM << "[teardown] enemy this=" << (void *)e << " base-region dump (engine fields):\n"
<< " collisionVolume@0x2d8=" << *(void **)(e + 0x2d8)
<< " collisionTemplate@0x2dc=" << *(void **)(e + 0x2dc)
<< " containedByNode@0x2e0=" << *(void **)(e + 0x2e0) << "\n"
<< " collisionLists@0x2e4=" << *(void **)(e + 0x2e4)
<< " lastCollisionList@0x2e8=" << *(void **)(e + 0x2e8)
<< " collisionAssistant@0x2ec=" << *(void **)(e + 0x2ec) << "\n"
<< " segmentTable.array@0x2f0=" << *(void **)(e + 0x2f0)
<< " segmentCount@0x308=" << *(int *)(e + 0x308)
<< " jointSubsystem@0x30c=" << *(void **)(e + 0x30c) << "\n"
<< " -> dispatching DestroyEntityMessage\n" << std::flush;
Entity::DestroyEntityMessage destroy_msg(
Entity::DestroyEntityMessageID,
sizeof(Entity::DestroyEntityMessage));
gEnemyMech->Dispatch(&destroy_msg);
}
// By default we do NOT tear the wreck down: FryDeathRow -> ~Mech -> ~JointedMover
// -> ~Mover CRASHES. ⚠ ROOT-CAUSED (P5, see btbuild/HARD_PROBLEMS.md): NOT
// "collision solids never built" (that was wrong -- Mover::Mover allocates
// collisionLists@0x2e4 unconditionally; it reads a valid heap ptr at death).
// The real cause is the Entity/Mover BASE-REGION LAYOUT divergence: this file's
// raw binary-offset accesses (this+0x2d4 netOrientation / +0x2e0 arrivalTime /
// +0x2e8 physicsBody / +0x2ec groundRef / +0x2f0 groundCell) land on the elsewhen
// engine Mover's collision cluster (collisionVolumeCount@0x2d4 .. collisionAssistant
// @0x2ec) and CORRUPT it (incl. deref-writes at :1020 through +0x2ec), so teardown
// deletes garbage -- crashing at varying base-member sites (~Mover collisionLists,
// ~JointedMover member). Enable with BT_ENABLE_TEARDOWN to repro under cdb.
// FIX = reconcile those raw offsets to the relocated declared members (the base-
// region audit). Meanwhile the mech "dies" (explosion + no longer targeted).
DEBUG_STREAM << "[damage] *** TARGET DESTROYED after "
<< gShotCount << " hits ***\n" << std::flush;
// KEEP the wreck targeted (do NOT null gEnemyMech): the wreck
// STAYS in the world (removal = the P5 teardown crash), so the
// beam convergence must keep terminating ON it -- nulling the
// lock here made every later beam a "free" ray that visibly
// passed through the standing wreck. Damage + kill scoring are
// latched off above via gEnemyDestroyed.
}
// DEATH detection + effects moved to the VICTIM's own death
// transition (UpdateDeathState, task #42): the killing blow can be
// a MISSILE landing frames after this block (or collision damage),
// and post-#41 the boresight pick skips a dead mech so this block
// never ran against it again -- the death chain silently skipped
// (internally dead, smoking, standing, invulnerable). The
// transition fires once for ANY kill source. (The
// BT_ENABLE_TEARDOWN cdb repro harness was removed with this
// block -- its findings are recorded in docs/HARD_PROBLEMS.md;
// re-add at the transition if the P5 work resumes. The wreck
// STAYS -- teardown = the P5 base-region crash.)
}
}
}