From 5cde991f562d3a4d17cad3e885f3c3e29ec9727e Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Thu, 13 Aug 2026 02:42:07 -0500 Subject: [PATCH] #174 root-caused + fixed with a negative control: the binary's ONLY re-descend protection is the burst loop's zone-state guard wrapping the WHOLE per-burst application (@0x4a0446, part_012.c:14661 -- crit roll, TakeDamage, score tally, destroyed-flag; RecurseSegmentTable/SendSubsystemDamage have NO internal re-entry protection: no used-marking, no top gate). The port had narrowed the guard to the crit roll -- continued fire re-saturated dead zones (cascade re-fired: night-16 zone 18 x7, each pass re-pushing crit allotment = the ram 'crits worsened with each collision'), and hits on wreckage kept SCORING (binary awards zero -- a quiet farm-the-wreck exploit). Fix: guard restored to binary scope. Bench: BT_ZONE_HAMMER hook (4Hz explicit-zone self-damage) -- fixed build 1 cascade/10 hits/0 post-saturation applications; NEGATIVE CONTROL (fix stashed): 147 cascades. 147 -> 1. Gotcha 31: guard-scope narrowing -- diff the BRACE SCOPE when transcribing decomp ifs; and prove a bench detects the bug before trusting its pass --- context/reconstruction-gotchas.md | 20 ++++++++++++ game/reconstructed/mech.cpp | 54 ++++++++++++++++++++----------- game/reconstructed/mech4.cpp | 38 ++++++++++++++++++++++ 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index f4b687a..2ec5c50 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -1044,3 +1044,23 @@ initializes every reconstructed field -- an unwritten int in pooled memory is a Verified: weaponx_bench P1 (crit kill: 0 FIRED after, 5,664 refusals, X+gate agree) + P2 (partial crit keeps firing -- the freeze is gone). Full mechanism: the night-16 workflow dossier (4 agents, cross-examined) + docs/GEN_WARNING_AUDIT-style lanes in the #165 ticket thread. + + +## 31. GUARD-SCOPE NARROWING: a binary guard wrapping a whole block, transcribed onto its first statement (#174, 2026-08-13) +The binary's burst loop guards the ENTIRE per-burst application on zone-not-destroyed +(`if (piVar1[9] != 1) { crit-roll | zone TakeDamage; score tally; destroyed-flag }`, +part_012.c:14661-14677 @0x4a0446) -- a burst landing on a dead zone is a structural no-op: +no re-application, no cascade re-fire, NO SCORE. The port transcribed that guard onto the +crit roll ONLY; the unguarded TakeDamage re-saturated the zone every burst, whose >=1.0 tail +re-ran RecurseSegmentTable (night-16 field: zone 18 x7, zone 9 x4 per mission), each pass +re-pushing crit allotment into surviving subsystems (the ram "crits worsened with each +collision") -- and hits on wreckage kept SCORING (the binary awards zero). Neither +RecurseSegmentTable nor SendSubsystemDamage has any internal re-entry protection in the +binary (verified @0049cad4/@0049c9a8: no `used` write, no top gate) -- the delivery-layer +guard is the ONLY protection, so its scope is load-bearing. +**Rule:** when transcribing a decomp `if` whose body spans multiple statements, diff the +BRACE SCOPE against the port -- a guard attached to the wrong statement compiles, runs, and +passes every reader-side bench. **Bench rule (the #165 lesson, second application in one +week):** prove the bench detects the bug BEFORE trusting its pass -- the negative control +here (fix stashed) scored 147 cascades where the fixed build scores 1. +Bench: BT_ZONE_HAMMER=[,] (mech4.cpp) + scratchpad/night16/cascade_bench.sh. diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 1f0b31f..0ba05ed 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -1233,29 +1233,47 @@ void { Mech__DamageZone *zone = (Mech__DamageZone *)damageZones[zoneIndex]; // this[0x120][idx] - Subsystem *critted = 0; - if (zone->GetDamageZoneState() != DamageZone::BurningState // zone state != 1 - && BTMechCriticalChance(this, zoneIndex)) // @0x4a0164 (roll below) + // + // #174 (2026-08-13): the binary's zone-state guard wraps the WHOLE + // per-burst application -- `if (piVar1[9] != 1) { crit-roll | + // TakeDamage; tally; destroyed-flag }` (part_012.c:14661-14677, + // @0x4a0446) -- a burst landing on an already-destroyed zone is a + // structural NO-OP: no re-application, no cascade re-fire, NO SCORE. + // The port had narrowed the guard to the crit roll only, so + // continued fire into a dead zone re-saturated it every burst -> + // Mech__DamageZone::TakeDamage's >=1.0 tail re-ran + // RecurseSegmentTable (night-16: zone 18 x7, zone 9 x4 in single + // missions), each pass re-pushing the crit allotment into the + // surviving subsystems (the ram "crits worsened with each + // collision") -- and the shooter's damageTally kept scoring hits + // on wreckage the binary scores as ZERO. + // + if (zone->GetDamageZoneState() != DamageZone::BurningState) // zone state != 1 { - critted = zone->CriticalHit(message->damageData); // @0049ccc4 - if (critted != 0) + Subsystem *critted = 0; + if (BTMechCriticalChance(this, zoneIndex)) // @0x4a0164 (roll below) { - damageTally += - ((MechSubsystem *)critted)->CriticalScoreBonus(); // +0x108 - if (BTEnvOn("BT_CRIT_LOG", 0)) - DEBUG_STREAM << "[critroll] zone=" << zoneIndex - << " -> " << (critted->GetName() ? critted->GetName() : "?") - << " subLvl=" << ((MechSubsystem *)critted)->GetSubsystemDamageLevel() - << "\n" << std::flush; + critted = zone->CriticalHit(message->damageData); // @0049ccc4 + if (critted != 0) + { + damageTally += + ((MechSubsystem *)critted)->CriticalScoreBonus(); // +0x108 + if (BTEnvOn("BT_CRIT_LOG", 0)) + DEBUG_STREAM << "[critroll] zone=" << zoneIndex + << " -> " << (critted->GetName() ? critted->GetName() : "?") + << " subLvl=" << ((MechSubsystem *)critted)->GetSubsystemDamageLevel() + << "\n" << std::flush; + } } - } - if (critted == 0) - zone->TakeDamage(message->damageData); // zone vtbl+0x18 @0x4a0488 + if (critted == 0) + zone->TakeDamage(message->damageData); // zone vtbl+0x18 @0x4a0488 - damageTally += message->damageData.damageAmount; // +0x30 - if (zone->GetDamageZoneState() == DamageZone::BurningState) - zoneDestroyed = 1; + damageTally += message->damageData.damageAmount; // +0x30 + if (zone->GetDamageZoneState() == DamageZone::BurningState) + zoneDestroyed = 1; // set only by the burst that + } // DESTROYS it (binary: inside + // the guard, part_012.c:14674) if (--burstsLeft == 0) break; diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index aea459d..2bab2b6 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -7554,6 +7554,44 @@ void } } + // #174 BENCH (BT_ZONE_HAMMER=[,]): 4x/sec, dispatch a + // TakeDamage with an EXPLICIT damageZone at the viewpoint mech itself. + // Pointed at a descend=1 non-vital zone (dz_larm=2 on most chassis), + // the zone saturates, cascades ONCE, the mech survives, and the hammer + // keeps landing on the DEAD zone -- the exact repro of the night-16 + // re-descend (zone 18 x7). With the burst-loop guard at binary scope: + // ONE [cascade] for the life and the [dmghit] stream for that zone + // STOPS at saturation (the guard skips application entirely). + if ((Entity *)this == application->GetViewpointEntity() + && getenv("BT_ZONE_HAMMER")) + { + static int s_zhFrame = 0; + ++s_zhFrame; + if (s_zhFrame >= 900 && (s_zhFrame % 15) == 0) // settle, then ~4 Hz + { + int zidx = 2; + float amt = 8.0f; + { + char spec[64]; + strncpy(spec, getenv("BT_ZONE_HAMMER"), sizeof(spec) - 1); + spec[sizeof(spec) - 1] = 0; + char *comma = strchr(spec, ','); + if (comma != 0) { *comma = 0; amt = (float)atof(comma + 1); } + if (spec[0]) zidx = atoi(spec); + } + Damage dmg; + dmg.damageType = Damage::ExplosiveDamageType; + dmg.damageAmount = amt; + dmg.burstCount = 1; + dmg.impactPoint = localOrigin.linearPosition; + Entity::TakeDamageMessage td( + Entity::TakeDamageMessageID, + sizeof(Entity::TakeDamageMessage), + GetEntityID(), zidx, dmg); + Dispatch(&td); + } + } + // task #13 scripted verify (BT_VALVE_TEST=1): one MoveValve press at // frame ~600 -- Condenser1's valveState cycles 1 -> 5, so the flow // redistribution gives it 5/10 of the total coolant flow ([valve] log