From 305c928923c7ee0cf0982683784e8ca9e92ba302 Mon Sep 17 00:00:00 2001 From: arcattack Date: Thu, 9 Jul 2026 00:21:03 -0500 Subject: [PATCH] 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 --- context/combat-damage.md | 8 ++ game/reconstructed/mech4.cpp | 174 +++++++++++++++-------------------- 2 files changed, 80 insertions(+), 102 deletions(-) diff --git a/context/combat-damage.md b/context/combat-damage.md index e3092b5..8546056 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -271,6 +271,14 @@ Full decomp map of the death path. Two tiers: `dbr.bgf` on the tree root (gendbr fallback; pending-swap if death precedes tree build). Mesh flames + the settle motion are noted follow-ups; burning reads via 1001/1007 + the wreck-smoke re-arm. Verified: `[BTrender] wreck swap: victim -> 'blhdbr.bgf'`. [T2] + ⚠ **The death-effects DISPATCH lives at the VICTIM's death transition** (`UpdateDeathState`'s + movementMode→9 moment, task #42) — NOT in any shooter's fire path. It originally sat in the + laser fire block's kill check, which a MISSILE killing blow (landing frames later in + `BTUpdateProjectiles`) never reached — and post-#41 the boresight pick skips a dead mech, so the + block never re-ran: internally dead, smoking, standing, invulnerable (user-reported). The + transition fires once for ANY kill source (laser/missile/collision); the killer id for the + Explosion message comes from `lastInflictingID` (the task-#31 bookkeeping). Verified: missile + kill → full chain (blhdead → wreck swap → smoke → sink → burial → INERT). [T2] - `Mech::IsDestroyed()` (`FUN_0049fb54`) = `movementMode==2||9` (reconstructed as `IsDisabled`, btstubs.cpp); `FUN_004ab1c8` freezes locomotion (zeros mech+0x298) when destroyed. - **`MechDeathHandler`** (ctor `FUN_0042a984` + Performance `FUN_0042aa2c`, cached mech+0x850 / `mech[0x214]`) — diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index e00332c..3e73868 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -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 dbr.bgf + flames in the 1996 binary). Resolve - // "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.) } } }