diff --git a/context/combat-damage.md b/context/combat-damage.md index 869e2a2..b7bf16a 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -939,6 +939,32 @@ the subsystem TakeDamage virtual into its PRIVATE crit zone). Un-won rolls land same way. Measured on bhk1 (mass 60000, e=0.2): scale 4.5e-5; a 94k spawn-slam = 4.2 rattle points; taps free. `[colldmg]` (BT_DMG_LOG) + `[crashdmg]` (always-on, rare) are the probes. +## The zone DESTRUCTION CASCADE is live -- an arm takes its gun (#110, 2026-08-02) [T1 decomp / T2 verified] + +`Mech__DamageZone::RecurseSegmentTable @0049cad4`: when a zone reaches damageLevel +1.0 (non-leg, non-vital path in TakeDamage), the binary walks the mech's SEGMENT +TREE (mech+0x300, the engine `JointedMover::segmentTable` [T0]): + * `destroySiblingsOnDestruction` (streamed, Wword 0x68) -> every other zone on + the SAME segment (`EntitySegment::damageZoneTable`, seg+0xD0) recurses and is + set graphic state 2 (**Gone**); + * `descendOnDestruction` (Wword 0x67) -> every zone on every CHILD segment + (`childIndexTable` seg+0xE8 -> child's damageZoneTable) recurses -- **the arm + takes the gun pod with it**. Each recursed zone's `SendSubsystemDamage + @0049c9a8` pushes the unused crit allotment and `ForceCriticalFailure`s any + subsystem driven to 1.0 -- which the #86 fire gates then refuse. +Authored (measured, ava1): descend=1 on the four arm zones (2/6/9/17); everything +else 0. Verified live: dz_rarm destroyed -> zone 17 cascades -> AFC100 + bin + +Condenser6 force-failed, `AFC100 -> NoAmmo (gate1): destroyed=1`; dz_larm -> PPC +force-failed, `[emitter] 'PPC' fire REFUSED (destroyed=1)` x4998. Torso weapons +unaffected. Re-entry note: every further hit on a 1.0 zone re-runs the cascade +(no guard in the binary's child loop; only the sibling loop checks graphic state +!= 1) -- authentic, idempotent in effect. + +⚠ The walk was a SILENT STUB until 2026-08-02 (three shim types whose iterators +returned NULL) -- the cascade "ran" and touched nothing, which is why blown-off +arms left firing gun pods across every chassis (Conn Man's three-chassis audit). +See reconstruction-gotchas #25. + ## Key Relationships - Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]]. - P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5. diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index 832ab50..3755196 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -774,3 +774,24 @@ splash code derives it from a distance falloff — nobody spends instructions ro value. **If a field is called dead, ask who still writes it, and why.** **Rule:** tag the VERIFIED clause, not the paragraph. An inference sitting next to a [T1] fact is still [T4] — split them, or the next reader (including you) will build on the guess. + +## 26. The SILENT-STUB walk: empty iterators make a faithful-looking mechanism a no-op (#110) + +`RecurseSegmentTable` was "reconstructed" against local shim types whose +iterators unconditionally returned NULL (`SegmentIterator::Next() { return 0; }`, +`SegTableX::operator[] { return 0; }`). Every line of the walk mirrored the +binary -- and none of it did anything: the destruction cascade fired, logged, +"descended", and touched no other zone. The symptom (gun pods surviving their +blown-off arms) was field-reported for weeks while the code READ correct. + +The trap: a stub that returns EMPTY (rather than asserting/crashing) converts a +reconstruction into silent fiction, and the no-stand-ins rule cannot catch it by +reading the call site -- the stand-in hides in the TYPES. The engine already had +the real structures (`EntitySegment::damageZoneTable` / `childIndexTable`, T0, +byte-matching the decomp's seg+0xD0/+0xE8) -- the stub was never necessary. + +Rules: (a) when a reconstructed mechanism runs but nothing downstream changes, +grep its helper types for `return 0` bodies FIRST; (b) a bring-up shim must +either Fail() loudly or log its own emptiness, never silently iterate nothing; +(c) before shimming an engine-side structure, check whether the engine already +has it -- the offsets in the decomp comment ARE the lookup key. diff --git a/engine/MUNGA/SEGMENT.h b/engine/MUNGA/SEGMENT.h index 5c3624d..8a00721 100644 --- a/engine/MUNGA/SEGMENT.h +++ b/engine/MUNGA/SEGMENT.h @@ -126,6 +126,17 @@ protected: // Accessors // public: + // #110 (BT reconstruction): read access to the two streamed tables the + // binary's zone-destruction cascade walks (Mech__DamageZone:: + // RecurseSegmentTable @0049cad4 -- seg+0xD0 damage zones, seg+0xE8 child + // indices). Read-only; population stays with JointedMover streaming. + IntegerTable& + GetDamageZoneTable() + {Check(this); return damageZoneTable;} + IntegerTable& + GetChildIndexTable() + {Check(this); return childIndexTable;} + void ModifySegment(Logical modified = True) {Check(this); segmentModified = modified;} diff --git a/game/reconstructed/emitter.cpp b/game/reconstructed/emitter.cpp index 22e827f..9e16d58 100644 --- a/game/reconstructed/emitter.cpp +++ b/game/reconstructed/emitter.cpp @@ -436,6 +436,14 @@ void if (simulationState == 1 || statusAlarm.GetLevel() == 1 || GetFaultState() == 2 || BTMechDestroyed((Entity *)owner)) { + // #110 verification: name the refusal, so "the destroyed PPC stays + // silent" is a log fact rather than an inference (the FIRED line does + // not name its weapon). Once per weapon per destruction is enough -- + // keyed on the firing state actually being reset. + if (getenv("BT_DMG_LOG") && GetWeaponState() != 0) + DEBUG_STREAM << "[emitter] '" << (GetName() ? GetName() : "?") + << "' fire REFUSED (destroyed=" << (int)(simulationState == 1 + || statusAlarm.GetLevel() == 1) << ")" << std::endl; ResetFiringState(); // @004ba9a8 currentLevel = 0.0f; // 0x414 ComputeOutputVoltage(); // (*vtable+0x44)() diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index 3c6648c..2c6517f 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -104,10 +104,11 @@ void BTGimpStompTrap(void *sim, unsigned cur, unsigned nw, void *ra) 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 SegmentIterator(const A &) {} DZRef *Next() { return 0; } }; -struct SegTableX { SegmentRecord *operator[](int) { return 0; } }; // cast of EntitySegment::SegmentTable +// (#110: the DZRef/SegmentRecord/SegmentIterator/SegTableX stubs that lived here +// are GONE. They silently no-op'ed the whole destruction cascade walk -- +// RecurseSegmentTable "descended" into nothing, so blowing an arm off never +// touched the gun pod hanging from it. The walk now uses the REAL engine +// segment tree; see RecurseSegmentTable below.) // Subsystem facets the recovered code reaches that the engine Subsystem does // not expose under these names (cast of Subsystem*). @@ -283,7 +284,15 @@ Mech__DamageZone::Mech__DamageZone( << " scale={" << damageScale[0] << "," << damageScale[1] << "," << damageScale[2] << "," << damageScale[3] << "," << damageScale[4] << "}" << " leg=" << (int)(rightLeg || leftLeg) - << " vital=" << (int)vitalDamageZone << std::endl; + << " vital=" << (int)vitalDamageZone + // #110: the destruction-cascade authoring -- whether this zone's + // death descends the segment tree / takes its siblings, and how many + // critical subsystems it can fail. The arm chain lives or dies by + // these values. + << " descend=" << (int)descendOnDestruction + << " destroySibs=" << (int)destroySiblingsOnDestruction + << " segIdx=" << segmentIndex + << " crits=" << criticalSubsystemCount << std::endl; // // Critical-subsystem table. @@ -805,8 +814,11 @@ void } else if (getenv("BT_DEATH_LOG")) { - DEBUG_STREAM << "[deathfx] crit-subsys " << i << " damaged -> " - << level << "\n" << std::flush; + DEBUG_STREAM << "[deathfx] crit-subsys " << i + << " '" << (s->GetName() ? s->GetName() : "?") << "'" + << " damaged -> " << level + << " (pct=" << cs->damagePercentage + << " used=" << cs->damagePercentageUsed << ")" << std::endl; } } } @@ -822,35 +834,66 @@ void void Mech__DamageZone::RecurseSegmentTable(Mech *my_mech) { + if (getenv("BT_DEATH_LOG")) + DEBUG_STREAM << "[cascade] zone " << damageZoneIndex + << " DESTROYED -> descend=" << (int)descendOnDestruction + << " destroySibs=" << (int)destroySiblingsOnDestruction + << " crits=" << criticalSubsystemCount << std::endl; SendSubsystemDamage(); // FUN_0049c9a8 SetGraphicState(1); // slot 0xc - SegmentRecord *seg = ((SegTableX &)my_mech->segmentTable)[segmentIndex]; // mech+0x300, Wword(0x65) + // + // The segment-tree walk (@0049cad4, re-read 2026-08-02 for #110). The + // binary iterates the mech's segment table (mech+0x300, vtbl+0x34 GetNth) + // to this zone's segment, then walks two lists ON the EntitySegment: + // seg+0xD0 the damage-zone indices ATTACHED to this segment + // seg+0xE8 the CHILD segment indices + // Those are the engine's own EntitySegment::damageZoneTable / + // childIndexTable -- byte-identical offsets (TableOf is 0x18: 0xD0+0x18 = + // 0xE8), populated at stream time by JMOVER.cpp:373 (AddDamageZone) [T0]. + // The previous "reconstruction" here walked stub iterators that always + // returned NULL, so the cascade never left this zone: an arm died and the + // gun pod hanging off it kept firing (#110, Lynx + Conn Man's three-chassis + // audit). Element payload = PlugOf (operator int). + // + EntitySegment::SegmentTableIterator segs(my_mech->segmentTable); // mech+0x300 + EntitySegment *seg = segs.GetNth(segmentIndex); // vtbl+0x34 + if (seg == 0) + return; // (binary derefs blind) if (destroySiblingsOnDestruction) // Wword(0x68) { - SegmentIterator sibs(&seg->siblings); // seg+0xd0 - for (DZRef *r; (r = sibs.Next()) != 0; ) // slot 0x28 + // Other zones on the SAME segment. + EntitySegment::IntegerTableIterator sibs(seg->GetDamageZoneTable()); // seg+0xD0 + for (EntitySegment::IntegerPlug *r; (r = sibs.ReadAndNext()) != 0; ) { - Mech__DamageZone *sib = my_mech->Zone(r->index); // inherited damageZones[..], typed - if (sib != this && sib->GetGraphicState() != 1) + Mech__DamageZone *sib = my_mech->Zone((int)*r); + if (sib != 0 && sib != this && sib->GetGraphicState() != 1) { sib->RecurseSegmentTable(my_mech); - sib->SetGraphicState(2); // slot 0xc + sib->SetGraphicState(2); // 2 = Gone } } } if (descendOnDestruction) // Wword(0x67) { - SegmentIterator kids(&seg->children); // seg+0xe8 - for (DZRef *r; (r = kids.Next()) != 0; ) + // Every zone on every CHILD segment, recursively -- the arm takes the + // gun with it. The binary's child-zone loop has NO graphic-state + // guard (only the sibling loop has one); reproduced as-is. + EntitySegment::IntegerTableIterator kids(seg->GetChildIndexTable()); // seg+0xE8 + for (EntitySegment::IntegerPlug *r; (r = kids.ReadAndNext()) != 0; ) { - SegmentRecord *child = ((SegTableX &)my_mech->segmentTable)[r->index]; - SegmentIterator czones(&child->siblings); // child+0xd0 - for (DZRef *cr; (cr = czones.Next()) != 0; ) + EntitySegment::SegmentTableIterator segs2(my_mech->segmentTable); + EntitySegment *child = segs2.GetNth((int)*r); + if (child == 0) + continue; + EntitySegment::IntegerTableIterator czones(child->GetDamageZoneTable()); // child+0xD0 + for (EntitySegment::IntegerPlug *cr; (cr = czones.ReadAndNext()) != 0; ) { - Mech__DamageZone *cz = my_mech->Zone(cr->index); + Mech__DamageZone *cz = my_mech->Zone((int)*cr); + if (cz == 0) + continue; cz->RecurseSegmentTable(my_mech); cz->SetGraphicState(2); } diff --git a/scratchpad/night9/armchain.sh b/scratchpad/night9/armchain.sh new file mode 100644 index 0000000..317d47b --- /dev/null +++ b/scratchpad/night9/armchain.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# #110: destroy the ARM zone; does the GUN die with it? +# $1 = chassis (default ava1 -- ConnMan's audit chassis) +# $2 = zone to destroy (default dz_rarm) +set -x +. /c/git/bt411/scratchpad/night6/bench_common.sh +cd /c/git/bt411/content || exit 1 +V="${1:-ava1}"; Z="${2:-dz_rarm}" +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2 +sed "s/^map=.*/map=grass/; s/^time=.*/time=day/; 0,/^vehicle=.*/s//vehicle=$V/" MP.EGG > ARM.EGG +LOG=armchain_${V}_${Z}.log +rm -f "$LOG" +export BT_SELF_DAMAGE=4 BT_SELF_DAMAGE_ZONE=$Z +export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_AUTOFIRE=1 BT_AF_PERIOD=4 +export BT_SPAWN_ENEMY=1 BT_GOTO=enemy BT_GOTO_STOP=80 +bt_launch "$LOG" ARM.EGG 0x03 +sleep 110 +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2