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>
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
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
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
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
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
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
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
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
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
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
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
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The base-page missile/round count sat on the fire-ready "dot" (the cluster
image blitted green when charged) as green digits on an opaque black box --
a messy dark rectangle punched into the solid green dot.
The binary swaps the ammo count's colours with the dot state. Verified from
the decomp: BallisticWeaponCluster overrides DrawWarningLamp (@004c9b50) --
it chains the base (@004c932c, which blits the dot in on-colour 0xff /
off-colour 0) and then calls the ammo NumericDisplayInteger's SetColors:
dot ABSENT -> SetColors(0, 0xff) == green digits on black
dot PRESENT -> SetColors(0xff, 0) == BLACK digits cut out of the green dot
so the count is always legible (a stencil against the solid dot -- the same
black-on-green look as the loop/generator squares). SetColors ForceUpdate()s
the numeric so it repaints over the freshly drawn dot.
The port had only the base WeaponCluster::DrawWarningLamp (non-virtual, no
swap), so the count stayed green-on-black and clashed with the green dot.
Made DrawWarningLamp virtual and added the BallisticWeaponCluster override.
Render-verified (STREAK "0024" now black-cut-out of the solid dot); energy
weapons (no ammo count) unaffected. Both builds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The eng-page generator-voltage bar (evolt.pcc, ScalarBarGauge @004c721c) and
the MyomerCluster seek-voltage graph were fed by GeneratorVoltageConnection,
which walked the same dead path as the generator-letter lamp:
ResolveLink(AttributePointerOf(subsystem,"InputVoltage")) -> read resolved
+0x1DC == Generator::outputVoltage. The BT_DEV_GAUGES audio attribute rows
shifted the chained attribute ids so AttributePointerOf no longer lands on
voltageSource@0x1D0 -> the bar always read 0.
New BTGeneratorVoltage(subsystem) bridge (powersub.cpp) reads the NAMED member
via PoweredSubsystem::ResolveVoltageSource()->Generator::MeasuredVoltage()
(outputVoltage@0x1DC), bypassing the attribute table -- same pattern as
BTPowerSourceFrame/BTCoolingLoopFrame. Both GeneratorVoltageConnection callers
(the ScalarBarGauge param + the MyomerCluster seekValue) now pass subsystem_in.
[voltfeed]-verified: Myomers -> 10000V from GeneratorD (was 0 on the dead
path). Both pod + glass builds clean. Eng-page render awaiting live playtest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SubsystemCluster per-weapon MFD panels render two image-strip lamps in
the TEMP/STATUS area: the cooling-loop number (btploop, 1..6) and the
generator letter (btpbus, A..D). Both drew as completely empty boxes.
Three stacked databinding bugs, all now fixed:
1. Color drop (btl4gau2.cpp): AnimatedSubsystemLamp/AnimatedSourceLamp
ctors dropped the bg/fg color params the binary passes (bg=0xff,fg=0,
same as the neighboring temp bar) and hardcoded 0,0 -> OneOfSeveral::
Execute did SetColor(0)+DrawBitMapOpaque(0), i.e. black-on-black.
Restored 0xff,0 (verified vs @004c70a4 / caller SubsystemCluster
@004c8140).
2. Shadow-field trap (btl4gau2.hpp, gotcha #2): both lamp classes
re-declared `int selected;` while already inheriting it from
OneOfSeveral (@0xAC). The CoolingLoop/PowerSource connection wrote the
derived shadow copy while OneOfSeveral::Execute read the base @0xAC
(always 0) -> every lamp stuck on frame 0 ("OFF"). Removed the
redeclarations so &selected binds the inherited member. Loop numbers
now render (verified: PPC->4, Myomers->5, ERMLaser->1/6, etc.).
3. Attribute-table shift (powersub.cpp + btl4gau2.cpp, gotcha #8): the
generator-letter lamp resolved its source via
ResolveLink(AttributePointerOf(subsystem,"InputVoltage")), but the
BT_DEV_GAUGES audio attribute rows shifted the chained attribute ids so
AttributePointerOf no longer landed on voltageSource@0x1D0 -> the link
always resolved to 0 (OFF) even though the master PoweredSubsystem ctor
DID bind voltageSource to its Generator. New BTPowerSourceFrame bridge
reads the NAMED member via PoweredSubsystem::ResolveVoltageSource() and
returns Generator::generatorNumber (@0x1E0), bypassing the attribute
table -- the same pattern as the BTCoolingLoopFrame fix. Generator
letters now render (verified: PPC_1->GeneratorA="A", Myomers->
GeneratorD="D", SRM6->GeneratorB="B").
Result: the "4 A / 1 B / 5 D" boxes render with both the loop number and
generator letter, matching the reference. Kept BT_LOOP_LOG-gated
[loopfeed]/[busfeed] feed diagnostics; both pod + glass builds clean.
Awaiting live playtest verification.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>