Commit Graph
305 Commits
Author SHA1 Message Date
arcattackandClaude Fable 5 6bb03aed0b Gitea #48 ROOT-CAUSED + FIXED: MFD "stray blocks / misplaced lamps" = uninitialized translation-table entries leaking pixels into other displays' bit-planes
The conviction was empirical, not theoretical: a new write-site trap
(BT_PLANE_AUDIT) in the seven L4VB16 drawing primitives logs any draw whose
color carries bits outside its port's plane mask -- the exact cross-display
corruption condition (Replace ORs an unmasked color; Or/Xor ignore the mask
entirely; And clears foreign planes).  A quiet solo session produced 15-30
leaks per minute:

    [plane] PORT 'sec' pixmap draw at(0,0) idx 217 entry=0xffffff00 mask=0x3f
    [plane] LEAK DrawPixelMap8[table] at(639,0) color=0xffffff00 mask=0x3f leak=0xff00

THE DEFECT: L4GraphicsPort::translationTable[256] is never initialized in the
ctor, and BuildSecondaryTranslation fills only the entries its BitWrangler
reaches -- 2^numberOfBits: 64 for the sec plane (mask 0x3F), 4 for the overlay
(0xC0).  Entries above that stay heap garbage.  Every draw resolves color
through this table, and the PIXMAP path indexes it with raw pixel values
0..255: the 480x640 radar background carries pixel index 217, whose garbage
entry's high bits (0xFF00 = ALL EIGHT MFD planes) were stamped into the shared
640x480 buffer -- invisible on the culprit page (the in-plane bits happened
dark) and visible as bright fragments at the same coordinates on every OTHER
display.  That is the operator's screenshot exactly: the same-position blips
on Mfd1+Mfd2 and the Heat-display block.

FIX (engine, both layers):
  * zero translationTable in the L4GraphicsPort ctor (plane-neutral default);
  * BuildSecondaryTranslation now cycles the in-plane pattern across entries
    [2^bits..255] -- high-index art degrades to its (index mod 2^bits) colour
    IN-PLANE and can never leak.  The 1995 binary ships the SAME 64-entry fill
    and survived on 6-bit art discipline; garbage is not a preservable
    behaviour, so the cycle-fill is a guarded PORT deviation (documented).

A/B PROOF: same probe, 60s -- 0 leaks after the fix (15-30/min before).
sim3 3-pod regression: zero crashes.

SECOND real defect found + fixed en route: sessions configure the CAMERA seat
first ("cameraInit" -- which includes the MISSION-REVIEW context:
configure(0, sec, 0, 0x00FF, native, rgb, mrpal.pcc), a DirectColor context
whose translation tables legitimately hold full RGB565 values spanning all 16
plane bits), and the viewpoint swap to the mech re-configured WITHOUT tearing
that tree down (btl4app's s_gaugeTreeBuilt latch treats the mech build as the
first).  The orphaned review gauges kept executing through stale DirectColor
ports.  BTL4GaugeRenderer::ConfigureForModel now overrides (ConfigureForModel
made virtual in L4GREND.h) and tears the prior entity-bound tree down before
every rebuild -- safe on first build, and the review screen rebuilds the same
way when the seat returns to the camera.

Also logged (open-questions): the review-screen PlayerStatus panels read the
compiled player at RAW BINARY OFFSETS (+0x1FC vehicle / +0x1C8 score /
+0x1C4 alive-dead box) -- the databinding trap, dormant until the review
screen runs; bridge before enabling the review.

Ruled out along the way (with evidence): the pilot-list label/erase geometry
(erase box 128x32 covers the 64x16 name rasters), the radar name labels
(view-level ClipImage clips them), PlayerStatus in-game execution ([ps] probe:
never runs in-game), and the PNAME bitmap dimensions (egg generator emits
64x16 exactly).

Why it "started with the comms feature" (#43): that wave registered the new
gauge classes with the config interpreter, letting more of the authored page
furniture parse and draw than before -- the leaking high-index pixmaps rode in
with it.  [T3 correlation detail; the leak + fix are T2 live-verified.]

Diagnostics kept (env-gated): BT_PLANE_AUDIT (the leak trap, now a permanent
regression tripwire), BT_PS_LOG, the port-level pixmap identity trap.

KB: gauges-hud.md (#48 section), decomp-reference.md (BT_PLANE_AUDIT),
open-questions.md (PlayerStatus databinding entry).  checkctx CLEAN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:20:19 -05:00
arcattackandClaude Fable 5 6f5a264835 Gitea #47 COMPLETE: the ENG-button attention FLASH is live (jam / bay fire) -- MechTech status scan + BTL4GaugeAlarmManager + lamp chain, all from the binary
The pod behaviour Cyd described -- "on a jam or bay fire the display eng button
for the system flashes" -- is authored data + a five-stage chain, now running:

  MechTech::TechnicalAssistance (@004ad33c, per frame)
    edge-scans every monitored subsystem's GetStatusFlags() 7-bit condition
    mask (TechStatusType: Destroyed 0, Damaged 1, CoolantLeaking 2,
    Overheating 3, AmmoBurning 4, Jammed 5, BadPower 6)
  -> Start/StopEntityAlarmMessage (@00436688 id 7 size 0x20 / @004366b8 id 8
     size 0x1C; broadcast @004364e4; port shape: direct
     Start/StopEntityAlarmImplementation calls on the gauge renderer)
  -> GaugeAlarmManager::Activate(alarmModel) -- alarmModel = MechTech+0x100 =
     the 'mechalrm' ModelList (id 83) -> SearchList(83, type 31) -> the baked
     GaugeAlarmStream (id 331, 11 items {condition, lampCode}), decoded:
         Destroyed      -> gotoEngineering + engCooling + engBusMode
         CoolantLeaking -> gotoEngineering + engCooling
         AmmoBurning    -> gotoEngineering + engEject
         Jammed         -> gotoEngineering + engEject
         BadPower       -> gotoEngineering + engBusMode
  -> BTL4GaugeAlarmManager::ReadGaugeAlarmStreamItem -- THE REAL BODY, from
     @004cc2fc + helpers @004cc108/148/1a0/264/27c + tables @0051cf1c..0x51d084
     (gotoEngineering 0x80 = the subsystem's QUAD-SELECT bezel button via
     lamp[aux]/mode[aux] tables; 0x81+ descend the eng-page bank; Condenser /
     Generator specials on CoolantLeaking; <0x80 = the heat-bank fixed map).
     btl4galm.cpp's old bodies were admitted fabrications and its provenance
     note ("no override body exists in the image") was wrong -- corrected.
  -> LampManager::FindLamp (@00444c80) -> Lamp::SetAlertState (@00444e64, a
     COUNTER so stacked alarms hold the flash) -> L4Lamp::NotifyOfStateChange
     emits RIO flashFast states 0x37/0x13 (== T0 L4LAMP.cpp:234-239) -> the
     pod's physical lamps AND the glass panels (PadRIO IS rioPointer there).

FOUR load-bearing defects found and fixed en route -- each independently fatal
to the feature:

  1. HeatableSubsystem's Derivation chained Subsystem directly, SKIPPING
     MechSubsystem (written before the WAVE-1 re-basing) -- so EVERY subsystem
     on both family branches failed IsDerivedFrom(MechSubsystem), which is
     exactly MechTech's monitor filter: the status scan watched NOTHING.
  2. MechSubsystem::GetStatusFlags was non-virtual (binary: vtable slot 12) --
     the scan's MechSubsystem* call bound statically to the base tier and the
     weapon bits could never surface.
  3. ProjectileWeapon::GetStatusFlags sat in a Ghidra export gap -- raw-disasm
     @004bbf88: base | 0x20 (weaponAlarm==5 Jammed) | 0x10 (linked bin
     cookOffArmed@0x18C AmmoBurning).  The port had "defer to base".
  4. Mech::Reset's respawn sweep blanket-cast every roster entry to
     MechSubsystem and called RespawnRepair -- wrong for the Subsystem-level
     entries (MechTech 0xBDC, SubsystemMessageManager 0xBD3).  On MechTech the
     recon damageZone slot lands on its subsystemMonitors chain head: NULL
     while the scan was broken (fix #1's bug MASKED this one), but the moment
     the monitors populated, RespawnRepair virtual-called through a
     SubsystemMonitor as if it were a DamageZone -> load-time crash in
     StateIndicator::SetState (caught with cdb; call [edx+14h] on code bytes).
     The sweep now filters IsDerivedFrom(MechSubsystem) before the cast.

Also: GUID identities pinned -- 0x50f4bc = PoweredSubsystem::ClassDerivations
(via @004b1208 = its TestInstance; btl4gau2's two "Generator" comments were
wrong, swept), 0x50fb60 = Generator's.  A shadow-field instance documented for
the deferred de-shadow: MechSubsystem::damageZone re-declares the PUBLIC engine
Subsystem::damageZone (SUBSYSTM.h:159) -- mechtech's naive `sub->damageZone`
read the never-written engine member (0 monitors again); now reads the recon
member via GetDamageZoneProxy().  Logged in open-questions.

Wiring: BTL4GaugeRenderer now constructs + assigns the BTL4GaugeAlarmManager
(the base ctor NULLs it and Activate Check()s it); MechTech's Report*/
StatusMessageSink stubs are real; alarmModel confirmed baked (= 83) at runtime.

VERIFIED LIVE (scratchpad/lampflash.py, BT_LAMP_LOG chain trace):
    [techstat] MechTech id 32 monitors 29 subsystems, alarmModel 83
    [techstat] LRM15_1 condition 4 SET (alarmModel 83)      <- bay fire armed
    [galarm] condition 4 code 0x80 -> lamp 0xd mode 0x1 FLASH
    [lamp] 0xd <- 0x37  (FLASHING)                          <- the select button
    [galarm] condition 4 code 0x85 -> lamp 0xb mode 0x4 FLASH   <- engEject
    [techstat] LRM15_1 condition 4 CLEARED                  <- detonation clears
    [techstat] AmmoBinLRM15_1 condition 0 SET               <- bin Destroyed
Regressions: baytest PASS (bay fire kills), baypurge PASS (purge extinguishes),
sim3 3-pod brawl PASS with ZERO crashes (6 kills, organic heat-route bay fires,
respawns clean through the new sweep filter).

Env: BT_LAMP_LOG ([techstat]/[galarm]/[lamp]).  KB: gauges-hud (the flash
section), decomp-reference (the GaugeAlarm closure + tables + GUIDs),
open-questions (built-note + the de-shadow deferred item), btl4gau2 comment
sweep.  checkctx CLEAN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:04:34 -05:00
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 f3bdb3b85a Searchlight + ThermalSight ToggleLamp WIRED (#61) -- and a swapped table attribution ROOT-CAUSED, retracting a false "1995 latent bug"
Both classes' Receiver::MessageHandlerSet were default-constructed blackholes
(input-path audit systemic cause #1): entryCount 0, no parent chain, Find()
returns NullHandler for every id, Receive drops silently.  The new unhandled-
message trace printed it on the first press:

    [btntest] PRESS 0x14 at poll 400
    [msg] UNHANDLED: Searchlight has no handler for message id 3

Each class now has a GetMessageHandlers() function-local static chained to
PowerWatcher's (which name-resolves through HeatWatcher/MechSubsystem to
Receiver's empty ROOT set, so id 3 does NOT collide with HeatSink's
ToggleCooling), plus a correctly-typed forwarder -- Receiver::Handler is
void(const Message*) while the decomp shape is Logical(Message&), so a cast
would be UB that merely happens to work on x86 __thiscall.  DefaultData
re-pointed off the dead empty sets.  MessageArg now reads the NAMED
ReceiverDataMessageOf<int>::dataContents with a static_assert locking it to the
binary's message+0xC (was a raw offset read -- databinding rule).

ROOT CAUSE of a long-standing misattribution: @004b860c is **ThermalSight's**
ToggleLamp (table @0x51120C), NOT Searchlight's.  The two TUs emit parallel
shared-data blocks with identical stride (msg entry, +0x5C attrs, +0x84
Performance triple); what pins each entry to its TU is that "ToggleLamp" is NOT
pooled across them -- two copies exist, each emitted immediately before its own
class-name string ("Searchlight"@0x51144B, "ThermalSight"@0x511475).
[T1: reference/decomp/section_dump.txt:69661-69711]

Searchlight's own handler is @004b838c, which sits in a Ghidra EXPORT GAP (#60).
RECOVERED by raw disassembly of content/BTL4OPT.EXE (scratchpad/dis838c.py, the
EjectAmmo technique) -- and it corrected two things I had inferred wrong:
  * NO ControlsAllowLights/+0x25C novice gate.  Searchlight tests the press
    alone; that lock is ThermalSight-only.  A NOVICE pilot CAN work the lamp.
  * `or word ptr [this+0x18],1` sits AT the jle target -> the graphics-dirty bit
    (updateModel == ForceUpdate(), per #59) is raised UNCONDITIONALLY.

Consequence: the "ORIGINAL 1995 LATENT BUG -- the searchlight can never light"
claim is RETRACTED.  It compared Searchlight's Performance (@004b841c, reads
requestedOn@0x1E0) against ThermalSight's toggle (0x1DC) -- two different
classes -- and so invented a missing 0x1DC->0x1E0 bridge.  Every sibling toggles
the field its own Performance reads.  The searchlight DID light in the arcade,
the searchlight->fog swap was NOT inert there, and building PullFogRenderable is
FAITHFUL rather than a designer-intent deviation (the 2026-07-13 "left as-is"
decision is void; the sim needs no repair).

Verified live, real click seam (BT_BTNTEST):
  0x14 -> [light] requested ON -> reported lightState 0 -> 1     (lamp lights)
  0x12 -> [light] thermal sight requested ON -> thermalActive 0 -> 1
UNHANDLED lines for both classes gone; 40 LNK2019 unchanged (the pre-existing
CreateStreamedSubsystem + Entity__SharedData::DefaultData families only).

Swept the misattribution out of searchlight.cpp/.hpp, thermalsight.cpp/.hpp,
hud.cpp:282 (it had claimed @00511180/@004b838c as HUD's), btplayer.cpp's +0x25C
consumer list, context/{decomp-reference,subsystems,rendering,open-questions,
pod-hardware,experience-levels}.md, docs/{GLASS_COCKPIT,INPUT_PATH_AUDIT}.md.
checkctx.py CLEAN.

Still open (documented, not fixed): ThermalSight has no visible IR effect
(ToggleGlobalThermalVision is a marked no-op, pvision unported, IsLocallyViewed
returns False); ThermalSight publishes "LightState"->0x1D8 where the binary has
"LightOn"->0x1D8 and "LightState"->0x1E0; Searchlight's commandedOn@0x1DC has no
identified role and measured 21 live, hinting our SubsystemResource +0x28 differs
from the binary's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 10:46:59 -05:00
arcattackandClaude Opus 5 62513b2f8a Gitea #53: generators can be switched off -- ToggleGeneratorOnOff reconstructed from @004b1ed0
TWO missing pieces, both now in place.

1. Generator had NO handler set of its own, so every message aimed at it hit a
   default-constructed blackhole.  Proved empirically with the new
   unhandled-message trace by pressing button 0x1A:
     [msg] UNHANDLED: Generator has no handler for message id 4 -- silently dropped
   Added Generator::GetMessageHandlers() chained to **HeatSink**, deliberately NOT
   PoweredSubsystem: id 4 there is SelectGeneratorA (the weapon-side generator
   SELECTION that already works), so chaining to it would have made these four
   buttons invoke the WRONG function instead of doing nothing.  HeatSink owns id 3
   (ToggleCooling), leaving id 4 free.

2. ToggleGeneratorOnOff had no body at all (0 references in game/).  Transcribed
   instruction-for-instruction from @004b1ed0:
     - call @004ac9c8 -> BTPlayerRoleLocksAdvanced: a NOVICE pilot cannot use it
     - [msg+0xc] <= 0 -> press only, ignore the release
     - ON  : startTimer = 0; if heatAlarm(@0x184) < 1 also stateAlarm -> 0
             (Starting, so it spins up -- a heat-faulted generator keeps its fault
             level); generatorOn = 1, coolantAvailable = 1, coolantFlowScale = 1.0
     - OFF : stateAlarm -> 1 (GeneratorIdle); generatorOn = 0,
             coolantAvailable = 0, coolantFlowScale = 0 (its coolant share is
             released back to the loop)
   So taking a generator off line idles it, drops its coolant draw and stops it
   feeding its taps -- the authentic heat-management trade from the manual.

VERIFIED LIVE (BT_BTNTEST=0x1A,400,900 through the real click seam):
  [btntest] PRESS 0x1a at poll 400
  [gensel] GeneratorA generator OFF LINE (stateAlarm 1, coolantFlowScale 0)
and the UNHANDLED warning for Generator is gone.

Answers Cyd's report from two playtests ('still cannot turn of gennies').  Note
this is the ON/OFF control -- generator SELECTION (which generator a subsystem
draws from) is a different message and already worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 10:15:25 -05:00
arcattackandClaude Opus 5 33ca99eb69 Input-audit cause #1: revive the Avionics power bank + add the unhandled-message trace
THE CLASS OF BUG.  A default-constructed Receiver::MessageHandlerSet is a total
blackhole: entryCount 0 and NO parent chain, so Find() returns NullHandler for
every id and Receiver::Receive dropped the message with no log, no counter,
nothing.  docs/INPUT_PATH_AUDIT.md ranked this systemic cause #1; running its own
lint finds SEVEN such sets (Sensor, Searchlight, ThermalSight, Gyroscope,
MechTech, SubsystemMessageManager, Torso).  It is also why generators cannot be
switched off (#53) -- same shape.

FIX 1 -- Sensor / Avionics (the big one).  The streamed mappings aim 108 records
across the 18 mechs at it: the MFD2 ENG1 power bank, 6 clickable buttons plus keys
F5-F9 (route Avionics power to generators A-D, gen mode, coolant cut).  All
silently swallowed.  Sensor needed NO handlers of its own -- ids 4-8 already live
on PoweredSubsystem, which chains HeatSink for id 3, covering the whole 3-8 range
those buttons send.  It only ever needed to CHAIN, which is exactly why the
byte-identical bank on ENG2 works (Myomers chains, myomers.cpp:88-100; Sensor did
not).  Added Sensor::GetMessageHandlers() with the same function-local-static
idiom and pointed DefaultData at it instead of the empty set.

VERIFIED LIVE (scratchpad/avionics.py -- pages MFD2 and presses the bank):
  [gensel] Avionics -> GeneratorA (tapped)   ... B, C, D
  [gensel] Avionics mode -> 2
i.e. Avionics now behaves exactly like Myomers.  24 handler hits, 0 unhandled.

FIX 2 -- the standing guard.  Receiver::Receive now logs once per (class,
message id) pair when no handler is found: '[msg] UNHANDLED: <class> has no
handler for message id N -- silently dropped (is its MessageHandlerSet chained?)'.
Once-per-pair on purpose, since some ids broadcast every frame.  This one line
would have printed Avionics, Searchlight, ThermalSight, crouch and all four
generator buttons on the first boot instead of costing us months of player
reports.  Uses SharedData::derivedClasses -> Derivation::className.

DEFERRED, with reasons (not guessed at): Searchlight's ToggleLamp body exists but
is unreachable -- it needs a correctly-typed forwarder (Receiver::Handler is
void(const Message*); ToggleLamp is Logical(Message&), so a cast would be UB that
happens to work on x86) AND an id check, because its id 3 collides with HeatSink's
ToggleCooling and I have not traced whether PowerWatcher's chain reaches HeatSink.
#53 needs more than chaining: ToggleGeneratorOnOff @004b1ed0 has no body at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 10:05:57 -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
arcattackandClaude Opus 5 2dd4ee3910 ROOT CAUSE: target designation wrote a Mech* into deathPending, disabling respawn (#57/#48)
MEASURED our compiled BTPlayer layout (new one-shot [layout] ctor diagnostic):
  sizeof=652 (0x28c)  killCount@0x270  pad_0x280@0x274
  objectiveMech@0x278  deathPending@0x284

The three target-designation sites wrote RAW at pilotArray[0]+0x284 intending the
BINARY's objectiveMech.  On our object 0x284 is deathPending -- the death-cycle
latch.  So EVERY target designation (the Comm bank 0x30-0x37, or the typed
t/y/u/i/o keys) stored a Mech pointer into deathPending, and a non-zero
deathPending makes VehicleDeadMessageHandler take its dedup early-return, which
skips the warp, ++deathCount, the PLAYER_DEAD record AND the drop-zone hunt.

=> designating a target silently disabled your respawn for the rest of the
   process.  That is #57's missing first cause: it explains David's very first
   death being swallowed with no prior death, and the 3-of-3 swallowed deaths in
   the final round.  It also explains the operator's persistent observation that
   the trouble began when the comms panel went live -- before the pilot list
   populated, there was nothing to designate.
   And the designation never worked either: the value never reached
   objectiveMech (input-audit finding #1).

FIX: all three sites now use named-member bridges in the complete-BTPlayer TU --
BTPilotSetObjectiveMech / BTPilotObjectiveMech / BTPilotVehicle / BTPilotPosition
/ BTVehicleDestroyed.  Also retires the raw +0x1fc (playerVehicle) reads and the
raw +0x100 position reads in ChooseNearestPilot, and routes its destroyed-check
through the real Mech predicate instead of the Is_Destroyed stub.

LAYOUT LOCKS: static_asserts on objectiveMech@0x278, deathPending@0x284,
killCount@0x270 and sizeof==0x28c, in the friend fn BTPlayerLayoutSelfCheck, so
a member shift fails the BUILD instead of silently clobbering the latch again.

Rigs: scratchpad/commstest.py (drives designation + captures panels),
valvetest.py, lampgallery.py (428-bitmap gallery for #48 visual search).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 08:33:22 -05:00
arcattackandClaude Opus 5 cb50a1d491 Gitea #48: add BT_NO_PILOTLIST=1 -- make the operator's comms hypothesis decidable
The operator reports consistently, across two sessions, that the MFD artifacts
began the night the Comm pilot list was enabled (#43) and were never seen before.
Four static theories of mine died -- plane masks (all 7 primitives invert
correctly), L4GraphicLamp (never instantiated, dead code), the sec-port lamps
(wrong plane AND wrong colour, operator confirmed on sight from the decoded art),
vertBar/VertTwoPartBar (clamps every value, sets its cursor per draw).  And no
rig reproduces it: not 2 pilots, not 5 pilots on last night's exact roster, not
autofire + repeated valve moves.

So stop theorising and make the field observation decidable: BT_NO_PILOTLIST=1
makes PilotList::Execute draw nothing, everything else untouched.
  artifacts gone with it / present without => confirmed, and it is the workaround
  artifacts either way                     => not the pilot list

Diagnostic only, default OFF.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 08:22:08 -05:00
arcattackandClaude Opus 5 5174c638ce K/D scoreboard ROOT-CAUSED (#45) + respawn latch fix (#57) + plate stage ops (#58)
FIXES (compile clean; exe link pending -- a game client still holds btl4.exe):

#57 respawn latch: deathPending was released in ONE place (btplayer.cpp:343) and
only if a LATER re-post happened to find the mech alive -- so if the +5s re-post
landed before the drop-zone reply, or never arrived, the flag stayed set while
the pilot respawned and fought on, and their NEXT death hit the dedup at :391
and was swallowed whole (no warp, no tally, no hunt) for the rest of the
process.  Tonight 3 of 3 deaths in the final round were swallowed, each by a
player who had respawned successfully earlier in the same process.  Clear moved
to the actual completion point (after Mech::Reset at the drop zone) + the four
abandon paths that also stranded it.  Cycle logging promoted to always-on
(START / RESET / SWALLOWED warning) + a RESPAWN matchlog record.

#58 reticle callsign plate drew as a SOLID RECTANGLE on some maps: my
dpl2d_DrawTexturedRect bound a texture but never set COLOROP/ALPHAOP, and
L4D3D.cpp:1085 sets both to SELECTARG1 from TFACTOR (ignores the texture, fills
with a constant).  Which state was live depended on what the 3D pass drew last
-- hence 'depends on the level'.  Now sets MODULATE texture x diffuse for both
channels and restores.  Latent sibling noted (CameraHUDDrawQuad, same omission).

ROOT-CAUSE DOC (no code): docs/KD_SCOREBOARD_PLAN.md.  Headlines:
 - the port reads DEATHS from the WRONG FIELD: the binary's PilotList draws
   +0x27c/+0x280; we labelled +0x280 'pad_0x280', never write it, and
   substituted the engine's Player::deathCount (+0x200, the respawn identity
   number seeded to -2) -> remote rows read a clamped fake 0 by construction;
 - BTPlayer::VehicleDeadMessageHandler @0x4c05c4 is MISSING from our decomp
   export (functions_index.tsv jumps 4c052c -> 4c083c, verified) -- that silence
   is what produced the 'pad' belief.  An absent function is not evidence of an
   absent behaviour;
 - neither counter rides an update record, so divergence never self-heals and
   bystanders show 0/0 all mission;  BTPlayerCountObservedDeath is unreachable
   dead code;
 - 'a kill I didn't earn' = last-hitter-takes-all via lastInflictingID, and
   COLLISIONS count (5 of 47 deaths had a type=0 killing blow);
 - the phantom kill is authentic 1995 (the kill handler bumps the victim's
   killCount too);
 - btplayer.cpp:453 does simulationFlags |= 0x1 where the binary does
   ForceUpdate() -- bit 0 is DelayWatchersFlag and nothing clears it, so the
   first death of any pilot permanently disables ExecuteWatchers on that
   player's simulation.  Filing separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 01:50:07 -05:00
arcattackandClaude Opus 5 23229e573e Reverse thrust was dead on the desktop (user report): wire the 0x3F throttle-handle button
LALT -> button 0x3F was bound in both binding layers and PadRIO pushed the RIO
ButtonPressed event correctly, but nothing ever set the mapper's ReverseThrust:

 - the authentic route is BTL4.RES 'L4' streamed record [2] (Button Throttle1
   0x3F -> attr 6 ReverseThrust@0x124), but our streamed BUTTON mappings are
   not consumed at all -- MechControlsMapper::AddOrErase is still the
   unreconstructed Fail stub, so the event never reaches the attribute;
 - the ONLY writer of reverseThrust was the keyboard bridge's
   'key_throttle < 0' test, which is bypassed whenever a RIO is operational
   (the pad RIO always is, so the bridge is OFF on the desktop) AND is
   unreachable regardless, because L4PADRIO clamps the Throttle channel to
   [0,1].  Net: Alt did nothing and the flag was re-zeroed every frame.

FIX: publish the 0x3F hold state from PadRIO::EmitButton -- the one chokepoint
every desktop source funnels through (keyboard, gamepad, DirectInput joystick,
glass-panel clicks via SetScreenButton) -- and apply it in InterpretControls
gated on BTPadRIOActive() so real pod hardware is untouched (there the streamed
mapping owns the flag).  Marked [T3]; delete once streamed button mappings land.
Reverse is a HOLD (manual: hold the red throttle button, release = forward).

Verified with scratchpad/revtest.py (keybd_event injection, BT_KEY_NOFOCUS):
forward = rev=0 dmd +61.501; LALT held = rev=1 dmd -61.501; release edge logged.

KB: pod-hardware.md now records the streamed-button-mapping gap + this seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 19:19:21 -05:00
arcattackandClaude Opus 5 6c3fca2f67 Gitea #38 ROOT CAUSE + FIX: mech paint lost on geometry re-load (view toggle / respawn)
Found from the user's live 2-node demo: a Crimson MadCat went GREY in its own
view after a V (inside/outside) toggle, with NO new [paint] or
MakeMechRenderables line in the log -- so no rebuild, just a re-parse.

ROOT CAUSE: the per-pilot colour/badge/patch is applied by rewriting MATERIAL
NAMES while a BGF parses, and only while the substitution callback is installed
(SetupMaterialSubstitutionList .. TearDown, bgfload.cpp:15-18).
BTL4VideoRenderer::ApplyViewSkeleton re-parses every shown segment BGF on each
view toggle AND on respawn (that is what its fresh graphic-state read is for),
but did so OUTSIDE that bracket -> raw %color% placeholders -> unpainted
materials -> grey.  Nothing caches the geometry (d3d_OBJECT caches only
textures), so every call re-parses.  This is #38's mechanism: 'colors not
preserved on respawn' was never a replication or teardown bug.

FIX: re-install the substitution list around the reload, reusing the serial the
mech was BUILT with -- SetupMaterialSubstitutionList advances the global %serno%
per call, so a naive re-bracket would stamp a different serial and still resolve
the wrong names.  MechRenderTree gains paintSerno (captured before Setup at
build); ApplyViewSkeleton installs/restores around the loop and logs it.

Rig-verified: the crimson MadCat stays crimson (yellow patch intact) across
repeated inside/outside toggles; each toggle now logs '[paint] color=red
serno=0' + '(paint serno 0)'.

ALSO -- corrections to yesterday's #44 name plate from the adversarial pass:
 - kPlateARGB 0xFF808080 -> 0x80FFFFFF.  BMAP.BMF tag 0x0027 is
   dpfB_MATERIAL_OPACITY_TAG [T0 libDPL/dsys/PFBIZTAG.H], NOT a diffuse colour:
   the materials carry NO colour tag at all, so the plate is the unmodulated
   callsign raster at ~50% opacity, not a grey label.  (Two independent
   workflows converged on this.)
 - the plate's K = 1/2.8 is NOT the hotbox's constant (2.8145) -- de-unified.
 - the Lock attribute is HUD attr id 10, an int/Logical*, not a Scalar*.
 - the PNAME pair split is the V half, not U (our per-player texture is one
   128x32 cell, so the port quad samples v 0..1).
 - retracted bgf-format's 'tag 0x0027 likely SPECULAR [T4]'.

KB: new reconstruction-gotchas section on the whole bug class (load-time-only
state must be re-installed on every RE-load, incl. the serno trap).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 19:06:04 -05:00
arcattackandClaude Opus 5 35c750dc7c Gitea #44: the reticle TARGET NAME PLATE (PNAME1-8) -- the target's callsign under the crosshair
Reconstructed the last deferred piece of the 1995 reticle, decoded from the
Execute disassembly @004cdcf0 [T1]:
 - selection: target_mech+0x190 (owning BTPlayer) -> player+0x1e0
   (playerBitmapIndex), 1-based into the mesh table at this+0x2e8
 - placement: re-placed every frame the aim moves -- scale 0.12 uniform,
   translate (K*retPos.x, K*retPos.y - 0.08, -1.0), K = 1/2.8 (the x87 long
   double @0x4cee64) -- so the label TRACKS THE AIM POINT, not screen centre
 - visibility: the LOCK attribute (this+0x184, cached +0x188) AND an owning
   player; reticle-off / simple-X also hide it
 - art: the plate quad UV-addresses a shared 128x64 bitslice texmap whose
   texels the pod OVERWROTE each mission with the egg's callsign rasters
   (original source commented out at L4VIDEO.cpp:5682-5710) -- the baked
   'PLAYER n' art in BMAP.BSL is only the shipped fallback.  Material colour
   is a neutral (0.5,0.5,0.5): grey-white, not phosphor green.

Port: same geometry in reticle units (0.224 below the aim point, 0.336x0.084)
drawn as the target's egg callsign texture through the reticle's own MapX/MapY
mapping, so it follows the world view in every layout and BT_SHOT captures it.
New: dpl2d_DrawTexturedRect, BTReticleTargetPlate, BTGetPlayerNameTexture.

Also: kRetCaret corrected 0.02f -> 0.025f (_DAT_004cd7f4 read from the binary;
the old value was a T3 guess, 20% small).

KB: gauges-hud + open-questions corrected (the chain was filed as deferred and
mis-attributed to 'the 3D marker'); TWO PNAME consumers now distinguished (this
plate, and CameraShipHUDRenderable's ranking window which shipped 2026-07-18);
new bgf-format section on the plate atlas.

Rig-verified: 2-node relay, BT_GOTO=enemy -> '[hud] name plate: bmp=2 tex=1'
and 'Boreas' rendered under the crosshair on the locked target.  Solo clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 18:36:02 -05:00
arcattackandClaude Opus 5 b7107b1020 Gitea #43: the Comm pilot list now shows EVERY pilot -- three stacked fixes
1. Roster count used the binary's raw entry+0x29&0x40 read on our compiled
   objects (databinding trap) -> garbage latched pilotCount=1.  Decoded: the
   flag is Player::NonScoringPlayerFlag (bit 14 = Entity::NextBit); both
   loops now use IsScoringPlayer().  Also closes the pilotIDs[1] overrun.
2. The build-once latch races async replicant arrival (rig-proven 1-then-2):
   rebuild on scoring-census change [T3 accommodation, demand-latch
   precedent]; also un-dangles departed peers.  Forensic: '[score] pilot
   roster built: N'.
3. Name icons were a NULL stub + zeros blank authentically -> rows invisible.
   Wired BTPilotNameBitmap (playerBitmapIndex -> Mission small callsign
   raster, the radar-label chain); replaces the raw pilot+0x1e0 key.

Rig-verified: Aeolus + Boreas rows with callsigns on both nodes incl. the
staggered-start race; solo unregressed (1 scoring pilot).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-24 17:51:52 -05:00
arcattackandClaude Opus 4.8 a35cef4c01 Issue #42: doubled MFD joystick -- restore the dropped MoveToAbsolute(0,0)
The binary's ConfigMapGauge dirty-redraw (@004c6f1c) is SetColor(0xFF)
-> MoveToAbsolute(0,0) -> DrawBitMapOpaque; the transcription dropped
the reposition.  The FIRST draw worked by accident (fresh view cursor
at the origin); every forced redraw -- the weapon panel's DISPLAY
round-trip (main -> ENG DATA -> main, BecameActive sets dirty) --
blitted btjoy.pcc at the state-lamp loop's leftover cursor (0xc, 0x55),
painting an offset ghost joystick over the original on every MFD.

Repro pinned live by the operator (initial draw correct; doubles on
the DISPLAY round-trip, every MFD).  LIVE-VERIFIED fixed by the
operator: round-trips redraw cleanly.  NOT a regression -- present
since the joystick went live (89b4f53; bisect-confirmed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 09:25:12 -05:00
arcattackandClaude Opus 4.8 f7daf03507 Crash self-report + round-completed latch + PDB archiving
CRASH SELF-REPORT (the #35/#41 heisenbug hunt): every unhandled
exception now logs its code, address, and an EBP-walked stack as
module-relative offsets (btl4+0xNNNN) into BT_LOG before dying --
any field crash on any machine hands us a resolvable stack.  The
walker is per-dereference guarded (a corrupt chain truncates).
BT_CRASHTEST=1 exercises the path end-to-end (verified: forced AV
logs code/addr/5-frame stack).  mkdist archives each build's PDB
next to its zip so offsets resolve per distributed version.

Context: a flaky release-only crash in the mech build path surfaced
during #38 rigs -- it moves with build layout, spares neither peer,
and masks under a debugger heap (13 clean cdb runs vs 2/3 crashes
without).  A real latent memory bug; the self-report will catch it
wherever it strikes next.

ROUND-COMPLETED LATCH (issue #38, awaiting verification): relay-mode
clients refuse RunMission after a round has ended -- the lobby-loop
relaunch owns the next round (in-process round 2 rebuilt every
player/mech entity on stale globals; matchlog caught player=3:1->3:36
in one generation).  Latch sets ONLY when the stop ends a RUNNING
mission (a stray pre-mission StopMission must not poison round 1 --
the first cut ate a legit launch, caught + fixed in rig).  Regression
verification pending (contaminated by the heisenbug above).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 08:51:50 -05:00
arcattackandClaude Opus 4.8 ac7f0cb13a Issue #38 (partial): egg camo-color validation + paint forensics
Respawn-paint investigation: 10 forced respawns on the i22 rig showed
paint STABLE across in-place respawns on both master and replicant --
the in-place reset does NOT repaint.  Two real findings landed:

1. 'Red' is NOT a camo color (the vehicle table authors Crimson=red for
   camo; Red exists only as a PATCH color) -- an egg with color=Red
   paints the mech silent gray from first spawn (the engine tolerates
   the table miss by design).  The relay now WARNS at egg load on any
   color= outside the camo set; our MPSHORT test egg had exactly this
   bug (fixed).
2. [paint] log now carries the entity id, so the one-time-observed
   nondeterministic video-model rebuilds (sernos 2-5 with swapped
   colors, run 1 only) will be attributable when they recur.

The reported color/style change on respawn remains unreproduced in the
deterministic rig -- needs the observer detail (whose mech, which view)
from the next field sighting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 00:22:42 -05:00
arcattackandClaude Opus 4.8 3d8bfdeba6 Hardening: unknown vehicle= in the egg fails loud instead of segfaulting
Found during the #35 (Owens laser crash) hunt: FindResourceDescription
returns NULL for an unknown vehicle name and release builds (Check
compiled out) segfaulted on Lock() -- a typo'd or corrupted vehicle
tag in a served egg would crash EVERY pod at drop.  Now logs
'[spawn] FATAL: unknown vehicle' + Fail() with a clean exit.
Verified: vehicle=nosuch -> message + exit 127 (was a segfault).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 00:10:04 -05:00
arcattackandClaude Opus 4.8 d3ede2e635 Issue #33/#34: relaunch-storm + duplicate-seat fixes (pod-grade session stability)
Night-2 capture: one flapping pod cascaded 3 ROUND ABORTs in 8s; every
client relaunch-churned (~1 gen/s) until the D3D device-creation 20s
deadline fired on every machine ('could not connect direct 3d'), and
claim-less restarts minted duplicate roster seats.

Console (relay):
- Round abort clears stale console-conn ACK flags and HOLDS the next
  egg release for an 8s settle window (_tick_settle re-releases when
  quiet) -- the abort->instant-re-release->drop->abort cycle is broken.
- HELLO from a stale identity gets a REJOIN reply (identity resync via
  the seat-request path, 5s/IP rate limit) instead of a bare drop.
- STATIC SEATS: seats remember (IP + callsign); a claim-less restart
  from the same machine+name RECLAIMS its own seat (displacing its
  zombie beacon) instead of minting a duplicate -- the pod contract:
  same box, same seat, always.
- Operator local instances log with BT_LOG_APPEND (the night-2 crash
  loop left a 2-line file; generations now leave a full trail).

Client:
- Relaunch storm damper: a generation that lived <15s sleeps 5s before
  respawning (storm decays to a gentle cadence; normal multi-minute
  round relaunches untouched; menu clicks explicitly never delayed).

Rig-verified: normal round + mid-MISSION drop = no abort (correct);
mid-LOAD kill = exactly ONE abort + 8s hold + seat reclaimed + no
cascade.  The night-2 'host 7' identity-derivation subtlety remains
under forensics (append logs + first-breath line will capture it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:35:25 -05:00
arcattack 26439c2e1a Boot forensics: first-breath log line + generation-safe bat logging
Field 2026-07-23 (second night): a player's join.bat failed 3x in a row
with a 0-byte join.log -- indistinguishable from 'never ran'.  Two
causes of evidence loss fixed:

1. The first flushed log line now happens the INSTANT the log opens
   (version, pid, args, first-process vs relaunched-generation).  An
   empty log after this build means the exe was killed before WinMain
   -- an external cause (antivirus/loader), not ours.
2. The menu->game relaunch chain truncated join.log each generation
   (BT_LOG_APPEND was unset in the player bats): the crashed
   generation's evidence was erased by the next attempt.  The bats now
   delete the log once per bat run and append across generations.

Also explains the confusing 'The game has exited' bat message on
SUCCESSFUL launches (the menu process exits after relaunching the game
detached) -- the log now records the whole chain.
2026-07-23 21:33:27 -05:00
arcattackandClaude Opus 4.8 c2ee7299b2 Generic joystick / HOTAS / pedals support: DirectInput 8 layer + capture wizard
Tester ask: configure non-Xbox controllers.  New L4JOY layer (DI8):
enumerates every non-XInput game device (up to 4), DIJOYSTATE2 polling
with range normalization, reacquire-on-loss, 3s hot-plug re-enum.
XInput devices are excluded via the documented RawInput IG_ VID/PID
check (live-verified skipping a real XBOX360 pad -- no double-feed).

bindings.txt grows joydev/joyaxis/joybutton/joyhat rows (device slots
by name substring or ordinal; axes X..RZ/SL0/SL1 with invert/slew/
deadzone; hats as 4-direction buttons with diagonal chords).  PadRIO
runs them through the existing channel/event machinery -- per-binding
edge arrays release automatically on detach (the #24 rule), and a
direct throttle axis maps full lever travel onto the 0..1 channel
while the gait detent still snaps a lever parked in the walk/run dead
band.

BT_JOYCONFIG=1 (joyconfig.bat) runs a console capture wizard: move
each control when prompted (stick/twist/throttle/fire), invert derived
from the move direction, hat look cluster auto-added, output written
between markers in bindings.txt with the rest of the file preserved.

Legacy L4DINPUT DIJoystick (L4CONTROLS=DIJOYSTICK) untouched.
Verified: grammar accept/reject per line, XInput-exclusion live,
30s in-mission no-device polling clean.  Awaiting a tester with real
stick hardware for axis verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:36:32 -05:00
arcattackandClaude Opus 4.8 86e387b6c7 Valve forensics + KB: the boost mechanic is real; the field cook-off was a detent question
Measured A/B/C (Blackhawk, sustained autofire, 150s soaks): SRM6 on
Condenser2 plateaus ~531 and COOLS at share 0.91 (boost 2), climbs to
the jam band at balanced (0.167), and runs at the 2000 failure line
starved (0.018).  MoveValve reruns the redistribute every press;
veteran passes the novice guard.  The field 'boosted loop 2 still
cooked' therefore means the valve was NOT at the 50 detent during the
fight -- the 1->5->50->0 cycle turns the loop OFF one press past max.
MoveValve presses now log always-on ('[valve] <name> -> detent N' +
a CLOSED warning) so the next field session records the detents.

Also: solo mission clock verified live (60s egg self-ends,
'[mission] solo game clock expired' -> RunMissions returns).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:59:04 -05:00
arcattackandClaude Opus 4.8 2edd1b5363 Issue #31: wire the EJECT button (EjectAmmo msg 0xb @004bb9b8) -- the in-mission unjam
Disasm-faithful transcription of the export-gap handler onto
ProjectileWeapon (MESSAGE_ENTRY id 0xb, the #19 pattern).  PRESS arms
the ~3s UpdateEject countdown (bin alarm -> Ejecting(3); gate 2 parks
the weapon offline mid-eject -- authentic).  HOLD to completion =
DumpAmmo jettisons the bay -> NoAmmo.  TAP (release early) cycles ONE
round out via FeedAmmo and returns the weapon to Loading -- clears a
jam at the cost of a round (and recovers a FailureHeat 7 while rounds
remain).  Binary structure kept exactly incl. the press-no-bin
fall-through into the release block.

AmmoBin: Ejecting(3)/Ejected(4) alarm levels decoded + named
SetAmmoState accessor (databinding rule).  Always-on forensics:
'[weap] EJECT tap' + '[ammo] bay DUMPED overboard (N rounds)'.

Rig-verified live (BT_EJECTTEST=1 / =hold on LRM15): taps eject one
round each and return the weapon to service (15->8 across cycles);
hold dumps all 16 -> NoAmmo.  Awaiting a human press of the actual
ENG-page button on a jammed weapon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:49:11 -05:00
arcattackandClaude Opus 4.8 225fe43f0c CORRECTION: EJECT tap IS the in-mission unjam (EjectAmmo @004bb9b8 decoded)
Old-timer testimony (tap = eject one round, hold = eject the bay, eject
clears a jam) checked against the binary: raw disasm of the export-gap
handler @004bb9b8 confirms it byte-for-byte.  PRESS arms the ~3s
UpdateEject countdown (hold-to-completion = DumpAmmo whole bay ->
NoAmmo); RELEASE before completion (TAP) ejects ONE round (@004bd4f4)
and, with ammo remaining, sets weaponAlarm 3 + restarts the reload --
the weapon returns to service.  A tap even recovers a FailureHeat
level-7 launcher while rounds remain (empty-bin gate 2 re-pins 7 only
when the bay is dry).

The earlier 'jams are mission-permanent / no unjam exists' claim read
only the HOLD path -- corrected + swept: projweap.cpp case-5/jam-log/
CheckForJam comments, players/README.txt, decomp-reference.md
(message-tables entry), open-questions.md (state-7 recovery note).
Gitea #31 updated; wiring the handler now restores the pod's actual
jam-recovery mechanic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:21:06 -05:00
arcattackandClaude Opus 4.8 cbebb0f1c1 Audio source-pool census + fix the unbuilt jam-log literal
Field report (playtest night + operator): audio cutting in and out
toward the end of the match -- the classic shape of source-pool
pressure (sources release only at entity teardown; long matches
accumulate looping occupants like wreck burn/smoke until transients
lose the voice fight; OpenAL mixing capacity is finite).  L4AUDRND now
tracks live/deleted/failed sources: a 30s '[audio] source census' line
plus an ALWAYS-logged line on every acquisition failure (the smoking
gun).  Soak test next to confirm the leak/occupation slope.

Also: the ffa3933 jam-entry log had a raw newline in a string literal
(committed unbuilt -- process violation, caught by this build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:59:39 -05:00
arcattackandClaude Opus 4.8 cc6639d952 projweap: correct the msg-2 jam claim (it CAUSES jams, decomp-verified)
HandleMessage @004bcabc: message 2 -> SetLevel(weaponAlarm, 5) -- a
malfunction INDUCER, not an unjam; the state-machine comment had it
backwards.  There is NO in-mission unjam in the binary (jams clear only
at ResetToInitialState = mission reset/respawn), and the EJECT cycle is
the anti-cook-off ammo jettison (DumpAmmo -> NoAmmo), not an unjam.
The old 'unjam delay' follow-up suggestion is retired as unauthentic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:33:37 -05:00
arcattackandClaude Opus 4.8 ffa39335e0 Weapon jam/failure/dry transitions always log (field diagnosability)
Solo field report: both SRM launchers went inert with ammo remaining and
the default log could not answer whether they jammed or heat-failed --
every weapon-state transition log was gated behind BT_AMMO_LOG.  The
edges are rare and mission-permanent, so they now log unconditionally:
[weap] JAMMED with temperature/alarm evidence at the CheckForJam hit,
and both NoAmmo edge logs (gate 1 destroyed/failHeat/disabled, gate 2
dry bin) ungated.  The missing MFD presentation for these states is
issue #30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:15:15 -05:00
arcattackandClaude Opus 4.8 12a1fea823 Score replication: peers' scores now reach every pod (rank fix)
Field report (playtest night, voice channel): everyone's HUD showed
them in 1st place.  Root cause: the engine's score-replication
machinery was complete -- Player::WriteUpdateRecord ships currentScore,
the replicant ReadUpdateRecord applies it, CalcRanking sums every
scoring player -- but nothing marked the player's update record dirty
on a score change (the engine only ForceUpdates at mission end for the
fade sync).  Every peer's replicant player therefore stayed at score 0
and each machine ranked its own (only-nonzero) player first.

Fix: ForceUpdate() after every score application in the BTPlayer
handlers (main, inflicted, and the severed-vehicle branch) -- the
existing update-record path does the rest.

Verified 2-node (force-damage kill scoring): the victim's machine ranks
the killer's REPLICANT at its replicated score (24) above its own
master (0); standings consistent on both nodes.  BT_RANK_LOG=1 kept as
the 1 Hz per-player ranking dump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:39:19 -05:00
arcattackandClaude Opus 4.8 c8cba8c764 Connection audit: every connect/disconnect scenario handled + verified
Systematic actor-death x lifecycle-phase sweep (operator request after
the WaitingForEgg zombie).  New handling, all live-verified:

1. PRE-EGG console-pad loss (the real zombie phase): before the egg the
   pod's only link is the console pad -- the game socket doesn't exist,
   so the earlier RelayGameDown hook could never fire (the verification
   run itself caught this).  Detection now lives in
   HostDisconnectedMessageHandler ConsoleHostType (relayMode +
   pre-scene -> BTRelayRejoinNow); mesh keeps 1995 behavior.
2. MID-MISSION relay loss: the STOP is relay-sent (1995: the console
   owned the clock), so a pod losing its relay mid-match played a
   peer-less FOREVER-mission.  RelayGameDown (scene presented) now
   posts StopMissionMessage at +15s -> normal end -> lobby relaunch.
3. POST-RELEASE pod death stalled the round (survivors wait forever on
   the dead peer's connection gate): relay _abort_round -> route -11
   REJOIN to survivors -> everyone re-seats in seconds (reset-first
   ordering makes the drop cascade re-trigger-proof).
4. BTRelayRejoinNow carries BT_SEAT_CLAIM (mirrored tag): without it
   the rejoiner's own reclaim-hold blocked assignment -> ROSTER FULL
   loop for the 90s window (found by verification round 2).
5. Beacon NAT keepalive (SIO_KEEPALIVE_VALS 60s/10s) so long
   between-rounds waits can't be silently unseated.
6. BT_LOG_APPEND=1 preserves the log across lobby-loop generations
   (truncation erased round-1 verification evidence).

Verified non-issues: relay pods never bind the -net listener (no
double-launch collision on the internet path); mesh bind-fail exits
cleanly.  Full matrix + evidence: context/multiplayer.md;
scratchpad/audit_verify.sh is the repeatable rig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:23:23 -05:00
arcattackandClaude Opus 4.8 02a41f165f Issue #27: projectiles carry the weapon's real damage type (ballistic)
Playtest matchlog forensics: across 2,591 applied-damage events in two
rounds, Ballistic (type 1) damage NEVER appeared -- Collision/Explosive/
Laser/Energy only -- despite 136 projectile impacts and autocannon/Gauss
mechs.  The weapon parses its authentic damageData.damageType correctly
(mechweap.cpp:316 maps "BallisticDamage" -> 1), but BTPushProjectile
never carried the type and every projectile-impact site in mech4.cpp
hardcoded ExplosiveDamageType.  So autocannon/Gauss rounds were scaled
against armor by damageScale[Explosive] instead of damageScale[Ballistic]
(the model indexes a distinct scale cell per damage type) -- every
ballistic weapon did the wrong damage all night.

Lasers/PPCs were unaffected (their SendDamageMessage path carries the
weapon's own damageData); missiles happen to BE Explosive so only
autocannon & Gauss were mis-scaled.

BTProjectile gains a damageType field, threaded from the firing weapon
(damageData.damageType) at launch through to the impact dispatch;
missiles resolve Explosive from their own streamed type, autocannon/
Gauss now resolve Ballistic.  The missile-splash template stays
Explosive.  Enum is anonymous so the field casts to Enumeration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:09:13 -05:00
arcattackandClaude Opus 4.8 4e0fcbf328 Issue #26: runtime master volume (-/= keys, persisted)
The game audio plays hot with no in-game control -- testers couldn't
hear voice chat without the Windows mixer (playtest night).  The -/=
keys now step the OpenAL listener gain (the master scale every source
inherits) in 5% steps, clamped 0..150%, edge-detected in the per-frame
poll with a foreground guard; the value persists to content\volume.cfg
and reloads at boot (BT_AUDIO_VOLUME env still wins when set; default
stays 0.6).  README updated.

Verified: volume.cfg 0.25 -> boot log "master gain=0.25"; key stepping
needs a live focused session (awaiting live verification).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:53:07 -05:00
arcattackandClaude Opus 4.8 5f115eca19 Issue #22: respawn resets ammo + repairs weapons (the +0x28 mislabel)
ROOT CAUSE [T1]: Mech::Reset's subsystem sweep (@0049fb74 loop-2) calls
vtable slot +0x28, which the DATA-section vtables prove is
ResetToInitialState (0050f9d8/00511d2c/00512078/0051242c all carry the
class RTIS at +0x28) -- our reconstruction mislabeled it DeathReset,
the EMPTY engine base virtual, so the whole per-subsystem reset was a
silent no-op ("ammo and weapons do not reset on respawn", playtest
night; matchlogs: 3 of 7 players fired zero projectiles after their
first death).

SECOND LAYER: the binary never refilled ammo at all -- its respawn
severed the vehicle and DropZoneReply built a NEW mech (fresh ctor =
full bins, pristine subsystems: the pod behavior testers remember).
Our port deliberately reuses the entity (the new-mech path was the
two-mech/camera-glitch family), so Reset now EMULATES ctor-freshness:

- the sweep starts at i=2 (binary-faithful: skips mapper + voltage
  bus) and dispatches the engine DeathReset virtual as the vehicle;
  per-class overrides chain each class's own authentic slot-10 RTIS
  body (the recon RTIS namesakes are statically bound -- no common
  virtual): MechWeapon @004b96ec, Emitter @004ba4d0 (charge cycle
  restarts from Loading), ProjectileWeapon @004bbaf8 (unjams).
- MechSubsystem::RespawnRepair (PORT): heals the subsystem's PRIVATE
  crit damage zone + status alarm -- the mech-zone heal never touched
  those, which kept destroyed weapons dead across respawns.
- AmmoBin::DeathReset (PORT): refills to initialAmmoCount (@0x220, the
  ctor value) + Loaded alarm + idle feed + cook-off disarmed;
  ResetToInitialState stays authentically EMPTY per AMMOBIN.TCP.

Verified (2-node force-damage kill/respawn rig, BT_DEATH_LOG): every
respawn logs [respawn] ammo bin refills for both bins to streamed
capacity across 4 death cycles; mission un-regressed.  KB corrected +
swept (multiplayer.md task #52 notes).  Awaiting live tester
verification of depleted-bin refill + revived weapons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:42:31 -05:00
arcattackandClaude Opus 4.8 8368163b04 Lobby loop: auto-rejoin between rounds + seat reclaim + live mission edits
The between-rounds arcade flow (first-playtest-night field request): at
mission end every pod exited, seats dropped, and every round required
everyone to re-run join.bat with no way to adjust the mission.

1. AUTO-REJOIN: StopMission (operator END / mission clock -- NOT a
   window close) sets gBTMissionStoppedByConsole; after the matchlog
   upload btl4main relaunches the pod into the join wait with the same
   cmdline (BT_CALLSIGN/BT_MECH ride the inherited environment).

2. SEAT RECLAIM: the assigned tag is mirrored file-scope
   (BTRelaySelfTag) and re-presented as the 3rd SEAT_REQUEST payload
   field (BT_SEAT_CLAIM); the relay holds a dropped seat for its tag
   for 90s and a returning player gets their EXACT seat back -- static
   ordering across rounds, the real-pod model.  Non-claim joiners skip
   reclaim-held seats.

3. LIVE MISSION EDITS: the console gains "Apply mission settings"
   (enabled while a session runs) writing arena/time/weather/length
   into the session egg; the relay re-reads the file at round reset and
   egg release (roster shape locked mid-session).

Verified 2-pod 3-round: pods self-relaunched after each clock end; the
SECOND-to-rejoin still reclaimed its original seat (ordering held); the
between-rounds edit landed ("mission length now 75s").  Known edge
noted in the KB: a stale ready flag from a not-yet-exited old conn can
satisfy the launch gate mid-rejoin -- operators launch on green dots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:28:56 -05:00
arcattackandClaude Opus 4.8 95735db5ae Operator console: relay-mode local instances go through the JOIN menu
The Launch-local-instances path pinned BT_SELF, which claims a seat
directly and SKIPS the seat request -- the join menu (and the callsign/
mech choice it carries) never ran, so a local test instance couldn't
exercise the walk-up flow.  Relay-mode local instances now launch
zero-arg with BT_FE_JOIN=1: the JOIN-GAME menu opens, the relay assigns
the seat, and the choice lands in the egg like any join.bat player.
Each instance gets a distinct relaunch port via BT_FE_JOINPORT
(btl4main JoinRelay case; default 1501) -- two -net listeners can't
share a port on one box.  Mesh-mode locals keep the direct path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:14:19 -05:00
arcattackandClaude Opus 4.8 fcedc046cf Walk-up callsign/mech request: join menu -> relay -> the session egg
The 1995 front-desk conversation, over the internet.  join.bat/
join_lan.bat now open a JOIN-GAME menu (the FE in BT_FE_JOIN trim:
CALLSIGN edit + the 18-mech list + JOIN); the choice relaunches into the
normal join with BT_CALLSIGN/BT_MECH env, rides the relay SEAT_REQUEST
as {callsign NUL mech NUL} (empty payload = roster defaults, wire-
compatible), and the relay validates the mech tag, HOLDS egg delivery
until every pod's console pad is connected, rewrites the egg via
eggmodel (vehicle= + rasterized callsign bitmaps, graceful no-PySide6
fallback) and streams it to all pads -- so every player's egg copy
carries every player's callsign.

The hold is gated on CONSOLE-PAD count, not game-side registration: a
pod HELLOs only after parsing the egg's roster, so a registration gate
deadlocks (hit live in the first run; pods animate in WAITING FOR
MISSION ASSIGNMENT during the hold).

Verified 2-node e2e (env-injected requests): thor/vulture requested over
roster defaults bhk1/ava1 -> relay held 1/2, released at 2/2, rewrote
both seats (relay log), the delivered egg carries vehicle=thor/vulture +
name=VIPER/MONGOOSE, both pods launched and built [cyl] tables for both
requested mechs.  Join-menu layout screenshot-verified; the menu's
click-through relaunch awaits live human verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:58:51 -05:00
arcattackandClaude Opus 4.8 eb618fe9e6 BT_EXPIRE: tester builds stop working 14 days after build (compile gate)
Distributed test zips go stale and stray.  BT_EXPIRE (CMake option,
DEFAULT ON so a zip can never accidentally ship without it) makes the
exe refuse to start BT_EXPIRE_DAYS (default 14) after its build day:
boot logs the remaining window, an expired build logs + shows a
"test build expired -- ask the operator for the current build" box and
exits.  btversion.h now stamps BT_BUILD_UNIX (UTC-midnight-rounded so
the only-on-change header churns once per day, not every build) +
BT_BUILD_DATE.  Dev is unaffected: every build re-stamps the day.

-DBT_EXPIRE=OFF builds a non-expiring exe; mkdist reads the cache and
marks such a zip "-noexpire" + prints a warning, and the tester README
expiry note is {EXPIRE}-marker-gated like the steam lines.  Second
compile-gate exception after BT_STEAM (convention updated in
glass-cockpit.md).  Quality gate, not DRM -- a clock rollback defeats it.

Verified live: default build logs "test build window: 14 day(s) left";
a -DBT_EXPIRE_DAYS=0 build logs TEST BUILD EXPIRED and parks on the
explanation box (window title checked) without starting the game;
restored to 14 and rebuilt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:39:19 -05:00
arcattackandClaude Opus 4.8 753540de96 MP match forensics: per-peer matchlog + relay auto-upload + matchcheck
For the 8-player playtest.  Damage authority is VICTIM-side, so no single
peer sees the whole match: every -net instance now writes a compact
machine-parseable matchlog_<date>_<time>_<pid>.txt (auto-armed by -net;
BT_MATCHLOG=1/0 overrides; solo silent), and at mission end a relay-mode
pod dials the relay game port (the seat-request throwaway-dial pattern,
envelope route -9) and streams the file back -- the relay saves every
peer's copy under matchlogs/ on the operator's machine, so testers send
nothing by hand.  tools/matchcheck.py <dir> reconciles all of them:
damage claimed (FIRE/PROJ/SPLASH/RAM, shooter-side) vs applied (DMG,
victim-side authoritative), kill/death replication across observers,
PLAYER_DEAD counts, scores, version skew, replicant-application and
unattributed-damage anomalies, mid-mission disconnects.

Hooks at the authoritative sites: Mech::TakeDamageMessageHandler (DMG,
post-base, zone + post-application damageLevel), MechWeapon::
SendDamageMessage (FIRE), projectile impact/splash/collision dispatch
(PROJ/SPLASH/RAM), wreck entry (DEATH -- a ONE-SHOT latch at
MovementMode 9, NOT the transition: a replicant arrives at 9 straight
from the type-6 record header and never runs the transition branch),
BTPlayer vehicle/death/score handlers (VEHICLE/PLAYER_DEAD/SCORE), zone
crit cascade (CRIT), APP.cpp RunningMission (MISSION), L4NET host
handlers (PEER_UP/PEER_DOWN).  Every line t=/w=/st=-stamped + fflushed.

Verified 2-node: mesh run pairs A's 22 FIRE 1:1 with B's 22 DMG (same
amounts, cylinder-resolved zones, ~200ms latency); force-damage kill run
logs 103 DMG + 3 DEATH inst=M + 3 PLAYER_DEAD across three respawn
cycles victim-side and the kill SCOREs shooter-side; relay-mode run
auto-uploaded BOTH peers' files at the mission-clock stop and matchcheck
produced the full report with exactly one (correct) anomaly -- the force
hook's claim-less damage, the unattributed-damage detector working.
Relay gained a route-specific 8MB cap for the upload frame (game frames
stay capped at 1600).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:22:10 -05:00
arcattackandClaude Opus 4.8 9e85f6dc56 Desktop default platform = GLASS (kills the recurring "old keymap" trap)
Root cause of the keymap whack-a-mole: three input layers exist (PadRIO/
bindings.txt under glass; btinput/CONTROLS.MAP under DEV; the bring-up
drive bridge also under DEV, which clobbers e.g. Alt-reverse every frame),
and DEV was the DEFAULT -- so every launcher that forgot BT_PLATFORM=glass
booted the old stack: the play bats, then play_steam, then the operator
console's local instances, each fixed with the same one line.

Omission can't resurrect it now: with no BT_PLATFORM, desktop boots GLASS
(and the menu on a zero-arg launch -- bare double-click of btl4.exe opens
the mission menu).  BT_PLATFORM=dev explicitly opts into the legacy DEV
profile for debugging.  BT_PLATFORM=pod is the cabinet profile; the real
pod's SETENV.BAT must set it (contract recorded in glass-cockpit.md --
the old "bare boot == cabinet shape" menu-guard assumption is retired).

Verified: no-env mission launch boots GLASS; bare no-env launch opens the
MENU; BT_PLATFORM=dev boots DEV.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:18:19 -05:00
arcattackandClaude Opus 4.8 af22c4c78d FE menu: column 1 overlapped the footer (MenuClientH 620 -> 672)
The col-1 stack (MAP 8 + SCENARIO 2 + TIME 4 + WEATHER 3 + LENGTH 4 with
headers/gaps) needs 630px; at 620 the last LENGTH rows collided with the
'click to select' footer band (client.bottom-30) -- user-reported from a
screenshot.  672 fits the stack + footer.  The LAUNCH/steam buttons and
frame rect derive from MenuClientH and follow automatically.

Also: 'No Return (one life)' clipped at the column edge -> '(1 life)'.

Verified by screen capture of the live menu: LENGTH fully above the
footer, scenario label unclipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:08:52 -05:00
arcattackandClaude Opus 4.8 21b2bfc1c9 Issue #21: overcharge-bricked weapons -- latent ARCADE bug, deliberate divergence
The tester's ER small laser died permanently (recharge arc + ready dot dark,
no fire) after idle seek-gear clicking.  Root cause, byte-verified + headless-
reproduced -- a latent bug in the SHIPPED ARCADE BINARY:

- The charge integrates toward the GENERATOR voltage (@004ba838), not the
  gear target; "full" is only detected inside the +-1% snap window around
  seekVoltage[idx] (@004ba738), and overcharge reads ZERO (the byte-verified
  _DAT_004ba830 = 0.0 clamp).
- Toggle seek while a charge is in flight and the level can land ABOVE the
  new gear's window: rechargeLevel pins to 0, the ==1.0 Loaded test can
  never fire, and charging continues to the generator ceiling where EVERY
  gear reads overcharged.  Permanent brick; generator reselect cannot help.
- The arcade dodged it by circumstance (locked 60 fps, rare seek use); the
  port's clickable seek button hits it in seconds of casual clicking.

Fix (marked divergence in the Loading tick): overcharge counts as fully
charged -> Loaded.  The arcade's own discharge algebra says full == the
gear's seek voltage, so an overcharged weapon IS full.

Verified: the same 2.5-min BT_SEEKTEST abuse that bricked the laser pre-fix
now fires 38 overcharge rescues and ends Loaded/pct=1.  [seek] log now also
prints the per-gear voltage table + gen voltages (diagnosis instrumentation).

Ruled out on the way (documented in #21): overheat fuse (no firing occurred;
a 4-min autofire+coolant-abuse run wouldn't fuse), generator detach (gen
cycling + healthy siblings), seek-table corruption (table {6000,7000,8000,
9900} healthy).

KB: decomp-reference.md records the arcade-latent-bug analysis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:39:56 -05:00
arcattackandClaude Opus 4.8 e9db161404 Issue #20: wire Mech BalanceCoolant (id 0x16) -- the auto-cooling-balance button
Third instance of the silent-swallow pattern (unregistered handler id ->
Receiver NullHandler).  The Mech handler table @0x50BDF8 binds
{0x16, "BalanceCoolant" -> @0049f728}; the reconstruction never registered it.

Disasm-verified body (@0049f728): press-only guard (msg+0xc > 0; the binary
has NO novice lockout on this one), set EVERY condenser's valveState@0x1D0
to 1 (equal weights), then the shared redistribute @0049f788 -- which the
port ALREADY had faithfully as BTRecomputeCondenserValves (flow =
valve/total + condenserAlarm pulse; MoveValve calls it).  So the fix is:
- mech.hpp/mech.cpp: BalanceCoolantMessageID=0x16 + handler + MESSAGE_ENTRY
- heatfamily_reslice.cpp: BTBalanceCondenserValves bridge (Condenser is a
  complete type there; sets valves to 1 + redistributes)
- mech4.cpp: BT_BALTEST scripted harness (MoveValve press alternating with
  a Balance press)

VERIFIED headless (BT_BALTEST=1 BT_VALVE_LOG=1):
  boot     -> all valves 1, flows 0.1667 each
  MoveValve-> condenser#1 valve 5, flows 0.5 / 0.1x5 (the priority boost)
  BALANCE  -> all valves 1, flows 0.1667 each (equalized)
Awaiting the tester's live ADV-mode confirmation.

KB: decomp-reference.md Mech-table row marked wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:31:46 -05:00
arcattackandClaude Opus 4.8 4aab10ba89 Issue #19: wire ToggleSeekVoltage (Myomers id 9 + energy weapons id 0xb)
Root cause -- NOT issue #2 (the experience mis-seed pins everyone to VETERAN,
which PASSES the novice lockout; the gates were never the blocker).  Two
unwired handlers, the same silent-swallow pattern ToggleCooling had
(Receiver::Receive -> NullHandler for an unregistered id):

- Myomers (handler table @0x51158C {9,"ToggleSeekVoltage"@004b8a48}): the
  reconstruction had an EMPTY stub (the address falls in the untagged decomp
  gap 0x4b8837..0x4b8a8c) and never registered the id.  Body reconstructed
  faithfully from the raw disassembly (tools/disas2.py 0x4b8a48): novice
  lockout (@4ac9c8) -> heat-model gate (@4ad7d4) -> press-only (msg+0xc>0) ->
  currentSeekVoltageIndex@0x320 = (idx+1) % (maxSeekVoltageIndex@0x32C + 1).
  Modulo from zero, min index not consulted, no ResetFiringState.

- Emitter (energy-weapon table @0x511DB8 {0xb,"ToggleSeekVoltage"@004ba478}):
  the faithful body already existed as the orphaned "AdvanceSeekVoltage"
  (ZERO callers) -- converted to the registered message handler (same guards;
  cycle + ResetFiringState when not wrapping, disasm re-verified).  PPC and
  GaussRifle copy-inherit the set; the ammo branch keeps its own id 0xb ==
  EjectAmmo (still unwired, tracked separately).

Both registered via MESSAGE_ENTRY on top of the inherited chains.  Permanent
diagnostics: [seek] gear log (BT_SEEK_LOG) + BT_SEEKTEST scripted dispatch
harness (mech4.cpp, the cooltoggle pattern).

VERIFIED headless: [seek] Myomers -> 3,0,1,2,3... and [seek] PPC_1 ->
3,0,1,2,3... (modulo-4 wrap, matching the binary's arithmetic).  Awaiting
the tester's live ADV-mode confirmation for the real MFD button route (the
routing layer is the same streamed-EventMapping path ToggleCooling verified).

KB: decomp-reference.md message-table rows marked wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:57:18 -05:00
arcattackandClaude Opus 4.8 04d50c9242 FE menu: BT_FE_SOLO=1 trims to a solo-only menu (play_solo.bat)
The user hit the full session menu from play_solo -- HOST/JOIN LAN modes,
NET PORT and the Steam buttons don't belong in the solo flow (LAN goes
through the join bats + operator; Steam through play_steam.bat).

BT_FE_SOLO=1 (set by play_solo.bat + the btoperator generated bat):
- the MODE cycle keeps only the two solo entries (kModes lists them first:
  SOLO MISSION, RAW SOLO) via the GroupSize clamp
- NET PORT row and the HOST/JOIN STEAM buttons are not laid out
- a stale fe_last.ini mode of hostlan/joinlan is rejected by the existing
  LoadPersisted GroupSize clamp, so the selection can never land on a
  hidden mode
play_steam.bat keeps the full menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:54:09 -05:00
arcattackandClaude Opus 4.8 10ed66bd7c UNIFY: one exe -- retire the pod/glass/steam compile split
The three build dirs (build\ pod, build-glass\, build-steam\) were compile-
time splits of the same game; the tax was real: a stale steam exe shipped 2
days behind, the felt keymap flipped with the boot flavor, and cockpit
clicks were silently dead in the pod build (the trigger-config incident).

Now everything compiles into the single build\ exe:
- CMakeLists: BT_GLASS/BT_STEAM options removed; the glass TUs (PadRIO,
  bindings, panel, glass windows, plasma) + FE/console + Steam transport
  compile unconditionally; both macros always defined (seam markers).
- Steam: steam_api.lib linked with /DELAYLOAD:steam_api.dll + delayimp --
  the DLL maps only when SteamAPI is first called, and every call site
  gates on BT_STEAM_NET=1 (BTSteamNet_* short-circuit on steamActive), so
  machines without the DLL run everything but Steam sessions.
- CABINET GUARD (btl4main): the miniconsole menu previously claimed any
  zero-arg launch -- unified, that would hijack the pod cabinet's boot
  shape.  The menu now requires BT_PLATFORM=glass (or BT_FE_MENU=1);
  platform parse moved above the fe_menu_mode computation.  A bare boot
  behaves exactly like the old pod build (DEV profile, wait for egg).
- Bats (play_solo/join/join_lan/play_steam) + the btoperator.py template:
  single build\ path + BT_PLATFORM=glass.  mkdist: one exe (+DLL scan
  picks up OpenAL32 + steam_api).  build-glass\/build-steam\/build-glass2\
  (a stale Jul-20 cache) deleted.

Verified matrix on the unified exe:
  bare zero-arg boot == old pod exe (same DEV profile + egg-load path;
    the segfault on a missing egg is pre-existing, baselined vs the zip exe)
  glass + BT_BTNTEST scripted click -> [cfgmap] ENTER/EXIT (trigger config)
  runs with steam_api.dll ABSENT (delay-load proven)
  glass zero-arg -> miniconsole menu
  2-node loopback MP: 145 ticks each, cross-connected
  BT_STEAM_NET=1 without Steam client -> "staying on Winsock", game runs

KB: context/glass-cockpit.md compile-gates section rewritten for the
unified model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:28:51 -05:00
arcattackandClaude Opus 4.8 babf5b1e77 Keymap: sync CONTROLS.MAP to the bindings.txt layout (one map everywhere)
The user standardized on the "newer" layout (numpad aim cluster, arrows =
throttle/pedals, Alt = reverse thrust).  That layout lived only in
bindings.txt (PadRIO, GLASS-profile boots); CONTROLS.MAP (btinput, DEV/pod
boots) lacked the numpad cluster entirely and had NO reverse binding -- so
the felt keymap flip-flopped with the boot flavor.

Added to CONTROLS.MAP + the byte-identical compiled-in default profile
(btinput.cpp sDefaultProfile):
  Alt          -> button 0x3F (throttle-head REVERSE THRUST)
  NumPad8/2    -> JoystickY aim up/down
  NumPad4/6    -> JoystickX torso twist
  NumPad7/9    -> pedals (turn)
  NumPad5      -> AllStop
  Shift        -> throttle up (alt)
Parse-verified: [input] loaded 61 binding(s) from CONTROLS.MAP, no warnings.
Both engines now carry the same layout, so DEV/pod boots (MP join bats) and
GLASS boots (play_solo) feel identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 09:36:27 -05:00
arcattackandClaude Opus 4.8 4215b98655 Trigger config: root-cause the dead cockpit clicks (missing BT_PLATFORM=glass)
The user held the MFD PROGRAM button and nothing happened (weapons kept
firing).  Diagnosis, verified end-to-end:

- The cockpit mouse handler DID register the click ([cockpit] CLICK addr=0x8
  press) and 0x8 IS a streamed PROGRAM element (EVENT msg 0x9 -> weapon,
  Mfd1 quad page mask).
- But every launch ran the DEV platform profile (no BT_PLATFORM) ->
  L4CONTROLS=KEYBOARD -> no PadRIO instance -> PadRIO::SetScreenButton
  no-ops (activeInstance NULL) -> the press never entered the RIO queue ->
  ConfigureMappables (id 9) never dispatched -> the mode never flipped ->
  fire keys kept firing.  The config machinery itself was never broken.
- With BT_PLATFORM=glass (the GLASS profile: L4CONTROLS=PAD -> PadRIO), the
  full chain now proves out headlessly:
    [btntest] PRESS 0x8 -> [cfgmap] ENTER session on ERMLaser_1
      mode 0x450421 -> 0x448421 (NonMapping 0x10000 -> Mapping 0x8000)
    [btntest] RELEASE 0x8 -> [cfgmap] EXIT (mode restored)

Landed:
- mechweap.cpp: [cfgmap] BT_FIRE_LOG diagnostics in the real
  ConfigureMappables/ChooseButton handlers (they were silent -- the G-key
  harness had logs but the authentic button path had none).
- L4PADRIO.cpp: BT_BTNTEST="addr,pressPoll,releasePoll" scripted screen-
  button harness through the REAL click seam (EmitButton -> RIO queue ->
  manager drain -> buttonGroup mapping) for headless verification.
- play_solo.bat: prefer the glass build when present AND set
  BT_PLATFORM=glass so PadRIO exists and cockpit clicks work.

Side finding (the user's keymap "flip-flop"): CONTROLS.MAP (btinput, DEV/pod
profile) and bindings.txt (PadRIO, GLASS profile) are two parallel binding
engines; which one runs depends on the platform profile, so the felt keymap
changes with the boot flavor.  Unification pending (user decision).

Awaiting live verification of the full hold-PROGRAM + tap-fire regroup flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 09:31:52 -05:00
arcattackandClaude Opus 4.8 89b4f53046 Gauges: trigger-config joystick is LIVE -- @004c6ee0 is LinkToEntity, not SetColor
The weapon panel's btjoy trigger-config display (ConfigMapGauge) never rendered
because the reconstruction labelled @004c6ee0 as "SetColor(int)" and, finding no
caller, concluded the gauge was authentically dormant (task #6), gating it
behind a BT_CONFIGMAP dev env.

The user challenged this (the PROGRAM/TRIGGER CONFIG button implies the display
visualizes your config).  Re-verification found the old analysis failed twice:
the "no caller" search looked for direct calls to a VIRTUAL, and its slot math
used the wrong vtable copy.  The real ConfigMapGauge vtable @0051a1b8, matched
against the T0 GaugeBase virtual roster (GAUGE.h), pins @004c6ee0 at slot 9
(+0x24) == GaugeBase::LinkToEntity, and @0x94 is the linkedEntity -- the
Execute gate is "not yet linked", not "disabled".  The engine broadcasts
LinkToEntity(viewpointEntity) to every gauge at viewpoint bind (APP.cpp:1277 ->
GaugeRenderer::LinkToEntity GAUGREND.cpp:3011), arming the gate -- the joystick
DOES render in the shipped game, showing per-trigger mapping state (solid =
mapped, matching the DOSBox reference).

Port fix: SetColor(int color) -> LinkToEntity(Entity*), color -> linkedEntity,
BT_CONFIGMAP dev enable deleted (the authentic path lights it).  Render-verified
with no env override: the joystick + mapped-trigger lamp draw on weapon panels.

KB swept per the correction mandate: gauges-hud.md, decomp-reference.md,
open-questions.md, GAUGE_COMPOSITE.md, VEHICLE_SUBSYSTEMS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 08:49:58 -05:00
arcattackandClaude Opus 4.8 4fbc911f55 Gauges: fix temp/status bar colours (black remainder, not green)
Follow-up to the tiled-render fix: the two colour params were swapped.  The
binary caller (@004c8269) pushes the colours 0xff then 0; they land at
[this+0xA0]=fillColor (0xff green) and [this+0xA4]=backgroundColor (0 black).
The binary uses the GREEN for zone 1 (tile SetColor / dots) and zone 2 (the
over-degrade fill), and BLACK only for zone 3 (the unfilled remainder
[valPix,width]).

The port had them reversed -- zone 3 used fillColor -- so the whole remainder
of the bar (most of it, since valPix is small when cold) rendered solid green
instead of black.  Swapped: zone 1 + zone 2 = fillColor (green), zone 3 =
backgroundColor (black).  Render-verified: black background + green dotted tick
scale + a green hatch fill growing from the left, matching the reference.
Both builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 08:19:05 -05:00
arcattackandClaude Opus 4.8 4d4436ed03 Gauges: temp/status bar tiles the striped pattern from x=0 (HorizTwoPartBar)
The per-weapon TEMP/STATUS bar (HorizTwoPartBar) rendered as a solid block
starting at the warn-threshold pixel -- it "started in the middle" and was
never striped.  The port's Execute had been rewritten with DrawFilledRectangle
and never used the interned tile image, unlike the (correct) VertTwoPartBar
which tiles via DrawTiledBitmap.

Restored the binary's three-zone render (disasm @004c4340), along X:
  [0, warnPix)      DrawTiledBitmap(tileImage) -- the striped/dotted pattern
  [warnPix, valPix) backgroundColor solid (only when value > low)
  [valPix, width)   fillColor solid
So the bar now fills the striped tile from the beginning and matches the
reference (hatched fill block on the left + dotted tick scale).  Render-
verified vs the DOSBox reference capture.  Both builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 08:03:41 -05:00