Gitea #46: ammo bay fire now DETONATES and KILLS (+ #47 fire icon) -- three stacked kill-switches removed, the fuse decoded, and a vptr-alias corruption caught by regression

Field report (night 3): "two ammo bay fires and no death" (Cyd + RajelAran; one
purged, one left burning).  Root cause = THREE independent kill-switches stacked
on the same path, all in ammobin.cpp:

  1. `GameClock::Now() { return 0; }` -- `cookOffTime < Now()` was `0 < 0`, so
     an ARMED bay fire never detonated.
  2. `InjectHeat(void*) {}` -- the detonation body was a no-op.
  3. The bin's Damage record was never stamped -- 0 damage of type 0 (which the
     mech TakeDamage handler drops) even if 1+2 had fired.

THE FUSE (raw disasm, scratchpad/disammo.py): the old "RandomDelay" was a Ghidra
carve artifact -- FUN_004dcd94 is __ftol and the export DROPPED the caller's x87
expression.  The real bytes @004bd450: fld 10.0 / fmul [ticksPerSecond] /
fadd 0.5 / __ftol -- a FIXED 10.0-SECOND fuse in clock ticks.  New gotcha #19
(reconstruction-gotchas.md) documents the __ftol export blind spot.

THE DAMAGE RECORD: bin+0x1F0..0x21C is a real engine `Damage` (FUN_0041db7c IS
Damage::Damage(), byte-matched to T0 DAMAGE.cpp).  The linked ProjectileWeapon's
ctor @004bc3fc stamps it from weapon->damageData @0x3A8 via
owner->roster[0x128][res+0x1C0] -- projweap.cpp's old comment called this "the
bin's HUD display block ... wired in the AmmoBin family"; both halves were wrong
and it was wired nowhere.  Now stamped (before MissileLauncher's ctor divides by
missileCount -- a missile bin authentically holds the per-SALVO amount).

THE DETONATION (@004ac274 = MechSubsystem::DistributeCriticalHit -- the old
"HeatableSubsystem::InjectHeat" label was wrong, and the old reconstruction
iterated a stand-in CriticalChain whose First()/Next() returned 0):
statusAlarm pulse Exploding(2)->Destroyed(1) (slot 13 = the printSimulationState
state PRINT @004ac8c0, not an "explosion notify"), own private zone pinned
destroyed, then collect the mech DamageZones whose crit entries plug the bin
(the binary filters plug classID 0x4E = DamageZoneClassID -- VDATA.h idx 78,
cross-checked via idx 28 = AudioStateTrigger), split the amount evenly, and send
the OWNER one full Entity::TakeDamageMessage per zone: inflictingEntity = SELF,
damageZone = the zone index, inflictingSubsystemID = the bin (the message-
manager explosion-bundling key, ENTITY3.h's own NOTE), printing the binary's
exact "ammo explosion damaging <zoneName>" @0050df61.
Port shape: Mech::AmmoExplosionFanOut (mechdmg.cpp) behind a databinding bridge;
guarded deviation: zoneCount==0 warns instead of the binary's unguarded divide.
CriticalChain/CriticalEntry stand-ins DELETED from mechrecon.hpp.

VERIFIED LIVE (BT_BAYTEST hook = message 1, the crit-induced arm channel):
  scratchpad/baytest.py : arm -> 10s -> "20 rounds x 35 = 700 (type 2)" ->
    "ammo explosion damaging dz_ltorso" -> zone cascade -> mech DESTROYED
    (authentic death list).
  scratchpad/baypurge.py: arm -> eject-hold purge -> "bay fire EXTINGUISHED
    (bin empty)", no detonation.
  scratchpad/sim3.py    : the HEAT route arms organically in combat (overheated
    AFC100), detonates "11 x 25 = 275 (type 1)" split across dz_larm + dz_lgun.
  BAYBOOM matchlog record added for MP field forensics.

FIX-OF-THE-FIX (caught by the sim3 regression, would have shipped a crash):
MechSubsystem's ReconDamageZone proxy puts structureLevel at OFFSET 0 -- which
ALIASES THE REAL DamageZone's VTABLE POINTER (the private zone is `new
DamageZone`, mechsub.cpp:154; mechsub.hpp:260 documents the alias).  My first
DistributeCriticalHit kept the old body's `damageZone->structureLevel = 1.0f`
and OVERWROTE THE ZONE'S VPTR with 0x3F800000; the respawn sweep's virtual
SetGraphicState (vtable+0xC) then called through it -> AV at 0x3f80000c in
RespawnRepair, one frame after a bay-fire death.  ALL EIGHT proxy-view sites in
mechsub.cpp swept to the engine view (((DamageZone*)damageZone)->damageLevel
@0x158) -- including two silently-wrong LIVE readers: GetStatusFlags (vptr as
float -> always "intact") and ApplyDamageAndMeasure (the crit cascade's
measure).  Ruled out first by evidence: the weapon->bin stamps were all clean
(six stamps, all classID 0xbcb, logged).

#47 (half 1 -- the FIRE ICON): BallisticWeaponCluster::Execute @004c9a38 reads
bin+0x18C = cookOffArmed into the btefire.pcc TwoState, and while armed computes
(Now - cookOffTime)/ticksPerSecond -- the COOK-OFF COUNTDOWN -- into the numeric
beside it.  The old reconstruction misread 0x18C as "the reload state" and
bridged the icon to BTAmmoBinFeeding, so it blinked on every feed and never lit
on a bay fire (RajelAran: "it doesn't").  Now driven by the
BTAmmoBinCookOffArmed/CookOffTime complete-type bridges.  [T2 -- the data path
is rig-verified; the pixels await the next live session.]

#47 (half 2 -- the ENG-BUTTON FLASH): fully mapped, deliberately NOT built this
session.  The authored data SHIPS (BTL4.RES carries exactly one type-31
GaugeAlarmStream); the chain is alarm SetLevel -> gauge-watcher socket ->
Renderer msg 7 -> GaugeAlarmManager::Activate @00448d00 (T0) -> the BTL4
override @004cc148..@004cc2fc (btl4galm.cpp's provenance note claiming "no
override body exists" is WRONG -- corrected in-file) -> LampManager::FindLamp
@00444c80 -> Lamp::SetAlertState @00444e64 (flash counter) -> the L4 flush
@00474e94 emitting flashFast states 0x37/0x13 (== T0 L4LAMP.cpp:234-239).
Missing: the override bodies, the gauge-watcher sender, the aux-button lamps.
3-piece plan in context/open-questions.md.

Also logged: HandleMessage is vtable slot 8/9 in the binary but NON-virtual
across 10 reconstruction classes (bit the BT_BAYTEST hook; typed call used, gap
documented in open-questions).

KB: combat-damage.md (the full cook-off section), decomp-reference.md (the
cluster addresses + the GaugeAlarm/lamp map + BT_BAYTEST env), gauges-hud.md
(the fire-icon correction), reconstruction-gotchas.md #19 (__ftol),
open-questions.md (2 entries), btl4galm.cpp provenance correction.
checkctx CLEAN.  40 LNK2019 unchanged (the two pre-existing families).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-25 14:58:16 -05:00
co-authored by Claude Fable 5
parent d39227ef39
commit 5ae4410914
19 changed files with 808 additions and 92 deletions
+47 -24
View File
@@ -288,7 +288,7 @@ void MechSubsystem::SetSubsystemDamageLevel(Scalar level)
LWord
MechSubsystem::GetStatusFlags()
{
Scalar structure = damageZone->structureLevel; // *(this[0x38]+0x158)
Scalar structure = ((DamageZone *)damageZone)->damageLevel; // *(this[0x38]+0x158) (engine view; the recon proxy's structureLevel@0 ALIASES THE VPTR)
if (StatusThreshold <= structure) return 1; // _DAT_004ac18c
if (StatusFloor < structure) return 2; // _DAT_004ac190
return 0;
@@ -305,14 +305,14 @@ Logical
MechSubsystem::HandleMessage(int message)
{
(*BaseSlot0x18())(this, message); // *(this[0x38].vtable+0x18)
if (StatusThreshold <= damageZone->structureLevel) // _DAT_004ac140
if (StatusThreshold <= ((DamageZone *)damageZone)->damageLevel) // _DAT_004ac140 (engine view)
{
statusAlarm.SetLevel(1); // FUN_0041bbd8(this+0xb, 1)
if (printSimulationState != 0) // this[0x41]
{
OnAlarmChanged(); // (*this.vtable+0x34)(this)
}
damageZone->structureLevel = 1.0f; // dz+0x158 = 1.0
((DamageZone *)damageZone)->damageLevel = 1.0f; // dz+0x158 = 1.0 (engine view)
if (vitalSubsystem != 0) // this[0x39]
{
((Mech *)owner)->RaiseStatusAlarm(9); // FUN_0041bbd8(owner+0x2c, 9)
@@ -333,7 +333,7 @@ void
{
if (damageZone != 0)
{
damageZone->structureLevel = 1.0f; // dz+0x158 = 1.0
((DamageZone *)damageZone)->damageLevel = 1.0f; // dz+0x158 = 1.0 (engine view)
}
statusAlarm.SetLevel(1); // FUN_0041bbd8(this+0xb, 1)
if (printSimulationState != 0)
@@ -354,7 +354,7 @@ void
void
MechSubsystem::ClearStatus()
{
damageZone->structureLevel = 0.0f; // dz+0x158 = 0
((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)
if (printSimulationState != 0)
@@ -409,34 +409,57 @@ Logical
Scalar
MechSubsystem::ApplyDamageAndMeasure(Damage &damage)
{
Scalar before = damageZone->structureLevel; // dz+0x158
Scalar before = ((DamageZone *)damageZone)->damageLevel; // dz+0x158 (engine view)
TakeDamage(damage); // (*this.vtable+0x24)(this, &damage)
return damageZone->structureLevel - before;
return ((DamageZone *)damageZone)->damageLevel - before;
}
//
// @0x4ac274 -- distribute a critical hit across the contained critical
// subsystems. Drives the status alarm through level 2 then 1, pins structure
// to 1.0, then iterates the critical-subsystem chain (ClassID 0x4E entries),
// divides the incoming damage amount by the critical count, and re-applies the
// scaled damage to each, logging "ammo explosion damaging " per hit.
// @0x4ac274 -- the AMMO-EXPLOSION fan-out (Gitea #46, re-read from the raw
// decomp). The old reconstruction here was wrong in kind: it "re-applied the
// scaled damage to critical subsystems" through the CriticalChain STAND-IN in
// mechrecon.hpp, whose CountOfType/First/Next were stubs -- it iterated
// nothing, divided by a fabricated count, and had no caller anyway (AmmoBin's
// CookOff went to a local no-op). The real body [T1, part_012.c @004ac274]:
//
// 1. statusAlarm pulse 2 (Exploding) -> 1 (Destroyed), each followed by the
// slot-13 state PRINT when printSimulationState is set (this+0x104 gates
// `(*vtbl+0x34)(this)` = @004ac8c0, the "<name> = __Exploding/__Destroyed"
// debug print -- NOT an "explosion notify").
// 2. *(this[0x38] + 0x158) = 1.0f -- the subsystem's OWN DamageZone structure
// pinned to destroyed.
// 3. Iterate the plugs connected to THIS SUBSYSTEM (FUN_004acfa9 walks the
// Node link chain at this+8), collecting every object whose classID@+4 ==
// 0x4E = **DamageZoneClassID** (VDATA.h enum -- index 78; cross-checked:
// index 28 = AudioStateTrigger matches the audio-trace finding) -- i.e.
// the MECH damage zones that list this subsystem as a critical subsystem.
// 4. damage.damageAmount /= zoneCount (evenly split; the binary divides
// UNGUARDED -- every authored bin is in at least one zone).
// 5. Per zone: print "ammo explosion damaging <zoneName>" (@0050df61, name
// from zone+0x15C = damageZoneName) and send the OWNER a full 100-byte
// Entity::TakeDamageMessage (id 0x12) with
// inflictingEntity = the owner mech itself (owner+0x184)
// damageZone = the zone's index (zone+0x13C)
// damageData = the split Damage record
// inflictingSubsystemID = this subsystem's ID (this+0xD8)
// -- the message-manager uses inflictingSubsystemID to bundle the
// explosion resource for the view (ENTITY3.h's own comment).
//
// Steps 3-5 need the Mech/zone complete types, so they live in
// Mech::AmmoExplosionFanOut (mechdmg.cpp) behind the BTAmmoExplosionFanOut
// bridge (databinding rule).
//
void
MechSubsystem::DistributeCriticalHit(Damage &damage)
{
statusAlarm.SetLevel(2); if (printSimulationState) OnAlarmChanged();
statusAlarm.SetLevel(1); if (printSimulationState) OnAlarmChanged();
damageZone->structureLevel = 1.0f;
statusAlarm.SetLevel(2); if (printSimulationState) OnAlarmChanged(); // Exploding
statusAlarm.SetLevel(1); if (printSimulationState) OnAlarmChanged(); // Destroyed
SetSubsystemDamageLevel(1.0f); // own private zone gutted (dz+0x158, ENGINE view)
CriticalChain criticals(this); // FUN_004acfa9
int count = criticals.CountOfType(0x4E); // entries whose +4 == 0x4E
damage.damageAmount /= (Scalar)count; // per-critical share
for (CriticalEntry *c = criticals.First(); c != 0; c = criticals.Next())
{
DebugStream << "ammo explosion damaging " << c->Subsystem()->GetName(); // 0050df61
c->Subsystem()->TakeDamage(damage); // (*owner.vtable+0xc)(...)
}
extern void BTAmmoExplosionFanOut( // mechdmg.cpp (Mech-complete TU)
void *owner_mech, void *inflicting_subsystem,
int inflicting_subsystem_id, Damage &total_damage);
BTAmmoExplosionFanOut(owner, this, subsystemID, damage);
}
//