Commit Graph
3 Commits
Author SHA1 Message Date
arcattackandClaude Fable 5 5ae4410914 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>
2026-07-25 14:58:16 -05:00
arcattackandClaude Opus 5 08977ff128 Gitea #55: respawn now restores COOLANT / heat / generator state (+ a Generator reset re-transcribed from the binary)
THE GAP: Mech::Reset sweeps every subsystem with the virtual DeathReset
(mech4.cpp:1789), but only 7 weapon/ammo classes overrode it -- the entire
heat/coolant/power family fell through to the empty Subsystem::DeathReset base
(SUBSYSTM.h:161), so the respawn reset was a SILENT NO-OP for them.  All the
ResetToInitialState bodies already existed; nothing ever called them.  Hence the
field report: 'respawned with drained coolant'.

Added DeathReset forwarders (ResetToInitialState is not virtual, so each class
with its own body needs one): HeatableSubsystem, HeatSink, Condenser,
PoweredSubsystem, Generator.  HeatSink's also serves Reservoir -- the coolant
tank -- which has no body of its own; that is the one that refills coolant.

TWO MIS-TRANSCRIPTIONS FIXED, both verified against the binary with capstone:

1. HeatSink::ResetToInitialState chained HeatableSubsystem::ResetToInitialState
   with the comment 'FUN_004ac22c'.  But @004ac22c is Subsystem's terminus --
   disassembled: it reads the damage zone at this+0xe0, clears zone+0x158, and
   Set_Alarm_Levels zone+0x10 and this+0x2c to 0.  And the binary's HeatSink
   @004ad760 calls only @004ad884 (ClearHeatFilter), @004ad7f0 (UpdateHeatLoad)
   and @004ac22c -- never HeatableSubsystem.  Worse, HeatableSubsystem's body sets
   currentTemperature = 300.0f, CLOBBERING the startingTemperature that HeatSink
   assigned four lines earlier.  Dropped the call; the terminus work is already
   done for every subsystem by MechSubsystem::RespawnRepair (mech4.cpp:1790).

2. Generator::ResetToInitialState chained HeatableSubsystem and set
   outputVoltage = 0 -- which matches GNRATOR.TCP, but BT's BINARY DIVERGES from
   the TCP source, and the binary is what shipped.  Disassembled @004b215c
   instruction by instruction: it chains HeatSink (so the coolant refill DOES run
   for a generator), then generatorOn=1, startTimer=0, stateAlarm 0 then 2,
   **outputVoltage = ratedVoltage**, coolantAvailable=1, coolantFlowScale=1.0,
   startTimer=startTime.  Every offset matches our declared members exactly.
   The old version brought generators back from a respawn at ZERO output voltage,
   so energy weapons could never charge -- no recharge ring, no ready dot, nothing
   damaged.  That is a strong candidate for #54 (David's three dead lasers).

LIVE VERIFICATION (sim3.py, 3 mechs + force damage): 4 death/respawn cycles, and
every heat sink logged coolant == capacity on every respawn, with temp restored to
startingTemperature (77) rather than the clobbered 300.  Left as a silent
regression guard that only speaks if a respawn leaves coolant short.

KNOWN REMAINING [T3, noted in code]: Condenser's body chains HeatableSubsystem
rather than HeatSink, so a coolant loop's VALVE DETENT may still persist across a
respawn -- the binary's Condenser reset is not yet decompiled.  Do not claim
valves are fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 09:19:10 -05:00
arcattackandClaude Opus 5 2518e43719 Gitea #59: the first death permanently disabled ExecuteWatchers -- simulationFlags |= 0x1 should be ForceUpdate()
Verified the claim in the binary myself with capstone before changing behaviour:
  0x4c0155:  or word ptr [ebx + 0x18], 1
+0x18 is Simulation::updateModel, a **Word** -- and the 16-bit 'or word ptr'
settles it, since simulationFlags is an LWord at +0x28 and would assemble as
'or dword ptr'.  So the authentic op is updateModel |= DefaultUpdateModelFlag,
which is exactly Simulation::ForceUpdate() (SIMULATE.h:146-147).

The old transcription wrote simulationFlags |= 0x1 instead.  Bit 0 there is
DelayWatchersFlag (SIMULATE.h:170); Simulation::Simulate then skips
ExecuteWatchers() forever (SIMULATE.cpp:461), and NOTHING clears it -- the only
ClearWatcherDelay() in the tree is in the encore path (UPDATE.cpp:215), which a
Player never takes.  So every pilot's first death permanently disabled watcher
execution on their simulation, and the replication mark the binary intended was
never set at all.  context/reconstruction-gotchas.md had flagged this exact line
as the un-audited sibling of the #12 dirty-bit class, asking for the disasm
first; that is now on the record.

LIVE VERIFICATION (scratchpad/sim3.py, 3 mechs + BT_MP_FORCE_DMG, 180s):
4 consecutive death/respawn cycles on pod3, every one completing --
  death cycle START (death #N) -> RESET at drop zone [COMPLETE]
with 'player watchersDelayed=0' after every death, and no crash.  Also
re-confirms the #57 latch fix under repeated deaths (the interleaved SWALLOWED
lines are the authentic dedup of a second notification, each followed by a
completed RESET).

Leaves a silent regression guard: the death path now logs only if it ever finds
the watcher-delay flag set again.

Rigs: sim3.py (3-mech combat + force damage), destest.py (2-node designation
proof: 18 designations, deathPending clean on all of them).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 09:04:07 -05:00