Files
BT411/game/reconstructed/mechdmg.cpp
T
arcattackandClaude Fable 5 267059ab88 Damage economy RECONCILED: 3 stand-ins fixed, 3 paths confirmed authentic (task #60)
Full 5-path audit of the damage economy vs the decomp (5-finder +
adversarial-verify workflow), resolving the KB self-contradiction the
binary-coverage audit flagged.

CONFIRMED AUTHENTIC as-is (no change needed):
- Energy beam (emitter.cpp): damagePortion = authored DamageAmount x
  (charge/seekV)^2; the ctor x1e7 and fire x1e-7 cancel (_DAT_004bafbc
  dumped from the exe = x87 80-bit 1e-7 exactly).
- Autocannon (projweap.cpp): full authored DamageAmount from resource
  +0x19C delivered unmodified; the 0.0625 at :667 is the shooter's own
  gyro recoil, not the round.
- Zone-armor BASE model: damageLevel += amount x damageScale[type]
  (engine DAMAGE.cpp:379, called mechdmg.cpp:427), legs x0.5, 1.0=dead.

3 STAND-INS FIXED (all byte-verified against the decomp):
- A. mechdmg.cpp:451 -- read the phantom `stance` member (no binary
  offset, zero writers -> perma-0), so the leg-shot-out -> fall/death
  branch was DEAD. Now MovementMode() (mech+0x40, @part_012.c:6910).
- B. mechdmg.cpp:458 -- guarded the leg partial-failure graphic on the
  always-0 IsAirborne() stub where the binary calls IsDisabled()
  (@0049fb54 = movementMode 2||9). On a wreck the binary SUPPRESSES the
  write; the stub let it corrupt graphicAlarm 9->4/3 -- the task-#52
  wreck-graphic bug, now fixed AT SOURCE (was only masked by the
  IsMechDestroyed latch).
- C. mech4.cpp:1551 -- flat kShotDamage=12 fed as the kill-score
  damageAmount (the KB self-contradiction: task #8 claimed it retired,
  but it was live). The score handler @0x4c02e4 derives the whole kill
  award from it, so every kill scored identically regardless of weapon.
  Now lastInflictingDamage -- the real killing-blow magnitude, latched
  in TakeDamageMessageHandler (mech.cpp:624), mirroring the per-hit path
  (mech4.cpp:1207). The phantom `int stance` slot is reused for the new
  Scalar member (size-neutral, no layout shift); init 0 in the ctor.

DEFERRED (task #60-D, documented): the missile CLUSTER model -- the port
fires N flying rounds (net armor total authentic) vs the binary's ONE
missile with a random burstCount cluster roll (loses cluster variance +
single-zone concentration). Blocked on an OPEN decomp semantic (does
burstCount multiply armor or only the gyro kick? settle at FUN_004bef78
-> FUN_004be078 -> EXPLODE.cpp:209-210).

VERIFIED live: clean build; 2-node fight -> clean center-mass kill (no
crash, kills 0->1); [zone-armor] dump confirms per-zone armor 50-140 +
legs x0.5. NB the displayed POINTS score still reads 0 -- a SEPARATE
open gap (scoreAward + role/team/tonnage multipliers unwired); fix C
corrected the damage INPUT to that formula.

KB swept: open-questions.md (self-contradiction resolved + task #60
summary + deferred missile item), combat-damage.md (damageScale is
type-indexed not even/odd; task-#52 source fix; kill-score section),
RECONCILE.md (missile = ONE spawn not N), stale comments in mechweap.cpp
(SendDamageMessage is LIVE), mislanch.hpp, mechdmg.cpp (FUN_0049fb54 =
IsDisabled).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:38:04 -05:00

1144 lines
46 KiB
C++

//==========================================================================//
// File: mechdmg.cpp //
// Project: BattleTech //
// Contents: Mech::DamageZone -- maps incoming damage to mech locations / //
// subsystems (the dz_* zones streamed from the .BGF SV_SPECIAL //
// tokens), critical-hit selection, LOD redirection, and the //
// descend-on-destruction segment walk. //
//---------------------------------------------------------------------------//
// Date Who Modification //
// -------- --- ---------------------------------------------------------- //
// 06/05/95 JM Initial coding. //
//---------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. //
// All Rights reserved worldwide //
// This unpublished sourcecode is PROPRIETARY and CONFIDENTIAL //
//===========================================================================//
//
// RECONSTRUCTED from the shipped binary (Ghidra pseudo-C, recovered/all/
// part_012.c). Class shape is the SURVIVING MECHDMG.HPP; bodies follow the
// decompiled object at the cited @ADDRs. The companion Mech-side loop that
// streams every zone, Mech::CreateDamageZoneStream @004a474c, lives in
// mech.cpp; this file is the per-zone class.
//
// Float constants recovered from the code section (section_dump.txt):
// _DAT_0049c99c = 1.0f _DAT_0049c9a0 = 0.5f _DAT_0049c9a4 = 0.0f
// _DAT_0049cacc = 0.0f _DAT_0049cad0 = 1.0f
// _DAT_0049ce48 = 0.5f _DAT_0049ce4c = 1.0f
// _DAT_0049c50c = 0.25f _DAT_0049c510 = 10.0f (LOD reuse-window seconds)
// _DAT_0049c5f8 = 0.0f _DAT_0049c5fc = 1.0f (structure clamp)
// _DAT_0049d1c4 = 1.0f _DAT_0049d1c8 = 1.0e-4f _DAT_0049d1cc = 0.5f (leg halving)
//
// Helper-function name mapping (engine internals referenced by the decomp):
// FUN_0041df5c DamageZone::DamageZone (base ctor, vtable @004e3cf0)
// FUN_0041e394 DamageZone::~DamageZone (base dtor)
// FUN_0041e4e0 DamageZone::TakeDamage (base armor/structure model)
// FUN_0041e590 DamageZone::CreateStreamedDamageZone (base streamer)
// FUN_00424767 DamageZoneIndexTable::TableOf(0,1)
// FUN_0042478a DamageZoneIndexTable::~TableOf
// FUN_00424830 DamageZoneIndexTableIterator::TableIteratorOf(&table)
// FUN_0042484f DamageZoneIndexTableIterator::~TableIteratorOf
// FUN_00424716 IntegerPlug::PlugOf(&int)
// FUN_00424811 ...IteratorOf(&list) (segment child iterators)
// FUN_00417ab4 Slot::GetCurrentPlug (READ; was mislabeled Resolve)
// FUN_00417a80 Slot::AddImplementation (BIND -- Ghidra dropped its args)
// FUN_00417858 SlotOf<>::Release
// FUN_0041a1a4 IsDerivedFrom(classDerivations)
// FUN_0041b9ec AlarmIndicator(levels) FUN_0041bbd8 AlarmIndicator::SetLevel(n)
// FUN_00420ea4 EntityID::operator=(copy) FUN_00420ef4 EntityID::operator=
// FUN_0042104c EntityID::operator==
// FUN_00414b60 TheTime.CurrentTick() (/ DAT_0052140c == TicksPerSecond)
// FUN_00408050 (Scalar)Random -> [0,1) FUN_0040807c(rng,n) Random(n)
// FUN_004ac07c Subsystem::ApplyDamage(damage) -> Scalar
// FUN_004b11bc Generator::ForceShortRecovery (class derivations @0050f4bc)
// FUN_0049fb54 Mech::IsDisabled() (movementMode 2||9; NOT a torso/leg query)
// FUN_0040485c/00404720/00404088/00404190/004040d8 NotationFile readers
// FUN_00402298/004022b0/004022d0 operator new / array new / delete
// FUN_004dbb24/004db92c/004d9c38/004dbd4c DebugStream << / endl
//
#include <bt.hpp>
#pragma hdrstop
#if !defined(MECHDMG_HPP)
# include <mechdmg.hpp>
#endif
#if !defined(MECH_HPP)
# include <mech.hpp>
#endif
#if !defined(POWERSUB_HPP)
# include <powersub.hpp>
#endif
#include <stdio.h> // sscanf (offline streamer)
#if !defined(RANDOM_HPP)
# include <random.hpp> // the engine's global RandomGenerator Random
#endif
//===========================================================================//
// Reconstruction stand-ins LOCAL to this translation unit.
// The per-zone damage code reaches engine clock / RNG / segment-tree / NULL
// subsystem facets through an invented name set; these behaviour-neutral
// stand-ins (and proxy casts onto the real Subsystem* / NotationFile* /
// ResourceFile*) carry that surface so the recovered logic compiles.
//===========================================================================//
static struct ReconClock { Scalar CurrentTick() { return 0; } } TheTime; // FUN_00414b60
static const Scalar TicksPerSecond = 1.0f; // DAT_0052140c
struct DZRef { int index; };
struct SegmentRecord { int siblings; int children; }; // mech segment-tree node
struct SegmentIterator { template<class A> SegmentIterator(const A &) {} DZRef *Next() { return 0; } };
struct SegTableX { SegmentRecord *operator[](int) { return 0; } }; // cast of EntitySegment::SegmentTable
// Subsystem facets the recovered code reaches that the engine Subsystem does
// not expose under these names (cast of Subsystem*).
struct SubProxy2
{
Scalar ApplyDamage(Damage &) { return 0; } // FUN_004ac07c
void OnDestroyed() {} // vtable slot 0x34
int isVital; // +0x39
AlarmIndicator failureAlarm; // +0x2c
int hasDestructor; // +0x41
void *damageZone; // +0xE0
};
// Offline .dmg/.skl notation readers (cast of NotationFile* / ResourceFile*).
struct NotationEntry { const char *name; const char *text; NotationEntry *Next() { return 0; } };
struct NotationList { int Count() { return 0; } NotationEntry *First() { return 0; } };
struct NoteX2 // cast of NotationFile*
{
template<class...A> int ReadLogical(A&&...) { return 0; }
template<class...A> int ReadString(A&&...) { return 0; }
template<class...A> NotationList FindList(A&&...) { return NotationList(); }
void Close() {}
};
struct RFileX2 { template<class A> NotationFile *Open(A) { return 0; } }; // cast of ResourceFile*
inline int Str_Equal(const char *a, const char *b) { return a && b && strcmp(a, b) == 0; }
template<class...A> inline int FindSegment(A&&...) { return -1; } // FUN_004274f8
//
// Sentinel for "no zone". Returned by GetSegmentIndex/RandomRedirect on a miss.
//
const int Mech__DamageZone::NullDamageZone = -1;
// LOD reuse window: within [0.25s, 10.0s] of the previous hit, the artifact
// zone tends to keep routing to the same child (damage clustering).
static const Scalar LODReuseWindowMin = 0.25f; // _DAT_0049c50c
static const Scalar LODReuseWindowMax = 10.0f; // _DAT_0049c510
// Structure level is always clamped to [0,1].
static const Scalar StructureMin = 0.0f; // _DAT_0049c5f8 / _DAT_0049c9a4 / _DAT_0049cacc
static const Scalar StructureMax = 1.0f; // _DAT_0049c5fc / _DAT_0049c99c / _DAT_0049cad0
// A leg whose zone is half-gone already trips the partial leg-failure graphic
// when the mech is not in a normal standing/walking stance (the oracle reads
// _DAT_0049c9a0 == 0.5 in that branch, vs StructureMax (1.0) for the full
// "leg destroyed -> fall" case).
static const Scalar LegHalfStructure = 0.5f; // _DAT_0049c9a0
// CriticalHit passes only half of the inflicted damage on to the armour model.
static const Scalar CriticalDamageFraction = 0.5f; // _DAT_0049ce48
static const Scalar CriticalDamageMax = 1.0f; // _DAT_0049ce4c
// Probability of re-using the previous child zone inside the reuse window.
// (Ghidra read the operand at @0049c514; exact value not cleanly recoverable
// from the pseudo-C -- it behaves as a high "stay on the same spot" bias.)
static const Scalar LODReuseHysteresis = 0.33f; // _DAT_0049c514 -- a DOUBLE in the
// binary (bytes 1f85eb51b81ed53f =
// 0.33); the old 0.82 was a misread
// (task #8 audit)
//###########################################################################
//###########################################################################
// MechCriticalSubsystem
//###########################################################################
//###########################################################################
//
// @0049dd18 -- owner is the DamageZone that will destroy this subsystem when
// the zone's structure runs out. damagePercentage / damagePercentageUsed and
// criticalWeight are filled in afterwards by the streaming ctor of the zone.
//
MechCriticalSubsystem::MechCriticalSubsystem(DamageZone *owner)
: subsystemPlug(owner) // FUN_0049dd7e (SlotOf<Subsystem*>)
{
criticalWeight = 0; // Wword(6) @0x18
damagePercentageUsed = 0; // Wword(5) @0x14
}
//
// @0049dd44 -- releases the subsystem slot (FUN_0049dd9d).
//
MechCriticalSubsystem::~MechCriticalSubsystem()
{
}
//
// @0049dd74
//
Logical
MechCriticalSubsystem::TestInstance() const
{
return True;
}
//###########################################################################
//###########################################################################
// Mech::DamageZone
//###########################################################################
//###########################################################################
//#############################################################################
// Construction -- @0049ce50
//
// Chains to the base DamageZone (which parses armour/structure from the .dmg
// record and allocates the redirect table), then reads this class's own
// stream fields, normalises the per-facing armour scalars, builds the
// critical-subsystem array and finally the LOD redirect table.
//
Mech__DamageZone::Mech__DamageZone(
Mech *mech,
int damage_zone_index,
MemoryStream *stream
)
: DamageZone(mech, damage_zone_index, stream), // FUN_0041df5c
parentArtifactZone(0) // FUN_0049ddc9(,0) -- Socket(NULL) legal
{
// vtable @0050bb90
redirectTable.Construct(0, 1); // Wword(0x58) = FUN_00424767(,0,1)
// last-redirect bookkeeping
lastDamageTime = (Scalar)TheTime.CurrentTick() / TicksPerSecond; // Wword(0x5f)
lastInflicting = mech->entityID; // Wword(0x60) = FUN_00420ea4(,mech+0x184)
criticalWeightSum = 0; // Wword(0x6c)
//
// Read the streamed scalar/flag block. (Stream order set by
// CreateStreamedDamageZone below.)
//
stream->ReadBytes(&descendOnDestruction, 4); // Wword(0x67)
stream->ReadBytes(&destroySiblingsOnDestruction, 4); // Wword(0x68)
stream->ReadBytes(&segmentIndex, 4); // Wword(0x65)
stream->ReadBytes(&leftLeg, 4); // Wword(0x69)
stream->ReadBytes(&rightLeg, 4); // Wword(0x6a)
stream->ReadBytes(&vitalDamageZone, 4); // Wword(0x66)
stream->ReadBytes(&criticalSubsystemCount, 4); // Wword(0x6b)
//
// Normalise the damage-scale vector damageScale[Damage::DamageTypeCount]
// (this[0x51..0x55], == base DamageZone::damageScale[5]). These are NOT
// per-facing: the base TakeDamage indexes them by Damage::damageType
// (it reads this[0x51 + damage.damageType]; see DamageZone::TakeDamage
// @0041e4e0), so the 5 cells are the {Collision,Ballistic,Explosive,Laser,
// Energy} per-type armour scale for this zone. Each cell is rewritten so a
// stored "armour points" figure becomes a damage-per-point fraction
// (1/(points*count)); a leg zone then halves every type's scale.
//
// damageScale[5] and defaultArmorPoints are the INHERITED engine DamageZone base
// members (read raw from the stream by the base ctor, DAMAGE.cpp:301-307); we
// normalise them in place here -- not a raw 0x144/0x140 offset (which would be the
// binary layout, wrong when compiled against the engine base).
Scalar rawScale[Damage::DamageTypeCount];
for (int type = 0; type < Damage::DamageTypeCount; ++type)
rawScale[type] = damageScale[type];
for (int type = 0; type < Damage::DamageTypeCount; ++type) // 5 damage types
{
Scalar even = 1.0f / defaultArmorPoints; // _DAT_0049d1c4 / count
if (fabsf(damageScale[type] - even) > 1.0e-4f) // _DAT_0049d1c8
damageScale[type] = 1.0f / (damageScale[type] * defaultArmorPoints);
if (rightLeg || leftLeg)
damageScale[type] *= 0.5f; // _DAT_0049d1cc
}
// ARMOR ECONOMY DUMP (the ram-kill forensics): the authored per-type
// absorption -- damageLevel += amount * scale[type]; level 1.0 = zone
// destroyed. scale[0] (Collision) x the StaticBounce kinetic number is
// the authored ram economy.
if (getenv("BT_DMG_LOG"))
DEBUG_STREAM << "[zone-armor] zone=" << damageZoneIndex
<< " armorPts=" << defaultArmorPoints
<< " raw={" << rawScale[0] << "," << rawScale[1] << "," << rawScale[2]
<< "," << rawScale[3] << "," << rawScale[4] << "}"
<< " scale={" << damageScale[0] << "," << damageScale[1] << ","
<< damageScale[2] << "," << damageScale[3] << "," << damageScale[4] << "}"
<< " leg=" << (int)(rightLeg || leftLeg)
<< " vital=" << (int)vitalDamageZone << std::endl;
//
// Critical-subsystem table.
//
if (criticalSubsystemCount == 0)
{
criticalSubsystems = 0; // Wword(0x6d)
}
else
{
criticalSubsystems =
(MechCriticalSubsystem **)Memory::AllocateArray(criticalSubsystemCount * 4);
for (int i = 0; i < criticalSubsystemCount; ++i)
{
criticalSubsystems[i] =
new (Memory::Allocate(0x1c)) MechCriticalSubsystem(this); // FUN_0049dd18
}
int subsystemIndex = -1;
for (int i = 0; i < criticalSubsystemCount; ++i)
{
stream->ReadBytes(&criticalSubsystems[i]->criticalWeight, 4); // +0x18
stream->ReadBytes(&criticalSubsystems[i]->damagePercentage, 4); // +0x10
criticalWeightSum += criticalSubsystems[i]->criticalWeight;
stream->ReadBytes(&subsystemIndex, 4); // roster index (mech+0x128 order)
// THE BINDING (task #2, disasm @0049d0e1-0049d10d -- Ghidra DROPPED
// the two arg pushes, which is why this read as a bare Resolve():
// the real call is Slot::AddImplementation(roster[index])). Gate =
// MasterInstance + DynamicFlag on the flags LWord @+0x28
// (simulationFlags). Replicant plugs stay UNBOUND on purpose:
// subsystem criticals are master-authoritative. [T1]
if ((mech->simulationFlags & Entity::InstanceMask) == Entity::MasterInstance
&& (mech->simulationFlags & Entity::DynamicFlag) != 0)
{
Subsystem *target =
(subsystemIndex >= 0 && subsystemIndex < mech->GetSubsystemCount())
? mech->GetSubsystem(subsystemIndex) : 0;
if (target != 0)
{
criticalSubsystems[i]->subsystemPlug.Add(target); // FUN_00417a80
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[crit] zone " << damage_zone_index
<< " entry " << i << " -> roster[" << subsystemIndex
<< "] = " << (void *)target << "\n" << std::flush;
}
else if (getenv("BT_DEATH_LOG"))
{
// roster slot NULL: idx 0 (mapper, installed post-ctor) or
// idx 1 (voltage bus, ReconSlot stub). The engine
// Link::AddToPlug derefs the plug, so a null bind crashes.
DEBUG_STREAM << "[crit] zone " << damage_zone_index
<< " entry " << i << " roster[" << subsystemIndex
<< "] is NULL -- left unbound\n" << std::flush;
}
}
}
}
//
// LOD redirect table: <count> followed by <count> child zone indices.
//
int redirectCount = 0;
stream->ReadBytes(&redirectCount, 4);
for (int i = 0; i < redirectCount; ++i)
{
int childZone = 0;
stream->ReadBytes(&childZone, 4);
redirectTable.Add(new (Memory::Allocate(0x10)) IntegerPlug(&childZone)); // FUN_00424716
}
lastHitZone = (redirectCount == 0) ? 0 : RandomRedirect(); // Wword(0x5e)
}
//#############################################################################
// SetLODParentPointers -- @0049d1d0
//
// Run after every zone of the mech exists. For each child index in this
// (artifact) zone's redirect table, point that real child zone's
// parentArtifactZone slot back at us.
//
void
Mech__DamageZone::SetLODParentPointers()
{
Mech *mech = (Mech *)GetOwningSimulation();
DamageZoneIndexTableIterator it(&redirectTable); // FUN_00424830
for (IntegerPlug *plug; (plug = it.Next()) != 0; ) // slot 0x28
{
Mech__DamageZone *child = mech->Zone(plug->value); // inherited Entity::damageZones[idx], typed
child->parentArtifactZone.Add(this); // slot @0x188: FUN_00417a80 (NOT flag-gated)
}
}
//#############################################################################
// Destruction -- @0049d23c (vtable slot 0)
//
Mech__DamageZone::~Mech__DamageZone()
{
*(void **)this = &vtable_0050bb90;
if (criticalSubsystems != 0)
{
for (int i = 0; i < criticalSubsystemCount; ++i)
delete criticalSubsystems[i];
Memory::Free(criticalSubsystems);
}
{
DamageZoneIndexTableIterator it(&redirectTable); // FUN_00424830
it.DeleteContents(); // FUN_00417858(,1)
}
// (binary tail: FUN_0049dde8 parentArtifactZone.~DZSlot, FUN_0042478a
// redirectTable.~DamageZoneIndexTable, then base ~DamageZone FUN_0041e394 --
// all COMPILER-EMITTED dtor epilogue glue; C++ re-emits member + base
// destruction implicitly at the closing brace, so writing them as source runs
// them twice. See the reconstruction rule in mech.cpp ~Mech.)
}
//#############################################################################
// TakeDamage -- @0049c690 (vtable slot 6)
//
// If this zone is an artifact (has a redirect table) the hit is handed to the
// LOD router so it lands on a real child zone. Otherwise the base DamageZone
// applies it to armour/structure, the parent artifact (if any) recomputes its
// displayed level, special damage may short an attached generator, and the
// zone updates its graphic / leg-failure state.
//
// The base armour model (DamageZone::TakeDamage @0041e4e0, == the WinTesla
// engine DAMAGE.cpp body we link against) is:
//
// damageLevel += damage.damageAmount * damageScale[damage.damageType];
// Clamp(damageLevel, 0.0f, 1.0f); // _DAT_0041e57c / _DAT_0041e580
// SetDamageLevelChangedFlag(); // flags |= 4
// if (damageLevel >= 1.0f) { // zone destroyed
// SetDamageZoneState(BurningState); // state indicator -> 1
// SetGraphicState(DestroyedGraphicState);// vtable slot 3 -> 1
// SetGraphicStateChangedFlag(); // flags |= 8
// }
//
// i.e. there is no separate "armour then structure" pool -- a single 0..1
// damageLevel accumulates, scaled per damage TYPE by damageScale[5] (the
// vector normalised in the ctor above); 1.0 == destroyed. (damageLevel in
// this reconstruction names that same base damageLevel cell @0x158.)
//
void
Mech__DamageZone::TakeDamage(Damage& damage)
{
Mech *mech = (Mech *)GetOwningSimulation();
DamageZoneIndexTableIterator it(&redirectTable); // FUN_00424830
if (it.Count() != 0) // slot 0x14 -> artifact zone
{
EntityID inflicting = mech->lastInflictingID; // mech[0x43c]
Damage local = damage; // copy 10 words
LODDamageRouter(inflicting, local); // FUN_0049c40c
return;
}
// ---- real zone: apply to armour/structure ----
DamageZone::TakeDamage(damage); // FUN_0041e4e0
Mech__DamageZone *parent = parentArtifactZone.GetCurrent(); // FUN_00417ab4(this+0x62)
if (parent != 0)
parent->UpdateLODDamageLevel(); // FUN_0049c51c
// ---- ShortAttachedGenerators (inlined): special damage type 4 ----
if (damage.damageType == 4 && criticalSubsystemCount > 0)
{
int n = Random(criticalSubsystemCount); // FUN_0040807c
Subsystem *s = criticalSubsystems[n]->subsystemPlug.GetCurrent();
if (s != 0 // unbound-plug guard (idx-0/1 roster residue)
&& s->IsDerivedFrom(*Generator::GetClassDerivations())) // FUN_0041a1a4(,0x50f4bc)
((Generator *)s)->ForceShortRecovery(); // FUN_004b11bc
}
// ---- graphic / failure state from current structure level ----
if (vitalDamageZone == 0 || damageLevel < StructureMax)
{
if (leftLeg == 0 && rightLeg == 0)
{
if (damageLevel >= StructureMax)
RecurseSegmentTable(mech); // FUN_0049cad4
}
else if ((unsigned)(mech->MovementMode() - 3) < 2) // live sim state 3||4 (mech+0x40)
{
// task #60: the gate reads the LIVE MovementMode (mech+0x40),
// @part_012.c:6910 `*(int*)(iVar1+0x40) - 3U < 2` -- NOT the phantom
// `stance` member (no binary offset, zero writers -> perma-0, so
// this whole leg-shot-out -> fall/death branch was DEAD).
if (rightLeg != 0 && damageLevel >= StructureMax)
mech->graphicAlarm.SetLevel(9); // fall / leg gone
else if (leftLeg != 0 && damageLevel >= StructureMax)
mech->graphicAlarm.SetLevel(9);
}
else if (mech->IsDisabled() == 0) // FUN_0049fb54 (NOT disabled)
{
// A live (non-disabled) mech: a half-destroyed leg zone trips the
// partial-failure graphic (right -> 4, left -> 3). The oracle
// compares against _DAT_0049c9a0 (0.5), NOT StructureMax. task #60:
// the guard is IsDisabled (@0049fb54 = movementMode 2||9), NOT the
// always-0 IsAirborne stub -- on a WRECK the binary SUPPRESSES this
// write; the stub let it corrupt graphicAlarm 9->4/3 (the task-#52
// wreck-graphic bug, now fixed at source not just masked).
if (rightLeg != 0 && damageLevel >= LegHalfStructure)
mech->graphicAlarm.SetLevel(4);
else if (leftLeg != 0 && damageLevel >= LegHalfStructure)
mech->graphicAlarm.SetLevel(3);
}
}
else
{
// vital zone destroyed -> mech death
SetDamageZoneState(1); // this+4
mech->graphicAlarm.SetLevel(9);
}
damageLevel = Clamp(damageLevel, StructureMin, StructureMax);
}
//#############################################################################
// SetGraphicState -- @0049c66c (vtable slot 3)
//
// Drives the secondary "graphic state" alarm (this+0x64) and flags the zone
// as needing a visual refresh (Wword(0xb8) |= 8).
//
// Mech__DamageZone::SetGraphicState removed -- byte-identical to the engine base
// DamageZone::SetGraphicState (damageZoneGraphicState.SetState(new_state) +
// SetGraphicStateChangedFlag(), the latter == the old `flags |= 8`). Calls below
// resolve to the inherited base override.
//#############################################################################
// LODDamageRouter -- @0049c40c
//
// An artifact zone decides which real child takes the hit. If the same
// attacker hit recently (within [0.25s, 10.0s]) and a random roll succeeds
// the previous child is reused; otherwise a fresh RandomRedirect() is chosen.
// A brand-new attacker always re-rolls.
//
void
Mech__DamageZone::LODDamageRouter(EntityID inflicting, Damage damage)
{
int zone;
if (inflicting != lastInflicting) // FUN_0042104c
{
zone = RandomRedirect(); // new attacker
}
else
{
Scalar dt = (Scalar)TheTime.CurrentTick() / TicksPerSecond - lastDamageTime;
if (dt < LODReuseWindowMin)
zone = lastHitZone; // too soon: same spot
else if (dt > LODReuseWindowMax)
zone = RandomRedirect(); // stale: re-roll
else if ((Scalar)Random <= LODReuseHysteresis) // _DAT_0049c514
zone = lastHitZone;
else
zone = RandomRedirect();
}
lastHitZone = zone; // Wword(0x5e)
lastDamageTime = (Scalar)TheTime.CurrentTick() / TicksPerSecond;
lastInflicting = inflicting; // FUN_00420ef4
Mech *mech = (Mech *)GetOwningSimulation();
mech->Zone(zone)->TakeDamage(damage); // slot 6 on the real child
UpdateLODDamageLevel(); // FUN_0049c51c
}
//#############################################################################
// RandomRedirect -- @0049c600
//
// Uniformly pick one of the redirect table's child zone indices.
//
int
Mech__DamageZone::RandomRedirect()
{
DamageZoneIndexTableIterator it(&redirectTable); // FUN_00424830
int n = Random(it.Count()); // FUN_0040807c(rng, slot 0x14)
IntegerPlug *plug = it[n]; // slot 0x34
return plug->value; // +0xc
}
//#############################################################################
// UpdateLODDamageLevel -- @0049c51c
//
// An artifact zone's displayed structure is the mean of its real children's
// structure levels, clamped to [0,1].
//
void
Mech__DamageZone::UpdateLODDamageLevel()
{
Mech *mech = (Mech *)GetOwningSimulation();
unsigned count = 0;
Scalar sum = 0.0f;
DamageZoneIndexTableIterator it(&redirectTable); // FUN_00424830
for (IntegerPlug *plug; (plug = it.Next()) != 0; ) // slot 0x28
{
sum += mech->Zone(plug->value)->damageLevel; // +0x158
++count;
}
damageLevel = (count != 0) ? sum / (Scalar)count : StructureMin;
damageLevel = Clamp(damageLevel, StructureMin, StructureMax);
}
//#############################################################################
// CriticalHit -- @0049ccc4
//
// Apply (half of) a critical hit. Half the damage amount flows through the
// normal armour model (TakeDamage); the remainder rolls one critical
// subsystem, weighted by criticalWeight, and damages it. Returns the chosen
// subsystem (or 0 if none was selected).
//
Subsystem*
Mech__DamageZone::CriticalHit(Damage &damage_data)
{
Scalar bite = damage_data.damageAmount * CriticalDamageFraction; // damage+4 * 0.5
if (bite > CriticalDamageMax)
bite = CriticalDamageMax;
damage_data.damageAmount -= bite;
TakeDamage(damage_data); // slot 6 (this+0x18)
Scalar roll = (Scalar)Random; // FUN_00408050
if (criticalSubsystems != 0)
{
for (int i = 0; i < criticalSubsystemCount; ++i)
{
Scalar cum = criticalSubsystems[i]->criticalWeight;
for (int j = 0; j < i; ++j)
cum += criticalSubsystems[j]->criticalWeight;
if (roll <= cum / criticalWeightSum)
{
Subsystem *s = criticalSubsystems[i]->subsystemPlug.GetCurrent();
MechCriticalSubsystem *cs = criticalSubsystems[i];
if (s != 0 // unbound-plug guard
&& cs->damagePercentageUsed < cs->damagePercentage)
cs->damagePercentageUsed +=
((MechSubsystem *)s)->ApplyDamageAndMeasure(damage_data); // FUN_004ac07c
return s;
}
}
}
return 0;
}
//#############################################################################
// SendSubsystemDamage -- @0049c9a8
//
// When this zone is destroyed it forces full structure (1.0) on itself and
// pushes each critical subsystem's outstanding damage fraction onto the zone
// that physically carries that subsystem; any zone driven to destruction
// there raises its alarms and fails the subsystem.
//
void
Mech__DamageZone::SendSubsystemDamage()
{
SetDamageZoneState(1); // this+0x10
damageLevel = StructureMax; // this+0x158 = 1.0
for (int i = 0; i < criticalSubsystemCount; ++i)
{
MechSubsystem *s =
(MechSubsystem *)criticalSubsystems[i]->subsystemPlug.GetCurrent(); // +4
MechCriticalSubsystem *cs = criticalSubsystems[i];
// Unbound-plug guard: with the authentic binding live (the ctor loop)
// this is only the idx-0/1 roster residue (mapper installed post-ctor
// / voltage-bus stub), not the norm. The 1995 binary derefs with no
// check because every plug there is bound.
if (s == 0)
{
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] crit-subsys " << i << "/" << criticalSubsystemCount
<< " UNBOUND (roster slot residue) -- skipped\n" << std::flush;
continue;
}
// Push the UNUSED crit allotment into the subsystem's OWN private
// DamageZone (the 0x160-byte zone its ctor built at +0xE0) -- NOT a
// mech dz_* zone (the old SubProxy2 cast misread this). [T1 @0049c9a8]
Scalar level = s->GetSubsystemDamageLevel()
+ (cs->damagePercentage - cs->damagePercentageUsed);
level = Clamp(level, StructureMin, StructureMax);
s->SetSubsystemDamageLevel(level);
if (level >= StructureMax) // subsystem destroyed
{
if (s->IsVitalSubsystem()) // +0xE4
((Mech *)GetOwningSimulation())->graphicAlarm.SetLevel(9); // mech kill
s->ForceCriticalFailure(); // status 1 + print + zone valve
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] crit-subsys " << i
<< " DESTROYED (vital=" << s->IsVitalSubsystem() << ")\n" << std::flush;
}
else if (getenv("BT_DEATH_LOG"))
{
DEBUG_STREAM << "[deathfx] crit-subsys " << i << " damaged -> "
<< level << "\n" << std::flush;
}
}
}
//#############################################################################
// RecurseSegmentTable -- @0049cad4
//
// Walks the model's segment tree from this zone. First pushes this zone's
// subsystem damage out (SendSubsystemDamage), then -- per the two streamed
// flags -- destroys sibling zones (destroySiblingsOnDestruction) and/or
// descends into child segments (descendOnDestruction), recursing on each.
//
void
Mech__DamageZone::RecurseSegmentTable(Mech *my_mech)
{
SendSubsystemDamage(); // FUN_0049c9a8
SetGraphicState(1); // slot 0xc
SegmentRecord *seg = ((SegTableX &)my_mech->segmentTable)[segmentIndex]; // mech+0x300, Wword(0x65)
if (destroySiblingsOnDestruction) // Wword(0x68)
{
SegmentIterator sibs(&seg->siblings); // seg+0xd0
for (DZRef *r; (r = sibs.Next()) != 0; ) // slot 0x28
{
Mech__DamageZone *sib = my_mech->Zone(r->index); // inherited damageZones[..], typed
if (sib != this && sib->GetGraphicState() != 1)
{
sib->RecurseSegmentTable(my_mech);
sib->SetGraphicState(2); // slot 0xc
}
}
}
if (descendOnDestruction) // Wword(0x67)
{
SegmentIterator kids(&seg->children); // seg+0xe8
for (DZRef *r; (r = kids.Next()) != 0; )
{
SegmentRecord *child = ((SegTableX &)my_mech->segmentTable)[r->index];
SegmentIterator czones(&child->siblings); // child+0xd0
for (DZRef *cr; (cr = czones.Next()) != 0; )
{
Mech__DamageZone *cz = my_mech->Zone(cr->index);
cz->RecurseSegmentTable(my_mech);
cz->SetGraphicState(2);
}
}
}
}
//#############################################################################
// CreateStreamedDamageZone -- @0049d304
//
// Parse ONE zone out of the .dmg notation file into the memory stream that
// the ctor above will later replay. Called once per entry in the .dmg
// "DamageZones" list by Mech::CreateDamageZoneStream (@004a474c).
//
// Token stream written, in order:
// (base fields via DamageZone::CreateStreamedDamageZone @0041e590)
// DescendOnDestruction (Logical)
// DestroySiblingsOnDestruction (Logical)
// GetSegmentIndex(name, skl) (int)
// leftLeg, rightLeg (from "LegDamageZone" == LeftLeg/RightLeg)
// vitalDamageZone (from "VitalDamageZone" == True/False, required)
// criticalSubsystemCount, then per "CriticalSubsystem <name> <%f> <%f>":
// subsystem index ("ControlsMapper"->0, "Power"->1, else Subsystems list),
// the two percentages
// redirectCount, then per "LODRedirect" entry: GetSegmentIndex of the name.
//
Logical
Mech__DamageZone::CreateStreamedDamageZone(
ResourceFile *resource_file,
NotationFile *model_file,
const char *model_name,
NotationFile *skl_file,
const char *damage_zone_name,
NotationFile *dmg_file,
const ResourceDirectories *directories,
MemoryStream *damage_zone_stream
)
{
CString name(damage_zone_name);
// base armour/structure record
if (!DamageZone::CreateStreamedDamageZone( // FUN_0041e590
model_file, model_name, skl_file, name, damage_zone_stream, dmg_file, directories))
return (Logical)-1;
Logical descend = 0;
((NoteX2 *)dmg_file)->ReadLogical(damage_zone_name, "DescendOnDestruction", &descend);
damage_zone_stream->WriteBytes(&descend, 4);
Logical destroySibs = 0;
((NoteX2 *)dmg_file)->ReadLogical(damage_zone_name, "DestroySiblingsOnDestruction", &destroySibs);
damage_zone_stream->WriteBytes(&destroySibs, 4);
int segIndex = GetSegmentIndex(name, skl_file); // FUN_0049db20
damage_zone_stream->WriteBytes(&segIndex, 4);
Logical isLeftLeg = 0, isRightLeg = 0;
const char *leg;
if (((NoteX2 *)dmg_file)->ReadString(damage_zone_name, "LegDamageZone", &leg))
{
if (Str_Equal(leg, "RightLeg")) isRightLeg = 1;
if (Str_Equal(leg, "LeftLeg")) isLeftLeg = 1;
}
damage_zone_stream->WriteBytes(&isLeftLeg, 4);
damage_zone_stream->WriteBytes(&isRightLeg, 4);
const char *vital;
if (!((NoteX2 *)dmg_file)->ReadString(damage_zone_name, "VitalDamageZone", &vital))
{
DebugStream << dmg_file << damage_zone_name << " missing VitalDamageZone!";
return False;
}
Logical isVital;
if (Str_Equal(vital, "True")) isVital = 1;
else if (Str_Equal(vital, "False")) isVital = 0;
else
{
DebugStream << model_name << " " << damage_zone_name
<< " Improper format for LegDamageZone";
return False;
}
damage_zone_stream->WriteBytes(&isVital, 4);
//
// CriticalSubsystem list ("<name> <%f> <%f>").
//
NotationList crits = ((NoteX2 *)dmg_file)->FindList(damage_zone_name, "CriticalSubsystem");
int critCount = crits.Count();
damage_zone_stream->WriteBytes(&critCount, 4);
for (NotationEntry *e = crits.First(); e != 0; e = e->Next())
{
char subName[64];
Scalar pctA, pctB;
if (sscanf(e->text, "%s %f %f", subName, &pctA, &pctB) == 0) // s__s__f__f
{
DebugStream << model_name << " error in CriticalSubsystem Format!";
return (Logical)-1;
}
damage_zone_stream->WriteBytes(&pctA, 4);
damage_zone_stream->WriteBytes(&pctB, 4);
int subIndex;
if (Str_Equal(subName, "ControlsMapper"))
subIndex = 0;
else if (Str_Equal(subName, "Power"))
subIndex = 1;
else
{
// search the model's "Subsystems" list, indices start at 2
const char *subFile;
((NoteX2 *)model_file)->ReadString("gamedata", "Subsystems", &subFile);
NotationFile *sf = ((RFileX2 *)resource_file)->Open(subFile);
NotationList list = ((NoteX2 *)sf)->FindList(0);
subIndex = 2;
NotationEntry *se;
for (se = list.First(); se != 0; se = se->Next(), ++subIndex)
if (Str_Equal(se->name, subName))
break;
((NoteX2 *)sf)->Close();
if (se == 0)
{
DebugStream << model_name << ':' << subName << " does not exist!";
return (Logical)-1;
}
}
damage_zone_stream->WriteBytes(&subIndex, 4);
}
//
// LODRedirect list: each redirect names a child zone resolved via the
// segment table (TableOf::Find @004274f8); -1 == not found is an error.
//
NotationList redirects = ((NoteX2 *)dmg_file)->FindList(damage_zone_name, "LODRedirect");
int redirectCount = redirects.Count();
damage_zone_stream->WriteBytes(&redirectCount, 4);
for (NotationEntry *e = redirects.First(); e != 0; e = e->Next())
{
CString childName(e->text);
if (childName.Length() == 0)
{
DebugStream << model_name << " error in LODRedirect Format!";
return (Logical)-1;
}
int childIndex = FindSegment(childName, skl_file); // FUN_004274f8
if (childIndex == -1)
{
DebugStream << resource_file << ' ' << childName << " Not Found!";
return (Logical)-1;
}
damage_zone_stream->WriteBytes(&childIndex, 4);
}
return True;
}
//#############################################################################
// GetSegmentIndex -- @0049db20
//
// Walk the skeleton (.skl) notation file's segment groups in order. Skipping
// the "LAB_ONLY" and "DamageZones" pseudo-groups, count real segments; for
// each segment read its "dzone" sub-list and, if any dzone name matches
// damage_zone_name, return the running segment index. Returns -1 on miss.
//
int
Mech__DamageZone::GetSegmentIndex(CString damage_zone_name, NotationFile *skl_file)
{
int index = -1;
NotationList segments = ((NoteX2 *)skl_file)->FindList(0); // FUN_0040485c
for (NotationEntry *seg = segments.First(); seg != 0; seg = seg->Next())
{
if (Str_Equal(seg->name, "LAB_ONLY")) continue;
if (Str_Equal(seg->name, "DamageZones")) continue;
++index;
NotationList dzones = ((NoteX2 *)skl_file)->FindList(seg->name, "dzone"); // FUN_00404720
for (NotationEntry *dz = dzones.First(); dz != 0; dz = dz->Next())
{
CString dzName(dz->text);
if (dzName == damage_zone_name) // FUN_004027d8
return index;
}
}
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
}
void
Mech__DamageZone::Heal()
{
// Respawn heal: restore this zone to the undamaged/intact state the loader
// builds -- full structure, intact skin, no burning/destroyed flag. The
// authentic Mech::Reset (@0049fb74) heals via each subsystem's
// ResetToInitialState; the zone's structural state is restored here.
damageLevel = StructureMin; // 0.0 -- full structure (this+0x158)
ApplyDamageGraphicState(ExistsGraphicState); // 0 -- intact skin (undo Destroyed/Gone)
SetDamageZoneState(0); // clear burning / vital-destroyed state
}
//#############################################################################
// 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, int segment_index);
// Render bridge (btl4vid.cpp): swap a mech's wrecked segment meshes onto its
// already-built render tree when a damage zone's graphic state changes (the
// unported "RemakeEntity" render state). Entity* param -> Mech* binds fine.
extern void BTRemakeMechModel(Entity *entity);
MechDeathHandler::MechDeathHandler(Mech *mech) // @0042a984
: owner(mech), wasWrecked(0)
{
// 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];
// FALLING edge = the master's Mech::Reset healed + re-broadcast this zone
// (ForceUpdate(DamageZoneUpdateModelFlag)). The rising-damage swap below is
// one-way; on a REPLICANT observer, reverse it here -- drop the destroyed
// segment meshes (BTRebuildMechModel) and play the recovery warp -- so the
// peer's wreck un-wrecks + warps back on the observer's screen. Gated to
// ReplicantInstance: the master already un-wrecks + warps locally in
// Mech::Reset (mech4.cpp), so it must NOT double-fire here. The authentic
// engine drives this off the type-0 graphic-state transition in
// Mech::ReadUpdateRecord (mech.cpp case 0 -> ResetPose); our port has no
// ported RemakeEntity state, so we react to the replicated zone level.
if (level < prev)
{
lastLevel[i] = level;
if (wasWrecked && prev >= 1.0f && level < 1.0f)
{
wasWrecked = 0; // one un-wreck per death->respawn
if (owner->GetInstance() == Entity::ReplicantInstance)
{
extern void BTRebuildMechModel(Entity *entity);
extern void BTStartWarpEffect(float x, float y, float z);
BTRebuildMechModel((Entity *)owner);
BTStartWarpEffect((float)owner->localOrigin.linearPosition.x,
(float)owner->localOrigin.linearPosition.y,
(float)owner->localOrigin.linearPosition.z);
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[respawn] replicant un-wrecked + warp at ("
<< owner->localOrigin.linearPosition.x << ","
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
}
}
continue;
}
if (level <= prev) // unchanged -- no new damage
continue;
lastLevel[i] = level;
// AUTHENTIC GATE (decomp re-verified 2026-07-12, workflow): the binary
// fires the CURRENT band descriptor's effect whenever the zone's
// damage-CHANGED flag is set (+0xb8 & 4, set by DamageZone::TakeDamage,
// coalesced per tick) -- i.e. EVERY hit on a damaged mech re-puffs its
// band effect. Bands 3/4 are authored ember/bright-FIRE plumes, so a
// nearly-destroyed mech under fire burns continuously (the demo look).
// A level RISE this tick IS the flag analog -- no crossing test. (The
// earlier metronome was SHKWAVE mispacing, not this path; an observer
// node sees a few replication-step puffs per volley -- smoke, fine.)
const Mech__DamageZone::DamageDescriptor *d = zone->DescriptorForLevel(level);
if (level >= 1.0f && prev < 1.0f) // just destroyed -> the Destroyed descriptor
{
wasWrecked = 1; // latch: a wreck the observer must later reverse
const Mech__DamageZone::DamageDescriptor *dd =
zone->DescriptorForGraphicState(DamageZone::DestroyedGraphicState); // enum 1
if (dd != 0)
d = dd;
}
if (d != 0)
{
// THE AUTHENTIC CHAIN (@004d097c dispatcher, landed 2026-07-13):
// RendererManager::StartEntityEffect -> the Renderer message
// handler [T0: GameModel resolve + ExplosionResourceTable
// graphic-state remap] -> BTL4VideoRenderer::
// StartEntityEffectImplementation -> the effect ATTACHED to the
// zone's segment (smoke follows the walking mech) -- and the
// AudioRenderer's implementation hears the same message (zone
// sounds ride free). Explosion::Make fallback only when the
// manager isn't up (early bring-up).
extern int BTStartZoneEffect(Mech *mech, void *zone, int resource);
if (!BTStartZoneEffect(owner, zone, d->effectResource))
{
BTSpawnDamageEffect(owner, d->effectResource, // explosion AT the zone's
zone->segmentIndex); // segment (world position)
}
zone->ApplyDamageGraphicState(d->graphicState); // destroyed skin (graphic state)
// A graphic-state change means the segment's MODEL changed (intact ->
// destroyed variant, keyed by GetVideoObjectName(skl, gstate)). Fire
// the engine's model-rebuild flag so the renderer re-runs
// MakeMechRenderables and loads the destroyed segment geometry -- the
// same mechanism CulturalIcons use to visibly deform on damage
// (CULTURAL.cpp:91; EXPTBL.cpp:549 does this per zone in the engine's
// ExplosionTable). Without this the tree stays as built at spawn (intact).
// The render's "RemakeEntity" state (which consumes this flag) was
// NOT ported -- the mech tree is built once at spawn -- so we drive the
// equivalent directly: set the flag (authentic signal; also flags the
// zone for net damage replication) AND call the render bridge, which
// swaps the wrecked segment mesh onto the already-built tree in place.
// The single-threaded frame loop makes the immediate swap safe.
if (d->graphicState != DamageZone::ExistsGraphicState)
{
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
BTRemakeMechModel(owner); // RemakeEntity: swap in the destroyed mesh
}
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] zone " << i << " level " << level
<< " -> effect " << d->effectResource << " gstate " << d->graphicState
<< "\n" << std::flush;
}
}
}