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
+2 -8
View File
@@ -194,14 +194,8 @@ void Mech::Reset(const Origin &origin, int /*mode*/)
Check_Fpu();
}
//===========================================================================//
// Mech__DamageZone method stub.
//===========================================================================//
// TODO(bring-up): stream in the per-zone critical-subsystem plug list.
void Mech__DamageZone::LoadCriticalSubsystems(MemoryStream * /*stream*/)
{
}
// (Mech__DamageZone::LoadCriticalSubsystems was a no-op stub here; it is now the
// real type-0x1e damage-state descriptor-table loader in mechdmg.cpp.)
//===========================================================================//
// MechSubsystem method stubs.
+10 -4
View File
@@ -218,7 +218,7 @@ extern Subsystem *CreateGaussRifleSubsystem(Mech *, int, void *); // 0xBCE (WAVE
// Post-stream entity glue + cosmetic objects.
struct StandingAnimation { template<class...A> StandingAnimation(A&&...) {} };
struct MechDeathHandler { template<class...A> MechDeathHandler(A&&...) {} };
// MechDeathHandler is now the REAL class (mechdmg.hpp) -- the local stub was removed.
struct StabilityMessage { StabilityMessage() {} };
// "Mechs" object directory in the global registry (decomp registry verbs).
@@ -1297,13 +1297,19 @@ Mech::Mech(
{
critRes->Lock();
{
// Load each zone's damage-state descriptor table from the type-0x1e
// stream, sequentially (binary loops mech[0x47] subsystems == our zones,
// calling FUN_0041e4a8 per object over the same stream). This is what
// MechDeathHandler walks to fire destroyed-skins + explosions on damage.
DynamicMemoryStream critStream(critRes->resourceAddress, critRes->resourceSize);
for (int z = 0; z < damageZoneCount; ++z)
{
Zone(z)->LoadCriticalSubsystems(&critStream); // FUN_0041e4a8
Zone(z)->LoadCriticalSubsystems(&critStream); // FUN_0041e4a8 (now the real loader)
}
}
deathHandler = (int)new (Memory::Allocate(0x18)) MechDeathHandler(this); // FUN_0042a984
// The REAL MechDeathHandler (mechdmg.hpp), not the binary's 0x18 placement --
// plain new (our class carries a std::vector cache). Ticked from PerformAndWatch.
deathHandler = (int)new MechDeathHandler(this); // FUN_0042a984
critRes->Unlock();
}
@@ -1426,7 +1432,7 @@ Mech::~Mech()
if (deathHandler != 0) // Wword(0x214)
{
((Releasable *)deathHandler)->Release();
delete (MechDeathHandler *)deathHandler; // real class -> plain delete
}
ReleaseRefCounted(resourceNameC); // Wword(0x213)
+37
View File
@@ -843,6 +843,36 @@ static void
}
}
//###########################################################################
// MechDeathHandler effect-spawn bridge (mechdmg.cpp calls this)
//
// Spawns a damage-state descriptor's explosion at the mech. The authentic path
// (FUN_0043663c -> FUN_004364e4) broadcasts a class-5 message to the effect
// manager app+0x38 (the 0xBD3 SubsystemMessageManager, unreconstructed); we use
// the same established Explosion::Make port the weapon path uses. The binary
// derives the position from the subsystem (mech+0x184); we use the mech origin.
//###########################################################################
void
BTSpawnDamageEffect(Mech *mech, int effect_resource)
{
if (mech == 0)
return;
ResourceDescription::ResourceID res = (ResourceDescription::ResourceID)effect_resource;
if (res <= 0)
res = gExplodeRes; // fall back to the resolved generic explosion
if (res <= 0)
return; // nothing to spawn yet
Origin o = mech->localOrigin; // at the mech (binary: per-subsystem)
Explosion::MakeMessage m(
Explosion::MakeMessageID, sizeof(Explosion::MakeMessage),
(Entity::ClassID)RegisteredClass::ExplosionClassID, EntityID::Null,
res, Explosion::DefaultFlags, o,
mech->GetEntityID(), mech->GetEntityID());
Explosion *e = Explosion::Make(&m);
if (e)
Register_Object(e);
}
//###########################################################################
// Death sequence -- the un-exported master-perf death branch (region
// 0x4a9770-0x4ab188), reconstructed from its EXPORTED consumers + the RP
@@ -920,6 +950,13 @@ void
// dead mech keeps its death state instead of reverting to a live gait.
UpdateDeathState();
// MechDeathHandler (@0042aa2c): per-frame destroyed-skin + explosion effects as
// this mech's zones cross damage thresholds. The binary ticks it off the mech's
// Performance list (mech+0xbc), which the bring-up drive override bypasses, so
// it is driven here. Runs for EVERY mech (so the enemy visibly falls apart too).
if (deathHandler != 0)
((MechDeathHandler *)deathHandler)->Tick();
// WAVE 7 Phase B: advance/render/impact the flying projectiles once per frame (viewpoint).
if (isPlayerMech && dt > 0.0001f)
BTUpdateProjectiles(dt);
+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;
}
}
}
+59
View File
@@ -29,6 +29,8 @@
#if !defined(MECHDMG_HPP)
# define MECHDMG_HPP
#include <vector>
# if !defined(DAMAGE_HPP)
# include <damage.hpp>
# endif
@@ -140,6 +142,7 @@
// damage code (FUN_004a0230 / FUN_0049fb54 etc.) is a Mech method touching
// these zone fields directly.
friend class Mech;
friend class MechDeathHandler; // walks the descriptor table + applies the destroyed skin
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// LOD Support
//
@@ -248,6 +251,36 @@
MechCriticalSubsystem
**criticalSubsystems; // @0x1b4 array[criticalSubsystemCount]
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Damage-state descriptor table (binary this+0xd4). Streamed from the
// type-0x1e resource by LoadCriticalSubsystems (FUN_0041e4a8 -> FUN_0042a748);
// as this zone's damageLevel crosses each entry's threshold, MechDeathHandler
// fires the entry's explosion + applies its GraphicState (destroyed skin).
// Entries ascend by damageLevel. (Was un-built -- the loader was a stub.)
//
public:
struct DamageDescriptor // binary entry 0x1C (ctor FUN_0042a2c8)
{
Scalar damageLevel; // +0xc threshold [0,1]
int graphicState; // +0x10 GraphicState enum (Destroyed=1)
int effectResource; // +0x14 ExplosionModelFile resource id
Scalar timeDelay; // +0x18
};
// @0042a664: first entry whose threshold > level (0 if none / fully destroyed).
const DamageDescriptor *DescriptorForLevel(Scalar level) const;
// @0042a5f4: true iff some entry's threshold lies in (oldLevel, newLevel].
Logical DescriptorCrossed(Scalar oldLevel, Scalar newLevel) const;
// @0042a6c4: first entry whose GraphicState == gstate (0 if none).
const DamageDescriptor *DescriptorForGraphicState(int gstate) const;
Scalar GetStructureDamageLevel() const; // inherited damageLevel (binary +0x158)
// Apply a descriptor's GraphicState (Destroyed=1) to this zone -- the
// destroyed-skin swap the renderer reads (public wrapper so MechDeathHandler
// can drive it; the base SetGraphicState is otherwise unreachable from it).
void ApplyDamageGraphicState(int gstate);
protected:
std::vector<DamageDescriptor>
damageDescriptors; // binary this+0xd4 collection
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Graphic State Support
//
@@ -297,4 +330,30 @@
);
};
//###########################################################################
//###########################################################################
// MechDeathHandler (@0042a984 / @0042aa2c)
//
// A per-frame handler built by the Mech ctor (cached at mech[0x214]) that turns
// subsystem/zone damage into VISIBLE destruction: each tick it walks the mech's
// damage zones and, as a zone's damageLevel crosses a descriptor threshold,
// spawns that entry's explosion effect at the zone + applies its GraphicState
// (the destroyed skin). The binary registers it on the mech's Performance list
// (mech+0xbc); the bring-up drive override does not tick that list, so
// Mech::PerformAndWatch drives Tick() directly. Effect spawn uses the
// established Explosion::Make port (the authentic effect-message manager, the
// 0xBD3 SubsystemMessageManager, is unreconstructed) -- see BTSpawnDamageEffect.
//###########################################################################
class MechDeathHandler
{
public:
MechDeathHandler(Mech *mech); // @0042a984
~MechDeathHandler(); // @0042a9f4
void Tick(); // @0042aa2c (the Performance)
private:
Mech *owner; // @0x14
std::vector<Scalar> lastLevel; // @0x10 per-zone last damageLevel cache
};
#endif