#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=<name>
(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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
This commit is contained in:
Joe DiPrima
2026-07-31 12:11:09 -05:00
co-authored by Claude Fable 5
parent 62fc8409b6
commit 5410371b0c
6 changed files with 86 additions and 2 deletions
+11
View File
@@ -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**,
+20
View File
@@ -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.
+7 -1
View File
@@ -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
+38
View File
@@ -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=<name>): 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
+1
View File
@@ -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"))
+9 -1
View File
@@ -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());