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>
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>
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>
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>
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>
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>
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>
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>
Night-2 capture: one flapping pod cascaded 3 ROUND ABORTs in 8s; every
client relaunch-churned (~1 gen/s) until the D3D device-creation 20s
deadline fired on every machine ('could not connect direct 3d'), and
claim-less restarts minted duplicate roster seats.
Console (relay):
- Round abort clears stale console-conn ACK flags and HOLDS the next
egg release for an 8s settle window (_tick_settle re-releases when
quiet) -- the abort->instant-re-release->drop->abort cycle is broken.
- HELLO from a stale identity gets a REJOIN reply (identity resync via
the seat-request path, 5s/IP rate limit) instead of a bare drop.
- STATIC SEATS: seats remember (IP + callsign); a claim-less restart
from the same machine+name RECLAIMS its own seat (displacing its
zombie beacon) instead of minting a duplicate -- the pod contract:
same box, same seat, always.
- Operator local instances log with BT_LOG_APPEND (the night-2 crash
loop left a 2-line file; generations now leave a full trail).
Client:
- Relaunch storm damper: a generation that lived <15s sleeps 5s before
respawning (storm decays to a gentle cadence; normal multi-minute
round relaunches untouched; menu clicks explicitly never delayed).
Rig-verified: normal round + mid-MISSION drop = no abort (correct);
mid-LOAD kill = exactly ONE abort + 8s hold + seat reclaimed + no
cascade. The night-2 'host 7' identity-derivation subtlety remains
under forensics (append logs + first-breath line will capture it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Field 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>
The between-rounds arcade flow (first-playtest-night field request): at
mission end every pod exited, seats dropped, and every round required
everyone to re-run join.bat with no way to adjust the mission.
1. AUTO-REJOIN: StopMission (operator END / mission clock -- NOT a
window close) sets gBTMissionStoppedByConsole; after the matchlog
upload btl4main relaunches the pod into the join wait with the same
cmdline (BT_CALLSIGN/BT_MECH ride the inherited environment).
2. SEAT RECLAIM: the assigned tag is mirrored file-scope
(BTRelaySelfTag) and re-presented as the 3rd SEAT_REQUEST payload
field (BT_SEAT_CLAIM); the relay holds a dropped seat for its tag
for 90s and a returning player gets their EXACT seat back -- static
ordering across rounds, the real-pod model. Non-claim joiners skip
reclaim-held seats.
3. LIVE MISSION EDITS: the console gains "Apply mission settings"
(enabled while a session runs) writing arena/time/weather/length
into the session egg; the relay re-reads the file at round reset and
egg release (roster shape locked mid-session).
Verified 2-pod 3-round: pods self-relaunched after each clock end; the
SECOND-to-rejoin still reclaimed its original seat (ordering held); the
between-rounds edit landed ("mission length now 75s"). Known edge
noted in the KB: a stale ready flag from a not-yet-exited old conn can
satisfy the launch gate mid-rejoin -- operators launch on green dots.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 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>
'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>
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>
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>
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>
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>
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>
The Launch-local-instances path pinned BT_SELF, which claims a seat
directly and SKIPS the seat request -- the join menu (and the callsign/
mech choice it carries) never ran, so a local test instance couldn't
exercise the walk-up flow. Relay-mode local instances now launch
zero-arg with BT_FE_JOIN=1: the JOIN-GAME menu opens, the relay assigns
the seat, and the choice lands in the egg like any join.bat player.
Each instance gets a distinct relaunch port via BT_FE_JOINPORT
(btl4main JoinRelay case; default 1501) -- two -net listeners can't
share a port on one box. Mesh-mode locals keep the direct path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 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>
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>
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>
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>
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>
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>
Solo practice already started in-cockpit (BT_START_INSIDE) but didn't
open the MFD gauges window -- inconsistent with the join bats. Add
BT_DEV_GAUGES=1 so single-player practice mirrors the pod experience.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report: a remote player had smooth gameplay but in 3RD-PERSON with
NO gauges. Cause: view mode + gauges are CLIENT launch flags, not
something the relay/console can enforce -- and join.bat set neither, so
remote players booted in the game's desktop default (external camera, no
MFDs). The operator's own local launch sets both (the app's 'Start in
cockpit' + 'Dev gauges' checkboxes, on by default), which is why the
operator saw the correct view but remote players didn't.
The pod is a first-person cockpit with MFD gauges and no '3rd person' at
all, so cockpit is the right default for players. join.bat/join_lan.bat
now set BT_START_INSIDE=1 + BT_DEV_GAUGES=1. (Players can still press V
to toggle the external camera.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report: after a mission timed out normally, relaunching a mission
did nothing. The relay was single-mission -- once it fired a launch
(launches_sent==2) it never re-armed.
Fix (operator-side only; the BINARY needs nothing -- a rejoined pod is a
brand-new process at WaitingForLaunch):
- btconsole _check_launch_gate: when a previous mission finished
(launches_sent==2) and ALL seats have RE-ACKED (every pod exited,
re-ran join.bat, reconnected), reset the launch state and re-print
'WAITING FOR OPERATOR LAUNCH'. Only reachable on a pod ACK (never
mid-mission). Launch is CONSUMED after RunMission #2 (launch_at=None,
launch_requested=False) so the next press is a deliberate new round.
- btoperator SessionMonitor: reset on each new
'WAITING FOR OPERATOR' so the LAUNCH button RE-ENABLES for mission 2
(it stayed disabled forever before -- the other half of the bug).
Verified e2e: two full missions on ONE relay session -- mission 1 runs +
times out on the clock, pods rejoin, relay re-arms + re-announces ready,
operator LAUNCH -> mission 2's RunMission #1/#2 fire (total fires 2) ->
pods running. BACK-TO-BACK PASS. Friends' zip unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report: a remote player ran join.bat from the wrong place (inside
the zip, or the .bat separated from content\/build\) and got 'The system
cannot find the path specified' + dumps -- the relative 'cd %~dp0content'
failed cryptically.
Now each bat: cd to its own dir, verify build\Release\btl4.exe +
content\<egg> exist, else jump to :badpath and print a clear message
('this file must sit in the extracted folder next to content and build;
right-click the zip - Extract All and run it from there'). Verified: run
from a dir with no content/build -> the guidance prints, no dump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two additions after the live-session troubleshooting:
1. LAUNCH-SHORT is now VISIBLE (btconsole). Pressing LAUNCH with an
empty roster seat previously did NOTHING (eggs_done_at only sets when
ALL seats ACK, so the manual-launch arming silently no-op'd -- the
'I pressed launch and nothing happened' confusion). Now the relay
logs 'LAUNCH pressed but NOT all seats are filled' + a per-seat
readiness line + a WARNING naming the empty seat(s) and the fix
(reduce the roster). When all seats DO fill it auto-arms (launch
request persists). Verified: 2/3 filled -> clear warning naming
seat3.
2. Friend logs are now retrievable (btoperator exporter). join.bat /
join_lan.bat set BT_LOG=join.log and their exit message tells the
player to send content\join.log to the operator if the game closed
unexpectedly -- closes the friend-side-crash blind spot (their log
was the generic btl4.log, un-named, easy to miss).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every field bug this session needed the relay's OWN view to diagnose,
but the app only shows it in the (post-mortem-unreadable) GUI log pane.
Now _console_output also appends to content/operator_relay.log (fresh
per session; stderr is merged so relay tracebacks are captured too).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FIELD BUG (2026-07-18): operator's local instance crashed + the mission
never launched. Root cause (confirmed by code inspection + stub test,
NOT the earlier mis-read of stale logs): the relay assigns a no-BT_SELF
joiner 'the lowest free seat'. The operator's local instance uses an
EXPLICIT seat (BT_SELF) but takes ~15s to boot; a remote join.bat
requests a seat immediately and gets assigned the operator's seat 1
first. The operator's later HELLO for seat 1 -> 'already registered'
-> dropped ('closed by relay') -> its seat sits empty -> the
All-connections-completed gate never fires -> game never starts.
Fix: the operator's LOCAL seats are RESERVED. btoperator passes the
Local roster tags as ; the
relay excludes those host_ids from seat ASSIGNMENT (an explicit HELLO
still claims them). Verified by stub test: a racing joiner is assigned
host 3 (seat 1 skipped), the operator's HELLO for the reserved seat 1
registers fine.
Also fixed the UX bug that produced the EARLIER 4-window mess: Local
now defaults checked only on the operator's own seat (row 0), not every
relay roster row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User: 'isn't the game supposed to time out tho?' Yes -- and it was
HALF-implemented: the egg's [mission] length drives the pods' countdown
and the final-30s ranking window, and the full StopMission -> fade ->
end chain exists, but nothing ever fired it: in 1995 the CONSOLE sent
StopMission at expiry. Our console never did, so missions ran forever.
- btconsole relay: arms the clock when RunMission #2 fires; sends
Application::StopMissionMessage (clientID 4, msgID 6 per APP.h:383,
exitCode NullExitCodeID) at length + 2s grace; stdin 'stop' command
ends the mission early (works in auto and manual modes).
- btoperator: END MISSION button (enabled once launched; resets per
session).
Verified live: 40s test mission -- relay logged the armed clock, sent
StopMission on time, the pod ran the authentic end chain and exited
cleanly ('[boot] RunMissions returned'). Standard eggs carry
length=600, so real sessions are now authentic 10-minute pod missions
with the score display in the last 30 seconds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A zip recipient can dork around offline -- no -net argument means the
game self-launches a solo mission (all maps ship in content/). Solo
gets its own exit message (btl4.log pointer, no server language).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report (first internet-join attempt): both join bats 'hard
crash' on the remote machine. Root cause was procedural -- the host
session was not running -- but the failure PRESENTATION was the bug:
Release Fail() is a bare abort(), so a dead relay = silent process
death + instantly-closing console window.
- L4NET: the two player-facing dead-ends (LAN discovery no-answer,
seat request unreachable/full) now show a MessageBox saying what
happened and what to do before exiting (verified live against a
dead relay: 'BattleTech -- can't join the game' appears).
- join bats: echo + pause after exit so the window stays readable;
join_lan.bat header now says it is LAN-only (the remote user ran
both -- join_lan can never work over the internet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'Launch local instances' did nothing visible when no roster row had
Local checked (log pane quietly said 'launched 0') or when the session
was not started yet (instances would dial a dead relay and fail after
the bounded retry). Both now pop a clear dialog saying what to do.
Found live: user pressed the button with both Local boxes unchecked
and no session running -- 'nothing happened'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 1995 console let the operator type player callsigns; the pods render
them from 1bpp BITMAPS streamed in the egg ([largebitmap]/[smallbitmap]
lists + hex raster pages, bitmapindex per pilot; large 128x32 on the
score/ranking display via DPLRenderer name textures, small 64x16 on the
kill-feed and radar/target labels). The textual name= pilot key is dead
in the shipped binary -- its pilot parse reads bitmapindex only (decomp
part_014). Historical bug surfaced by this work: every egg in the repo
listed only ONE name bitmap (Aeolus) while pilot 2 pointed at index 2,
so player 2+'s callsign has never rendered (null-checked, silently
blank).
eggmodel.py: EggDoc.set_callsigns rewrites the lists + one raster page
per callsign per size + per-pilot name=/bitmapindex= (duplicate names
get disambiguated page tags); get_callsign_rows decodes pages back
(exact inverse of the engine's nibble-stream parse, GRAPH2D.cpp:340,
MSB-first bit order :558); rasterize_callsign/make_callsigns render
text via Qt (auto-shrink to fit, NoAntialias, 1bpp threshold) -- the
only PySide6-dependent corner of the module. validate() now catches
dangling bitmapindexes and missing raster pages.
btoperator.py: Callsign column in the roster (free text, PLAYERn
default); every save rasterizes the column into the egg exactly like
the original console did.
MP_BHMC.EGG template repaired: authentic Aeolus raster preserved
bit-exact, Boreas page generated and listed.
Verified: eggmodel self-test 20/20 (bit-exact raster round-trip, dup
disambiguation, dangling-index catch); ASCII-art decode of the
app-generated VIPER/MONGOOSE rasters reads correctly; live 2-node
relay session on the generated egg -- full ladder, launch, zero
name-bitmap load warnings; operator GUI e2e PASS after the column
shift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pod launched with no BT_SELF asks the relay for a seat before joining:
new control frames SEAT_REQUEST (-6) -> relay reserves the lowest roster
seat not claimed or reserved (60s reservation so simultaneous joiners
can't race onto one seat) -> SEAT_ASSIGN (-7, int32 hostID + NUL tag)
becomes relaySelf; everything downstream (egg self-match, HELLO) runs
exactly as if BT_SELF had been set. Roster full -> SEAT_FULL (-8) ->
clean Fail(). A real HELLO pops the reservation; a pod drop frees the
seat. Explicit BT_SELF still claims a specific seat (the operator's
local launches use it).
Client: L4NetworkManager::RelayRequestSeat (throwaway TCP dial to the
relay game port, 10s reply window). Relay: SEAT_REQUEST branch in
_handle_game_frame. Operator exporter collapsed from per-seat
join_as_playerN.bat to ONE universal join.bat (+ join_lan.bat with
BT_RELAY=auto) -- every player gets the same file, first come first
served, the arcade walk-up-to-a-pod model. players/ regenerated.
Verified: 8/8 seat stub tests (distinct seats in roster order, FULL on
exhaustion, claimed+reserved stays FULL, HELLO claim accepted, duplicate
HELLO refused); 2-node localhost e2e with NO BT_SELF on either pod ->
both seated, full ladder, RunMission pair, UDP flowing post-launch;
regression smoke: explicit-BT_SELF relay session clean with zero seat
requests, classic mesh (no BT_RELAY) un-regressed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found live during the port-forward verification: external port-checker nodes
(and by extension any internet scanner) that connect to the exposed console
port were streamed the egg AND counted as pods ('1/2 pods have it' from a
node in Turkey) -- a lingering scanner could hold a phantom roster slot or
nudge the ready/launch state.
Real pods send AcknowledgeEggFileMessage (clientID=0, msgID=4) after parsing
the egg (L4NET StartConnecting); scanners never speak the protocol. The
launch gate (auto timer AND manual-launch READY) now counts only console
connections that ACKED:
- RelayConsoleConn.acked; _console_read detects the ACK, logs
'pod ACK from <addr> (n/m ready)', drives _check_launch_gate.
- _eggs_out (egg_sent count) replaced by _pods_ready (acked count);
gate logic extracted to _check_launch_gate (called on each ACK).
- btoperator monitor regex updated to the ACK line.
Verified: scanner-simulation (3 lingering non-protocol connections + 1 real
ACK on a 3-pilot roster -> gate NOT tripped, ACK counted 1/3); full operator
e2e PASS (registered -> READY held -> operator launch -> both pods LAUNCHED).
(The two intermediate e2e failures during this work were a stale port-check
relay squatting on 1500 -- a test-rig cleanup bug (Get-Process CommandLine
filter matches nothing; use Get-CimInstance Win32_Process), not the
hardening.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The operator now holds the lobby: with 'Manual launch' checked (default),
the relay stops at 'all pods have the egg; WAITING FOR OPERATOR LAUNCH'
instead of auto-firing on a 20s timer -- the app shows 'ALL PODS READY --
press LAUNCH MISSION' with the roster lights, and the big green button
fires the mission when the operator is ready (everyone seated, voice chat
confirmed). A 'Restart session' button cycles the same mission/roster for
the next round (players just re-run their join script).
- btconsole.py --relay --manual-launch: launch armed by the line 'launch'
on stdin (a daemon reader thread; the app's Launch button writes it via
QProcess). The settle window is still honoured relative to egg delivery
(max(now, eggs_done + 20s)) -- firing RunMission before the pods reach
WaitingForLaunch Fail()s them, the same hazard the auto timer guards.
Auto mode (no flag) unchanged -- CLI/test recipes unaffected.
- btoperator.py: Manual-launch checkbox (Network box, default on), LAUNCH
MISSION button (enabled by the relay's READY line, disabled after fire),
Restart session button, status headline shows the ready state.
- operator_e2e.py: now exercises the gate -- waits for READY, asserts the
button enabled, presses it, verifies the operator-gated LAUNCH.
Verified: scripted e2e PASS -- registered -> READY (held) -> operator
launch -> settle honoured -> both pods LAUNCHED (operator-gated=True).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LAN players now find the game with zero configuration -- the 90s arcade
model (cabinets locate the operator station) restored:
- Client (L4NET RelayDiscover): BT_RELAY=auto broadcasts 'BTR1DISC' on
udp/15999 (255.255.255.255 AND 127.0.0.1 -- broadcast doesn't reliably
loop back on Windows; covers same-box sessions), 5 x 1s attempts; the
answering relay's SOURCE IP + its advertised console port become the
relay address. Explicit <host>:<port> path unchanged; mesh untouched.
- Relay (btconsole.py): best-effort discovery responder on udp/15999
answers 'BTR1HERE' + <u16 consolePort>; degrades gracefully (logged) if
the port is taken.
- Operator app: Export player scripts now writes a join_as_playerN_lan.bat
pair (BT_RELAY=auto) next to each internet script -- LAN guests
double-click and are found; internet guests use the public-host script.
Verified 2-node: both pods BT_RELAY=auto -> probe answered through a real
interface (not just loopback) -> discovered 172.19.x.x:1500 -> full session
to RunningMission. KB: multiplayer.md D1 section updated (+ the operator
console entry).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The operator station the archive never preserved: build missions with
authoritative dropdowns, run the console/relay, watch pods arrive, launch --
LAN mesh or internet relay -- plus one-click local instances for testing.
tools/eggmodel.py (headless, importable, self-tested 14/14):
- Order-preserving egg parse/emit: the editor manages [mission]/[pilots]/
per-pilot pages; bitmap rasters, ordinals and role pages survive untouched.
- EVERY value set read LIVE from content/BTL4.RES (the resscan parser,
importable): maps = MakeMessageStream(14) INTERSECT ExistanceBoxStream(26)
(the game aborts unless both exist); colors/badges/patches from the
VehicleTable resource (type 25, NUL-line NotationFile text with ;-comments);
vehicles = ModelList names, known-good mechs (bhk1, madcat) surfaced first.
- validate(): the full required-key ruleset from MISSION.cpp/btl4mssn.cpp
(missing map/time/weather/scenario, per-pilot hostType/vehicle/dropzone/
color/patch/badge/experience/role, role page model=, illegal values,
duplicate addresses) -- kills the hand-edited-egg crash class, incl. the
color=Red-vs-Crimson gotcha that bit the dev eggs.
tools/btoperator.py (PySide6, dark Fusion theme):
- Mission form + pilot roster table (all dropdowns from eggmodel), relay-tag
auto-numbering, add/remove pilots, egg New/Open/Save/Validate.
- Mode switch: Relay (internet; runs btconsole --relay) / Mesh (LAN legacy;
dial-out console at address-port minus 1 per the +1 rule).
- Session panel: QProcess drives btconsole.py; stdout parsed live into
per-pilot state lights (waiting/egg/registered/LAUNCHED) + relay stats.
- Local launcher: spawns btl4.exe per roster row with BT_RELAY/BT_SELF (or
mesh -net port), BT_DEV_GAUGES/BT_START_INSIDE toggles.
- Export player scripts: per-pilot join_as_playerN.bat with the public
relay hostname (a remote player's whole setup = run one .bat).
- Wire/protocol code stays in btconsole.py/eggmodel.py -- the GUI is only
UI + process management; CLI + test harnesses share the same tested core.
Verified: eggmodel self-test 14/14; scripted end-to-end
(scratchpad/operator_e2e.py) drives the real window through its own methods --
start relay session -> launch 2 local instances -> monitor observes the
mission LAUNCH (user-confirmed live on screen: both pods in-mission).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>