From 5410371b0cb10b7cd3d447bff8b622008a5b5d94 Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Fri, 31 Jul 2026 12:11:09 -0500 Subject: [PATCH] #86: destroyed weapons can no longer fire -- the fire gates read a never-written cell (the split-cell gotcha) Both weapon fire gates (ProjectileWeapon gate 1 @4bbd36, Emitter hard-failure @4baab9) test the binary's subsystem+0x40 for Destroyed(1). In the 1995 layout that offset sits INSIDE the embedded status alarm (statusAlarm@0x2C + level@+0x14 = 0x40) -- ONE cell, written by ForceCriticalFailure when a zone's crit cascade kills the subsystem. The port models the same address as TWO members: the AlarmIndicator AND a plain int simulationState@0x40. Every destruction path writes the ALARM (so the MFD draws its X correctly) while the gates read the plain int, which nothing ever writes -- so a weapon on a blown-off arm showed destroyed on every panel and kept firing and scoring, locally and on peers (night-7: all three testers, screenshots of a missile leaving a destroyed pod). The tell had been sitting in our own logs for weeks: [ammo] SRM6_1 -> NoAmmo (gate1): destroyed=0 ... on a mech whose launcher was X'd out. Fix: both gates now read statusAlarm.GetLevel()==1 as well as the int. Also added: the crit-cascade log names the destroyed subsystem, a BT_SELF_DAMAGE_ZONE=dz_* named-zone bench mode, and BT_KILL_SUBSYS= (force ForceCriticalFailure on one named subsystem -- the exact call the zone cascade makes, so a bench can ask 'the panel says dead, does it still shoot?' without hunting for the zone that carries a given weapon). Verified A/B in ONE run: before the kill both SRM6 launchers fired 2 salvos each; after, the destroyed launcher fired ZERO (gate log destroyed=1) while its twin kept firing normally. KB: new gotcha #22 (the SPLIT CELL -- one binary offset, two port members, only one written; sibling of #1) + combat-damage entry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J --- context/combat-damage.md | 11 +++++++++ context/reconstruction-gotchas.md | 20 ++++++++++++++++ game/reconstructed/emitter.cpp | 8 ++++++- game/reconstructed/mech4.cpp | 38 +++++++++++++++++++++++++++++++ game/reconstructed/mechdmg.cpp | 1 + game/reconstructed/projweap.cpp | 10 +++++++- 6 files changed, 86 insertions(+), 2 deletions(-) diff --git a/context/combat-damage.md b/context/combat-damage.md index d345549..bb88e99 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -376,6 +376,17 @@ thruster table (ModelList ids aliased to their type-15 member); both launch path Note: the Black Hawk's "SRM6" fires **strk** (Streak) ammo per its bin -- the ammo model, not the launcher name, decides the flight profile. +## Destroyed weapons kept FIRING -- FIXED (2026-07-31, issue #86) [T1/T2] +The fire gates (`ProjectileWeaponSimulation` gate 1 @4bbd36, `EmitterSimulation` hard-failure +@4baab9) test `subsystem+0x40` for Destroyed(1). In the binary that offset IS the status alarm's +level cell; the port splits it into `statusAlarm` + a never-written `int simulationState`, so the +gates read a dead cell -- a weapon on a destroyed mount showed its X on the MFD and kept firing +and SCORING (night-7: all three testers, with screenshots of a missile leaving a destroyed pod). +Fixed by reading BOTH cells in both gates; the full pattern is [[reconstruction-gotchas]] §22. +Verified A/B in one run (BT_KILL_SUBSYS bench hook -> ForceCriticalFailure, the same call the +zone crit-cascade makes): before the kill both SRM6 launchers fired 2 salvos each; after, the +destroyed launcher fired ZERO (gate log `destroyed=1`) while its twin kept firing. + ## Ballistic damage type -- FIXED (2026-07-23, issue #27) [T2] Playtest matchlog forensics (2,591 applied-damage events over 2 rounds): the damageType histogram had Collision/Explosive/Laser/Energy but **Ballistic (type 1) NEVER appeared**, diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index b0a8751..a23806b 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -717,3 +717,23 @@ capstone over the CODE section for the addressing form (e.g. FPU reads of `[reg+ `[reg+reg*4+0x330]`). Minutes of scanning; it found in one pass what three decomp sweeps missed. Corollary: when the decomp and a PRIMARY SOURCE (the manual, a pod veteran) disagree, treat the disagreement as a hole in YOUR evidence first, not in theirs. + + +## §22 — The SPLIT CELL: one binary offset, two port members (only one gets written) +**(2026-07-31, gitea #86 "destroyed weapons keep firing".)** The binary's weapon fire gates test +`subsystem+0x40`. In the 1995 layout that offset is *inside* the embedded status alarm +(`statusAlarm@0x2C` + the indicator's level at `+0x14` = `0x40`) — **one cell**, written by +`ForceCriticalFailure` when a zone's crit-cascade kills the subsystem. The port models the same +address as TWO independent members: `AlarmIndicator statusAlarm` **and** a plain +`int simulationState@0x40`. Every destruction path writes the ALARM (so the MFD correctly draws +its X, the paper doll correctly greys the mount) while the fire gates read the plain int — which +nothing ever writes. Result: a weapon on a blown-off arm shows destroyed on every panel and +keeps firing and scoring, on the shooter's screen and on peers' (all three night-7 testers). +**Detection smell:** a diagnostic that prints the gate's own inputs and shows a state flag +reading 0 while the UI bound to "the same" state shows destroyed. (`[ammo] NoAmmo (gate1): +destroyed=0` on a mech with an X'd-out launcher was the tell — it sat in the logs for weeks.) +**Rule:** when a binary offset falls inside an embedded object in OUR layout, do not mirror it as +a sibling scalar — read it through the object that owns it, or (if a duplicate member already +exists) make every gate read BOTH and every writer write BOTH. Sibling of gotcha #1 (shadowed +base field): same failure shape — two cells where the binary has one, and the readers pick the +dead one. diff --git a/game/reconstructed/emitter.cpp b/game/reconstructed/emitter.cpp index bdf3a8d..22e827f 100644 --- a/game/reconstructed/emitter.cpp +++ b/game/reconstructed/emitter.cpp @@ -428,7 +428,13 @@ void // simulationFlags@0x28, a latent kill-switch), own heatAlarm at FailureHeat // (this+0x184 == 2), or the owning mech disabled (FUN_0049fb54) -- a dead // mech's weapons drop everything. - if (simulationState == 1 || GetFaultState() == 2 || BTMechDestroyed((Entity *)owner)) + // #86 FIX (2026-07-31): the binary's +0x40 IS the statusAlarm level cell + // (indicator @0x2C, level at +0x14 = +0x40 -- ONE cell); the port split + // them and the destruction path writes only the alarm, so X'd-out energy + // weapons on a blown-off arm kept firing. Read BOTH cells (see + // projweap.cpp gate 1 for the full note). + if (simulationState == 1 || statusAlarm.GetLevel() == 1 + || GetFaultState() == 2 || BTMechDestroyed((Entity *)owner)) { ResetFiringState(); // @004ba9a8 currentLevel = 0.0f; // 0x414 diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 867fd28..c9f9f4d 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -6035,6 +6035,14 @@ void if (zz != 0 && zz->IsLegZone()) { sdZone = zi; break; } } } + else if (ze[0] == 'd' && ze[1] == 'z') + { + // #86 bench: a NAMED zone (e.g. dz_rarm) resolved via + // the engine's own name->index map -- lets a bench + // destroy a specific ARM and watch its weapons brick. + int byName = Entity::GetDamageZoneIndex(CString(ze)); + if (byName >= 0) sdZone = byName; + } else sdZone = atoi(ze); } @@ -6052,6 +6060,36 @@ void } } + // #86 BENCH (BT_KILL_SUBSYS=): force the AUTHENTIC destruction of + // one named subsystem -- MechSubsystem::ForceCriticalFailure, the exact + // call the zone crit-cascade (SendSubsystemDamage) makes, and the thing + // that raises the MFD's X. Lets a bench answer "the panel says it's + // dead -- does it still shoot?" without hunting for the zone that + // happens to carry a given weapon (per-mech, and dz_* names vary). + // Env-gated, one-shot, viewpoint mech only. + if ((Entity *)this == application->GetViewpointEntity() + && getenv("BT_KILL_SUBSYS")) + { + static int s_ksFrame = 0; + if (++s_ksFrame == 900) // ~15 s in, after the mech settles + { + const char *want = getenv("BT_KILL_SUBSYS"); + 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 *)s)->SetSubsystemDamageLevel(1.0f); + ((MechSubsystem *)s)->ForceCriticalFailure(); + DEBUG_STREAM << "[killsub] '" << s->GetName() + << "' force-destroyed (statusAlarm=1, zone=1.0)" + << 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/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index 42e8675..3c6648c 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -800,6 +800,7 @@ void } if (getenv("BT_DEATH_LOG")) DEBUG_STREAM << "[deathfx] crit-subsys " << i + << " '" << (s->GetName() ? s->GetName() : "?") << "'" << " DESTROYED (vital=" << s->IsVitalSubsystem() << ")\n" << std::flush; } else if (getenv("BT_DEATH_LOG")) diff --git a/game/reconstructed/projweap.cpp b/game/reconstructed/projweap.cpp index bcbe2da..3bfc829 100644 --- a/game/reconstructed/projweap.cpp +++ b/game/reconstructed/projweap.cpp @@ -752,8 +752,16 @@ void // FailureTemperature for the ballistic family), or the owning mech disabled // (FUN_0049fb54) -> pin recoil at the full rechargeRate + latch alarm 7. // No return: the frame continues into the state machine. + // #86 FIX (2026-07-31): in the BINARY, "simulationState@0x40" IS the + // statusAlarm's current-level cell (the indicator lives at +0x2C and its + // level sits at +0x14 inside it = +0x40) -- ONE cell. The port models + // them as TWO members, and the destruction path (ForceCriticalFailure / + // the crit sink) writes only the ALARM -- so this gate read a + // never-written int and a weapon on a blown-off arm kept firing with its + // MFD X'd out (all three night-7 testers). Read BOTH cells. { - int gate1Destroyed = (simulationState == 1); + int gate1Destroyed = (simulationState == 1 + || statusAlarm.GetLevel() == 1); int gate1FailHeat = (heatAlarm.GetLevel() == HeatSink::FailureHeat); int gate1Disabled = (owner != 0 && owner->IsDerivedFrom(*Mech::GetClassDerivations()) && ((Mech *)owner)->IsDisabled());