From 7c455303bdf85fa354436a4510ba673ad5eff10d Mon Sep 17 00:00:00 2001 From: arcattack Date: Wed, 8 Jul 2026 11:18:30 -0500 Subject: [PATCH] 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 --- game/reconstructed/btstubs.cpp | 10 +-- game/reconstructed/mech.cpp | 14 ++- game/reconstructed/mech4.cpp | 37 ++++++++ game/reconstructed/mechdmg.cpp | 152 +++++++++++++++++++++++++++++++++ game/reconstructed/mechdmg.hpp | 59 +++++++++++++ 5 files changed, 260 insertions(+), 12 deletions(-) diff --git a/game/reconstructed/btstubs.cpp b/game/reconstructed/btstubs.cpp index 0ca706b..1be3aad 100644 --- a/game/reconstructed/btstubs.cpp +++ b/game/reconstructed/btstubs.cpp @@ -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. diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 1ab58af..afad437 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -218,7 +218,7 @@ extern Subsystem *CreateGaussRifleSubsystem(Mech *, int, void *); // 0xBCE (WAVE // Post-stream entity glue + cosmetic objects. struct StandingAnimation { template StandingAnimation(A&&...) {} }; -struct MechDeathHandler { template 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) diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 1b7214f..71215b7 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -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); diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index 2e39eec..f3b98e7 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -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; + } + } +} diff --git a/game/reconstructed/mechdmg.hpp b/game/reconstructed/mechdmg.hpp index 1845b78..a129f8c 100644 --- a/game/reconstructed/mechdmg.hpp +++ b/game/reconstructed/mechdmg.hpp @@ -29,6 +29,8 @@ #if !defined(MECHDMG_HPP) # define MECHDMG_HPP +#include + # if !defined(DAMAGE_HPP) # include # 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 + 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 lastLevel; // @0x10 per-zone last damageLevel cache +}; + #endif