#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

This commit is contained in:
Joe DiPrima
2026-08-13 02:42:07 -05:00
parent e693b481b3
commit 5cde991f56
3 changed files with 94 additions and 18 deletions
+20
View File
@@ -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=<zone>[,<amt>] (mech4.cpp) + scratchpad/night16/cascade_bench.sh.
+36 -18
View File
@@ -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;
+38
View File
@@ -7554,6 +7554,44 @@ void
}
}
// #174 BENCH (BT_ZONE_HAMMER=<zoneIdx>[,<amt>]): 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