BT410 Phase 5.3.17: the destruction cascade -- zone deaths DESTROY things

Mech::DamageZone::TakeDamage is the full binary cascade (@0049c690): LOD
artifact router with same-attacker clustering (@0049c40c, hysteresis 0.33),
artifact parent level = mean of children, Energy-hit generator shorts
through the crit plug binding (novice-exempt), weighted CriticalHit
(@0049ccc4), zone-death allotment push (SendSubsystemDamage @0049c9a8) and
the segment-table destruction descent (RecurseSegmentTable @0049cad4 --
SIBS + DESCEND via the engine EntitySegment iterators).

Supporting bricks: Mech's OWN handler table with the TakeDamage override
(latches lastInflictingID @0x43c; gyro feed staged; cylinder table
deferred); friend class Mech__DamageZone (authentic); subsystem private
zones ARMED (structureReference + armorByFacing[5] normalized -- crits are
measurable); ApplyDamageAndMeasure; ForceCriticalFailure sets
DestroyedState so the weapon FSMs' GetSimulationState()==1 hard gate went
live; Generator::ForceShort + PoweredSubsystem::ForceShortRecovery.

Fight-verified (mutual BT_SPAWN_ENEMY + BT_FORCE_ZONE=8 concentrated fire):
arm zone to 1.0 -> sibling searchlight zone dies (Searchlight2/ThermalSight
crits destroyed, HUD degrading) -> DESCEND kills the gun zone -> PPC_2 +
ERMLaser_2 + Condenser6 DESTROYED and FALL SILENT (0 shots after death;
undamaged PPC_1 keeps firing); vital-zone hit = MECH KILL; PPC hit shorted
GeneratorD. This art has zero artifact zones (redirect=0 across all 20 --
router verified dormant-correct). Smoke + novice regressions clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-24 11:13:28 -05:00
co-authored by Claude Fable 5
parent 86d6b950e5
commit d4f0aef6e1
13 changed files with 856 additions and 27 deletions
+6
View File
@@ -352,6 +352,12 @@ void
Mech *enemy = Mech::Make(&create_enemy);
Register_Object(enemy);
((Mech *)playerVehicle)->SetTargetEntity(enemy);
//
// Mutual: the enemy targets us back -- with BT_FORCE_FIRE its
// weapons return fire (its gates read dev-permissive, no player
// link), giving the damage cascade a live INCOMING side too.
//
enemy->SetTargetEntity(playerVehicle);
if (getenv("BT_MECH_LOG"))
{
@@ -172,3 +172,10 @@ and sets it as the player mech's target. The enemy has NO player link, so its
experience gates read dev-permissive; it never fires back (no target of its
own — set one for a mutual fight). DEV-ONLY: the real opponent path is the
network mesh / mission roster.
## 5.3.17 — BT_SPAWN_ENEMY is now a MUTUAL fight
The harness also sets the enemy's target back to the player: under
BT_FORCE_FIRE the enemy returns fire (its gates read dev-permissive with no
player link), so the damage cascade gets a live incoming side — used to
verify the player's own weapons fall silent when destroyed.
+56 -1
View File
@@ -64,10 +64,29 @@ Derivation
"Mech"
);
//
// The Mech's OWN handler table, chained to the JointedMover set. The
// TakeDamage entry OVERRIDES Entity's by message ID (Build overlays the
// inherited slot -- no gap risk, the parent chain already registered the
// ID); every other inherited message still routes to its base handler.
//
const Receiver::HandlerEntry
Mech::MessageHandlerEntries[] =
{
MESSAGE_ENTRY(Mech, TakeDamage)
};
Mech::MessageHandlerSet
Mech::MessageHandlers(
ELEMENTS(Mech::MessageHandlerEntries),
Mech::MessageHandlerEntries,
JointedMover::MessageHandlers
);
Mech::SharedData
Mech::DefaultData(
Mech::ClassDerivations,
JointedMover::MessageHandlers,
Mech::MessageHandlers,
JointedMover::AttributeIndex,
33,
(Entity::MakeHandler)Mech::Make
@@ -167,6 +186,7 @@ Mech::Mech(
lookPitch = 0.0f;
lookYaw = 0.0f;
targetEntity = NULL;
lastInflictingID = EntityID::Null;
//
// Look-view angles: defaults until the GameModel read below overrides them
@@ -551,6 +571,41 @@ Mech::~Mech()
{
}
//
//#############################################################################
// TakeDamageMessageHandler -- the Mech override of the Entity damage entry
// (binary hub @004a0230 via the handler glue @0049ed0c).
//
// Order in the binary: (1) feed the RAW Damage record to the gyro cockpit
// bounce FIRST -- even an invalid-zone hit shakes the cockpit (STAGED: the
// gyro bounce math is the gyro/feel wave; a gated log marks the feed site);
// (2) latch the attacker (mech+0x43c -- the zone LOD router keys its
// same-attacker redirect reuse off it); (3) resolve an unaimed hit's zone
// (invalidDamageZone) from the cylinder hit-location table -- DEFERRED, the
// type-0x1d table is not loaded yet, so unaimed hits fall through to the
// base handler's zone==-1 drop (authentic base behavior); (4) chain to the
// Entity handler which routes damageZones[zone]->TakeDamage.
//#############################################################################
//
void
Mech::TakeDamageMessageHandler(TakeDamageMessage *message)
{
Check(this);
Check(message);
if (gyroSubsystem != NULL && getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[gyro] hit feed staged (type="
<< (int)message->damageData.damageType
<< " amt=" << message->damageData.damageAmount << ")"
<< endl << flush;
}
lastInflictingID = message->inflictingEntity;
Entity::TakeDamageMessageHandler(message);
}
void
Mech::SetMappingSubsystem(Subsystem *subsystem)
{
+25 -1
View File
@@ -147,6 +147,12 @@
class Mech:
public JointedMover
{
//
// The per-zone damage code walks the roster / segment table / alarms
// directly (the 1995 arrangement, mirrored by the reconstruction).
//
friend class Mech__DamageZone;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Shared Data Support
//
@@ -334,6 +340,22 @@
void
SetTargetEntity(Entity *target) { Check(this); targetEntity = target; }
//
// Damage entry (overrides the Entity handler): latches the attacker
// (mech+0x43c, read by the zone LOD router) and feeds the gyro
// cockpit bounce before chaining to the base zone routing. The
// cylinder unaimed-hit resolver (type-0x1d table) is a later wave.
//
void
TakeDamageMessageHandler(TakeDamageMessage *message);
//
// Damage-side death flag: the body graphic alarm (statusAlarm,
// binary @0x714) at level >= 9 = the death/fall state.
//
Logical
IsMechDestroyed() { Check(this); return statusAlarm.GetLevel() >= 9; }
//
// Look-state commit (called by the controls mapper on a look-button
// state change): re-aim the eyepoint from the model's authored look
@@ -411,6 +433,8 @@
Scalar lookPitch;
Scalar lookYaw;
Entity *targetEntity;
EntityID lastInflictingID; // binary mech+0x43c -- last
// attacker (zone LOD router)
//
// The model's authored look-view angles (rad; deg in the resource).
@@ -425,7 +449,7 @@
// mech2/mech3/mech4 simulation. Reserved until that path is
// reconstructed with named fields.
//
int reservedState[201];
int reservedState[199];
};
#endif
+13
View File
@@ -380,3 +380,16 @@ Found while verifying the novice-mode heat lockout end-to-end.
all zones (binary Pass-3 order, FUN_004a1674). Full forensics in
MECHDMG.NOTES.md — the Entity ctor ONLY allocates the pointer array; an
unfilled or base-class fill is the 5.3.16 crash pair.
## 5.3.17 — the Mech damage entry + friend
- `Mech::MessageHandlerEntries/MessageHandlers` now DEFINED (chained to
JointedMover's set; Build overlays the TakeDamage slot by ID — no gap risk,
the parent chain registered it). DefaultData switched onto it.
- `TakeDamageMessageHandler` override (binary hub @004a0230): latches
`lastInflictingID` (mech+0x43c, keys the zone LOD router's same-attacker
clustering), gyro-bounce feed STAGED (log marker), cylinder unaimed-zone
resolution DEFERRED (type-0x1d table), chains to Entity's handler.
- `friend class Mech__DamageZone` (BT411 mech.hpp:301 — authentic): the
per-zone damage code walks the roster / segment table / statusAlarm.
- `IsMechDestroyed()` = statusAlarm (the @0x714 body-graphic alarm) >= 9.
+500 -6
View File
@@ -28,9 +28,37 @@
# include <mech.hpp>
#endif
#if !defined(MECHSUB_HPP)
# include <mechsub.hpp>
#endif
#if !defined(POWERSUB_HPP)
# include <powersub.hpp>
#endif
#if !defined(SEGMENT_HPP)
# include <segment.hpp>
#endif
#if !defined(RANDOM_HPP)
# include <random.hpp>
#endif
const int
Mech__DamageZone::NullDamageZone = -1;
//
// Cascade constants (binary data words, BT411-verified addresses).
//
static const Scalar LODReuseWindowMin = 0.25f; // _DAT_0049c50c
static const Scalar LODReuseWindowMax = 10.0f; // _DAT_0049c510
static const Scalar LODReuseHysteresis = 0.33f; // _DAT_0049c514 (double 0.33)
static const Scalar StructureMin = 0.0f; // _DAT_0049c5f8
static const Scalar StructureMax = 1.0f; // _DAT_0049c5fc
static const Scalar LegHalfStructure = 0.5f; // _DAT_0049c9a0
static const Scalar CriticalDamageFraction = 0.5f; // _DAT_0049ce48
static const Scalar CriticalDamageMax = 1.0f; // _DAT_0049ce4c
//
//#############################################################################
// MechCriticalSubsystem (binary ctor @0049dd18): one critical-subsystem
@@ -197,6 +225,22 @@ Mech__DamageZone::Mech__DamageZone(
redirectTable.AddValue(plug, child_zone);
}
}
lastHitZone = (redirect_count == 0) ? 0 : RandomRedirect();
if (getenv("BT_SKEL_DUMP"))
{
DEBUG_STREAM << "[zone] " << damage_zone_index
<< " '" << damageZoneName << "'"
<< " pts=" << defaultArmorPoints
<< " seg=" << segmentIndex
<< " crit=" << criticalSubsystemCount
<< " redirect=" << redirect_count
<< (vitalDamageZone ? " VITAL" : "")
<< ((leftLeg || rightLeg) ? " LEG" : "")
<< (descendOnDestruction ? " DESCEND" : "")
<< (destroySiblingsOnDestruction ? " SIBS" : "")
<< endl << flush;
}
Check_Fpu();
}
@@ -250,19 +294,469 @@ void
//
//#############################################################################
// Damage entry. STAGED: the engine base armor model applies directly --
// the authentic override (@0049c690) first routes ARTIFACT hits through the
// LOD redirect table to a real child zone, recomputes the artifact parent's
// displayed level, rolls critical-subsystem hits and updates the leg-failure
// state. All of that is the mechdmg cascade wave; the armor economy
// (amount x normalized scale, clamp, Burning/Destroyed at 1.0) is live NOW.
// TakeDamage (binary @0049c690, vtable slot 6) -- the full zone cascade.
//
// ARTIFACT zones (non-empty redirect table = the low-LOD hull shell) never
// take damage themselves: the hit is routed to a real child zone. REAL
// zones apply the armor model, refresh their artifact parent's displayed
// level, roll the special-damage generator short, and drive the mech's
// graphic/failure state: a destroyed non-leg zone recurses the destruction
// down the segment table; a destroyed VITAL zone (or leg) is the mech kill.
//#############################################################################
//
void
Mech__DamageZone::TakeDamage(Damage &damage)
{
Check(this);
Mech *mech = (Mech *)GetOwningSimulation();
Check(mech);
{
DamageZoneIndexTableIterator redirects(redirectTable);
if (redirects.GetSize() != 0)
{
//
// Artifact shell: hand the hit to a real child (same-attacker
// clustering lives in the router).
//
Damage routed = damage;
LODDamageRouter(mech->lastInflictingID, routed);
return;
}
}
//
// Real zone: the engine armor model (amount x scale[type], clamp,
// Burning/Destroyed at 1.0).
//
Scalar preLevel = damageLevel;
DamageZone::TakeDamage(damage);
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[dmghit] zone=" << damageZoneIndex
<< " '" << damageZoneName << "'"
<< " type=" << (int)damage.damageType
<< " amt=" << damage.damageAmount
<< " lvl " << preLevel << "->" << damageLevel
<< (vitalDamageZone ? " VITAL" : "")
<< endl << flush;
}
Mech__DamageZone *parent = parentArtifactZone.GetCurrent();
if (parent != NULL)
{
parent->UpdateLODDamageLevel();
}
//
// Special damage: an Energy (type 4) hit on a zone with critical
// subsystems rolls ONE of them; a generator pick shorts it (the
// screen-flicker / weapons-dead moment; novice cockpits are exempt
// inside ForceShortRecovery).
//
if (damage.damageType == Damage::EnergyDamageType
&& criticalSubsystemCount > 0)
{
int pick = Random(criticalSubsystemCount);
Subsystem *victim = criticalSubsystems[pick]->subsystemPlug.GetCurrent();
if (victim != NULL
&& victim->IsDerivedFrom(Generator::ClassDerivations))
{
Generator *generator = (Generator *)victim;
if (!generator->NoviceLockout())
{
generator->ForceShort();
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[short] '" << generator->GetName()
<< "' SHORTED by Energy hit on zone "
<< damageZoneIndex << endl << flush;
}
}
}
}
//
// Graphic / failure state from the new structure level. The leg
// branches are gated in the binary by the LIVE MovementMode (3||4 =
// driving; IsDisabled = 2||9 wreck) -- our gait FSM is a later wave, so
// the STAGED gate is "mech not already destroyed" (equivalent while the
// only wreck path is the death level itself).
//
if (vitalDamageZone == 0 || damageLevel < StructureMax)
{
if (leftLeg == 0 && rightLeg == 0)
{
if (damageLevel >= StructureMax)
{
RecurseSegmentTable(mech);
}
}
else if (damageLevel >= StructureMax)
{
// leg destroyed -> fall / mech kill
mech->statusAlarm.SetLevel(9);
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[death] LEG zone " << damageZoneIndex
<< " destroyed -- mech down" << endl << flush;
}
}
else if (!mech->IsMechDestroyed())
{
// half-gone leg -> the gimp-gait graphic (right 4, left 3)
if (rightLeg != 0 && damageLevel >= LegHalfStructure)
{
mech->statusAlarm.SetLevel(4);
}
else if (leftLeg != 0 && damageLevel >= LegHalfStructure)
{
mech->statusAlarm.SetLevel(3);
}
}
}
else
{
//
// Vital zone destroyed -> mech death.
//
SetDamageZoneState(BurningState);
mech->statusAlarm.SetLevel(9);
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[death] VITAL zone " << damageZoneIndex
<< " '" << damageZoneName << "' destroyed -- MECH KILL"
<< endl << flush;
}
}
if (damageLevel < StructureMin)
{
damageLevel = StructureMin;
}
if (damageLevel > StructureMax)
{
damageLevel = StructureMax;
}
}
//
//#############################################################################
// LODDamageRouter (binary @0049c40c): pick which real child of this artifact
// takes the hit. The same attacker hitting again within [0.25s, 10s] tends
// to land on the SAME child (damage clustering, hysteresis 0.33); a new
// attacker or a stale window re-rolls.
//#############################################################################
//
void
Mech__DamageZone::LODDamageRouter(EntityID inflicting, Damage damage)
{
Check(this);
int zone;
if (!(inflicting == lastInflicting))
{
zone = RandomRedirect();
}
else
{
Scalar dt = (Scalar)Now() - lastDamageTime;
if (dt < LODReuseWindowMin)
{
zone = lastHitZone;
}
else if (dt > LODReuseWindowMax)
{
zone = RandomRedirect();
}
else if ((Scalar)Random <= LODReuseHysteresis)
{
zone = lastHitZone;
}
else
{
zone = RandomRedirect();
}
}
lastHitZone = zone;
lastDamageTime = (Scalar)Now();
lastInflicting = inflicting;
Mech *mech = (Mech *)GetOwningSimulation();
if (zone >= 0 && zone < mech->damageZoneCount)
{
((Mech__DamageZone *)mech->damageZones[zone])->TakeDamage(damage);
}
UpdateLODDamageLevel();
}
//
//#############################################################################
// RandomRedirect (binary @0049c600): uniform pick over the redirect table.
//#############################################################################
//
int
Mech__DamageZone::RandomRedirect()
{
Check(this);
DamageZoneIndexTableIterator iterator(redirectTable);
int count = iterator.GetSize();
if (count <= 0)
{
return NullDamageZone;
}
IntegerPlug *plug = iterator.GetNth(Random(count));
return (plug != NULL) ? (int)*plug : NullDamageZone;
}
//
//#############################################################################
// UpdateLODDamageLevel (binary @0049c51c): an artifact's displayed level is
// the mean of its real children's levels.
//#############################################################################
//
void
Mech__DamageZone::UpdateLODDamageLevel()
{
Check(this);
Mech *mech = (Mech *)GetOwningSimulation();
int count = 0;
Scalar sum = 0.0f;
DamageZoneIndexTableIterator iterator(redirectTable);
IntegerPlug *plug;
while ((plug = iterator.ReadAndNext()) != NULL)
{
int child = *plug;
if (child >= 0 && child < mech->damageZoneCount)
{
sum += ((Mech__DamageZone *)mech->damageZones[child])->damageLevel;
++count;
}
}
damageLevel = (count != 0) ? sum / (Scalar)count : StructureMin;
if (damageLevel < StructureMin)
{
damageLevel = StructureMin;
}
if (damageLevel > StructureMax)
{
damageLevel = StructureMax;
}
}
//
//#############################################################################
// CriticalHit (binary @0049ccc4): half the damage (capped 1.0) is carved off
// as the critical bite; the rest runs the normal armor model. The bite
// rolls ONE critical subsystem weighted by criticalWeight and applies the
// damage to it, charged against the entry's damagePercentage allotment.
// Returns the chosen subsystem (NULL if none).
//#############################################################################
//
Subsystem*
Mech__DamageZone::CriticalHit(Damage &damage_data)
{
Check(this);
Scalar bite = damage_data.damageAmount * CriticalDamageFraction;
if (bite > CriticalDamageMax)
{
bite = CriticalDamageMax;
}
damage_data.damageAmount -= bite;
TakeDamage(damage_data);
Scalar roll = (Scalar)Random;
if (criticalSubsystems != NULL && criticalWeightSum > 0.0f)
{
for (int ii = 0; ii < criticalSubsystemCount; ++ii)
{
Scalar cumulative = criticalSubsystems[ii]->criticalWeight;
for (int jj = 0; jj < ii; ++jj)
{
cumulative += criticalSubsystems[jj]->criticalWeight;
}
if (roll <= cumulative / criticalWeightSum)
{
Subsystem *victim =
criticalSubsystems[ii]->subsystemPlug.GetCurrent();
MechCriticalSubsystem *entry = criticalSubsystems[ii];
if (victim != NULL
&& entry->damagePercentageUsed < entry->damagePercentage)
{
entry->damagePercentageUsed +=
((MechSubsystem *)victim)->
ApplyDamageAndMeasure(damage_data);
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[crit] zone " << damageZoneIndex
<< " -> '" << victim->GetName()
<< "' used=" << entry->damagePercentageUsed
<< "/" << entry->damagePercentage
<< endl << flush;
}
}
return victim;
}
}
}
return NULL;
}
//
//#############################################################################
// SendSubsystemDamage (binary @0049c9a8): the zone is destroyed -- pin its
// structure, then push each critical entry's UNUSED damage allotment into
// the bound subsystem's own zone; a subsystem driven to 1.0 fails (and a
// VITAL one kills the mech).
//#############################################################################
//
void
Mech__DamageZone::SendSubsystemDamage()
{
Check(this);
SetDamageZoneState(BurningState);
damageLevel = StructureMax;
for (int ii = 0; ii < criticalSubsystemCount; ++ii)
{
MechCriticalSubsystem *entry = criticalSubsystems[ii];
MechSubsystem *victim =
(MechSubsystem *)entry->subsystemPlug.GetCurrent();
if (victim == NULL)
{
// Unbound plug = roster slot 0/1 residue (mapper installs
// post-ctor / voltage-bus stub) -- the 1995 binary derefs
// unguarded because every plug there is bound.
continue;
}
Scalar level = victim->GetSubsystemDamageLevel()
+ (entry->damagePercentage - entry->damagePercentageUsed);
if (level < StructureMin)
{
level = StructureMin;
}
if (level > StructureMax)
{
level = StructureMax;
}
victim->SetSubsystemDamageLevel(level);
if (level >= StructureMax)
{
if (victim->IsVitalSubsystem())
{
((Mech *)GetOwningSimulation())->statusAlarm.SetLevel(9);
}
victim->ForceCriticalFailure();
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[critfail] '" << victim->GetName()
<< "' DESTROYED (vital=" << victim->IsVitalSubsystem()
<< ") by zone " << damageZoneIndex << " death"
<< endl << flush;
}
}
else if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[critfail] '" << victim->GetName()
<< "' damaged -> " << level << endl << flush;
}
}
}
//
//#############################################################################
// RecurseSegmentTable (binary @0049cad4): destruction descent. Push this
// zone's subsystem damage out, then -- per the streamed flags -- destroy the
// sibling zones on the same segment and/or descend into the child segments'
// zones, recursing on each.
//#############################################################################
//
void
Mech__DamageZone::RecurseSegmentTable(Mech *my_mech)
{
Check(this);
Check(my_mech);
SendSubsystemDamage();
SetGraphicState(DestroyedGraphicState);
EntitySegment *segment = my_mech->GetSegment(segmentIndex);
if (segment == NULL)
{
return;
}
if (destroySiblingsOnDestruction)
{
EntitySegment::IntegerTableIterator *siblings =
segment->MakeDamageZoneIndexTable();
EntitySegment::IntegerPlug *plug;
while ((plug = siblings->ReadAndNext()) != NULL)
{
int zi = *plug;
if (zi >= 0 && zi < my_mech->damageZoneCount)
{
Mech__DamageZone *sibling =
(Mech__DamageZone *)my_mech->damageZones[zi];
if (sibling != this
&& sibling->GetGraphicState() != DestroyedGraphicState)
{
sibling->RecurseSegmentTable(my_mech);
sibling->SetGraphicState(GoneGraphicState);
}
}
}
delete siblings;
}
if (descendOnDestruction)
{
EntitySegment::IntegerTableIterator *children =
segment->MakeChildIndexTable();
EntitySegment::IntegerPlug *plug;
while ((plug = children->ReadAndNext()) != NULL)
{
EntitySegment *child = my_mech->GetSegment(*plug);
if (child == NULL)
{
continue;
}
EntitySegment::IntegerTableIterator *child_zones =
child->MakeDamageZoneIndexTable();
EntitySegment::IntegerPlug *zone_plug;
while ((zone_plug = child_zones->ReadAndNext()) != NULL)
{
int zi = *zone_plug;
if (zi >= 0 && zi < my_mech->damageZoneCount)
{
Mech__DamageZone *child_zone =
(Mech__DamageZone *)my_mech->damageZones[zi];
// (defensive vs authored-cycle data; the 1995 binary
// trusted the tree and recursed unconditionally)
if (child_zone->GetGraphicState() != DestroyedGraphicState)
{
child_zone->RecurseSegmentTable(my_mech);
child_zone->SetGraphicState(GoneGraphicState);
}
}
}
delete child_zones;
}
delete children;
}
}
//
+74 -18
View File
@@ -1,6 +1,8 @@
# MECHDMG.CPP — Mech::DamageZone (hull zone armor economy)
# MECHDMG.CPP — Mech::DamageZone (hull zone armor economy + destruction cascade)
**Status: PARTIAL (5.3.16) — streamed ctor / LOD parent pass / dtor reconstructed; TakeDamage + SetGraphicState are staged base pass-throughs.**
**Status: RECONSTRUCTED (5.3.17) — streamed ctor, TakeDamage cascade, LOD
router, CriticalHit, SendSubsystemDamage, RecurseSegmentTable all live and
fight-verified. Remaining staged bits listed at the bottom.**
## The header is AUTHENTIC
@@ -64,20 +66,74 @@ criticals are master-authoritative) AND NULL-guarded: roster slot 0 (control
mapper) installs post-ctor, slot 1 (voltage bus) can be empty, and
`Link::AddToPlug` derefs the plug.
## Staged / deferred (the mechdmg cascade wave)
## The cascade (5.3.17, all binary-addressed via BT411, fight-verified)
- `TakeDamage` override = base pass-through. Authentic (@0049c690): artifact
zones route hits through the redirect table to a real child
(`LODDamageRouter`/`RandomRedirect`/`lastHitZone`), parent recomputes its
displayed level (`UpdateLODDamageLevel`), critical rolls (`CriticalHit`),
generator shorting, leg-failure state.
- `SetGraphicState` override = base pass-through (authentic also flips the
segment video objects to destroyed skins).
- Declared-not-defined (unreferenced, so they link): `LODDamageRouter`,
`RandomRedirect`, `UpdateLODDamageLevel`, `ShortAttachedGenerators`,
`SendSubsystemDamage`, `RecurseSegmentTable`, `CriticalHit`,
`CreateStreamedDamageZone`, `GetSegmentIndex`.
- `lastHitZone` staged 0 (authentic seeds via RandomRedirect when redirects
exist); `lastDamageTime` staged 0 (authentic = TheTime tick / TicksPerSecond).
- The type-0x1e critical-descriptor stream + MechDeathHandler (BT411
mech.cpp Pass-3 tail) are NOT loaded yet — same cascade wave.
- **`TakeDamage` (@0049c690)**: artifact zones (redirect table non-empty)
route via `LODDamageRouter`; real zones apply the engine armor model, then
refresh their artifact parent (`UpdateLODDamageLevel` = mean of children),
roll the special-damage short (Energy hit + a Generator crit pick →
`Generator::ForceShort()`, novice-exempt), and drive failure state: a
destroyed non-leg zone runs `RecurseSegmentTable`; a destroyed LEG or
VITAL zone is the mech kill (statusAlarm level 9 = the @0x714 body-graphic
death level; `Mech::IsMechDestroyed()`); half-gone legs set the gimp
graphic (right 4 / left 3). The binary's MovementMode(3||4)/IsDisabled
gates on the leg branches are STAGED as "not already destroyed" until the
gait FSM exists.
- **`LODDamageRouter` (@0049c40c)**: same-attacker clustering — reuse the
previous child inside [0.25s, 10s] (hysteresis 0.33 — a binary DOUBLE,
bytes 1f85eb51b81ed53f); new attacker or stale window re-rolls
(`RandomRedirect` @0049c600, uniform over the redirect table). Keyed by
`mech->lastInflictingID` (mech+0x43c), latched by the new
`Mech::TakeDamageMessageHandler` override.
- **`CriticalHit` (@0049ccc4)**: half the damage (cap 1.0) is the critical
bite; the rest runs the armor model. Weighted roll over criticalWeight;
the pick takes `ApplyDamageAndMeasure` charged against its
damagePercentage allotment. (Not yet called by the weapon path — the
aimed/critical fire distinction is the reticle wave.)
- **`SendSubsystemDamage` (@0049c9a8)**: zone death pins structure 1.0 and
pushes each crit entry's UNUSED allotment into the bound subsystem's own
zone; 1.0 → `ForceCriticalFailure` (+ mech kill if vital). NOTE: repeat
hits on a dead zone re-run the push (the binary is equally unguarded) —
contained equipment keeps degrading under continued fire (HUD climbing
0.33→0.67→0.99 in the verification run is this).
- **`RecurseSegmentTable` (@0049cad4)**: destruction descent via the ENGINE
EntitySegment API (`GetSegment` + `MakeDamageZoneIndexTable` /
`MakeChildIndexTable` — heap iterators, caller deletes). SIBS = other
zones on the same segment; DESCEND = child segments' zones; recursion
guarded by GetGraphicState()!=Destroyed (authentic for siblings; added
defensively on children).
- **Weapon hard-failure gate**: `ForceCriticalFailure` now also sets
`SetSimulationState(DestroyedState)` (MechSubsystem enum from the binary
PrintState strings: Destroyed=1 @0050df7f, Exploding=2 @0050df8c) — the
`GetSimulationState() == 1` check both weapon FSMs already carried goes
live: destroyed weapons fall silent.
## Fight verification (bhk1 vs bhk1, BT_FORCE_ZONE=8 concentrated fire)
Zone topology (BT_SKEL_DUMP `[zone]` dump): NO artifact zones in this art
(redirect=0 everywhere — matches the 1995 header comment); arms/guns carry
DESCEND+SIBS; all torso + leg zones VITAL; legs 70pts (halved scale ✓),
searchlights 25pts, utorso 124pts. Verified live: arm (zone 8) driven to 1.0
→ SIBS killed the searchlight zone (Searchlight2 + ThermalSight crits
destroyed, HUD degrading) → DESCEND killed the gun zone → **PPC_2 +
ERMLaser_2 + Condenser6 DESTROYED and the weapons STOP FIRING** (0 shots
after both mechs' PPC_2 died; undamaged PPC_1 kept firing); vital-zone kill
([death] MECH KILL on dz_reardtorso) in the random-spread run; PPC Energy
hit shorted GeneratorD through the crit plug binding. Dev hooks:
BT_FORCE_ZONE=n (pin every SendDamage hit), BT_SPAWN_ENEMY now MUTUAL (the
enemy returns fire under BT_FORCE_FIRE).
## Still staged / deferred
- `SetGraphicState` override = base pass-through (BT411 found the binary
override byte-identical to the base; the destroyed-skin video flip is the
render wave).
- Declared-not-defined (unreferenced): `ShortAttachedGenerators` (its logic
is inlined in TakeDamage per the binary), `UpdateLODDamageLevel` callers
beyond TakeDamage, `CreateStreamedDamageZone`, `GetSegmentIndex` (tool
side), `Heal`/death-reset family.
- The type-0x1e critical-descriptor stream + MechDeathHandler (band
descriptors, smoke/burn effects) — render/effects wave.
- The cylinder hit-location table (type 0x1d) for unaimed zone -1 hits —
the Mech handler currently lets the base drop them (authentic base
behavior); reticle/aimed fire is the render wave.
+59 -1
View File
@@ -119,8 +119,42 @@ MechSubsystem::MechSubsystem(
configureActivePress = -1;
statusAlarm.SetLevel(0);
damageZone = new DamageZone(owner, GetSegmentIndex());
//
// The subsystem's PRIVATE damage zone (binary @4abf28: `new
// DamageZone(this, 0)` -- owned by the SUBSYSTEM, index 0, NOT an entry
// in the entity's hull-zone array). The resource authors its armor the
// same way the hull stream does -- structureReference points +
// armorByFacing[5] per-type values -- normalized with the same rule as
// Mech::DamageZone (scale = 1/(raw x points); already-normalized cells
// pass through). This is what makes critical hits MEASURABLE
// (ApplyDamageAndMeasure) and the crit-allotment push meaningful.
//
damageZone = new DamageZone(this, 0);
Register_Object(damageZone);
damageZone->damageZoneName = GetName();
if (subsystem_resource->structureReference > 0.0f)
{
damageZone->defaultArmorPoints = subsystem_resource->structureReference;
for (int tt = 0; tt < Damage::DamageTypeCount; ++tt)
{
Scalar raw = subsystem_resource->armorByFacing[tt];
Scalar even = 1.0f / subsystem_resource->structureReference;
Scalar diff = raw - even;
if (diff < 0.0f)
{
diff = -diff;
}
if (diff > 1.0e-4f && raw > 0.0f)
{
damageZone->damageScale[tt] =
1.0f / (raw * subsystem_resource->structureReference);
}
else
{
damageZone->damageScale[tt] = raw;
}
}
}
}
//
@@ -181,12 +215,36 @@ void
{
Check(this);
statusAlarm.SetLevel(MechSubsystem::Destroyed);
//
// DestroyedState is the hard-failure gate the weapon simulations poll
// (`GetSimulationState() == 1`): a destroyed weapon drops its charge /
// pins the unavailable alarm from the next frame on.
//
SetSimulationState(DestroyedState);
if (damageZone)
{
damageZone->SetDamageZoneState(DamageZone::BurningState);
}
}
//
//#############################################################################
// ApplyDamageAndMeasure (binary @4ac07c): apply through the virtual
// TakeDamage and report the zone-level delta -- the critical accountant
// (Mech::DamageZone::CriticalHit) charges the delta against the entry's
// damagePercentage allotment.
//#############################################################################
//
Scalar
MechSubsystem::ApplyDamageAndMeasure(Damage &damage)
{
Check(this);
Scalar before = (damageZone != NULL) ? damageZone->damageLevel : 0.0f;
TakeDamage(damage);
Scalar after = (damageZone != NULL) ? damageZone->damageLevel : 0.0f;
return after - before;
}
//
//#############################################################################
// TakeDamage Forward to the damage zone (per-type scaling happens there).
+24
View File
@@ -75,6 +75,18 @@
TechStatusTypeCount
};
//
// The subsystem SIMULATION states (binary PrintState @4ac8c0 string
// pool: "Destroyed" @0050df7f, "Exploding" @0050df8c). DestroyedState
// (1) is the hard-failure gate every weapon simulation checks
// (`GetSimulationState() == 1` -- emitter @4baab9, ballistic @4bbd36).
//
enum {
DestroyedState = Simulation::DefaultState + 1,
ExplodingState,
MechSubsystemStateCount
};
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Test Class Support
//
@@ -132,6 +144,18 @@
void
ForceCriticalFailure();
//
// Critical-hit application (binary @4ac07c): run the damage through
// TakeDamage and return how much the subsystem's own zone level
// actually moved (the crit accountant charges it against the entry's
// damagePercentage allotment).
//
Scalar
ApplyDamageAndMeasure(Damage &damage);
Logical
IsVitalSubsystem() const { Check(this); return vitalSubsystem; }
//
// The NOVICE-experience lockout predicate (binary @4ac9c8): a novice
// pilot's advanced cockpit controls (condenser valves, coolant flush,
+18
View File
@@ -173,3 +173,21 @@ Then the Mech member layout (subsystemArray/subsystemCount + the capability chai
controllable/heatable/weaponRoster/damageable + the alarms/reticle/state-indicators
/SequenceControllers/NameFilter/CStrings embedded members) must be named in
MECH.HPP for the ctor to set them.
## 5.3.17 — the subsystem side of the damage cascade
- The subsystem's PRIVATE zone is now ARMED (binary @4abf28): owned by the
SUBSYSTEM (`new DamageZone(this, 0)` — Joint.cpp:512 proves subsystems are
valid zone owners), named, `defaultArmorPoints = structureReference`,
`damageScale[5] = armorByFacing[]` normalized with the hull-zone rule
(1/(raw×points); guard: structureReference<=0 leaves the zone inert).
This makes crits MEASURABLE.
- `ApplyDamageAndMeasure` (@4ac07c): TakeDamage + return the zone-level
delta (the crit accountant charges it).
- `ForceCriticalFailure` now also sets `SetSimulationState(DestroyedState)` —
the subsystem SIMULATION state enum (Destroyed=1/Exploding=2, from the
binary PrintState string pool) added to MECHSUB.HPP; both weapon FSMs'
`GetSimulationState() == 1` hard gate is now live (destroyed weapons stop
firing — fight-verified).
- `IsVitalSubsystem()` accessor (vital crit death = mech kill in
SendSubsystemDamage).
+17
View File
@@ -24,6 +24,7 @@
#endif
#include <string.h>
#include <stdlib.h>
#if !defined(RANDOM_HPP)
# include <random.hpp>
@@ -325,6 +326,22 @@ void
if (target->damageZoneCount > 0)
{
zone = Random(target->damageZoneCount);
//
// DEV hook BT_FORCE_ZONE=n: pin every hit to zone n (concentrated
// fire -- exercises the zone-death cascade headlessly).
//
{
const char *force_zone = getenv("BT_FORCE_ZONE");
if (force_zone != NULL)
{
int forced = atoi(force_zone);
if (forced >= 0 && forced < target->damageZoneCount)
{
zone = forced;
}
}
}
}
Entity::TakeDamageMessage
+32
View File
@@ -212,6 +212,38 @@ Scalar
return (stretched > voltageScale) ? stretched : voltageScale;
}
//
//#############################################################################
// ForceShortRecovery (binary @004b11bc): on a short event, drive the powering
// generator to Shorted and clear its output; GeneratorSimulation then runs
// the short-recovery timer back to Ready. Both @4ac9c8 calls in the binary
// are the not-novice experience predicate (this part and its source share
// the same mech, hence the same player) -- novice cockpits never see
// electrical shorts.
//#############################################################################
//
void
PoweredSubsystem::ForceShortRecovery()
{
Check(this);
if (NoviceLockout())
{
return;
}
Generator *source = (Generator *)voltageSource.Resolve();
if (source != NULL)
{
source->ForceShort();
if (getenv("BT_POWER_LOG") || getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[short] '" << source->GetName()
<< "' SHORTED by special damage" << endl << flush;
}
}
}
//
//#############################################################################
// PoweredSubsystemSimulation -- the per-frame electrical step (binary
+25
View File
@@ -102,6 +102,17 @@
Scalar
ChargeTimeScale();
//
// Short event (binary @004b11bc): drive the powering generator into
// the Shorted state and zero its output -- the electrical FSM then
// runs its short-recovery cycle. Gated on the not-novice experience
// predicate (both hops share the mech's player). Called by the
// special-damage path (a type-4 Energy hit on a zone with attached
// generator criticals).
//
void
ForceShortRecovery();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Per-frame simulation (heat step + the electrical state machine).
//
@@ -269,6 +280,20 @@
unsigned
GeneratorStateOf() { Check(this); return stateAlarm.GetLevel(); }
//
// The short event landing ON this generator (the tail of binary
// @004b11bc): Shorted state + output killed; GeneratorSimulation
// runs the short-recovery timer back to Ready. Callers gate on the
// novice experience predicate.
//
void
ForceShort()
{
Check(this);
stateAlarm.SetLevel(GeneratorShorted);
outputVoltage = 0.0f;
}
//
// Tap the generator for one load (a PoweredSubsystem attaching): -1 when
// every tap is taken, else 0.