diff --git a/context/operator-console.md b/context/operator-console.md index ed71895..0e20d51 100644 --- a/context/operator-console.md +++ b/context/operator-console.md @@ -202,7 +202,7 @@ surviving pods' sockets close, so every later `_maybe_reset_round` hits its own three automatic release paths compare against `len(self.roster)`, so with N−1 pods back nothing releases, walk-ups get `ROSTER FULL`, and the relay sits dead. -`_rearm_for_new_round`'s template restore is therefore **unconditional** — an earlier cut gated it +`_rearm_for_new_round` is **guarded on every channel** (ctl command, stdin, the GUI button -- the guard lived only on the LAUNCH path at first, so a ctl `rearm` mid-mission killed the mission clock and made the round unstoppable): it refuses while a mission is RUNNING (`launches_sent >= 2 and not stop_sent` -- NOT `launches_sent` alone, which stays 2 after a finished round and would resurrect the original wedge) and while a launch pair is in flight. It also re-keys `seat_beacons`/`seat_prefs` to the restored roster (`_rekey_seats_to_roster`, shared with the round reset) -- without that, round 2 trimmed the WRONG seats into the egg. Its template restore is **unconditional** — an earlier cut gated it on `eggs_released`, which made Re-arm useless in the one state that most needs it. It also clears `round_hold_until` so an abort's settle window is not inherited. @@ -229,19 +229,20 @@ phantom seat in the launch count. Only restarting the relay cleared it. Now: keepalive on every accepted socket (`_enable_keepalive`), a `last_seen` stamp on every read **and on every inbound UDP datagram**, and `_reap_dead_peers()` with `DEAD_PEER_SECONDS = 180`. -**The reaper is deliberately narrow, and the narrowness is load-bearing.** It runs **only in the -active staging window** — egg released, mission not yet running — and skips console pads with no egg -sent and game conns that have not HELLO'd. The first cut was gated only on "no mission running", -which armed it for the entire **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 the pod sets its own keepalive on it), and its -console pad has no egg yet so it cannot ACK. A real night showed 12-minute and 5-minute gaps -between rounds, so a 180 s deadline would have dropped **healthy** players and forced their clients -to relaunch — strictly worse than the ghost the reaper exists to remove. Caught in review before it -ever ran live. Regression guards: `scratchpad/test_relay_rearm.py` cases 5b/5c. +**The reaper's final doctrine (third revision — review 2026-07-26 — KEEP IT):** an app-level +deadline is valid only where silence is ABNORMAL, i.e. while a protocol response is OWED. The only +thing reaped is a console pad that was sent the egg and never ACKed within `DEAD_PEER_SECONDS` +(measured from EGG SEND, not from connect — a pad that sat through a long held-egg wait must get its +full window). Everything else is TCP keepalive's job (~130 s detection via `SIO_KEEPALIVE_VALS`). -Half-open sockets are TCP keepalive's job; this deadline is only the backstop for platforms where -the keepalive tunables do not take. +Why each earlier revision was wrong, so nobody re-widens it: +- Rev 1 armed it whenever no mission ran → it would have reaped healthy pods during 12-minute + BETWEEN-ROUNDS waits (a waiting pod is byte-silent by design). +- Rev 2 armed it for the whole STAGING window → it reaped every healthy **ACKed** pad 180 s into a + manual-launch hold, because a pad sends exactly ONE message in its life (the ACK) and is then + silent forever; and it reaped **registered game conns** that are quiet on TCP while loading + (fatal with `BT_RELAY_TCP_ONLY=1` or blocked UDP: `_abort_round` bounced everyone, blaming a + healthy player, every ~3 minutes). Game conns are now never reaped at all. ### Console-protocol reassembly [FIXED 2026-07-26] @@ -264,8 +265,7 @@ id — and the victim simply stopped receiving on a channel that still looked he 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 this never rejects a legitimate pod. Rejections are -counted (`udp_spoofed`) and logged once per offending `(host, IP)` pair. +TCP connection breaking and re-registering, so this never rejects a legitimate pod. Rejections are counted and now SURFACED — `spoofed N` rides the `[relay-stats]` line whenever non-zero — and logged once per offending `(host, IP)` pair (the warn set is capped at 256 under a flood). Limitation worth knowing: two pods on the **same machine** share an IP, so this cannot tell them apart — same-machine trust is assumed. @@ -320,8 +320,11 @@ doing nothing: `Launch local instances` was refused by the same guard even though the code below it already built `BT_RELAY` from the remote host. Now: all three enable conditions accept **either** channel (`console_proc` *or* `remote_link`), 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 pilot lights and a real seat count. + `roster: N pilot(s) -> hostIDs [...]: [...]` line, which the relay **re-issues from live state to + every newly AUTHed operator ahead of the history replay** — relying on the startup line alone + broke after ~33 minutes, when stats chatter aged it out of the 400-line history and a + late-connecting remote operator got a blank roster and a dead LAUNCH button. So a remote operator + gets real pilot lights and a real seat count at any time of night. - **`Start session` silently overwrites the egg on disk** (including re-rasterized callsigns), with no prompt and no backup. - **`Apply` writes only the six `[mission]` keys** (map/time/weather/scenario/temperature/length). diff --git a/docs/OPERATOR_GUIDE.md b/docs/OPERATOR_GUIDE.md index 8a45c7b..91eadfc 100644 --- a/docs/OPERATOR_GUIDE.md +++ b/docs/OPERATOR_GUIDE.md @@ -100,6 +100,12 @@ button is greyed out, or pressing it does nothing: Re-arm clears the finished round's state and re-opens the launcher **while keeping everyone who is already connected**. You do **not** need Stop Session / Start Session, which disconnects everybody. +Re-arm is **for between rounds only**, and the console enforces that: while a mission is running it +is greyed out (and the relay refuses it with *"press End Mission first"*), and while a launch is in +the middle of firing it is refused with *"launch sequence in flight"*. So you cannot break a live +round with it. Expect each already-waiting player's client to bounce once (a few seconds) right +after a Re-arm — that is the seat re-sync, not a crash. + **Why this happens:** the console used to only consider itself ready for a new round if *every* player from the last round came back, or if *all* of them left. In between — the normal case when one person closes their window or their client crashes — it got stuck, and said nothing. That is @@ -157,7 +163,7 @@ raise the seat count if you're simply out of seats. | `egg HELD (n/N pods present)` | normal staging — waiting for the rest of the seats | wait, or press LAUNCH to start with who is here | | LAUNCH greyed out after a round | the launcher is still holding the last round | press **↻ Re-arm** | | `LAUNCH pressed but NO players are seated yet` | nobody has claimed a seat | check the players are pointed at the right address/port | -| `LAUNCH pressed but NOT all seats have ACKed` | some pods are still taking the mission file | wait a few seconds; if one is never coming, press **Re-arm** | +| `LAUNCH pressed but NOT all seats have ACKed` | some pods are still taking the mission file | wait a few seconds; if one is never coming, press **Re-arm** (between rounds only — it is refused mid-launch/mid-mission) | | `launch HELD -- still loading: PLAYER n` | that pod has not finished loading | wait; it force-starts after 3 minutes | | `*** WARNING: ... EMPTY seat(s) ... the mission will STALL` | **can be a false alarm** after the roster is trimmed | if the round starts and plays fine, ignore it | | `seat RECLAIMED by identity` | a player who dropped got their seat back | nothing — this is working as intended | diff --git a/scratchpad/test_relay_rearm.py b/scratchpad/test_relay_rearm.py index 717b0d9..ccd4da5 100644 --- a/scratchpad/test_relay_rearm.py +++ b/scratchpad/test_relay_rearm.py @@ -127,20 +127,66 @@ check("RunMission actually reached the pods", all(c.sock.sent for c in r.console_conns), "%d pkt(s) to pod 1" % len(r.console_conns[0].sock.sent)) -print("\n=== 5. REAPER: reaps a silent peer DURING STAGING only ===") +print("\n=== 5. REAPER: only a pad that OWES an ACK is ever reaped ===") +# Doctrine (2nd review, 2026-07-26): silence is abnormal ONLY while a response +# is owed. An ACKed pad's silence is protocol-normal FOREVER (the ACK is its +# only message ever) -- the earlier cut reaped every healthy pad 180s into any +# manual-launch staging hold. Half-open ACKed ghosts are TCP keepalive's job. r = new_relay() r.launches_sent = 0 -r.eggs_released = True # staging: the egg is out, pods should be talking -dead = FakeConn(acked=True) -dead.last_seen = btconsole.time.time() - (btconsole.DEAD_PEER_SECONDS + 30) -live = FakeConn(acked=True) -r.console_conns = [dead, live] +r.eggs_released = True +owes = FakeConn(acked=False) # egg sent, never ACKed: dead +owes.egg_sent_at = btconsole.time.time() - (btconsole.DEAD_PEER_SECONDS + 30) +owes.last_seen = owes.egg_sent_at +acked_quiet = FakeConn(acked=True) # healthy: silent since its ACK +acked_quiet.last_seen = btconsole.time.time() - 9999 +r.console_conns = [owes, acked_quiet] r.game_conns = [] dropped = [] r._drop_console = lambda c: (dropped.append(c), r.console_conns.remove(c)) r._reap_dead_peers() -check("the silent conn was reaped", dead in dropped) -check("the live conn was kept", live in r.console_conns and live not in dropped) +check("the never-ACKed pad was reaped", owes in dropped) +check("an ACKed pad is NEVER reaped however silent", acked_quiet not in dropped) + +print("\n=== 5d. REAPER never touches game conns (loading pods are quiet) ===") +r = new_relay() +r.launches_sent = 0 +r.eggs_released = True +class FakeGame: # registered, TCP-only, loading + addr = ("10.0.0.7", 700); host_id = 2 + last_seen = btconsole.time.time() - 9999 +r.console_conns = [] +r.game_conns = [FakeGame()] +r._drop_game = lambda c, why: fails.append("game conn reaped: " + why) +r._reap_dead_peers() +check("a silent REGISTERED game conn is never reaped", + not any("game conn reaped" in f for f in fails)) + +print("\n=== 5e. the ACK-debt clock starts at EGG SEND, not connect ===") +r = new_relay() +r.launches_sent = 0 +r.eggs_released = True +early = FakeConn(acked=False) # connected long ago (held egg) +early.last_seen = btconsole.time.time() - 9999 # ...but the egg JUST went out +early.egg_sent_at = btconsole.time.time() - 5 +r.console_conns = [early] +r.game_conns = [] +reaped = [] +r._drop_console = lambda c: reaped.append(c) +r._reap_dead_peers() +check("a pad whose egg just went out is given its full window", not reaped) + +print("\n=== 5f. RE-ARM refusals: running mission / in-flight pair ===") +r = new_relay() +r.launches_sent = 2 +r.stop_sent = False # mission genuinely RUNNING +r.eggs_released = True +r.console_conns = [] +r._rearm_for_new_round("test") +check("re-arm REFUSED while a mission is running", r.launches_sent == 2) +r.stop_sent = True # round has ENDED +r._rearm_for_new_round("test") +check("re-arm allowed once the round has ended", r.launches_sent == 0) print("\n=== 5b. REAPER MUST NOT TOUCH A WAITING POD BETWEEN ROUNDS ===") # The regression this guards: a pod waiting for the next mission is byte-silent diff --git a/tools/btconsole.py b/tools/btconsole.py index cd54940..d8757f9 100644 --- a/tools/btconsole.py +++ b/tools/btconsole.py @@ -657,6 +657,7 @@ class Relay: _send_all_guarded(conn.sock, pkt, "egg chunk") n += 1 conn.egg_sent = True + conn.egg_sent_at = time.time() # the ACK-debt clock starts NOW print(f"[relay] console conn {addr[0]}:{addr[1]}: egg sent " f"({n} chunks); awaiting pod ACK", flush=True) except OSError as e: @@ -803,6 +804,30 @@ class Relay: print(f"[relay] seat-pref egg rewrite FAILED ({e!r}) -- " f"serving the original egg", flush=True) + def _rekey_seats_to_roster(self): + """Re-key live beacons/prefs by each TAG's position in the CURRENT + roster. Needed whenever the roster changes shape (round reset, re-arm): + seat ids are positional, so a beacon keyed by a TRIMMED round's host id + points at the wrong seat once the template roster is restored -- a + departed player's tag was being minted into the next round's egg while a + present player's was trimmed out (review 2026-07-26). Beacons whose tag + is no longer in the roster are dropped from the maps (their seats free + up); reservations are cleared wholesale, matching the round-reset + semantics.""" + old_beacons = self.seat_beacons + old_prefs = self.seat_prefs + self.seat_beacons = {} + self.seat_prefs = {} + self.seat_reservations = {} + for old_id, conn in old_beacons.items(): + tag = getattr(conn, "seat_tag", None) + if tag in self.roster: + new_id = FIRST_GAME_HOST_ID + self.roster.index(tag) + conn.seat_host = new_id + self.seat_beacons[new_id] = conn + if old_id in old_prefs: + self.seat_prefs[new_id] = old_prefs[old_id] + def _maybe_reset_round(self): # All pods gone from a FINALIZED round -> restore the template so the # next round's joins are honored (held egg, fresh prefs/trim). Live @@ -827,78 +852,55 @@ class Relay: self.stop_sent = False self.stop_requested = False self.mission_started_at = None - old_beacons = self.seat_beacons - old_prefs = self.seat_prefs - self.seat_beacons = {} - self.seat_prefs = {} - self.seat_reservations = {} - for old_id, conn in old_beacons.items(): - tag = getattr(conn, "seat_tag", None) - if tag in self.roster: - new_id = FIRST_GAME_HOST_ID + self.roster.index(tag) - conn.seat_host = new_id - self.seat_beacons[new_id] = conn - if old_id in old_prefs: - self.seat_prefs[new_id] = old_prefs[old_id] + self._rekey_seats_to_roster() print(f"[relay] round RESET -- roster/egg restored " f"({len(self.seat_beacons)} player(s) already waiting)", flush=True) def _reap_dead_peers(self): - """Age out peers that stopped speaking entirely. + """Age out a peer that OWES a protocol response and has gone silent. - TCP keepalive (set on accept) handles most half-open sockets, but it is - best-effort and platform-dependent, and a peer can also be alive at the - TCP level while its game process is gone. A silent connection is what - used to pin the relay: an acked console conn blocks the round reset - forever, and a registered game conn holds its by_host seat, so BOTH - launch re-arm paths stay unreachable and the operator has to restart. + THE DOCTRINE (rewritten twice in review, 2026-07-26 -- keep it): an + app-level deadline is only valid where silence is ABNORMAL, i.e. while a + response is owed. Everything else is TCP keepalive's job (set on every + accept; ~130s detection via SIO_KEEPALIVE_VALS), which kills the socket + and lands in the normal recv/drop path. - Deliberately generous (DEAD_PEER_SECONDS): a pod legitimately sends - nothing while sitting at the front end between rounds, so this must only - catch the genuinely gone. Never applied while a mission is running -- - an in-mission pod that goes quiet is the netcode's problem, not ours. + What a healthy peer's silence looks like, so nobody re-widens this: + * a console pad sends exactly ONE message in its life -- the egg ACK. + After ACKing it is silent FOREVER. The first cut reaped ACKed pads + 180s into any staging hold (a manual-launch operator waiting on a + straggler), force-relaunching every healthy client. [blocker] + * a REGISTERED game conn is loading the mission: quiet on TCP until + READY, and its 15s UDP HELLOs never arrive if the pod runs + BT_RELAY_TCP_ONLY=1 or UDP is blocked (the documented TCP fallback). + Reaping it _abort_round()s the whole night, every ~3 minutes, + blaming a healthy player. [major] + * between rounds every pod is byte-silent by design (the seat beacon + is write-only: L4NET.CPP "the relay ignores its silence"). + + So the ONLY thing this reaps is a console pad that was sent the egg and + never ACKed within DEAD_PEER_SECONDS -- a wedged or dead client holding + the eggs_done gate open. Debt paid (acked) = exempt. No egg = exempt. + Game conns are never reaped here at all. """ - if self.launches_sent >= 2: - return # mission running: leave peers alone - if not self.eggs_released: - # - # BETWEEN ROUNDS / BEFORE THE EGG GOES OUT, SILENCE IS CORRECT and - # can last a long time (a real night showed 12- and 5-minute gaps - # while the operator set up the next mission). A waiting pod has - # nothing to send: its seat beacon is deliberately 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. Reaping here - # would drop HEALTHY players and force their client to relaunch, - # which is worse than the ghost this reaper exists to remove. - # (Caught in review 2026-07-26, before it ever ran on a real night.) - # + if self.launches_sent >= 2 or not self.eggs_released: return - # - # So the reaper only runs in the ACTIVE STAGING WINDOW: the egg is out and - # we are waiting for ACK/READY, which is exactly when a pod that has gone - # quiet is a pod that is gone. A truly half-open socket is caught sooner - # and more cheaply by TCP keepalive (set on accept, and the pod sets it on - # its own beacon too) -- this deadline is only the backstop for platforms - # where the keepalive tunables do not take. - # now = time.time() for conn in list(self.console_conns): - if not conn.egg_sent: - continue # nothing was asked of it yet - if now - getattr(conn, "last_seen", now) > DEAD_PEER_SECONDS: + if not conn.egg_sent or conn.acked: + continue # nothing owed / debt already paid + # Measure the debt from EGG SEND, not from the last byte: a pad that + # connected early and sat through a long held-egg wait would + # otherwise be over the deadline the instant the egg went out. + owed_since = max(getattr(conn, "last_seen", now), + getattr(conn, "egg_sent_at", now)) + if now - owed_since > DEAD_PEER_SECONDS: print(f"[relay] console conn {conn.addr[0]}:{conn.addr[1]} " - f"REAPED -- silent for " - f"{now - conn.last_seen:.0f}s while staging " - f"(acked={conn.acked}); it can no longer hold the round " - f"open", flush=True) + f"REAPED -- egg sent {now - owed_since:.0f}s ago and " + f"never ACKed; it can no longer hold the round open", + flush=True) self._drop_console(conn) - for conn in list(self.game_conns): - if conn.host_id is None: - continue # beacon / not yet HELLO'd: silent by design - if now - getattr(conn, "last_seen", now) > DEAD_PEER_SECONDS: - self._drop_game(conn, "silent for %.0fs while staging -- reaped" - % (now - conn.last_seen)) def _tag_of(self, host_id): """The LIVE roster tag for a host id ('?' if out of range).""" @@ -1057,6 +1059,35 @@ class Relay: start a new round. Reset only the ROUND state -- seats, beacons, prefs and connections are left alone, so whoever is here stays here. """ + # + # GUARDED ON EVERY CHANNEL (review 2026-07-26). The `launch_at is None` + # guard lived only on the LAUNCH-press path, but the ctl `rearm` command, + # the stdin reader and the always-lit GUI button all reached here + # unconditionally. Mid-mission (launches_sent==2) a re-arm zeroes the + # counters, which permanently kills the mission clock AND makes End + # Mission print "no mission is running" -- the round becomes + # unstoppable except by Stop Session, the exact outcome this feature + # exists to eliminate. Mid-pair (launch_at set) it destroys the + # RunMission pair and re-releases eggs to mid-launch pods -- the re-arm + # storm. Refuse both, loudly. + # + # "Running" is launches_sent >= 2 AND no StopMission issued yet. NOT + # launches_sent alone: after a FINISHED round (clock expiry or End + # Mission -> stop_sent True) launches_sent still reads 2 until the + # re-arm itself clears it -- refusing on that would resurrect the very + # wedge this feature exists to fix (caught by the regression suite when + # the review's own proposed guard was applied verbatim). + if self.launches_sent >= 2 and not self.stop_sent: + print("[relay] RE-ARM refused -- a mission is RUNNING; press " + "End Mission first", flush=True) + self.launch_requested = False # do not let the press linger + return + if self.launch_at is not None: + print(f"[relay] RE-ARM refused -- a launch sequence is in flight " + f"(fires in {max(0.0, self.launch_at - time.time()):.0f}s)", + flush=True) + self.launch_requested = False + return print(f"[relay] RE-ARM for a new round ({why}); " f"previous round state cleared", flush=True) self.launches_sent = 0 @@ -1090,6 +1121,12 @@ class Relay: FIRST_GAME_HOST_ID + len(self.roster))) self.round_hold_until = 0 # do not inherit an abort's settle window self._reload_egg_file() + # Re-key beacons/prefs to the RESTORED roster (review 2026-07-26): they + # were keyed by the previous round's TRIMMED host ids, and the launch + # fall-through would reinterpret those against the full roster -- a + # departed player's tag minted into round 2's egg, a present player's + # trimmed out, and every callsign/mech shifted a slot. + self._rekey_seats_to_roster() for c in self.console_conns: c.acked = False # last round's ACK must not count c.egg_sent = False # every pod needs THIS round's egg @@ -1346,7 +1383,19 @@ class Relay: continue host_id = FIRST_GAME_HOST_ID + i if host_id in self.by_host: - break # actively PLAYING: not ours + # A half-open REGISTERED conn (machine slept, power + # cut) squats here until keepalive reaps it (~2 min), + # and its own player got ROSTER FULL meanwhile. No + # mission running + same source IP = this player's + # own ghost: displace it, exactly like a zombie + # beacon (review 2026-07-26). + ghost = self.by_host[host_id] + if (self.launches_sent < 2 + and ghost.addr[0] == req_ip): + self._drop_game( + ghost, "displaced by its returning player") + else: + break # actively PLAYING: not ours old = self.seat_beacons.get(host_id) if old is not None: # zombie duplicate: displace it print(f"[relay] displacing stale beacon on seat " @@ -1459,9 +1508,14 @@ class Relay: # trim remaps seat ids the GUI would light (and, since the roster # display fix, clear) the WRONG row. The relay's roster here is the # live -- possibly trimmed -- one, so this tag is the true identity. + # Stash the tag AT REGISTRATION: _drop_game resolves against the + # LIVE roster, and a trimmed-round conn dying after a re-arm restored + # the full roster would otherwise be blamed on the wrong tag -- and + # the tag-trusting GUI would clear the wrong seat (review 2026-07-26). + conn.reg_tag = self._tag_of(host_id) print(f"[relay] {conn.name()} REGISTERED " f"({len(self.by_host)}/{len(self.roster)}) " - f"tag='{self._tag_of(host_id)}'", flush=True) + f"tag='{conn.reg_tag}'", flush=True) if len(self.by_host) >= len(self.roster): self._release_eggs() # fallback; normally already fired # PEER_UP exchange: newcomer learns everyone; everyone learns newcomer. @@ -1550,8 +1604,10 @@ class Relay: def _drop_game(self, conn, why): # tag='...' for registered conns, same reason as the REGISTERED print: # the GUI must not resolve a remapped post-trim hostID positionally. - drop_tag = (f" tag='{self._tag_of(conn.host_id)}'" - if conn.host_id is not None else "") + drop_tag = "" + if conn.host_id is not None: + _dt = getattr(conn, "reg_tag", None) or self._tag_of(conn.host_id) + drop_tag = " tag='%s'" % _dt print(f"[relay] {conn.name()} dropped: {why}{drop_tag}", flush=True) try: self.sel.unregister(conn.sock) @@ -1625,6 +1681,8 @@ class Relay: if not hasattr(self, "_udp_spoof_warned"): self._udp_spoof_warned = set() key = (from_host, endpoint[0]) + if len(self._udp_spoof_warned) > 256: + self._udp_spoof_warned.clear() # bounded under a flood if key not in self._udp_spoof_warned: self._udp_spoof_warned.add(key) print(f"[relay] UDP from {endpoint[0]}:{endpoint[1]} CLAIMS " @@ -1771,6 +1829,16 @@ class Relay: # under the lock so no line is lost or duplicated between # the replay and the live stream with _CONTROL_LOCK: + # The GUI adopts its roster from the startup "roster:" line, + # which is printed ONCE and ages out of the 400-line history + # in ~33 minutes of stats chatter -- an operator AUTHing + # late into the night got a permanently blank roster and a + # dead LAUNCH button (review 2026-07-26). Re-issue the LIVE + # roster in the same adoptable format before the replay. + conn.queue_out( + (f"[relay] roster: {len(self.roster)} pilot(s) -> " + f"hostIDs {sorted(self.expected_ids)}: " + f"{self.roster}\n").encode("utf-8", "replace")) conn.queue_out(b"[ctl] --- session history replay ---\n") for past in _CONTROL_HISTORY: conn.queue_out(past) @@ -1879,9 +1947,15 @@ class Relay: return self.stats_at = time.time() + STATS_PERIOD s = self.stats + # spoofed rides along only when non-zero: the anti-hijack counter was + # invisible (never printed anywhere), so an actual hijack attempt left + # no operator-visible trace beyond the one-shot warning line. + spoofed = s.get("udp_spoofed", 0) + spoof_part = f", spoofed {spoofed}" if spoofed else "" print(f"[relay-stats] tcp rx/tx {s['tcp_rx']}/{s['tcp_tx']} | " f"udp rx/tx {s['udp_rx']}/{s['udp_tx']} " - f"(dropped {s['udp_dropped']}, tcp-fallback {s['udp_tcp_fallback']}) | " + f"(dropped {s['udp_dropped']}, tcp-fallback {s['udp_tcp_fallback']}" + f"{spoof_part}) | " f"registered {sorted(self.by_host)} udp-known {sorted(self.udp_endpoint)}", flush=True) diff --git a/tools/btoperator.py b/tools/btoperator.py index fefb043..a8b4a51 100644 --- a/tools/btoperator.py +++ b/tools/btoperator.py @@ -511,7 +511,8 @@ class Operator(QMainWindow): "Clear the finished round's state and re-open the launcher, " "keeping everyone who is already connected. " "Use this if LAUNCH looks dead after a round instead of " - "restarting the session.") + "restarting the session. Between rounds only: refused while " + "a mission is running or a launch is firing.") self.rearm_btn.clicked.connect(self._rearm_round) self.rearm_btn.setEnabled(False) self.launch_local_btn = QPushButton("Launch local instances") @@ -817,12 +818,35 @@ class Operator(QMainWindow): # ------------------------------------------------------------- session -- + def _restore_seat_defaults(self): + """Write every stashed configured (callsign, mech) back into its row and + clear the stash. MUST run before anything snapshots or saves the table: + Restart Session used to _collect_egg() with a departed walk-up's name + still in the cell, baking it into the next session's egg (name= AND the + rasterized bitmaps) and then re-capturing it as the seat's 'configured + default' -- the reported roster bug returning through the egg file + (review 2026-07-26).""" + for tag, (callsign, mech) in list(self._seat_defaults.items()): + for r in range(self.table.rowCount()): + item = self.table.item(r, 0) + if item is None or item.text().strip() != tag: + continue + cell = self.table.item(r, 1) + if callsign is not None and cell is not None: + cell.setText(callsign) + combo = self.table.cellWidget(r, 2) + if mech is not None and combo is not None: + combo.setCurrentText(mech) + break + self._seat_defaults.clear() + def _start_session(self): self.end_sent = False # A session stopped while a seat was OCCUPIED leaves its stash entry - # behind (the seat never emptied, so it was never popped); a new - # session must not restore last session's snapshot. - self._seat_defaults.clear() + # behind (the seat never emptied, so it was never popped). Write those + # configured values BACK into the cells before _collect_egg can bake a + # walk-up's name into the egg, then start clean. + self._restore_seat_defaults() relay_host = self.f_relayhost.text().strip() if (self.mode.currentIndex() == 0 and relay_host and relay_host.lower() not in ("localhost", "127.0.0.1")): @@ -916,6 +940,7 @@ class Operator(QMainWindow): self.pod_status.setText("remote link closed") def _stop_session(self): + self._restore_seat_defaults() # departed walk-up names must not outlive the session if getattr(self, "remote_link", None): link = self.remote_link self.remote_link = None # closed-signal becomes a no-op @@ -1130,8 +1155,16 @@ class Operator(QMainWindow): callsign, mech = info else: # Empty: restore the configured pilot and forget the stash, so - # the next player to take this seat re-snapshots. + # the next player to take this seat re-snapshots. Restore on + # `is not None`: an operator-BLANKED cell is a real configured + # value, and skipping it as falsy left the departed name up and + # then re-captured it as the default (review 2026-07-26). callsign, mech = self._seat_defaults.pop(tag, (None, None)) + if callsign is not None and cell is not None and cell.text() != callsign: + cell.setText(callsign) + if mech is not None and combo is not None and combo.currentText() != mech: + combo.setCurrentText(mech) + continue if callsign and cell is not None and cell.text() != callsign: cell.setText(callsign) if mech and combo is not None and combo.currentText() != mech: @@ -1145,6 +1178,9 @@ class Operator(QMainWindow): (self.monitor.ready or (self.monitor.relay_mode and seated > 0)) and not self.monitor.launched and have_relay) + # Re-arm is refused by the relay mid-mission/mid-pair; grey it too so the + # button beside END MISSION does not invite the press (review 2026-07-26). + self.rearm_btn.setEnabled(have_relay and not self.monitor.launched) if self.monitor.stats: self.stats_label.setText(self.monitor.stats)