From fcd592c0f208885cae53cd10b895b722a248bf1e Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Thu, 13 Aug 2026 00:35:33 -0500 Subject: [PATCH] #165 ROOT-CAUSED after three failed fixes -- the binary keeps ONE destroyed cell per subsystem (+0x40: fire gate, dial X, wire header, every writer) and the port split it into FOUR stores; the everyday CRIT-LOTTERY kill (MechWeapon::TakeDamage) bypassed the reconstructed base and wrote only the X store, so X'd weapons kept firing while every prior fix aimed at a gate that was never broken (the 4,213-refusal bench drove BT_KILL_SUBSYS, which writes all cells -- it validated readers, not writers). FIX: MechWeapon::TakeDamage chains to MechSubsystem::TakeDamage (@0x4ac0bc semantics); the port-authored FSM-from-damage tail is DELETED (it parked partial-crit weapons at level 1 -- no FSM case, the silent freeze, and with beamFlag armed the 883 hanging beam: #164's upstream writer, caught red-handed by berserker's 20x STUCK-ON receipts); the phantom uninitialized simulationState int is ctor-initialized and kept in lockstep at every destroyed write; always-on [crit] DESTROYED receipt makes crit kills field-visible. BT_CRIT_SUBSYS bench hook drives the REAL writer path; weaponx_bench PASS: kill phase 0 FIRED post / 5,664 refusals / X+gate agree, partial phase keeps firing. Gotcha 30; #174 filed (cascade re-descend) --- context/combat-damage.md | 16 +++++++++ context/open-questions.md | 9 +++++ context/reconstruction-gotchas.md | 30 ++++++++++++++++ context/test-harness.md | 8 +++++ game/reconstructed/mech4.cpp | 60 +++++++++++++++++++++++++++++++ game/reconstructed/mechsub.cpp | 23 ++++++++++++ game/reconstructed/mechweap.cpp | 53 ++++++++++++++------------- 7 files changed, 175 insertions(+), 24 deletions(-) diff --git a/context/combat-damage.md b/context/combat-damage.md index 9d12ef0..5f278ff 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -1328,3 +1328,19 @@ record shipped penalty-off for entire steam nights, #162); (3) the bench for ANY here is scratchpad/night15/kd_bench.sh -- five HARD invariants (deaths==increments, cost-per-death, credit-per-kill, zero swallowed, arithmetic witness); its predecessor's assertions were greps that matched nothing, which is how both halves shipped broken. + + +## Weapon destroyed-state: the ONE-cell contract (#165 root cause, night-16) [T1] +Binary: subsystem `+0x40` (statusAlarm level) is THE destroyed cell -- all writers (base +TakeDamage @0x4ac0bc zone-saturation tail; cascade descent @0049c9a8; slot-8 pump watchdog), +all readers (Emitter gate @004baa88 `==1` strict; small-dial X @004c7844; techstat; the wire +header). Four alarm cells exist per weapon and are NOT interchangeable: +0x40 status/health, ++0x184 temperature (gate clause `==2`), +0x278 power feed (eng-page X only!), +0x364 weaponAlarm +FSM (level 7 "unavailable" = AMMO weapons only, @004bb9b8/@004bbd04 -- the #30 answer). The +eng-page energy X (@004c8990) draws on `+0x40==1 OR power!=4`: an X over a LOADED emitter that +legally fires its one loaded shot is AUTHENTIC binary behavior (power-fault case). Destroyed +emitters park at weaponAlarm=3 (Loading) via the gate's per-frame refusal -- "recharging under +the X" is authentic. Replication: weapon statusAlarm NEVER crosses the wire; destroyed-ness +reaches peers via ZONE records only; replicant emitters run a gateless Simulate (@004ba8d0) -- +fire legality is master-only. Port fix + the four-store split it repaired: [[reconstruction-gotchas]] SS30; +bench: `scratchpad/night16/weaponx_bench.sh` (crit-path kill + partial-crit freeze regression). diff --git a/context/open-questions.md b/context/open-questions.md index 3e891fe..2bcc7e3 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -1226,5 +1226,14 @@ consistent with internals-only, see #103). Logs: `scratchpad/night16/` + Oracle no flash on any MFD, died ~9:38PM CT -- the match's only death, receipt findable in night16). Distinct from the closed #135: this is the SET-edge / first-flash arming path. - **#173 -- chicken-walker seek-4 myomer overheat** (see [[subsystems]] night-16 note). +- **#165 ROOT-CAUSED + FIXED + BENCHED (2026-08-13, the 4-agent hunt):** gotcha SS30 -- the + binary's ONE destroyed cell was split into four port stores and the crit-lottery kill path + (MechWeapon::TakeDamage) wrote none the gate reads. Fixed (chain to MechSubsystem::TakeDamage, + FSM tail deleted, phantom simulationState initialized + bridged); weaponx_bench PASS both + phases. RESIDUAL UNKNOWNS from the hunt: (i) Elengil's 20:17 ET no-X facet needs ALIA's + PRE-ROTATION steam_20260812.log (ask Elengil -- staged file starts 22:20 ET); (ii) cascade + re-descend re-entrancy (zone 18 x7 re-rolls, own ticket); (iii) incident-(b) "no damage on + target": shooter log computed FULL damage (11.76) -- wire/victim-side loss, needs paired + matchlogs (fleet-wide BT_DMG_LOG + matchlog next night would make this attributable). - Nanook lost the coolant MFD glass window, recovered only by config edit -> #76 comment (in-game layout-reset recovery path). diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index bfbac4b..f4b687a 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -1014,3 +1014,33 @@ sampled a cell whose write timing differs from the binary's". Fix shape: run the transition inline at the destruction edge (mech.cpp death_tail → `UpdateDeathState(0)`), keep the per-frame caller for maintenance. Verified: kd4_bench 9/9/9/9 + kd8_stress 26/26/26/26, both SWALLOWED=0 (the receiver dedup tripwire now never fires — no duplicate is ever GENERATED). + + +## 30. ONE binary cell, FOUR port stores: the destroyed-state split that survived three "fixes" (#165, night-16 2026-08-13) +The binary keeps ONE destroyed cell per subsystem: `+0x40` = the statusAlarm level -- written by +EVERY destruction path (base TakeDamage @0x4ac0bc at private-zone saturation; zone-cascade +descent @0049c9a8; the per-frame watchdog in the slot-8 message pump), read by EVERY consumer +(the Emitter fire gate @004baa88, the small weapon-dial X @004c7844, the techstat scan, the wire +via the Simulation record header). The port split that cell into FOUR stores that never talked: +the private DamageZone.damageLevel (drives the X), the ReconAlarm statusAlarm (gate clause 2 -- +written ONLY by the cascade + bench hook), a phantom `int simulationState` (gate clause 1 -- +ZERO writers, NEVER ctor-initialized: uninitialized pool memory, MEMBLOCK recycles unzeroed), +and the weaponAlarm FSM cell. `MechWeapon::TakeDamage` then bypassed the reconstructed +`MechSubsystem::TakeDamage` (which HAS the destroyed tail) for the raw engine base -- so the +everyday CRIT-LOTTERY kill saturated the zone (X lit) without ever writing a gate cell: X'd +weapons kept firing. Three prior "fixes" all aimed at the GATE, and the bench (BT_KILL_SUBSYS) +wrote both cells directly -- 4,213 refusals proving a gate that was never broken. +**Sibling defect in the same function:** the port-authored tail drove the weapon FSM from zone +state -- saturation -> level 0 (=Firing!), partial damage -> level 1 (NO case in either FSM: +silent freeze; with beamFlag armed = the 883 hanging beam, #164's upstream writer). +**Rules:** (1) when the binary reads/writes ONE offset, the port must have ONE authority -- +alias, don't duplicate; if layout forces a twin field, every writer writes BOTH (grep-audit the +writer census, not the reader census). (2) Never let an override "simplify away" the +reconstructed base -- `MechWeapon::TakeDamage` calling `Subsystem::TakeDamage` instead of +`MechSubsystem::TakeDamage` dropped the invariant silently. (3) A bench hook that writes state +directly validates READERS only; destruction/state benches must drive the REAL writer path +(BT_CRIT_SUBSYS: Damage -> ApplyDamageAndMeasure -> virtual TakeDamage). (4) Every ctor +initializes every reconstructed field -- an unwritten int in pooled memory is a time bomb. +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. diff --git a/context/test-harness.md b/context/test-harness.md index bafb85d..ef6be48 100644 --- a/context/test-harness.md +++ b/context/test-harness.md @@ -226,3 +226,11 @@ wrote); affinity masks 2-cores-each up to 4 nodes, 1-each at 8. - Uses: [[build-and-run]] (parity, env gates, BT_SHOT capture) · [[experience-levels]] (expert vs novice gating) - Informs: [[reconstruction-method]] (step 4 "verify honestly" — this file is the how) - Incident sources: [[reconstruction-gotchas]] §23 (pixels), §25 (same-pass latch timing — caught by the 4-node bench), §26 (silent stubs); [[multiplayer]] (replication layers) + +## weaponx_bench (night16) -- #165 crit-path destruction +`scratchpad/night16/weaponx_bench.sh`: single node, vulture, BT_AUTOFIRE; P1 kills ERSLaser_1 +via BT_CRIT_SUBSYS (the REAL writer path: Damage -> ApplyDamageAndMeasure -> virtual TakeDamage) +and asserts [crit] DESTROYED + [techstat] X + zero named FIRED after + REFUSED>0 + survivor +fires; P2 partial-crits to 0.5 and asserts the weapon KEEPS firing (freeze regression, the #164 +upstream writer). LESSON (gotcha SS30): BT_KILL_SUBSYS writes the cells directly and can only +validate READERS -- state benches must drive the real writer path. diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index cfcc7a0..aea459d 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -7494,6 +7494,66 @@ void } } + // #165 BENCH (BT_CRIT_SUBSYS=[,...]): kill named subsystems + // through the REAL crit path -- Damage record -> ApplyDamageAndMeasure -> + // virtual TakeDamage -- NOT the direct cell writes BT_KILL_SUBSYS does. + // This is the path the field takes (the crit lottery), and the path the + // old 4,213-refusal bench never exercised: BT_KILL_SUBSYS wrote + // statusAlarm AND the zone directly, so it validated the fire gate while + // the field's writer (MechWeapon::TakeDamage) silently dropped the + // destroyed-side write. Applies one energy-damage chunk per frame from + // frame 900 until the subsystem's own zone saturates ([crit] receipt + // fires from MechSubsystem::TakeDamage on the edge), then stops. + // Env-gated, viewpoint mech only. + if ((Entity *)this == application->GetViewpointEntity() + && getenv("BT_CRIT_SUBSYS")) + { + static int s_csFrame = 0; + if (++s_csFrame >= 900) // same settle window as killsub + { + char want_list[256]; + strncpy(want_list, getenv("BT_CRIT_SUBSYS"), sizeof(want_list) - 1); + want_list[sizeof(want_list) - 1] = 0; + for (char *want = strtok(want_list, ","); want != 0; + want = strtok(0, ",")) + { + // PARTIAL form ("Name=0.5"): stop at that zone level -- the + // freeze-regression phase (a partially-critted weapon must + // keep recycling; the old port tail froze it at alarm 1). + float target = 1.0f; + char *eq = strchr(want, '='); + if (eq != 0) + { + *eq = 0; + target = (float)atof(eq + 1); + } + for (int i = 2; i < GetSubsystemCount(); ++i) + { + Subsystem *s = GetSubsystem(i); + if (s == 0 || s->GetName() == 0) continue; + if (stricmp(s->GetName(), want) != 0) continue; + if (!s->IsDerivedFrom(MechSubsystem::ClassDerivations)) continue; + MechSubsystem *ms = (MechSubsystem *)s; + if (ms->GetSubsystemDamageLevel() >= target) break; // done + Damage d; + d.damageType = Damage::EnergyDamageType; + d.damageAmount = 0.75f; // FINE-GRAINED: a small-laser + // private zone saturates on a + // single 20.0 chunk (first run); + // ~0.04 level/frame ramps let + // the PARTIAL stop actually stop + d.burstCount = 1; + ms->ApplyDamageAndMeasure(d); // FUN_004ac07c -> virtual TakeDamage + if ((s_csFrame % 30) == 0) + DEBUG_STREAM << "[critsub] '" << s->GetName() + << "' zone=" << (float)ms->GetSubsystemDamageLevel() + << std::endl << std::flush; + break; + } + } + } + } + // 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 diff --git a/game/reconstructed/mechsub.cpp b/game/reconstructed/mechsub.cpp index ee50252..40f017a 100644 --- a/game/reconstructed/mechsub.cpp +++ b/game/reconstructed/mechsub.cpp @@ -142,6 +142,11 @@ MechSubsystem::MechSubsystem( { refCount = NewRefCount(0x10); // this[0x3d] this->owner = owner; // this[0x34] -- the owning Mech (canonical) + simulationState = 0; // #165: was NEVER initialized anywhere -- + // MemoryBlock pools recycle unzeroed + // (MEMBLOCK.cpp), so stale garbage ==1 + // could brick a weapon from spawn and + // draw the seek-graph destroyed bitmap controlDestination = 0; // this[0x3a] = param_7 (ctor pass-through; controlMessageID = 0; // this[0x3b] = param_8 weapons override -- task #6) vitalSubsystem = 0; // this[0x39] @@ -187,6 +192,8 @@ MechSubsystem::MechSubsystem( // ctor only set hostEntity; subclasses read // `owner` widely -> must be set or it is garbage.) statusAlarm.SetLevel(0); // FUN_0041bbd8(this+0xb, 0) + simulationState = 0; // #165: uninitialized-landmine fix (see + // the sibling ctor) controlDestination = 0; // this[0x3a]=param_6 (ctor pass-through; controlMessageID = 0; // this[0x3b]=param_7 weapons override -- task #6) resource = subsystem_resource; // this[0x3c] @@ -279,6 +286,7 @@ Scalar MechSubsystem::GetSubsystemDamageLevel() const void MechSubsystem::ForceCriticalFailure() { statusAlarm.SetLevel(1); // +0x2C: 1 = Destroyed + simulationState = 1; // #165 twin (see TakeDamage) if (printSimulationState) // +0x104 gate { PrintState(); // vtable slot 0x34 @4ac8c0 @@ -303,6 +311,7 @@ void MechSubsystem::ForceCriticalFailure() void MechSubsystem::RespawnRepair() { statusAlarm.SetLevel(0); // operational + simulationState = 0; // #165 twin (see TakeDamage) if (damageZone != 0) { ((DamageZone *)damageZone)->damageLevel = 0.0f; @@ -358,6 +367,7 @@ Logical if (StatusThreshold <= ((DamageZone *)damageZone)->damageLevel) // _DAT_004ac140 (engine view) { statusAlarm.SetLevel(1); // FUN_0041bbd8(this+0xb, 1) + simulationState = 1; // #165 twin (see TakeDamage) if (printSimulationState != 0) // this[0x41] { OnAlarmChanged(); // (*this.vtable+0x34)(this) @@ -386,6 +396,7 @@ void ((DamageZone *)damageZone)->damageLevel = 1.0f; // dz+0x158 = 1.0 (engine view) } statusAlarm.SetLevel(1); // FUN_0041bbd8(this+0xb, 1) + simulationState = 1; // #165 twin (see TakeDamage) if (printSimulationState != 0) { OnAlarmChanged(); // (*this.vtable+0x34)(this) @@ -407,6 +418,7 @@ void ((DamageZone *)damageZone)->damageLevel = 0.0f; // dz+0x158 = 0 (engine view) damageZone->alarm.SetLevel(0); // FUN_0041bbd8(dz+0x10, 0) statusAlarm.SetLevel(0); // FUN_0041bbd8(this+0xb, 0) + simulationState = 0; // #165 twin (see TakeDamage) if (printSimulationState != 0) { OnAlarmChanged(); @@ -520,7 +532,18 @@ void MechSubsystem::TakeDamage(Damage &damage) if (dz->damageLevel >= 1.0f) // _DAT_004ac140 { + // #165: named destruction receipt on the EDGE only (damage keeps + // landing on dead subsystems; the write below is idempotent) -- the + // crit-path kill had NO field receipt, which is how an X'd-but-firing + // weapon hid for a month. Always-on: once per subsystem per life. + if (statusAlarm.GetLevel() != 1) + DEBUG_STREAM << "[crit] '" << (GetName() ? GetName() : "?") + << "' DESTROYED (zone saturated)\n" << std::flush; statusAlarm.SetLevel(1); // Destroyed + simulationState = 1; // #165: the binary's +0x40 is ONE + // cell; the port split it -- keep + // the twin in lockstep (gate clause + // 1, seek-graph destroyed bitmap) if (printSimulationState) // +0x104 { PrintState(); // vtable +0x34 @4ac8c0 diff --git a/game/reconstructed/mechweap.cpp b/game/reconstructed/mechweap.cpp index 422ceac..093df11 100644 --- a/game/reconstructed/mechweap.cpp +++ b/game/reconstructed/mechweap.cpp @@ -411,34 +411,39 @@ Logical // @004b96d4 -- slot 9 (TASK #51 RENAME: this body was mislabeled slot-6 // ReadUpdateRecord; @004b96d4 chains @004b0efc = PoweredSubsystem::TakeDamage, // which dispatches on *param==4 == Damage::damageType -- damage semantics). -// The PoweredSubsystem::TakeDamage body (electrical-short handling) is not yet -// reconstructed; chain to the engine Subsystem base, and keep the damage-zone -// alarm tracking this port has shipped with (best-effort; the binary's alarm -// display on weapon damage flows through the unreconstructed @004b0efc chain). +// +// #165 ROOT CAUSE (night-16 hunt, 2026-08-13). The old body here called the +// raw ENGINE Subsystem::TakeDamage (zone accumulation ONLY) and then drove +// weaponAlarm from the zone state -- two defects in one: +// (1) it DROPPED the destroyed-side invariant: the binary keeps ONE +// destroyed cell per subsystem (+0x40 = statusAlarm level) that every +// destruction path writes and the fire gate reads. A weapon killed by +// the CRIT LOTTERY (the everyday field path) saturated its private +// zone (-> the MFD X, [techstat] condition 0) but never wrote the gate +// cell -- the X'd weapon kept firing (Oracle 20260812: ER PPC firing +// indefinitely; the PPC+SL pair firing until the arm CASCADE finally +// wrote the cell). The 4,213-refusal bench passed because +// BT_KILL_SUBSYS writes both cells -- it validated the gate, not the +// writers. +// (2) the weaponAlarm tail wrote the FSM cell BACKWARDS: saturation -> +// level 0 (Firing!), partial damage -> level 1 -- a state with NO case +// in either weapon FSM: the silent freeze (berserker's 20x [beam] +// STUCK-ON, and the state-1+beamFlag combination that drew Conn Man's +// 883 hanging beam -- #164's upstream cause). The binary never drives +// the FSM from damage: a destroyed weapon parks at Loading(3) via the +// fire gate's per-frame refusal (ResetFiringState, emitter.cpp). +// FIX: chain to MechSubsystem::TakeDamage -- the correctly-reconstructed +// binary base (@0x4ac0bc: zone TakeDamage, then damageLevel>=1.0 -> +// statusAlarm=1 + pin + vital-kill) -- and do NOT touch the FSM. The +// binary's electrical-short middle layer (@004b0efc) remains unreconstructed +// (a marked gap, unchanged); what is restored is the destruction invariant. +// MechSubsystem::TakeDamage guards damageZone==0 itself (the 2026-07-29 +// Owens NULL-zone field crash stays guarded). // void MechWeapon::TakeDamage(Damage &damage) { - // FIELD CRASH GUARD (2026-07-29, build 641): the engine base derefs - // damageZone unguarded; an Owens weapon reached here with zone == NULL - // the first night crits could land (#80). See ApplyDamageAndMeasure. - if (this->Subsystem::damageZone != 0) - Subsystem::TakeDamage(damage); // engine base (real chain: @004b0efc) - - // Port behavior (kept): a destroyed weapon clears its display alarm; - // otherwise the alarm tracks the resolved damage-zone state. - ::DamageZone *dz = this->Subsystem::damageZone; - if (dz != 0 - && dz->GetGraphicState() == DamageZone::DestroyedGraphicState) - { - weaponAlarm.SetLevel(0); - } - else - { - weaponAlarm.SetLevel( - (dz != 0 && dz->damageLevel > 0.0f) ? 1 : 0 - ); - } + MechSubsystem::TakeDamage(damage); // @0x4ac0bc semantics } //