Commit Graph
84 Commits
Author SHA1 Message Date
arcattackandClaude Opus 5 ef0ec48f9a map dropdown = the AUTHENTIC console catalog; the relay names a phantom-map stall instead of holding silently
THE 40-MINUTE OUTAGE'S REAL FIX.  The GUI's map dropdown offered every RES
name passing the type-14+26 existence check -- which includes INTERNAL
FRAGMENTS: artrucks is an include-node of arena1 (PROGRESS_LOG map anatomy:
arena1 -> {arenall -> cavern, artrucks}), not a mission.  Picking it stalled
every pod's mission load forever with no error anywhere, and End/Re-arm kept
restoring the poisoned egg -- 22:02..22:49, three "different" failures, one
cause.

  * eggmodel.CONSOLE_MAPS: the Mac 4.10 operator console's own adventure-tree
    catalog (Console.ini; the same 8 the solo menu ships in btl4fe.cpp kMaps):
    cavern grass rav polar3 polar4 arena1 arena2 dbase.  ValueSets.maps now
    offers exactly that, catalog order, filtered to what the RES carries
    (permissive fallback only if the intersection is empty -- a foreign RES
    must not present zero maps).  artrucks is the one fragment the old filter
    let through; now hidden.
  * The relay WARNS at egg load/reload when the mission names a non-catalog
    map (the camo-color-warning precedent: hand-edited eggs still work, the
    operator just cannot miss it).
  * The launch hold gains the PHANTOM-MISSION SIGNATURE diagnostic: 0/N ready
    for 60s means every pod is stalled in shared mission content -- one line
    naming the egg's map and the recovery (End Mission -> fix map -> Re-arm),
    instead of the silent 10s HELD drumbeat the operator stared at for 40
    minutes.  Hint re-arms per launch.

VERIFIED: new suite scratchpad/test_map_guard.py (5 checks: catalog exact +
ordered, artrucks hidden, warning fires on a doctored egg, silent on a real
one, the 60s hint names the map); all five console suites pass.  Python-only.

(While placing the hint, a blind text-insert broke the launch-received block's
indentation -- caught by py_compile before anything ran, repaired, and the
whole area re-verified by the rearm suite.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:19:20 -05:00
arcattackandClaude Opus 5 becaddb662 console logs ROTATE instead of truncate -- tonight's evidence loss cannot repeat
Two destroyers, both in btoperator.py:
  * Start Session zeroed operator_relay.log -- took the whole 23:08 session
    (the reservoir-crash round's relay trace) minutes after it happened;
  * Launch local instances os.remove()'d operator_N.log -- took the pod's
    [crash] self-report block before it could be copied.

Both now rotate the previous file to <name>.1 (btrelay_park's pattern: drop
the old .1, os.replace, never block on a locked file).  One generation of
history is exactly what post-mortems needed twice tonight.  All four console
suites pass; picks up on the console's next restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:13:06 -05:00
arcattackandClaude Opus 5 b7e4837738 seat identity swap (SAURON played as 'Draco'): the LEFT handler ate the pref of a re-seating player
THE CHAIN (reconstructed from the night's logs, then REPRODUCED offline in
scratchpad/test_seat_identity.py before fixing):

  1. At round end every pod relaunches and seat-requests again.  SAURON's
     request was walk-up-assigned the departed Draco's old seat, and his pref
     (callsign/mech) was correctly written -- the assign line even printed
     callsign='SAURON'.
  2. His request conn then closed BY DESIGN (the pod re-dials to HELLO) --
     and the beacon-death branch of _drop_game, added 2026-07-26 for the
     departed-player roster fix, treated any beacon death on an unclaimed
     seat as a LEAVE: it printed a false 'PLAYER n LEFT' and POPPED the pref
     written milliseconds earlier.
  3. The next egg release _reload_egg_file()'d the DISK egg -- where the GUI's
     Start Session had saved the ADOPTED roster names, including 'Draco' on
     that row -- and with no pref left to override it, Draco's callsign
     shipped on SAURON's seat.  His plasma (and that round's score
     attribution) wore the wrong name.  Per-seat HELLO-vs-close ordering
     roulette explains why only one seat swapped.

THE FIX: a LEFT-grace window (SEAT_LEFT_GRACE_SECONDS=15).  A beacon that
dies younger than the grace is the pod's designed post-assign re-dial: keep
the pref and the reservation, print nothing.  A real join-menu leaver has
held the seat far longer, so the departed-player roster fix keeps working
(pop + LEFT exactly as before); claimed seats were already exempt.

REGRESSION SUITE (new, offline, drives the real Relay class -- no ports):
  A. designed instant close: pref survives, seat stays protected  [was FAIL]
  B. real leave (aged beacon): pref popped + seat freed           [unchanged]
  C. claimed seat: pref survives an unrelated conn death          [unchanged]
  D. a different identity assigned onto a held seat overwrites
     the held pref (the operator's original suspicion, locked in) [unchanged]
All four console suites pass (rearm 25, net 17, roster 22, identity 7).

Python-only: no client update needed; the running console picks it up on its
next Start Session.  Awaiting live verification next games night.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:11:59 -05:00
arcattackandClaude Opus 5 6b0402badc park_relay.cmd: resolve a real interpreter -- a second admin account has NO working python
Setting up remote access exposed this: 'python' works for the operator's own
account only.  It resolves through pyenv SHIMS driven by PYENV/PYENV_HOME/
PYENV_ROOT, and all three are USER-scoped to that account; the single
machine-wide PATH entry points at a Python39-32 folder that no longer exists.
So a dedicated remote-admin login (the account just created for SSH/RDP) gets
NO python at all -- parking the relay from the road would have failed with
'python is not recognized', at night, with players waiting.

park_relay.cmd now resolves an interpreter explicitly and prints which one:
BT_PYTHON override -> the project's pyenv 3.11.4 (calling account's profile,
then the operator's -- Administrators can read it) -> the machine-wide py
launcher -> whatever is on PATH -> a clear error naming the fix.  Verified both
as the operator and with a simulated second-account environment (machine PATH
only, PYENV cleared): both resolve 3.11.4.

Also dropped a delayed-expansion bug in the first cut (!CAND! without
EnableDelayedExpansion would have silently evaluated to nothing).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:34:50 -05:00
arcattackandClaude Opus 5 05fdd319d6 parked relay: a supervisor that keeps it alive + a REMOTE restart the operator can actually reach
Completes the travelling-operator deployment.  The relay stays parked on the
home machine (so no player ever edits join.bat) and the console dials in from
anywhere -- but until now two things needed hands on that machine: bringing the
relay back when it died, and restarting it (the only way to clear a wedge or
pick up a roster resize).  Both are covered now.

tools/btrelay_park.py -- the supervisor:
  * runs the relay with cwd=content\ , which is what pins WHICH
    operator_secret.txt is live (the trap documented last commit);
  * relaunches on ANY exit, with 2/5/15/30/60s backoff when a relay dies inside
    20s, so a permanent fault (port taken, missing egg) cannot become a spin;
  * rotates content\parked_relay.log to .1 first, so the dead generation's
    evidence survives the relaunch that replaces it;
  * Ctrl-C stops it for good.  NOT a Windows service on purpose: session 0
    would hide the window an operator wants to tail.
tools/park_relay.cmd -- double-click to park; a shortcut to it in shell:startup
  gives start-after-reboot with no admin rights.

`restart` on the control port (btconsole.py): sets restart_requested, the run
loop returns, relay_main exits RESTART_EXIT_CODE=42 and the supervisor relaunches
-- re-reading the egg.  REFUSED while a mission is running (launches_sent >= 2
and not stop_sent): bouncing then would drop every pod out of a live round.
Local stdin gets the same command for parity.

btoperator.py: Restart Session in REMOTE mode now sends `restart` and reconnects
6s later instead of just tearing down our own link -- the old behaviour looked
like "Restart does nothing" against a parked relay.  (QTimer had to be imported;
it was missing, which py_compile does not catch -- it would have been a runtime
NameError on the first click.)

VERIFIED by scratchpad/test_parked_supervisor.py, 12/12, and the full
test_remote_console_e2e.py re-run green afterwards (15/15):
  * kill the relay -> a NEW pid is serving again, and the old log is kept as .1;
  * remote `restart` -> ACKed, new pid, egg re-read, serving again;
  * the supervisor distinguishes the two -- "operator requested a restart" vs
    the crash/backoff path -- proven from its own log, not assumed;
  * the mid-mission guard is present and wired to launches_sent/stop_sent.

Two harness defects fixed while proving it, both mine: a check written with
`or True` that could never fail (replaced with two real assertions against the
supervisor's log), and a recv that treated the relay's correct
close-after-restart as a ConnectionResetError failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:48:36 -05:00
arcattackandClaude Opus 5 12d6862c1f keylight: unship it -- opt-in (BT_KEYLIGHT=1), gone from all player docs
Operator decision: the player base is not on Windows 11, so the RGB keyboard
mirror should not be shipped or promised.  The feature is NOT deleted -- Cyd's
implementation stays intact behind the gate for whoever wants it -- it is
simply off by default and invisible:

  * PadRIO gate flips from opt-out (BT_KEYLIGHT=0 disables) to OPT-IN
    (BT_KEYLIGHT=1 enables).  Default boots produce zero keylight lines, and a
    stub build and a real build now behave identically for players: nothing.
  * Removed from every user-facing artifact: the README paragraph, the
    CONTROLS.md section, the CONTROLS.html footer line.
  * environ.ini template: the entry stays (it is the discoverable home for the
    opt-in) but now says OFF-unless-1, Windows 11 22H2+, and why it is
    unshipped.
  * mkdist's cut-time stub warning removed -- it existed to protect a README
    promise that no longer exists.
  * context/glass-cockpit.md records the decision, the opt-in, and the
    build-time SDK stub gate in one place.

VERIFIED: default boot logs zero keylight lines; BT_KEYLIGHT=1 engages the
feature (the stub on this machine: its one honest log line + the key map).
All three console suites pass; checkctx CLEAN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:06:00 -05:00
arcattackandClaude Opus 5 c4202d92ca zip-doc audit: last stale key claim + a keylight-stub warning at cut time
Full audit of every user-facing artifact in a freshly cut zip (33 checks, all
passing -- README substitutions/controls/migration story, CONTROLS.txt ASCII
flatten, CONTROLS.html loader + wrap, launchers byte-identical, exe carries the
migration):

  * README's intro still said 'Press V any time to toggle the external camera'
    -- the one key claim outside the rewritten controls block, stale for every
    fresh/migrated install since the board took V.  Backtick now.
  * mkdist warns at cut time when the exe carries the KEYLIGHT STUB (build
    machine's SDK < 10.0.22000), because the README promises the RGB feature
    -- nobody should ship a stub build believing that promise ships with it.

The audit zip itself was deleted after verification: its version stamp predates
today's doc commits, and a release cut should rebuild the exe at final HEAD
first (the 554 procedure).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:51:17 -05:00
arcattackandClaude Opus 5 62e89018f5 Merge glass-cockpit-refit: cockpit scaling + one button geometry, the keyboard button board, RGB keylight, crouch, the cwd guard
Cyd's 9-commit branch, reviewed before merge (clean merge-tree, zero overlap
with the console/relay work that landed after his fork point; his L4VB16 refit
preserves the #48 plane-audit tripwires, and his lamp-decode fix corrects the
#47 flash rendering).  Four review findings are fixed in the follow-up commit:
the stale README controls table, the unguarded backtick view-toggle, the
environ.ini one-shot loophole, and volume-key documentation (the -/= -> PgUp/
PgDn move itself landed pre-merge so this branch's Comm-bank -/= bindings are
collision-free).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:13:08 -05:00
arcattackandClaude Opus 5 c9e561d9ba console review round 2: six must-fixes from the adversarial pass (one of its fixes corrected)
The 4-dimension review of 4736cba..820caf8 returned 14 surviving findings.  All
six must-fixes plus the four deferables are in; one of the review's own
prescriptions was wrong and is fixed differently (below).

THE REAPER, FINAL DOCTRINE (blocker + major).  Rev 2 -- "reap anything silent in
the staging window" -- was still wrong twice over: a pad sends exactly ONE
message in its life (the egg ACK, L4NET.CPP:1259) and is then silent FOREVER, so
any manual-launch hold >180s reaped every healthy ACKed pad and force-relaunched
their clients; and a REGISTERED game conn is quiet on TCP while loading, so with
BT_RELAY_TCP_ONLY=1 (or blocked UDP) the reaper _abort_round()ed the whole night
every ~3 minutes blaming a healthy player.  The rule is now: an app-level
deadline is valid only while a RESPONSE IS OWED.  Only egg-sent-never-ACKed pads
are reaped, with the debt clock starting at EGG SEND (a pad that sat through a
long held-egg wait gets its full window -- an edge neither review round caught);
game conns are never reaped at all.  Half-open ghosts are TCP keepalive's job
(~130s, faster than the deadline anyway).

RE-ARM GUARDED ON EVERY CHANNEL (major).  The launch_at guard lived only on the
LAUNCH-press path; the ctl `rearm` command, stdin, and the always-lit GUI button
reached _rearm_for_new_round unconditionally -- one press mid-mission zeroed the
counters, permanently killing the mission clock AND making End Mission print "no
mission is running": an unstoppable round, the exact class this feature exists
to eliminate.  Now refused (loudly) while a mission is running or a pair is in
flight, and the button greys while launched.  THE REVIEW'S OWN FIX WAS WRONG
here: it prescribed refusing on `launches_sent >= 2`, but that stays 2 after a
FINISHED round -- applying it verbatim resurrected the original dead-LAUNCH
wedge, caught immediately by the regression suite.  "Running" is
`launches_sent >= 2 AND not stop_sent`.

RE-ARM RE-KEYS SEATS (major).  It restored the template roster but left
seat_beacons/seat_prefs keyed by the trimmed round's positional ids, so round
2's trim minted a departed player's tag into the egg and trimmed a present
player's out, shifting every callsign/mech a slot.  The re-key block is factored
out of _maybe_reset_round (_rekey_seats_to_roster) and shared.

RESTART SESSION NO LONGER BAKES A WALK-UP'S NAME INTO THE EGG (major).
_stop_session and _start_session now restore the stashed configured
callsign/mech into the cells BEFORE _collect_egg can snapshot them
(_restore_seat_defaults); previously the departed name went into the egg (name=
plus rasterized bitmaps) and was then re-captured as the seat's permanent
"configured default".

LATE REMOTE OPERATOR GETS A ROSTER (major).  The only line the GUI can adopt
tags from was printed once at relay startup and aged out of the 400-line control
history in ~33 minutes of stats chatter -- AUTH now re-issues the live roster
line ahead of the replay, so a remote operator connecting at any point gets
pilot lights and a working LAUNCH button.

DEFERABLES, all four: _drop_game blames the tag stashed AT REGISTRATION (the
live-roster resolve named the wrong tag for a trimmed-round conn dying after a
re-arm restore -- and the tag-trusting GUI would clear the wrong seat); an
operator-BLANKED callsign cell now restores (empty string is a real configured
value; the falsy skip left the departed name up and re-captured it); a returning
player displaces their own half-open registered ghost (same IP, no mission
running) instead of eating ROSTER FULL until keepalive fires; udp_spoofed now
rides the [relay-stats] line when non-zero and the warn set is capped at 256.

VERIFIED: rearm suite grown to 25 checks (ACKed pad never reaped however silent;
never-ACKed pad reaped; game conns never reaped; the egg-send debt clock; re-arm
refused mid-mission, allowed after round end) -- plus net 17/17, roster 22/22,
checkctx CLEAN.  Live rig: mid-mission `rearm` refused with the message and the
mission survived; End Mission -> re-arm -> full re-seat -> second mission
launched.  One bounded artifact observed and documented: each waiting pod
bounces once (identity resync) after an explicit re-arm.

Docs rewritten to the final doctrine (context/operator-console.md reaper +
re-arm + roster-replay + udp_spoofed sections; OPERATOR_GUIDE + tooltip now say
re-arm is between-rounds-only and warn about the one-bounce resync).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:11:07 -05:00
arcattackandClaude Opus 5 820caf8765 console review follow-up: two edge cases in my own roster fix, found and fixed
Self-review of 3a69448 (plus an adversarial pass still in flight) turned up two
real defects in the work I just shipped:

1. THE STASH LIFECYCLE LEAKED ACROSS SESSIONS AND EGGS.  _seat_defaults was
   keyed by ROW INDEX and initialised once in __init__ -- never cleared on Start
   Session, on Open/New egg (which rebuilds the table), or on a seat-count
   change.  Concrete harm: stop a session while a seat is OCCUPIED (the stash is
   only popped when a seat empties, so it survives), reconfigure or open a
   different egg, start again -- and when that seat next empties the GUI
   restores LAST SESSION'S snapshot over the new configuration.  Fixed by (a)
   keying the stash by TAG, the seat's actual identity, so a changed roster
   makes stale keys inert instead of landing on whatever row now sits at that
   index, and (b) clearing it at both natural boundaries: session start (both
   local and remote paths -- the clear sits before the remote branch) and the
   table rebuild in _load_egg_into_ui.

2. POSITIONAL hostID->tag MAPPING IS WRONG AFTER A LAUNCH TRIM -- and 3a69448
   widened its blast radius.  The GUI resolves REGISTERED/dropped lines via
   host_tag(), position into the UNTRIMMED egg roster; after a trim remaps seat
   ids (e.g. the lone returning player reclaim-held at seat 3 becomes host 2),
   those lines light the wrong row -- and, with the new seat_info pop on
   dropped, CLEAR the wrong seat's identity, leaving the real one's name up:
   the exact reported bug, resurfacing in the trimmed case.  Fixed on both
   sides: the relay's REGISTERED and dropped prints now carry tag='...' from
   its LIVE (possibly trimmed) roster via a new _tag_of() helper, and the GUI
   regexes prefer the explicit tag with the positional fallback kept for old
   logs.  SEATED/READY/LEFT already carried tags; only these two were
   positional.

VERIFIED
  * test_operator_roster.py grown to 22 checks, all passing: post-trim lines
    light and clear the RIGHT row while the wrong row stays untouched; the
    legacy no-tag format still resolves positionally; a stopped-while-occupied
    session cannot leak GHOST into the next session's empty seat; a stale
    foreign-tag stash never touches a live row.
  * Live 2-pod rig: both new formats confirmed on the wire
    (REGISTERED (1/2) tag='...' / dropped: closed tag='...'), mission launched
    and stopped cleanly, round RESET intact.  rearm 19/19, net 17/17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:54:13 -05:00
arcattackandClaude Opus 5 3a694485bf operator UI: a departed player no longer keeps their seat in the roster
Operator report: "if a player disconnects from the console, it leaves their name
up in the roster ... it should turn their seat back to empty."  I had not noticed
or fixed this -- the earlier review raised adjacent roster issues (lights mapped
through the untrimmed tag list after a trim) but not this one.

CAUSE.  The GUI only ever WROTE walk-up names into the table.  On a disconnect
the relay pops seat_info, the write-back loop hit `if not info: continue`, and
the cell kept the departed callsign for the rest of the session -- while the
pilot light beside it correctly said "waiting".  So the table and the light
disagreed and a free seat looked occupied.

Two changes, because the relay's two seat-clearing signals are NOT symmetric:

  * SessionMonitor now pops seat_info when a pilot goes idle from EITHER signal.
    "PLAYER n LEFT" is only printed inside the beacon-close branch gated on
    `if seat not in self.by_host` (the relay's own comment: "never claimed:
    player left"), so a player who was fully REGISTERED and then dropped
    produced only `game[...] dropped` -- the common case, and the one that left
    the name up.  Keying "seat is empty" off LEFT alone could never have worked.
  * _refresh_pod_status stashes the operator's configured callsign/mech PER
    OCCUPANCY when a seat fills, and restores it when the seat empties.
    Per-occupancy rather than once at egg load, so an edit the operator makes
    while a seat is empty is respected instead of being overwritten by a stale
    snapshot.

It restores the CONFIGURED PILOT, not a literal blank, deliberately: that cell is
editable and is what gets written to the egg on Save, so a placeholder like
"-- empty --" would end up in the mission file.  The "unoccupied" signal is the
grey `waiting` light beside it.

NOT CHANGED, and explained instead: a vacated seat is held for its player for 90s
(seat_reclaim) so a crash or a reconnect returns them to the same seat and mech.
Assignment skips claimed/reserved/reclaim-held seats, so a brand-new joiner in
that window gets the next free seat, or ROSTER FULL if there wasn't one; after 90s
the seat is fair game.  The operator said they were happy with players keeping
their seat/address, so this stays -- it is now documented in both docs rather than
silently removed.

VERIFIED: scratchpad/test_operator_roster.py (new, 14 checks) drives the REAL Qt
widget offscreen and feeds it the REAL relay log lines: a walk-up name appears;
after REGISTERED then `dropped` the light returns to waiting AND the seat shows
the configured pilot again; the seat is reusable by a different player and clears
for them too; the `PLAYER n LEFT` path clears it as well; an operator edit made
while the seat was empty survives an occupancy; and a neighbouring row is never
touched by another seat's traffic.  Other suites still pass (rearm 19/19, net
17/17); checkctx CLEAN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:43:18 -05:00
arcattackandClaude Opus 5 ab5828a866 relay/console: fix the three remaining hardening items from the review
1. UDP ENDPOINT HIJACK.  `from_host` in a UDP envelope is the sender's OWN claim,
   and the relay used it directly to refresh that host's downstream endpoint --
   so ANY datagram claiming host N silently stole host N's traffic (a zombie pod
   from a previous round, a stale NAT mapping, or anyone who guessed a host id).
   The victim simply stopped receiving on a channel that still looked healthy.
   The claim is now bound to the identity we actually authenticated: the IP of
   that host's live TCP game connection.  The PORT is deliberately not checked --
   it moves on a NAT rebind, which is the whole reason the endpoint map refreshes
   per datagram -- and a genuine IP change cannot happen without the TCP
   connection breaking and re-registering, so a legitimate pod is never rejected.
   Rejections are counted (udp_spoofed) and logged once per offending (host, IP).
   Known limit: two pods on one machine share an IP, so this cannot separate
   them; same-machine trust is assumed.

2. THE EGG ACK COULD BE MISSED ENTIRELY -- a silent, unrecoverable wedge.  The
   pod's ACK is the launch gate's ONLY signal, and it was detected by parsing a
   single recv() at fixed offset 0 behind a `len(data) >= 24` test.  On a real
   network (loopback hid both cases) the 28-byte ACK arriving SPLIT -- 16 bytes
   then 12 -- was dropped by that test and never looked at again, and two
   COALESCED messages meant only the first was read.  A missed ACK means that
   seat never counts toward the gate, so the round can never be released.
   _console_read now buffers per connection and walks every complete frame
   (16 + messageLength); an implausible length drops the connection WITH A REASON
   rather than mis-reading that pod all night, since a stream protocol cannot be
   resynced by guessing.  Bounds: CONSOLE_MSG_MAX 64K, CONSOLE_INBUF_MAX 1 MiB.

3. REMOTE-OPERATOR MODE WAS HALF A CONSOLE.  LAUNCH and END MISSION could never
   enable (both conditions required `console_proc is not None`, which the remote
   path never sets), the pilot lights were permanently blank (SessionMonitor was
   built with an empty tag list, so `seated` was always 0), and Launch-local was
   refused by the same guard even though the code below it already built
   BT_RELAY from the remote host.  All three enable conditions now accept EITHER
   channel, and the monitor ADOPTS THE ROSTER from the relay's
   "roster: N pilot(s) -> hostIDs [...]: [...]" line -- which the relay replays to
   every newly AUTHed operator -- so a remote operator gets real lights and a
   real seat count.

VERIFIED
  * scratchpad/test_relay_net.py (new, 17 checks): spoofed datagram cannot move
    an endpoint and is counted; a NAT rebind on the same IP still is honoured; an
    unregistered host id still dropped; the ACK is found whole, split in two,
    split three ways mid-header, and when hiding behind another message;
    a garbage length drops the conn; the roster line is adopted, maps a
    subsequent SEATED onto a real tag, lifts `seated` off 0, and re-feeding it
    preserves known state.
  * Live 2-pod rig: real pods ACKed through the new reassembly path (2/2 ready,
    zero desync drops), the mission launched and ran, UDP flowed throughout
    (93 rx / 85 tx, udp-known [2,3]) with ZERO false spoof rejections, then a
    clean StopMission + round RESET.
  * scratchpad/test_relay_rearm.py still 19/19; checkctx CLEAN.

Docs updated to match (context/operator-console.md gains reassembly and
anti-hijack sections; the guide no longer claims remote mode is crippled).
Remaining open items are now recorded in the topic's frontmatter: mesh mode's
operator buttons are inert by construction (no stdin reader in that path -- left
alone, mesh self-launches), _log_launch_readiness can cry STALL on a healthy
launch-with-whoever, and a straggler's late FIN is still misread as a pod dying
mid-load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:35:09 -05:00
CydandClaude Opus 5 5f88a4eeb2 The keyboard becomes the button board (RP412 bindings port) + a controls page
Ported RP412's bindings design and made it the DEFAULT: the letter and number
rows are the MFD button banks laid out WHERE THEY SIT ON THE PANEL, flight
moves to the numpad so the board stays free, F1-F12 are the map's two columns,
and G/B stay unbound as the physical gap between the lower clusters.  Expressed
in BT's OWN grammar (slew/deflect/set, the Turn channel) -- bindings.txt is a
documented compatibility surface and was not touched.

Coverage: 61 of 72 addresses on the keyboard, and the 11 absent ones are
exactly those the pod never wired (0x16/0x17/0x1E/0x1F column gaps, 0x38-0x3E
intercom/door).  All 72 stay clickable on the panel.

It lands on BT's addresses unreasonably well: 1-4 + QWER are the ENTIRE coolant
system (Condensers 1-6, flush, balance), F6/F7 the display and control-mode
cycles, F9-F12 Generators A-D, F4 the crouch button reconstructed earlier
today.

THE DELIBERATE COST.  A bound key is removed from the authentic 1995
typed-hotkey channel so it cannot double-dispatch, and this board binds nearly
everything -- so 5 (Quad page), z (Eng1), t/y/u/i/o (pilot select) and +/-
(target zoom) are given up.  Unbind a key to get its 1995 meaning back.
Documented in the file header, the markdown and the page.

NEW RULE: A BINDINGS ROW WINS OVER A BUILT-IN CONVENIENCE KEY
(PadRIO::KeyHasBinding).  V and J/K/L are board buttons now, and those polls
read GetAsyncKeyState directly -- without this they would have fired BOTH the
button and the built-in.  View toggle lives on BACKTICK alone.

CONTROLS.MAP rewritten to mirror the board so glass/pod/dev still feel
identical (the 2026-07-21 settlement): 90 bindings, 0 parse complaints.

HAT LABELS CORRECTED [T1].  INPUT_PATH_AUDIT flagged 0x41-0x44 and was right.
Settled from the streamed mapping (BT_CTRLMAP_LOG): elem 66 -> subsys 17
attrID 14, i.e. 0x42 is the TORSO subsystem, not a look; 0x44/0x43/0x41 are the
mapper's LookLeft/LookRight/LookBehind (attrID 10/11/12).  The .RES has no
"TORSO CENTER" string -- its names are LookBehind/Down/Forward/Left/Right -- so
the audit's wording was loose but its substance correct.  Swept L4GLASSWIN,
L4PADPANEL and L4VB16.

docs/CONTROLS.html: interactive keyboard (hover a key for its address and
meaning, MFD clusters outlined), pad diagram, panel map, the under-glass
press-target figure, radar placement thumbnails and the environ.ini table --
modelled on RP412's page, rewritten for BT's addresses and hazards.  mkdist
wraps the fragment in a doctype/charset shell and ships it beside CONTROLS.txt.

Verified: bindings.txt regenerates and loads (74 keys, 0 errors); CONTROLS.MAP
90 bindings, 0 errors; 72/72 panel addresses still dispatch; surround /
exploded / dock / pod / dev boot and simulate with zero faults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 03:23:27 -05:00
CydandClaude Opus 5 02b1b50e45 RGB keyboard lamp mirror + the shipped controls reference
KEYBOARD LAMP MIRROR (L4KEYLIGHT).  Ported from RP412, itself a port of vRIO's
KeyboardLampMirror.  Keys bound to a lamp address in bindings.txt glow with the
panel palette through Windows Dynamic Lighting -- yellow for the map's side
columns (0x10-0x1F), red for the rest -- flashing in step with the on-screen
buttons (its LampLevel copy matches the FIXED BTLampBrightnessOf).  Per-key
boards light each bound key; zone-lit boards mirror the strongest lamp
board-wide.  All WinRT runs on a private worker thread: watcher, claim, and a
100 ms paint loop that repaints only on change.

RP412's packing hazard does NOT transfer: it forces default struct packing there
because its engine is /Zp1, which would break the WinRT ABI.  BT411's BT_OPTS
carries no /Zp, so only the dialect flags are needed -- /std:c++17
/permissive- per-file, since the project otherwise builds C++14 /permissive.
The scalars-only interface is kept anyway so the isolation survives if packing
is ever added.

Wired in PadRIO: map built from bindings.keyBindings (ActionButton binds only,
first-binding-wins per key), fed from PadRIO::SetLamp, stopped in the dtor
(which hands the LEDs back to Windows).  BT_KEYLIGHT=0 opts out; a machine
without Dynamic Lighting logs once and stays dormant.

Verified live: claimed this machine's 24-zone keyboard and mirrors 25 bound
keys; BT_KEYLIGHT=0 and the pod profile produce zero keylight lines and run
clean.

SHIPPED CONTROLS REFERENCE.  docs/CONTROLS.md carries the keyboard table plus
the full 72-BUTTON POD MAP, grouped by the display each bank surrounds, with the
coolant-valve detent warning (1-5-50-CLOSED, one press past max shuts the loop)
and the jam/eject procedure.  mkdist flattens it to ASCII as CONTROLS.txt at the
zip root, the README's own idiom.  players/README.txt gained pointers to it, to
environ.ini, to -fit, and to the RGB mirror.

KB: context/glass-cockpit.md and docs/GLASS_COCKPIT.md updated for the whole
branch -- layout modes, L4RIOBANK and the under-glass rule, the letterbox fit
and its ordering trap, player-tunable displays, the measured legend grid, the
closed dead-button backlog, and this mirror.

Verified: five-mode regression (surround/exploded/dock/pod/dev) boots and
simulates with zero faults; mkdist writes a 5149-byte pure-ASCII CONTROLS.txt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 02:24:30 -05:00
arcattackandClaude Opus 5 1cda880c6d console/relay: document it properly + fix two regressions the review caught
DOCS (the ask: after a compaction this session lost track of how the console
works and launched the wrong program, twice).

  * NEW context/operator-console.md -- the dedicated topic that was missing.
    Leads with the thing I got wrong: btoperator.py is the PySide6 GUI the
    operator uses; btconsole.py is the headless relay it spawns.  Then ports,
    the route table, the roster/seat/identity model, the full round lifecycle
    with every launch gate, liveness, mode-specific traps, and log locations.
  * NEW docs/OPERATOR_GUIDE.md -- sysop-facing: start the console, set up a
    mission, watch pods arrive, launch, run back-to-back rounds, what to press
    when LAUNCH looks dead, a troubleshooting table keyed on the exact log
    lines, and what to save BEFORE restarting a session (Start Session
    truncates operator_relay.log, so restarting to clear a problem destroys the
    evidence of it).
  * CLAUDE.md: two Quick Lookup rows + a DO-NOT entry naming the two programs,
    so the distinction survives the next compaction.
  * context/multiplayer.md: a pointer out of the scattered console notes to the
    new topic (they were buried across ~8 places in a large file, which is
    exactly why they evaporated).

FIXES -- both are regressions in my own previous commit, found by the review
pass, and one would have made a games night WORSE:

  * THE REAPER WOULD HAVE KILLED HEALTHY PLAYERS.  It was gated only on "no
    mission running", which a round RESET satisfies -- so it was armed for the
    whole BETWEEN-ROUNDS wait, and that is a period when a pod is legitimately
    byte-silent: its seat beacon is write-only for the process lifetime
    (L4NET.CPP: "the relay ignores its silence") and its console pad has no egg
    yet so it cannot ACK.  A real night showed 12-minute and 5-minute gaps; the
    180s deadline would have dropped healthy pods and forced their clients to
    relaunch.  It now runs ONLY in the active staging window, skips pads with no
    egg and conns that have not HELLO'd, and last_seen is also stamped from
    inbound UDP (a pod streaming updates while its TCP idles was being counted
    as silent).  Half-open detection is keepalive's job; this is just a backstop.
  * UnboundLocalError in the operator UI.  My end_sent reset was an `elif` in
    the chain that assigns `head`, so that branch left `head` unbound -- a crash
    on the first status refresh after End Mission, which is exactly the path the
    relay's "StopMission sent" line produces.  Moved out of the chain.

Plus one pre-existing wedge with the same symptom as the reported bug, live-
proven in operator_relay.log (~5 minutes of a night lost): _abort_round clears
eggs_released BEFORE the survivors' sockets close, so _maybe_reset_round's own
`if not self.eggs_released: return` skips the template restore forever -- the
roster stays trimmed, the release gates can never be met, and walk-ups get
ROSTER FULL.  _rearm_for_new_round's restore is therefore now UNCONDITIONAL (it
was gated on eggs_released, which made Re-arm useless in the one state that most
needs it) and it clears round_hold_until so an abort's settle window is not
inherited.  Aborts are ordinary: nothing on the wire distinguishes a straggler's
late FIN from a pod dying mid-load.

scratchpad/test_relay_rearm.py now 19 checks, all passing, including the two new
regression guards (a 15-minute-silent waiting pod is NOT reaped; a pad that was
never sent an egg is NOT reaped) and the mid-pair no-re-arm guard.

Still open, recorded in the new topic's frontmatter: the UDP endpoint map trusts
the sender's self-declared fromHost; the egg-ACK is a fixed-offset parse of one
recv with no reassembly; remote-operator mode can never enable LAUNCH.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:36:14 -05:00
arcattackandClaude Opus 5 4736cba1ca relay: LAUNCH is never silently inert again -- fix the post-round wedge (operator report)
THE SYMPTOM: "after a game ends I push LAUNCH and nothing happens until I reset
the session entirely or reboot the console."  The operator also observed the
mirror image, which is the tell: with a STABLE group, relaunching worked fine
several times in a row.

ROOT CAUSE.  Two gates, both all-or-nothing, and a UI latch that agreed with
them:
  * btconsole.py: the manual LAUNCH branch is guarded on `launches_sent == 0`,
    but a finished mission leaves it at 2.  The only paths back to 0 were
    _check_launch_gate (needs EVERY seat of the last round to re-ACK) and
    _maybe_reset_round (needs EVERY pod gone).  A games night lives between
    those -- most pods rejoin, one player closes their window.  Worse, the
    "not all seats filled" diagnostic sits INSIDE the `launches_sent == 0`
    guard, so in exactly that state NOTHING was printed.
  * btoperator.py: the LAUNCH button is gated on `not monitor.launched`, and
    `launched` was cleared ONLY by the two relay lines those same gates emit
    ("WAITING FOR OPERATOR", "round RESET").  So the button was greyed out in
    precisely the wedged state -- the click was a no-op by construction.
That is why a stable group worked (everyone re-ACKs -> gate fires) and why only
a session restart recovered (fresh relay process = fresh state).

FIXES
  * An explicit operator LAUNCH is now sufficient authority to start a new
    round: _rearm_for_new_round() clears the finished round's state and restores
    the template roster/egg, KEEPING seats/beacons/connections, so whoever is
    here stays here.  Triggered on a press while a round is latched.
  * New `rearm`/`newround` operator command + a Re-arm button, so recovery never
    needs a session restart.  Proven live over the control port.
  * Every operator command is logged AT RECEIPT with the state that decides its
    fate, so "did it arrive or was it ignored?" is answerable from the log.
  * A stale End Mission can no longer kill the next mission: _tick_stop consumed
    a stop_requested set between rounds by latching it until launches_sent hit 2,
    then StopMissioning the new mission in the tick it launched -- also
    indistinguishable from "launch did nothing".  It is now consumed + announced
    while idle.  The UI's end_sent likewise reset per round, not per session
    (End Mission used to work exactly once a night).
  * The UI clears `launched` on "StopMission sent" and on the re-arm line, so the
    button returns when the round actually ends, and only greys once a command
    has really been written (a dead relay now says so instead of logging
    ">> sent" into the void -- _console_finished leaves console_proc non-None).

HARDENING (the "reboot the console" half)
  * SO_KEEPALIVE on every accepted socket + a last_seen deadline and a reaper:
    nothing detected a pod that vanished WITHOUT a FIN (sleeping laptop, dropped
    Wi-Fi, killed process, NAT timeout).  Such a conn kept acked=True forever,
    which permanently blocked the round reset and held a phantom seat.  The
    reaper stands down while a mission is running.
  * Every pod-facing send went blocking with NO timeout on the single-threaded
    selector loop, so one wedged peer could freeze the entire relay.  All sends
    now go through _send_all_guarded (10s timeout, drops the peer on failure).
  * _send_egg no longer calls getpeername() on a possibly-dead socket.

REGRESSION I INTRODUCED AND CAUGHT ON THE RIG: the first cut re-armed whenever
launches_sent >= 1, which also matched the NORMAL state between RunMission #1 and
#2 (launch_requested deliberately stays set across the pair).  That reset the
pair mid-flight and re-released eggs every tick -- a re-arm storm that kicked
every pod into an identity-resync loop.  The re-arm now additionally requires
`launch_at is None`, i.e. no launch sequence in flight.  Verified: 0 REJOIN lines
and exactly 1 RE-ARM line across a full 3-mission rig session.

VERIFIED
  * scratchpad/test_relay_rearm.py -- 6 groups, all passing: the exact wedge
    state recovers; a stale stop is consumed while idle; staging is NOT restarted
    under an impatient operator; mid-pair never re-arms; the happy path is
    untouched and RunMission still reaches the pods; the reaper drops a silent
    conn and keeps a live one, and stands down mid-mission.
  * Live 2-pod rig (scratchpad/rig_relay.ps1 + relay_ctl.py over the control
    port): two clean back-to-back missions, StopMission, round RESET, a live
    `rearm`, and LAUNCH-with-nobody-present now printing why instead of nothing.

NOT reproduced end-to-end: the field wedge needs 3+ pods with staggered rejoins
(this rig's pods re-exec together, so the all-gone reset always fires).  The
state itself is covered by the unit test.  Awaiting live confirmation on a real
games night.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:21:24 -05:00
arcattackandClaude Opus 4.8 210bdb05ee Remote operator control channel + THE teardown crash root-caused and fixed
REMOTE OPERATORS (dev-channel ask): the relay grows a control TCP
listener (console port + 7) speaking a line protocol -- AUTH <secret>
(auto-generated content\operator_secret.txt), then launch / stop /
ping / get mission / set key=value;...  The relay's timestamped log
stream tees to the authenticated operator (bounded per-conn buffers --
a stalled operator can NEVER stall the relay; overflow = drop), with a
400-line history replay on connect so mid-session operators see
current state.  One operator at a time; a new AUTH displaces the old.
stdin commands unchanged (rigs/GUI local mode untouched).

The operator GUI gains a 'Relay host' field: blank = today's local
flow byte-for-byte (child relay dies with the window); a hostname =
drive that machine's relay remotely (launch/stop/apply-settings over
the wire, roster rebuilt from the teed log, local-instance joins point
at the remote relay).  Only the relay host needs port forwarding.

Scripted protocol test: auth reject/accept, takeover, get/set, ping,
history replay, and a FULL 2-node mission launched and stopped
entirely over the socket.

CRASH FIX (the layout-shifting heisenbug -- and almost certainly issue
#35): the new crash self-report finally captured its 24-frame stack:
InterestManager::OrphanInterestOrigin -> RemoveUninterestingEntity ->
DestroyEntityAudioObjects -> {Static,Dynamic}3DPatchSource::
IsAudioSourceClipped, AV reading NULL+0x38 -- the unguarded chain
GetMissionPlayer()->GetPlayerVehicle()->GetSimulationState() (the
authentic burning-mech death-silence check) hit a NULL vehicle during
MP teardown windows.  Both sites now null-guard; burning semantics
preserved whenever the vehicle exists.  Also live-confirms that
interest-based entity teardown RUNS in MP (the #38 paint mechanism).
Regression: full combat round + control-channel drive, zero crashes.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:35:25 -05:00
arcattackandClaude Opus 4.8 c2ee7299b2 Generic joystick / HOTAS / pedals support: DirectInput 8 layer + capture wizard
Tester ask: configure non-Xbox controllers.  New L4JOY layer (DI8):
enumerates every non-XInput game device (up to 4), DIJOYSTATE2 polling
with range normalization, reacquire-on-loss, 3s hot-plug re-enum.
XInput devices are excluded via the documented RawInput IG_ VID/PID
check (live-verified skipping a real XBOX360 pad -- no double-feed).

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:23:23 -05:00
arcattackandClaude Opus 4.8 87ef6a85f9 Console: STAGING visibility + launch re-arm in launch-with-whoever mode
Field report (2026-07-23): with open seats the roster stays blue until
LAUNCH -- correct (loading cannot start before the roster is finalized,
and the LAUNCH press is what finalizes a partial roster) -- but the
operator lost all visibility into the post-launch staging, and worse,
the LAUNCH button's re-arm rode the "WAITING FOR OPERATOR" line that
only prints on a FULL-roster ACK, so in partial mode the button stayed
dead after round 1.

- The relay's 10s "launch HELD -- still loading: PLAYER n (k/m ready)"
  lines now surface as the status banner ("STAGING -- PLAYER 2 still
  loading (1/2 ready)") while the per-seat lights walk teal->green.
- "round RESET" re-arms the console (launched/ready/held cleared) so
  every round's LAUNCH works regardless of roster fullness.
- The standby banner explains the mode: seated players are STANDING BY,
  LAUNCH starts their loading, the mission fires itself at all-green.
- The seated count includes registered/ready players (it undercounted
  once pods advanced past blue).

Monitor states unit-tested (held -> launch -> clear -> reset -> re-arm).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:28:56 -05:00
arcattackandClaude Opus 4.8 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 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 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 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 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 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 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 cae616dc7c join_lan.bat: pin the operator's LAN IP (auto-discovery was unreliable)
Field report: LAN auto-discovery (BT_RELAY=auto) didn't work -- a joiner had to
hand-set BT_RELAY=10.0.0.46:1500 to reach the console every time.

- players/join_lan.bat: BT_RELAY=auto -> BT_RELAY=10.0.0.46:1500 (the operator PC's
  LAN IP), with a comment on updating it if the IP changes.
- tools/btoperator.py: the export now DETECTS the operator PC's LAN IP (UDP
  default-route probe, no packet sent) and stamps BT_RELAY=<lan-ip>:port into the
  generated join_lan.bat instead of "auto" -- so regeneration keeps it correct and
  doesn't revert to the broken auto-discovery.  Verified: detects 10.0.0.46 here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 22:27:12 -05:00