Commit Graph
477 Commits
Author SHA1 Message Date
arcattackandClaude Opus 4.8 fa935777cc Relay: hold the mission start until every pod is READY (loaded)
Live finding (round 3): firing RunMission while a pod is still loading
drowns that pod's load in the running mission's update streams -- the
same event queue serves both, so the load starves (the operator, always
last to join via the mech menu, loaded 5+ minutes or crashed while
testers took ~20s; solo loads on the same box take ~60s).  The launch
timer now also waits for every registered pod's READY (route -10) so
everyone enters together, 1995-style; a held launch announces who's
still loading every 10s, and a 180s cap force-fires so a wedged pod
can't hold the night hostage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:36:18 -05:00
arcattackandClaude Opus 4.8 0360a26612 Console: local instances get BT_AFFINITY=0xF0 (upper cores)
The game self-pins to core 0 by default; on the operator box core 0 is
shared with the console/relay/desktop, and the mission load drains
frame-paced -- a starved frame rate multiplied the operator's load time
to 3-5 min while internet testers loaded in ~20s (live finding, round
2).  The local-instance launcher now spreads the game across the upper
cores unless BT_AFFINITY is explicitly set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:20:30 -05:00
arcattackandClaude Opus 4.8 117b92cb24 Relay: snapshot by_host in the PEER_UP/DOWN fanouts (end-of-round crash)
When every pod disconnects at once (normal end of a multiplayer round),
a failed send inside the PEER_DOWN fanout recursively drops that peer
and mutates by_host mid-iteration -> RuntimeError -> THE RELAY DIES,
taking the round reset and the matchlog uploads with it (live crash,
first 3-player internet round).  Iterate over snapshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:03:16 -05:00
arcattackandClaude Opus 4.8 2f4b2f9e91 Console: seat lines speak PLAYER ordinals, not raw host ids
'SEAT 2 READY' for the first (only) player read as an off-by-one (field
report) -- host 1 is the CONSOLE in the authentic 1995 numbering, so
pods start at host 2.  Human-facing lines now read PLAYER 1/2/... with
the host id in parentheses for cross-reference against the game[host=N]
wire lines; GUI monitor regexes updated (tag-keyed as before, verified
seated/ready/left in isolation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:54:06 -05:00
arcattackandClaude Opus 4.8 7b3e76db38 Load-time attribution: the minute is the renderers' entity-make streams
Measured (loadphase markers + queue blocker dump): renderer LoadMission
31ms, model loads 0.2s -- the teal->green minute is the LoadingMission
ready-gate (IsPriorityEmpty counts timed events too) waiting out the
THREE renderers' entity-make streams: every entity/subsystem creation
posts message 3 to each Renderer receiver (classIDs 8/30/36 =
video/audio/gauge), hundreds deep per queue, draining across the whole
window (the audio renderer's share is why sound starts mid-load).

Instrumentation kept: [loadphase] markers (renderer span + busy-pass
counter) always on; GeneralEventQueue::DumpBlockers + the class-named
DumpData fallback behind BT_LOAD_DIAG=1 (hundreds of lines per load).
Optimizing the drain (batching/budget) is an open perf item --
deliberately untouched before the 8-player night.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:41:36 -05:00
arcattackandClaude Opus 4.8 0ef5a3c4e1 Load-phase markers: attribute the teal->green minute
The measured 58s uncontended load has ~0.2s of model loads -- the rest
is unattributed.  Stamp the renderer LoadMission span and count the
CheckLoad passes that find the event queue still busy (each delays the
ready transition by its 1s re-post), so the next live run names the
cost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:28:01 -05:00
arcattackandClaude Opus 4.8 54fd4a4ab0 Timestamps for the launch-chain logs (operator request)
Every relay/console print now carries a wall-clock prefix (_StampedOut
stdout wrapper; the GUI monitor regexes use search() so the prefix is
transparent), and the pod's READY/pend lines carry wall time + tick --
post-mortems measure delays instead of guessing (the 3m40s load
reconstruction needed heartbeat-counting archaeology).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:10:38 -05:00
arcattackandClaude Opus 4.8 224eaff5f8 Console: RE_RELAY_READY name collision -- the new seat-ready regex
silently replaced the WAITING-FOR-OPERATOR matcher (class-body last
assignment wins), breaking the LAUNCH button re-arm for back-to-back
missions.  Renamed the old matcher RE_RELAY_WAITOP; both paths
regression-checked in isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:04:45 -05:00
arcattackandClaude Opus 4.8 9bec4b2050 READY light: pods report load-complete to the operator roster
The roster showed "registered" for a still-loading pod and a ready one
alike -- with multi-minute contended loads the operator couldn't tell
who they'd be waiting on.  When the app ladder reaches WaitingForLaunch
the pod sends one empty route -10 frame on its live relay game socket
(BTRelayNotifyReady; socket mirrored at HELLO); the relay announces
SEAT n READY (k/m loaded) and the console GUI lights the seat
bright-green with a "N pod(s) LOADED+READY" banner.

Verified LIVE by the operator: blue (seated) -> teal (registered) ->
green (ready) in order, twice.  Also resolved the load-speed mystery:
loads are ~30s uncontended; the 1-2+ minute cases were assistant
background test runs competing with the user's interactive sessions on
the same machine (memory note added -- no game-spawning tests while the
user is testing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:57:03 -05:00
arcattackandClaude Opus 4.8 53c86fa1dd Launch pend counter: no timeout, no queue entry (mid-load launch crash 2)
The take-1 deferral (re-post at +1s, capped at 120) turned a SLOW load
into a crash: loads on a busy operator box measure 1-2+ minutes, the
cap expired, and the original "Not ready to run" Fail/abort fired
(field report: sound after ~1 min, crash ~2 min later; the log shows
exactly 120 deferrals all in LoadingMission).

A mid-load RunMission now just bumps gBTRunMissionPendCount -- nothing
enters the event queue and there is NO timeout to outlive -- and
CheckLoadMessageHandler drains the counter immediately after advancing
LoadingMission -> WaitingForLaunch, re-posting that many stack
RunMissionMessages (the engine's own no-console launch pattern at the
same site, which also demonstrates Post spools stack messages safely).

Verified (two-round rig, round 1): both relay launches pended through
the load, fired at load completion, the requested mech spawned and the
scene went live.  Round reset + round-2 rejoin remain verified from the
prior run; why loads take 1-2+ min on a busy box is left as an open
perf question.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:11:03 -05:00
arcattackandClaude Opus 4.8 3a9b35fc06 Round reset + device-creation retry + load-time pump (rejoin trilogy)
1. ROUND RESET (the "picked Hellbringer, got a Blackhawk" field bug):
   trim/prefs finalize the session egg for ONE round, and that was
   permanent for the relay's lifetime -- a rejoin after a mission or
   crash got the stale finalized egg and its new callsign/mech request
   was recorded but never applied.  _maybe_reset_round() (both drop
   paths) restores the original egg/roster/launch state once every pod
   is gone; live beacons re-key by tag position (conn.seat_tag).
   Verified two-round e2e: blkhawk mission -> exit -> RESET -> lok1
   request -> egg rewritten -> the Hellbringer spawned.

2. DEVICE-CREATION RETRY: CreateDevice(HARDWARE_VERTEXPROCESSING) fails
   transiently when a just-exited instance still holds the adapter
   (caught live; also the parked "exit 139" join crash).  The old
   double-failure path called PostQuitMessage(1) -- which does NOT stop
   execution -- and the NULL mDevice deref right after was the real
   crash.  Now: HW->SW retry with 500ms backoff and a 20s deadline,
   then a clean error box + ExitProcess.

3. LOAD-TIME PUMP: mission load is thousands of back-to-back
   synchronous model loads; the starved message pump ghosted the window
   "Not Responding" mid-load.  BTLoadPump() (hooked from
   d3d_OBJECT::LoadObject) pumps + repaints the wait screen at most
   every 150ms pre-run; no-op once the scene is live.

Also: round-reset originals captured after the roster parse (the first
cut crashed the relay constructor -- caught by the two-round test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:57:50 -05:00
arcattackandClaude Opus 4.8 638cfca18d Defer RunMission until the mission load is ready (silent launch crash)
The relay fires the RunMission pair a fixed 20s after the egg ACK -- but
the ACK happens at egg RECEIPT, and a long mission load (glass + dev
gauges on a busy box measured ~50s) is still in LoadingMission when the
launch lands.  L4Application::RunMissionMessageHandler's default case
Fail()ed on that state -- an abort() the 1995 code could afford because
the real console launched operator-paced -- which presented as a SILENT
crash at mission start (c0000409, no WER record; cdb stack:
ucrtbase!abort <- RunMissionMessageHandler <- RoutePacket; 3/3
reproducible; matches the field report's 464 crash that died
mid-LOD-load).

The default case now DEFERS: re-post the message to ourselves at +1s
(the VehicleDead re-post pattern) until the ladder reaches
WaitingForLaunch, bounded at 120 deferrals so a truly wedged load still
surfaces the original Fail.  Verified: the crashing console-shaped run
deferred 104x through its ~50s load, then launched, rendered (scene
LIVE) and ran clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:19:28 -05:00
arcattackandClaude Opus 4.8 66f22fb923 Wait screen: hard cutoff at first real scene present (handoff flicker)
The launch handoff (LoadingMission -> RunningMission) could interleave
the idle wait-overlay presents with the first real game frames (user
report: "the wait screen is still there when game graphics come up").
gBTSceneHasPresented latches at the render path's Present; ExecuteIdle
stands down permanently once set -- the overlay can never paint over
(or alternate with) live graphics.  Permanent one-shot/1Hz [waitscreen]
diagnostics record the active paint path, the scene-live moment, and
idle paints.

Verified: 70-frame 0.5s capture across the handoff -- 54 wait frames,
then game frames with at most ONE ambiguous frame at the cut (was
repeated interleaving); no black frames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 16:49:40 -05:00
arcattackandClaude Opus 4.8 c421ec4af8 Wait screen: paint into the backbuffer, not after Present (load flicker)
The pre-mission overlay painted the window DC AFTER ExecuteIdle's
Present -- every cycle flashed the black D3D frame before the GDI
redraw, visible as flicker while the game loads (user report).  The
overlay now lands IN the presented frame: the backbuffer refuses GetDC
on this driver (D3DERR_INVALIDCALL without LOCKABLE_BACKBUFFER, not
worth forcing game-wide), so the text+spinner GDI-paint goes into a
cached offscreen plain surface (GetDC always legal there) and
StretchRects onto the backbuffer before Present.  D3DPOOL_DEFAULT cache
released at every device-Reset site; window-DC path remains for the
pre-device seat wait and as the fallback; one-shot [waitscreen] boot log
says which path is live.

Verified: 14 rapid PrintWindow captures of the waiting state all carry
the text (pre-fix runs alternated black frames).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 16:33:41 -05:00
arcattackandClaude Opus 4.8 2cccf877f5 Console: Seats spinner replaces Add/Remove pilot in relay mode
Rows are capacity + per-seat defaults now -- joiners auto-populate them
live and launch trims to whoever is seated, so hand-managing rows is
busywork.  A Seats spinner (1-8) maintains the row count in relay mode;
Add pilot / Remove selected remain for mesh mode, where explicit
addresses matter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 15:59:56 -05:00
arcattackandClaude Opus 4.8 3ef179d1a6 Console: retire --reserve for local seats (locked the operator's own
join out -- ROSTER FULL on every seat request, field report).  Local
instances seat-request like every joiner now; trim-on-launch makes
exact seat ownership moot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:54:28 -05:00
arcattackandClaude Opus 4.8 10eee05b20 Launch with whoever connects + live roster display in the console
The operator no longer has to match the roster row count to the exact
player count (or watch the "empty seats will STALL" warning).  Two
mechanisms:

1. PRESENCE BEACONS: the pod keeps its seat-request TCP connection open
   for the process lifetime; the relay reads a live beacon as "seated"
   and a pre-claim FIN frees the seat (no ghost seats -- a ghost stalls
   every pod at the connection gate).  Reservation expiry spares
   beaconed seats.

2. TRIM-ON-LAUNCH (manual/GUI mode): operator LAUNCH before the roster
   fills shrinks the session to the seats present -- egg [pilots]/pages
   trimmed, seat ids REMAPPED by position (pods re-derive their host id
   from their tag's roster position, so walk-up prefs and beacons re-key
   consistently), roster/expected_ids shrink, eggs release, the launch
   pair fires as ACKs land.

GUI: roster rows now update LIVE from the walk-up requests (SEAT n
PRESENT/FREED lines -> callsign/mech cells + a blue "seated" status
light), and LAUNCH MISSION enables from the first seated player with a
"starts with whoever is here" banner.

Verified 2-node: 2-of-4 trim ran the mission with the requested mechs;
and the mid-roster GAP case -- three joined, the middle player quit
pre-launch (beacon FIN freed the seat), trim remapped seat 4->3 with the
survivor's callsign/mech following, both survivors registered under the
remapped ids and ran the mission.  Also: stdin reader guarded against
sys.stdin=None spawn shapes (the GUI QProcess pipe is the supported
operator channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:40:47 -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 3097d269b4 KB: menu-gate bullet updated for the glass desktop default
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:18:42 -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 18ce589f4c btoperator: local instances launch with BT_PLATFORM=glass
The console's one-click local-instance launcher predates the unified build
and never set the platform env -- instances booted the DEV profile (no
PadRIO), so the legacy arrow-drive bridge owned the keyboard instead of the
bindings.txt keymap (arrows drove fwd/back/turn, Alt-reverse dead).  The
join bats already set it; now the operator's QProcess env does too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:12:08 -05:00
arcattackandClaude Opus 4.8 f0918c66b0 Join wait: animate through EVERY pre-mission state
Two follow-ups to the waiting-screen work, both user-reported:

1. "Animation frozen after connecting": once the console seats the player
   and sends the egg, the state advances LoadingMission -> WaitingForLaunch
   -- where NOTHING presents until the operator's RunMission, so the last
   idle frame froze on screen with the mission audio already running.
   ExecuteForeground now keeps the idle clear + overlay alive through
   LoadingMission/WaitingForLaunch/LaunchingMission (no early return -- the
   load/replication work continues), and ExecuteIdle picks per-state text:
     WaitingForEgg    "WAITING FOR MISSION ASSIGNMENT"
     Loading/Launching "LOADING MISSION / stand by"
     WaitingForLaunch "MISSION READY / waiting for the operator to launch"

2. The roster-full retry pause was a raw Sleep(3000) -- with the relay up
   and answering SEAT_FULL instantly, the process spent ~95% of its time
   unpumped (IsHungAppWindow TRUE, spinner frozen).  Now 30 pumped 100ms
   slices with the reason on screen ("session is FULL -- waiting for a
   seat to free up").

Verified live against the running operator relay: roster-full wait shows
the reason text, spinner advances across 500ms captures, IsHungAppWindow
false on every sample.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:40:55 -05:00
arcattackandClaude Opus 4.8 a1c9a71898 Join wait: non-blocking dial -- the spinner no longer stutters
User-reported after the waiting-screen fix: "the animation keeps hanging."
Cause: RelayDialTcp used a BLOCKING connect() -- against a down/unreachable
operator each attempt froze the window thread for the OS SYN-retry window
(seconds), so the spinner ran only during the 2s sleep between attempts.

RelayDialTcp now connects NON-blocking and select()s in 100ms slices,
pumping the message loop + repainting the waiting screen every slice (both
in-flight and between retry attempts).  The success path already returned a
non-blocking socket (FIONBIO), so caller semantics are unchanged; the other
dial sites (console dial, game-socket dial) inherit the smooth behavior.

Verified: 4 window captures at 400ms intervals during a dead-host dial all
differ in the spinner region (uniform diff energy == continuous animation)
and IsHungAppWindow reads false on every sample.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:24:30 -05:00
arcattackandClaude Opus 4.8 f5e0ffcd14 Join wait: animated waiting screen instead of "Not Responding"
The relay seat-wait loop (RelayRequestSeat -- the join/join_lan "patient
walk-up") blocked the window thread's message pump for its whole 30-minute
patience window: dial (2s timeout) -> Sleep(2000) -> repeat, with status
going only to the parent bat console.  Windows flagged the frozen game
window "Not Responding" until the operator started the session
(user-reported).

- BTWaitScreenPaint (L4VIDEO.cpp): a GDI waiting screen on the main window
  -- headline + detail line in the console green + a 12-segment marching
  spinner phased on the tick clock.  Pure GDI: flicker-free during the
  seat wait (no D3D presenting yet), and re-painted after each
  ExecuteIdle Present so the WaitingForEgg phase (previously a bare black
  frame) shows "WAITING FOR MISSION ASSIGNMENT" too.
- RelayWaitTick (L4NET.CPP): PeekMessage pump + paint.  The outer dial
  loop's Sleep(2000) becomes 20 pumped 100ms slices ("WAITING FOR THE
  OPERATOR'S SESSION" / "session at <ip:port> -- close this window to
  cancel"); the inner seat-reply wait pumps per 200ms select slice.

Verified live against a dead relay address: window-only PrintWindow capture
shows the animated screen, and user32 IsHungAppWindow (the API behind "Not
Responding") reports FALSE throughout the wait.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:16:13 -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 1e647e39d7 CREDITS: the printed credits page (via VGL Lynx) -- definitive roster
Lynx supplied a photo of the period credits page (preserved at
reference/manual/credits_page.webp).  It confirms, corrects and completes
the oral-history identifications:

- ECH = Eric Huffman (Audio Director / 3D audio framework) -- corrects the
  uncertain "Eric Nichols" guess
- JMA = Joanna Mason (Simulation Code and Cameras) -- inferred mapping
  confirmed
- CPB = Chris Brewer: "Secondary/Auxiliary Screens and Low Level I/O" --
  the exact MFD gauge/mode layer this project reconstructs
- GDU = Gabe Underwood: "Simulation Code and 'Mech Internal Systems" --
  the heat/coolant/generator/seek economy
- Full roster: Jordan Weisman (producer/designer), David McCoy (art
  director), J.M. Albertson (technical director), Bill Redmann (corporate
  director of technology), plus Corson, Edsall, Ciolek, Olsen, the artists
  (Burlington, Molitor, McCoy, Story; Allen Workshop: Bonilla, Peters) and
  sound (Bing McCoy, Effinger, Huffman).
- 10 software engineers + 6 artists == Lynx's "maybe 8 dev, 6 art" memory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:38:25 -05:00
arcattackandClaude Opus 4.8 fe1cc0d79c docs/CREDITS.md: name the original 1995-96 VWE developers
Source archaeology (the per-file modification tables in the surviving
headers) surfaced six programmer initials: JM, ECH, CPB, GDU, JMA, GAH.
Read to VWE veterans on the project Discord (2026-07-21), VGL Lynx --
who watched the first multiplayer Thor walk terrain on a Red Planet map --
identified them: Jim Albertson, Eric Nichols (uncertain), Chris Brewer,
Gabe Underwood, Joanna Mason (mapping inferred), Garth Hermanson; hardware
initials MC = Marek Ciolek (AC unresolved), via Cyd.

Team size per Lynx: dev ~8, art ~6, MUNGA by a smaller parallel internal
team with crossovers, no outsourcing.  Identifications marked T3 (oral
history, as-typed spellings) against the T0 initials; a corrections
invitation is included.  Linked from context/project-overview.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:35:50 -05:00
arcattackandClaude Opus 4.8 4e5cec06b0 content: commit the TEST.EGG deletion (dev scratch egg, absent from the working tree for days; recoverable from history) -- clears the false dirty marker on version stamps
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:59:14 -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 c18d2b0213 gitignore: local root copies of the player bats (players/ is the source)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:57:22 -05:00
arcattackandClaude Opus 4.8 2717c33e4e play_steam.bat: clear BT_FE_SOLO (full menu even after play_solo in the same shell)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:56:01 -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 aaa252a4a3 play_solo.bat: open the mission menu (mech/map/options pickers)
play_solo hardcoded -egg ARENA1.EGG, bypassing the miniconsole front end
that already carries the full pickers: SOLO/RAW-SOLO/HOST-LAN/JOIN-LAN
modes, 8 maps, all 18 mechs, 4 experience levels, colours/badges/patches,
drop zones, advanced damage, time-of-day, weather and mission length.

The bat now launches zero-arg (BT_PLATFORM=glass -> the menu); the menu
relaunches the exe with the generated egg and the cockpit/gauge env
(BT_START_INSIDE/BT_DEV_GAUGES) inherits into the mission child.  Menu-boot
verified with the bat's exact env; the solo menu->mission->menu cycle was
verified at FE landing.  README + the btoperator generated-bat template
updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:49:01 -05:00
arcattackandClaude Opus 4.8 ec44bc5dfa play_steam.bat: cockpit view + gauges (BT_START_INSIDE + BT_DEV_GAUGES)
The steam bat missed the two client flags the join bats set -- steam-launched
missions booted 3rd-person (the same field bug fixed for join.bat earlier).
Env inherits through the menu's mission relaunch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:42:32 -05:00
arcattackandClaude Opus 4.8 1841c5575e KB: fix the convention line (BT_STEAM is the one compile-gate exception)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:40:24 -05:00
arcattackandClaude Opus 4.8 d53fcf61ad BT_STEAM restored as THE compile gate (license) -- default OFF
The unification flattened BT_STEAM into the always-on exe; the user flagged
the constraint that makes it the one legitimate compile gate: the Steamworks
SDK license bars a public release with Steam inside, so a release build must
exclude it entirely.

- CMakeLists: option(BT_STEAM OFF) restored around the Steamworks block --
  an OFF configure touches no SDK headers, no steam_api.lib, no DLL copy.
  BT_GLASS stays retired (always defined); ONE build dir either way.
  Dev checkout: build/ configured once with -DBT_STEAM=ON.
- mkdist: reads BT_STEAM from build/CMakeCache.txt.  Steam-OFF zips exclude
  play_steam.bat + steam_api.dll (explicitly filtering a STALE DLL left by
  an earlier ON build), strip the README steam section ({STEAM} line
  markers), and take a -nosteam name suffix so the flavors never clobber
  (found live: both stamped 4.11.436 and the release zip overwrote the dev
  zip).

Verified both flavors:
  ON  (dev build):  steam smoke degrades to Winsock without the client;
      zip carries play_steam.bat + steam_api.dll + README steam lines.
  OFF (scratch build-relcheck, deleted after): 0 compile errors; exe dir =
      btl4.exe + OpenAL32.dll only; boots + simulates; BT_STEAM_NET=1 inert
      (transport not in the binary); end-to-end zip has zero steam bits and
      a clean README.

KB: context/glass-cockpit.md unified-build section records the license gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:40:03 -05:00
arcattackandClaude Opus 4.8 dd74d13120 mkdist: redist runtime DLLs + versioned tester README
The first zips (pre-mkdist) shipped d3dx9_43.dll / msvcp140.dll /
vcruntime140.dll next to the exe -- testers without the VC++ / DirectX
redistributables cannot load the exe otherwise.  mkdist missed them (the
clean-extract boot test passed only because this machine has them system-
wide).  Carried into redist/ (recovered from BT411_4.11.341.zip) and
packaged next to the exe.

Also: players/README.txt -- the tester instructions (updated for the
unified build: one exe, clickable cockpit surround, trigger-config regroup,
the settled keymap, play_steam.bat) -- stamped with the version and placed
at the zip root, matching the original zips' shape.

Clean-extract verified on BT411_4.11.435.zip: root has README + 4 bats,
exe dir carries all 4 DLLs, glass-profile boot simulates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:32:11 -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 d89ae71db5 mkdist: include the Steam build + play_steam.bat when present
Rebuilt build-steam fresh (was stale at Jul 20 -- predated today's gauge/
trigger-config/keymap fixes); the DLL scan picks up steam_api.dll
automatically.  Clean-extract verified: the steam exe boots to the front-end
menu (Steam client only needed for live lobbies).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:12:57 -05:00
arcattackandClaude Opus 4.8 8765ea3571 mkdist: name zips by the BT411_<version> convention (btversion.h)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:09:37 -05:00
arcattackandClaude Opus 4.8 f7e674d9cb tools/mkdist.py: tester-distribution zip builder
Packages the player bats (zip root), BOTH exes (build\ pod fallback +
build-glass\ preferred -- the bats auto-select glass = clickable cockpit /
trigger config / bindings.txt keymap), the runtime DLLs next to each exe
(OpenAL32.dll -- the first clean-extract boot test failed without it), and
the git-TRACKED content/ files only (the working tree carries dev junk that
must not ship).  Output: dist/BT411-<date>-<hash>.zip (dist/ gitignored).

Verified: clean-extract boot test -- glass exe boots the GLASS profile and
simulates; pod exe boots as fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:08:37 -05:00
arcattackandClaude Opus 4.8 3b17b60c19 Join bats: same glass-preference treatment as play_solo
join.bat + join_lan.bat + the btoperator.py generator template now prefer
build-glass\Release\btl4.exe when present and set BT_PLATFORM=glass with it
-- so MP sessions on a dev checkout get PadRIO (mouse-clickable cockpit
buttons: trigger config, MFD banks, radar rails) and the bindings.txt keymap,
identical to solo play.  Player zips (which ship only build\) fall back to
the pod build exactly as before.

Verified: 2-node loopback MP smoke on the GLASS build (the documented
procedure: A -egg MP.EGG -net 1501 + B -net 1601 + tools/btconsole.py) --
both nodes cross-connected (GameMachineHost 1502/1602), ~150 sim ticks
each, no errors.  btoperator.py syntax-checked; regenerated bats carry the
same guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:04:02 -05:00
arcattackandClaude Opus 4.8 0e013fd82d KB: trigger-config + keymap unification human-verified
context/glass-cockpit.md: record the BT_PLATFORM=glass/PadRIO click gating
(the dead-clicks root cause), the verified live regroup loop, the [cfgmap]/
BT_BTNTEST diagnostics, and the one-keymap settlement (babf5b1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 09:41:37 -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