From d4f0aef6e1291ce57526937bb075a5fefcd9249b Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 24 Jul 2026 11:13:28 -0500 Subject: [PATCH] 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 --- restoration/source410/BT/BTPLAYER.CPP | 6 + restoration/source410/BT/BTPLAYER.NOTES.md | 7 + restoration/source410/BT/MECH.CPP | 57 ++- restoration/source410/BT/MECH.HPP | 26 +- restoration/source410/BT/MECH.NOTES.md | 13 + restoration/source410/BT/MECHDMG.CPP | 506 ++++++++++++++++++++- restoration/source410/BT/MECHDMG.NOTES.md | 92 +++- restoration/source410/BT/MECHSUB.CPP | 60 ++- restoration/source410/BT/MECHSUB.HPP | 24 + restoration/source410/BT/MECHSUB.NOTES.md | 18 + restoration/source410/BT/MECHWEAP.CPP | 17 + restoration/source410/BT/POWERSUB.CPP | 32 ++ restoration/source410/BT/POWERSUB.HPP | 25 + 13 files changed, 856 insertions(+), 27 deletions(-) diff --git a/restoration/source410/BT/BTPLAYER.CPP b/restoration/source410/BT/BTPLAYER.CPP index f35c6ee1..9435233f 100644 --- a/restoration/source410/BT/BTPLAYER.CPP +++ b/restoration/source410/BT/BTPLAYER.CPP @@ -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")) { diff --git a/restoration/source410/BT/BTPLAYER.NOTES.md b/restoration/source410/BT/BTPLAYER.NOTES.md index 7cfcd089..a609081c 100644 --- a/restoration/source410/BT/BTPLAYER.NOTES.md +++ b/restoration/source410/BT/BTPLAYER.NOTES.md @@ -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. diff --git a/restoration/source410/BT/MECH.CPP b/restoration/source410/BT/MECH.CPP index 04cebdfb..c23aff1a 100644 --- a/restoration/source410/BT/MECH.CPP +++ b/restoration/source410/BT/MECH.CPP @@ -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) { diff --git a/restoration/source410/BT/MECH.HPP b/restoration/source410/BT/MECH.HPP index 33d917ab..c828ca0e 100644 --- a/restoration/source410/BT/MECH.HPP +++ b/restoration/source410/BT/MECH.HPP @@ -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 diff --git a/restoration/source410/BT/MECH.NOTES.md b/restoration/source410/BT/MECH.NOTES.md index 4873b475..af9021f6 100644 --- a/restoration/source410/BT/MECH.NOTES.md +++ b/restoration/source410/BT/MECH.NOTES.md @@ -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. diff --git a/restoration/source410/BT/MECHDMG.CPP b/restoration/source410/BT/MECHDMG.CPP index 5529e157..62f09680 100644 --- a/restoration/source410/BT/MECHDMG.CPP +++ b/restoration/source410/BT/MECHDMG.CPP @@ -28,9 +28,37 @@ # include #endif +#if !defined(MECHSUB_HPP) +# include +#endif + +#if !defined(POWERSUB_HPP) +# include +#endif + +#if !defined(SEGMENT_HPP) +# include +#endif + +#if !defined(RANDOM_HPP) +# include +#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; + } } // diff --git a/restoration/source410/BT/MECHDMG.NOTES.md b/restoration/source410/BT/MECHDMG.NOTES.md index d7b56888..27a25eff 100644 --- a/restoration/source410/BT/MECHDMG.NOTES.md +++ b/restoration/source410/BT/MECHDMG.NOTES.md @@ -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. diff --git a/restoration/source410/BT/MECHSUB.CPP b/restoration/source410/BT/MECHSUB.CPP index ebba69dc..4927e263 100644 --- a/restoration/source410/BT/MECHSUB.CPP +++ b/restoration/source410/BT/MECHSUB.CPP @@ -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). diff --git a/restoration/source410/BT/MECHSUB.HPP b/restoration/source410/BT/MECHSUB.HPP index 5e4db625..260dc440 100644 --- a/restoration/source410/BT/MECHSUB.HPP +++ b/restoration/source410/BT/MECHSUB.HPP @@ -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, diff --git a/restoration/source410/BT/MECHSUB.NOTES.md b/restoration/source410/BT/MECHSUB.NOTES.md index 5e097d83..60f2a43b 100644 --- a/restoration/source410/BT/MECHSUB.NOTES.md +++ b/restoration/source410/BT/MECHSUB.NOTES.md @@ -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). diff --git a/restoration/source410/BT/MECHWEAP.CPP b/restoration/source410/BT/MECHWEAP.CPP index fe36217c..7f44896c 100644 --- a/restoration/source410/BT/MECHWEAP.CPP +++ b/restoration/source410/BT/MECHWEAP.CPP @@ -24,6 +24,7 @@ #endif #include +#include #if !defined(RANDOM_HPP) # include @@ -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 diff --git a/restoration/source410/BT/POWERSUB.CPP b/restoration/source410/BT/POWERSUB.CPP index ad3be554..fab25026 100644 --- a/restoration/source410/BT/POWERSUB.CPP +++ b/restoration/source410/BT/POWERSUB.CPP @@ -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 diff --git a/restoration/source410/BT/POWERSUB.HPP b/restoration/source410/BT/POWERSUB.HPP index 1fb1dd13..e5fe25a4 100644 --- a/restoration/source410/BT/POWERSUB.HPP +++ b/restoration/source410/BT/POWERSUB.HPP @@ -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.