MechDeathHandler: per-zone destroyed-skins + explosions (faithful, exported)

Fixes the "kills are invisible" report: a mech died only at the state level, with
no visible destruction on its body.  Root cause: the per-zone damage-state
descriptor table (binary zone+0xd4) that drives destroyed-skins + explosions was
never built -- its loader (Mech__DamageZone::LoadCriticalSubsystems / FUN_0041e4a8)
was a no-op stub, and MechDeathHandler itself was a no-op stub.  The surrounding
plumbing (the per-zone load loop over the type-0x1e resource, the death-handler
slot) was already correct.  Whole pipeline is EXPORTED -- no stand-ins.

Reconstructed:
- Mech__DamageZone descriptor table (binary this+0xd4): un-stubbed
  LoadCriticalSubsystems (FUN_0041e4a8/FUN_0042a748/FUN_0042a2c8) to parse the
  real type-0x1e stream -- entry = [f32 DamageLevel][i32 EffectResource]
  [i32 GraphicState][f32 TimeDelay], ascending by DamageLevel.  Verified live:
  6 entries/zone, first threshold 0.2, effect 26, across all 40 zones, no crash.
- The three lookups (FUN_0042a664 by-level / FUN_0042a5f4 crossing / FUN_0042a6c4
  by-graphic-state) as Mech__DamageZone methods.
- The real MechDeathHandler (FUN_0042a984 ctor / FUN_0042aa2c Performance /
  FUN_0042a9f4 dtor), replacing the stub: each tick it walks the zones and, as a
  zone's damageLevel rises across a descriptor threshold, fires that entry's
  explosion (binary +0xb8 & 4) and, on destruction, the Destroyed-graphic
  descriptor's explosion (+0xb8 & 8), applying the descriptor's GraphicState (the
  destroyed skin) to the zone.  Runs for EVERY mech, so the enemy visibly falls
  apart as it dies.

Integration: the binary ticks MechDeathHandler off the mech's Performance list
(mech+0xbc), which the bring-up drive override bypasses, so Mech::PerformAndWatch
drives Tick() directly (same approach as UpdateDeathState).  Effect spawn uses the
established Explosion::Make port (BTSpawnDamageEffect) -- the authentic dispatch
(class-5 message -> the 0xBD3 SubsystemMessageManager effect manager) is unported.

Runtime: builds clean, boots clean, 42 descriptor tables load with sensible
byte-aligned data, no crash.  Live effect firing is combat-triggered (verified by
the load + the wiring; the [deathfx] threshold-crossing log fires under BT_DEATH_LOG).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-08 11:18:30 -05:00
co-authored by Claude Opus 4.8
parent a9467481c5
commit 7c455303bd
5 changed files with 260 additions and 12 deletions
+152
View File
@@ -817,3 +817,155 @@ int
}
return -1;
}
//#############################################################################
// Damage-state descriptor table -- @0041e4a8 / @0042a748 / @0042a2c8
//
// Streams this zone's damage-state descriptor table from the type-0x1e resource
// (binary this+0xd4). Each entry (0x1C bytes) is [f32 DamageLevel][i32
// EffectResource][i32 GraphicState][f32 TimeDelay], written ascending by
// DamageLevel. (Was a no-op stub -> the table was never built, so
// MechDeathHandler had nothing to walk and death was invisible.)
//#############################################################################
void
Mech__DamageZone::LoadCriticalSubsystems(MemoryStream *stream) // @0041e4a8
{
int count = 0;
stream->ReadBytes(&count, 4); // FUN_0042a748: entry count
damageDescriptors.reserve(count > 0 ? count : 0);
for (int i = 0; i < count; ++i) // FUN_0042a2c8 per entry
{
DamageDescriptor d;
stream->ReadBytes(&d.damageLevel, 4); // +0xc
stream->ReadBytes(&d.effectResource, 4); // +0x14
stream->ReadBytes(&d.graphicState, 4); // +0x10
stream->ReadBytes(&d.timeDelay, 4); // +0x18
damageDescriptors.push_back(d);
}
if (count != 0 && getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] zone descriptor table loaded: " << count
<< " entries (first threshold=" << (count > 0 ? damageDescriptors[0].damageLevel : -1.0f)
<< " effect=" << (count > 0 ? damageDescriptors[0].effectResource : -1) << ")\n" << std::flush;
}
//
// @0042a664 -- first entry whose threshold is strictly greater than 'level'
// (0 if none / the zone is past the last threshold, i.e. fully destroyed).
//
const Mech__DamageZone::DamageDescriptor *
Mech__DamageZone::DescriptorForLevel(Scalar level) const
{
for (unsigned i = 0; i < damageDescriptors.size(); ++i)
if (damageDescriptors[i].damageLevel > level)
return &damageDescriptors[i];
return 0;
}
//
// @0042a5f4 -- true iff some entry's threshold lies in (oldLevel, newLevel]
// (a damage-state threshold was crossed this tick).
//
Logical
Mech__DamageZone::DescriptorCrossed(Scalar oldLevel, Scalar newLevel) const
{
for (unsigned i = 0; i < damageDescriptors.size(); ++i)
{
Scalar t = damageDescriptors[i].damageLevel;
if (t > oldLevel && t <= newLevel)
return True;
}
return False;
}
//
// @0042a6c4 -- first entry whose GraphicState enum == gstate (0 if none).
//
const Mech__DamageZone::DamageDescriptor *
Mech__DamageZone::DescriptorForGraphicState(int gstate) const
{
for (unsigned i = 0; i < damageDescriptors.size(); ++i)
if (damageDescriptors[i].graphicState == gstate)
return &damageDescriptors[i];
return 0;
}
// Inherited engine DamageZone::damageLevel (binary this+0x158) -- the [0,1]
// structural damage MechDeathHandler compares against the descriptor thresholds.
Scalar
Mech__DamageZone::GetStructureDamageLevel() const
{
return damageLevel;
}
void
Mech__DamageZone::ApplyDamageGraphicState(int gstate)
{
SetGraphicState(gstate); // engine DamageZone::SetGraphicState -> destroyed skin
}
//#############################################################################
// MechDeathHandler -- @0042a984 / @0042aa2c / @0042a9f4
//#############################################################################
// Effect-spawn bridge (mech4.cpp -- where Explosion::Make + the mech transform
// live): spawns the descriptor's explosion resource at the mech. The authentic
// path dispatches a class-5 message to the effect manager (app+0x38 -> the 0xBD3
// SubsystemMessageManager, unreconstructed); we use the established Explosion port.
extern void BTSpawnDamageEffect(Mech *mech, int effect_resource);
MechDeathHandler::MechDeathHandler(Mech *mech) // @0042a984
: owner(mech)
{
// per-zone last-damage cache, zeroed (binary this[0x10], size mech+0x11c).
int count = (mech != 0 && mech->damageZoneCount > 0) ? mech->damageZoneCount : 0;
lastLevel.assign(count, 0.0f);
}
MechDeathHandler::~MechDeathHandler() // @0042a9f4
{
}
//
// @0042aa2c -- each tick, walk the zones; where a zone's damageLevel rose since
// last tick, fire the current damage-band descriptor's explosion (binary +0xb8 &
// 4) and, on destruction, the Destroyed-graphic descriptor's explosion (+0xb8 &
// 8), applying that descriptor's GraphicState (the destroyed skin) to the zone.
//
void
MechDeathHandler::Tick() // @0042aa2c
{
if (owner == 0)
return;
int n = owner->damageZoneCount;
for (int i = 0; i < n && i < (int)lastLevel.size(); ++i)
{
Mech__DamageZone *zone = owner->Zone(i);
if (zone == 0)
continue;
Scalar level = zone->GetStructureDamageLevel();
Scalar prev = lastLevel[i];
if (level <= prev) // no new damage on this zone
continue;
lastLevel[i] = level;
const Mech__DamageZone::DamageDescriptor *d = zone->DescriptorForLevel(level);
if (level >= 1.0f && prev < 1.0f) // just destroyed -> the Destroyed descriptor
{
const Mech__DamageZone::DamageDescriptor *dd =
zone->DescriptorForGraphicState(DamageZone::DestroyedGraphicState); // enum 1
if (dd != 0)
d = dd;
}
if (d != 0)
{
BTSpawnDamageEffect(owner, d->effectResource); // explosion at the zone
zone->ApplyDamageGraphicState(d->graphicState); // destroyed skin
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] zone " << i << " level " << level
<< " -> effect " << d->effectResource << " gstate " << d->graphicState
<< "\n" << std::flush;
}
}
}