Files
BT411/tools/btoperator.py
T
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

1449 lines
66 KiB
Python

#!/usr/bin/env python
"""btoperator.py -- BT411 OPERATOR CONSOLE (PySide6).
The system-operator station the 1995 archive lost, recreated: build missions
with validated dropdowns (every value read live from BTL4.RES via eggmodel),
run the console/relay, watch pods arrive, and launch games -- LAN mesh or
internet relay -- plus one-click local instances for testing.
python tools/btoperator.py
Modes:
Relay (internet) -- runs `btconsole.py --relay`; pods dial OUT to this box
(players: BT_RELAY=<host>:<port> + BT_SELF=<tag>).
Mesh (LAN legacy) -- runs the classic dial-out console against each pod's
-net port (authentic cabinet topology).
The egg/protocol logic stays headless in eggmodel.py / btconsole.py; this file
is only UI + process management (QProcess), so the tested wire code is shared
with the CLI and the test harnesses.
"""
import os
import re
import socket
import sys
import threading
from PySide6.QtCore import Qt, QProcess, QProcessEnvironment, QThread, Signal
from PySide6.QtGui import QColor, QFont, QPalette, QAction
from PySide6.QtWidgets import (
QApplication, QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMessageBox,
QPlainTextEdit, QPushButton, QSpinBox, QSplitter, QTableWidget,
QTableWidgetItem, QVBoxLayout, QWidget, QStatusBar)
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, ".."))
CONTENT = os.path.join(REPO, "content")
DEFAULT_EXE = os.path.join(REPO, "build", "Release", "btl4.exe")
DEFAULT_EGG = os.path.join(CONTENT, "OPERATOR.EGG")
BTCONSOLE = os.path.join(HERE, "btconsole.py")
sys.path.insert(0, HERE)
import eggmodel # noqa: E402
# ---------------------------------------------------------------------------
# Pilot state model (parsed live from console/relay stdout)
# ---------------------------------------------------------------------------
ST_SEATED = "seated"
ST_READY = "ready"
ST_IDLE, ST_EGG, ST_REGISTERED, ST_LAUNCHED = (
"waiting", "egg sent", "registered", "LAUNCHED")
ST_COLORS = {ST_IDLE: "#666666", ST_EGG: "#b58900",
ST_REGISTERED: "#2aa198", ST_LAUNCHED: "#33cc55",
ST_SEATED: "#268bd2", ST_READY: "#8be000"}
class SessionMonitor:
"""Turns btconsole/relay stdout lines into per-pilot state + stats."""
RE_RELAY_EGG = re.compile(r"pod ACK from .*?\((\d+)/(\d+) ready\)")
# REGISTERED/dropped carry an explicit tag='...' since 2026-07-26: the
# positional hostID->tag fallback is computed against the UNTRIMMED egg
# roster, so after a LAUNCH trim remaps seat ids it names the wrong row.
# Prefer the relay's own tag; keep the fallback for old logs.
RE_RELAY_REG = re.compile(
r"game\[.* host=(\d+)\] REGISTERED(?:.*?tag='([^']*)')?")
RE_RELAY_RUN = re.compile(r"RunMission #(\d+) sent")
RE_RELAY_STAT = re.compile(r"\[relay-stats\] (.*)")
RE_RELAY_DOWN = re.compile(
r"game\[.* host=(\d+)\] dropped(?:.*?tag='([^']*)')?")
RE_RELAY_WAITOP = re.compile(r"WAITING FOR OPERATOR")
RE_RELAY_SEAT = re.compile(
r"PLAYER (\d+) SEATED \(host \d+\) tag='([^']*)' "
r"callsign='([^']*)' mech='([^']*)'")
RE_RELAY_FREED = re.compile(
r"PLAYER (\d+) LEFT \(host \d+\) tag='([^']*)'")
RE_RELAY_READY = re.compile(
r"PLAYER (\d+) READY \(host \d+\) tag='([^']*)'")
RE_RELAY_HELD = re.compile(
r"launch HELD -- still loading: (.+) \((\d+)/(\d+) ready\)")
RE_RELAY_RESET = re.compile(r"round RESET")
# The relay announces its roster at startup and replays that line to a
# newly AUTHed operator. A REMOTE operator has no egg loaded, so this is
# the only way it can learn the tags -- without them `state` stayed empty,
# every pilot light was invisible and `seated` was always 0.
RE_RELAY_ROSTER = re.compile(
r"roster: (\d+) pilot\(s\) -> hostIDs \[[^\]]*\]: \[(.*)\]")
# A ROUND HAS ENDED / THE LAUNCHER RE-ARMED. Before these, `launched`
# could only be cleared by "WAITING FOR OPERATOR" (needs every seat of the
# last round to re-ACK) or "round RESET" (needs every pod gone). A games
# night sits between those -- one player does not come back -- so the
# button stayed greyed out and the operator had to restart the session
# (report 2026-07-25). StopMission is the honest "the mission is over"
# signal, and RE-ARM is the relay saying the launcher is ready again.
RE_RELAY_REARM = re.compile(r"RE-ARM for a new round")
RE_RELAY_STOPPED = re.compile(r"StopMission sent")
RE_MESH_EGG = re.compile(r"\[([^\]]+)\] egg sent")
RE_MESH_RUN = re.compile(r"\[([^\]]+)\] RunMission #(\d+) sent")
def __init__(self, relay_mode, tags):
self.relay_mode = relay_mode
self.tags = tags # roster order == hostID order
self.state = {t: ST_IDLE for t in tags}
self.eggs = 0
self.ready = False # manual mode: awaiting operator
self.launched = False
self.stats = ""
self.seat_info = {} # tag -> (callsign, mech) requests
self.held_status = None # "PLAYER 2, 3 (2/4 ready)" while staging
def host_tag(self, host_id):
i = host_id - 2 # hostIDs 2..N+1 in egg order
return self.tags[i] if 0 <= i < len(self.tags) else None
def feed(self, line):
"""Consume one log line; return True if any state changed."""
changed = False
if self.relay_mode:
m = self.RE_RELAY_EGG.search(line)
if m:
self.eggs = int(m.group(1))
changed = True
m = self.RE_RELAY_ROSTER.search(line)
if m:
tags = re.findall(r"'([^']*)'", m.group(2))
if tags and tags != self.tags:
# keep any state we already know for a tag that survives
self.tags = tags
self.state = {t: self.state.get(t, ST_IDLE) for t in tags}
changed = True
if self.RE_RELAY_WAITOP.search(line):
# WAITING FOR OPERATOR = pods are ready to launch. Reset
# `launched` so the LAUNCH button re-enables -- this fires
# again for a NEW mission after the pods rejoin (back-to-back
# missions, 2026-07-18), not only the first time.
self.ready = True
self.launched = False
changed = True
m = self.RE_RELAY_SEAT.search(line)
if m:
tag = m.group(2)
if tag in self.state:
self.state[tag] = ST_SEATED
self.seat_info[tag] = (m.group(3), m.group(4))
changed = True
m = self.RE_RELAY_FREED.search(line)
if m:
tag = m.group(2)
if tag in self.state:
self.state[tag] = ST_IDLE
self.seat_info.pop(tag, None)
changed = True
m = self.RE_RELAY_READY.search(line)
if m:
tag = m.group(2)
if tag in self.state:
self.state[tag] = ST_READY
changed = True
m = self.RE_RELAY_REG.search(line)
if m:
tag = m.group(2) or self.host_tag(int(m.group(1)))
if tag in self.state and self.state.get(tag) != ST_READY:
self.state[tag] = ST_REGISTERED
changed = True
m = self.RE_RELAY_DOWN.search(line)
if m:
tag = m.group(2) or self.host_tag(int(m.group(1)))
if tag in self.state and self.state.get(tag) != ST_IDLE:
self.state[tag] = ST_IDLE
# ALSO forget the walk-up identity. "PLAYER n LEFT" -- the
# other line that clears a seat -- is only printed by the
# relay when the player never CLAIMED its seat, so a
# registered player dropping mid-session produced only this
# `dropped` line. Without the pop, seat_info kept their
# callsign and the roster showed a departed pilot as still
# sitting there (operator report 2026-07-26).
self.seat_info.pop(tag, None)
changed = True
m = self.RE_RELAY_HELD.search(line)
if m:
self.held_status = "%s still loading (%s/%s ready)" % (
m.group(1), m.group(2), m.group(3))
changed = True
if (self.RE_RELAY_REARM.search(line)
or self.RE_RELAY_STOPPED.search(line)):
# The round is over (or the relay re-armed): the LAUNCH button
# must come back WITHOUT needing a full-roster re-ACK.
self.launched = False
self.held_status = None
changed = True
if self.RE_RELAY_RESET.search(line):
# BETWEEN ROUNDS: re-arm the console for the next launch. The
# old re-arm rode "WAITING FOR OPERATOR" which only prints on a
# FULL-roster ACK -- in launch-with-whoever mode the button
# stayed dead after round 1 (field report 2026-07-23).
self.launched = False
self.ready = False
self.held_status = None
changed = True
m = self.RE_RELAY_RUN.search(line)
if m and int(m.group(1)) >= 2:
self.launched = True
self.held_status = None
for t in self.state:
if self.state[t] == ST_REGISTERED:
self.state[t] = ST_LAUNCHED
changed = True
m = self.RE_RELAY_STAT.search(line)
if m:
self.stats = m.group(1)
changed = True
else:
m = self.RE_MESH_EGG.search(line)
if m:
# mesh log key = host:consolePort; roster tag = host:gamePort
tag = self._mesh_tag(m.group(1))
if tag:
self.state[tag] = ST_EGG
changed = True
m = self.RE_MESH_RUN.search(line)
if m and int(m.group(2)) >= 2:
tag = self._mesh_tag(m.group(1))
if tag:
self.state[tag] = ST_LAUNCHED
self.launched = True
changed = True
return changed
def _mesh_tag(self, console_hostport):
host, _, port = console_hostport.rpartition(":")
try:
game = "%s:%d" % (host, int(port) + 1)
except ValueError:
return None
return game if game in self.state else None
# ---------------------------------------------------------------------------
# Main window
# ---------------------------------------------------------------------------
class RemoteRelayLink(QThread):
"""Operator control-channel client (2026-07-24): drives a RELAY RUNNING
ON ANOTHER MACHINE over the btconsole control protocol (AUTH, then the
relay's log stream in; launch/stop/set commands out). The reader runs
in this thread; send() is called from the GUI thread -- socket sendall
on small lines is safe cross-thread, guarded anyway."""
lineReceived = Signal(str)
closed = Signal(str)
def __init__(self, host, port, secret):
super().__init__()
self.host = host
self.port = port
self.secret = secret
self.sock = None
self._send_lock = threading.Lock()
self._stopping = False
def run(self):
try:
self.sock = socket.create_connection((self.host, self.port),
timeout=8)
self.sock.sendall(("AUTH %s\n" % self.secret).encode())
self.sock.settimeout(None)
buf = b""
while not self._stopping:
data = self.sock.recv(4096)
if not data:
break
buf += data
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
text = line.decode("utf-8", "replace").rstrip()
if text:
self.lineReceived.emit(text)
self.closed.emit("connection closed by relay"
if not self._stopping else "disconnected")
except Exception as e:
self.closed.emit("connect/read failed: %r" % e)
def send(self, text):
with self._send_lock:
if self.sock is None:
return False
try:
self.sock.sendall((text.rstrip() + "\n").encode())
return True
except OSError:
return False
def stop(self):
self._stopping = True
with self._send_lock:
if self.sock is not None:
try:
self.sock.close()
except OSError:
pass
self.sock = None
class Operator(QMainWindow):
COLS = ["Identity / Address", "Callsign", "Vehicle", "Color", "Badge",
"Patch", "Experience", "Local"]
def __init__(self):
super().__init__()
self.setWindowTitle("BT411 Operator Console")
self.resize(1180, 760)
self.values = eggmodel.ValueSets()
self.egg = eggmodel.new_from_template()
self.egg_path = DEFAULT_EGG
self.console_proc = None
self.remote_link = None
# row -> (configured callsign, configured mech), stashed while that seat
# is occupied by a walk-up player and restored when it empties, so a
# departed pilot's name does not sit in the roster (report 2026-07-26).
self._seat_defaults = {}
self.game_procs = []
self.monitor = None
self._build_ui()
self._load_egg_into_ui()
# ------------------------------------------------------------------ UI --
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
# -- top bar: mode + egg file actions
top = QHBoxLayout()
top.addWidget(QLabel("Mode:"))
self.mode = QComboBox()
self.mode.addItems(["Relay (internet)", "Mesh (LAN legacy)"])
self.mode.currentIndexChanged.connect(self._mode_changed)
top.addWidget(self.mode)
# REMOTE OPERATOR (2026-07-24): leave blank to run the relay HERE
# (unchanged local flow -- relay dies with this window). Enter a
# host to DRIVE a relay running on that machine instead (needs its
# content\operator_secret.txt value; only the relay host forwards
# ports).
top.addSpacing(12)
top.addWidget(QLabel("Relay host:"))
self.f_relayhost = QLineEdit()
self.f_relayhost.setPlaceholderText("blank = run relay here")
self.f_relayhost.setFixedWidth(150)
self.f_relayhost.setToolTip(
"Blank/localhost: start the relay on this machine (it stops when "
"you close this window).\nA hostname/IP: connect to a relay "
"already running there and operate it remotely.")
top.addWidget(self.f_relayhost)
top.addWidget(QLabel("Secret:"))
self.f_secret = QLineEdit()
self.f_secret.setPlaceholderText("remote only")
self.f_secret.setFixedWidth(120)
self.f_secret.setToolTip(
"The relay host's content\\operator_secret.txt value "
"(auto-created there on first relay start).")
top.addWidget(self.f_secret)
top.addSpacing(24)
for label, slot in (("New", self._egg_new), ("Open…", self._egg_open),
("Save", self._egg_save),
("Save As…", self._egg_save_as)):
b = QPushButton(label)
b.clicked.connect(slot)
top.addWidget(b)
self.egg_label = QLabel(os.path.basename(self.egg_path))
self.egg_label.setStyleSheet("color:#888;")
top.addWidget(self.egg_label)
top.addStretch(1)
self.validate_btn = QPushButton("Validate")
self.validate_btn.clicked.connect(self._validate_clicked)
top.addWidget(self.validate_btn)
root.addLayout(top)
split = QSplitter(Qt.Vertical)
root.addWidget(split, 1)
upper = QWidget()
upper_l = QHBoxLayout(upper)
upper_l.setContentsMargins(0, 0, 0, 0)
split.addWidget(upper)
# -- mission + network forms
left = QVBoxLayout()
upper_l.addLayout(left, 0)
mission_box = QGroupBox("Mission")
form = QFormLayout(mission_box)
self.f_map = QComboBox(); self.f_map.addItems(self.values.maps)
self.f_time = QComboBox(); self.f_time.addItems(self.values.times)
self.f_weather = QComboBox(); self.f_weather.addItems(self.values.weathers)
self.f_scenario = QComboBox(); self.f_scenario.addItems(self.values.scenarios)
self.f_temp = QLineEdit(); self.f_temp.setPlaceholderText("e.g. 25.0")
self.f_length = QLineEdit(); self.f_length.setPlaceholderText("seconds; blank = unlimited")
form.addRow("Map", self.f_map)
form.addRow("Time", self.f_time)
form.addRow("Weather", self.f_weather)
form.addRow("Scenario", self.f_scenario)
form.addRow("Temperature", self.f_temp)
form.addRow("Game length", self.f_length)
left.addWidget(mission_box)
net_box = QGroupBox("Network")
nform = QFormLayout(net_box)
self.f_port = QSpinBox()
self.f_port.setRange(1024, 65000)
self.f_port.setValue(1500)
self.f_public = QLineEdit()
self.f_public.setPlaceholderText("public hostname for player scripts")
self.f_manual = QCheckBox("Manual launch (wait for my Launch button)")
self.f_manual.setChecked(True)
nform.addRow("Console port", self.f_port)
nform.addRow("Public host", self.f_public)
nform.addRow(self.f_manual)
left.addWidget(net_box)
exe_box = QGroupBox("Local game")
eform = QFormLayout(exe_box)
self.f_exe = QLineEdit(DEFAULT_EXE)
pick = QPushButton("…")
pick.setFixedWidth(28)
pick.clicked.connect(self._pick_exe)
exe_row = QHBoxLayout()
exe_row.addWidget(self.f_exe, 1)
exe_row.addWidget(pick)
eform.addRow("btl4.exe", exe_row)
self.f_gauges = QCheckBox("Dev gauges"); self.f_gauges.setChecked(True)
self.f_inside = QCheckBox("Start in cockpit"); self.f_inside.setChecked(True)
opt_row = QHBoxLayout()
opt_row.addWidget(self.f_gauges)
opt_row.addWidget(self.f_inside)
eform.addRow(opt_row)
left.addWidget(exe_box)
left.addStretch(1)
# -- roster
roster_box = QGroupBox("Pilot roster (hostIDs assigned in this order)")
rl = QVBoxLayout(roster_box)
self.table = QTableWidget(0, len(self.COLS))
self.table.setHorizontalHeaderLabels(self.COLS)
self.table.horizontalHeader().setSectionResizeMode(
0, QHeaderView.Stretch)
self.table.verticalHeader().setVisible(True)
rl.addWidget(self.table, 1)
btns = QHBoxLayout()
# RELAY mode (2026-07-22): rows are just CAPACITY + per-seat defaults
# -- joiners auto-populate them live and launch trims to whoever is
# seated, so hand add/remove is busywork. A Seats spinner maintains
# the row count; Add/Remove stay for MESH mode (explicit addresses).
self.seats_label = QLabel("Seats:")
self.seats_spin = QSpinBox()
self.seats_spin.setRange(1, 8)
self.seats_spin.valueChanged.connect(self._set_seat_count)
self.add_btn = QPushButton("Add pilot")
self.add_btn.clicked.connect(self._add_pilot)
self.rem_btn = QPushButton("Remove selected")
self.rem_btn.clicked.connect(self._remove_pilot)
self.export_btn = QPushButton("Export player scripts…")
self.export_btn.clicked.connect(self._export_scripts)
btns.addWidget(self.seats_label); btns.addWidget(self.seats_spin)
btns.addWidget(self.add_btn); btns.addWidget(self.rem_btn)
btns.addStretch(1); btns.addWidget(self.export_btn)
rl.addLayout(btns)
upper_l.addWidget(roster_box, 1)
# -- session panel
lower = QWidget()
low = QVBoxLayout(lower)
low.setContentsMargins(0, 0, 0, 0)
split.addWidget(lower)
srow = QHBoxLayout()
self.start_btn = QPushButton("▶ Start session")
self.start_btn.setStyleSheet("font-weight:bold;")
self.start_btn.clicked.connect(self._start_session)
self.stop_btn = QPushButton("■ Stop session")
self.stop_btn.clicked.connect(self._stop_session)
self.stop_btn.setEnabled(False)
self.restart_btn = QPushButton("↻ Restart session")
self.restart_btn.clicked.connect(self._restart_session)
self.restart_btn.setEnabled(False)
self.apply_btn = QPushButton("Apply mission settings")
self.apply_btn.setToolTip(
"Write arena/time/weather/length changes into the running "
"session -- takes effect on the NEXT round (players keep their "
"seats between rounds)")
self.apply_btn.clicked.connect(self._apply_mission_settings)
self.apply_btn.setEnabled(False)
self.launch_btn = QPushButton("🚀 LAUNCH MISSION")
self.launch_btn.setStyleSheet(
"font-weight:bold; color:#33cc55; padding:4px 14px;")
self.launch_btn.clicked.connect(self._launch_mission)
self.launch_btn.setEnabled(False)
self.end_btn = QPushButton("⏹ END MISSION")
self.end_btn.setStyleSheet(
"font-weight:bold; color:#cc5533; padding:4px 14px;")
self.end_btn.clicked.connect(self._end_mission)
self.end_btn.setEnabled(False)
# ESCAPE HATCH (2026-07-26). When the launcher latched after a round,
# restarting the whole session was the ONLY way out -- which also
# disconnected every player who was still waiting. This asks the relay
# to clear just the round state and keep the seats.
self.rearm_btn = QPushButton("↻ Re-arm")
self.rearm_btn.setToolTip(
"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. 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")
self.launch_local_btn.clicked.connect(self._launch_local)
self.launch_local_btn.setEnabled(False)
self.stop_games_btn = QPushButton("Stop games")
self.stop_games_btn.clicked.connect(self._stop_games)
srow.addWidget(self.start_btn)
srow.addWidget(self.stop_btn)
srow.addWidget(self.restart_btn)
srow.addSpacing(20)
srow.addWidget(self.launch_btn)
srow.addWidget(self.end_btn)
srow.addWidget(self.rearm_btn)
srow.addSpacing(20)
srow.addWidget(self.launch_local_btn)
srow.addWidget(self.apply_btn)
srow.addWidget(self.stop_games_btn)
srow.addStretch(1)
self.stats_label = QLabel("")
self.stats_label.setStyleSheet("color:#7a9;")
srow.addWidget(self.stats_label)
low.addLayout(srow)
self.pod_status = QLabel("session not running")
self.pod_status.setTextFormat(Qt.RichText)
low.addWidget(self.pod_status)
self.log = QPlainTextEdit()
self.log.setReadOnly(True)
self.log.setMaximumBlockCount(2000)
self.log.setFont(QFont("Consolas", 9))
low.addWidget(self.log, 1)
split.setSizes([440, 300])
self.setStatusBar(QStatusBar())
self._status("ready — values loaded live from BTL4.RES: "
f"{len(self.values.maps)} maps, "
f"{len(self.values.vehicles)} vehicles, "
f"{len(self.values.colors)} colors")
def _status(self, text):
self.statusBar().showMessage(text)
# -------------------------------------------------------------- roster --
def _combo(self, items, current):
c = QComboBox()
c.addItems(items)
if current in items:
c.setCurrentText(current)
elif current:
c.insertItem(0, current)
c.setCurrentIndex(0)
return c
def _add_pilot_row(self, pilot, local=False):
r = self.table.rowCount()
self.table.insertRow(r)
addr = QTableWidgetItem(pilot.get("address", ""))
if self.mode.currentIndex() == 0: # relay tags are auto-managed
addr.setFlags(addr.flags() & ~Qt.ItemIsEditable)
self.table.setItem(r, 0, addr)
# callsign: free text, becomes the name BITMAPS in the egg on save
# (the 1995 console's job -- see eggmodel.set_callsigns)
self.table.setItem(r, 1, QTableWidgetItem(
pilot.get("name") or "PLAYER%d" % (r + 1)))
self.table.setCellWidget(r, 2, self._combo(
self.values.vehicles, pilot.get("vehicle") or "bhk1"))
self.table.setCellWidget(r, 3, self._combo(
self.values.colors, pilot.get("color") or "White"))
self.table.setCellWidget(r, 4, self._combo(
self.values.badges, pilot.get("badge") or "VGL"))
self.table.setCellWidget(r, 5, self._combo(
self.values.patches, pilot.get("patch") or "Yellow"))
self.table.setCellWidget(r, 6, self._combo(
self.values.experiences, pilot.get("experience") or "expert"))
chk = QCheckBox()
chk.setChecked(local)
wrap = QWidget()
wl = QHBoxLayout(wrap)
wl.setContentsMargins(0, 0, 0, 0)
wl.setAlignment(Qt.AlignCenter)
wl.addWidget(chk)
self.table.setCellWidget(r, 7, wrap)
def _row_pilot(self, r, template):
"""Read one roster row back into a pilot dict."""
p = dict(template)
p["address"] = self.table.item(r, 0).text().strip()
p["name"] = self._row_callsign(r)
p["vehicle"] = self.table.cellWidget(r, 2).currentText()
p["color"] = self.table.cellWidget(r, 3).currentText()
p["badge"] = self.table.cellWidget(r, 4).currentText()
p["patch"] = self.table.cellWidget(r, 5).currentText()
p["experience"] = self.table.cellWidget(r, 6).currentText()
p["bitmapindex"] = str(r + 1)
return p
def _row_callsign(self, r):
item = self.table.item(r, 1)
text = item.text().strip() if item else ""
return text or "PLAYER%d" % (r + 1)
def _row_local(self, r):
wrap = self.table.cellWidget(r, 7)
return wrap.findChild(QCheckBox).isChecked() if wrap else False
def _pilot_template(self):
pilots = self.egg.pilots()
if pilots:
t = dict(pilots[0])
else:
t = {"hostType": "0", "advancedDamage": "1", "loadzones": "1",
"name": "Pilot", "experience": "expert", "badge": "VGL",
"patch": "Yellow", "role": "Role::Default",
"dropzone": "one", "vehicle": "bhk1", "vehicleValue": "1000",
"color": "White"}
t.pop("address", None)
return t
def _add_pilot(self):
n = self.table.rowCount() + 1
if self.mode.currentIndex() == 0:
address = eggmodel.relay_tags(n)[-1]
else:
address = "192.168.1.%d:1502" % (99 + n)
p = self._pilot_template()
p["address"] = address
p.pop("name", None) # new row gets the PLAYERn default callsign
# a NEWLY-added pilot is a remote joiner by default (Local only on the
# operator's own seat, normally row 0 which the egg load already sets)
self._add_pilot_row(p, local=(self.mode.currentIndex() == 0
and self.table.rowCount() == 0))
self._renumber_relay_tags()
def _remove_pilot(self):
r = self.table.currentRow()
if r >= 0:
self.table.removeRow(r)
self._renumber_relay_tags()
def _renumber_relay_tags(self):
if self.mode.currentIndex() != 0:
return
tags = eggmodel.relay_tags(self.table.rowCount())
for r, tag in enumerate(tags):
self.table.item(r, 0).setText(tag)
def _set_seat_count(self, n):
if self.mode.currentIndex() != 0:
return
while self.table.rowCount() < n:
self._add_pilot()
while self.table.rowCount() > n:
self.table.removeRow(self.table.rowCount() - 1)
self._renumber_relay_tags()
def _apply_roster_controls_mode(self):
relay = self.mode.currentIndex() == 0
self.seats_label.setVisible(relay)
self.seats_spin.setVisible(relay)
self.add_btn.setVisible(not relay)
self.rem_btn.setVisible(not relay)
if relay:
self.seats_spin.blockSignals(True)
self.seats_spin.setValue(self.table.rowCount())
self.seats_spin.blockSignals(False)
def _mode_changed(self, _index):
relay = self.mode.currentIndex() == 0
self._apply_roster_controls_mode()
for r in range(self.table.rowCount()):
item = self.table.item(r, 0)
flags = item.flags()
item.setFlags((flags & ~Qt.ItemIsEditable) if relay
else (flags | Qt.ItemIsEditable))
if relay:
self._renumber_relay_tags()
self._status("mode: %s" % self.mode.currentText())
def _apply_mission_settings(self):
"""Write the mission-parameter fields into the running session's egg
file; the relay re-reads it at the next round's egg release, so the
seated players get the new arena/params WITHOUT rejoining."""
if getattr(self, "remote_link", None):
spec = ("set map=%s;time=%s;weather=%s;scenario=%s;"
"temperature=%s;length=%s"
% (self.f_map.currentText(), self.f_time.currentText(),
self.f_weather.currentText(),
self.f_scenario.currentText(),
self.f_temp.text().strip(),
self.f_length.text().strip()))
self.remote_link.send(spec)
self.log.appendPlainText(">> mission settings sent to the remote "
"relay -- takes effect next round")
return
try:
doc = eggmodel.EggDoc.load(self.egg_path)
doc.set_mission(
map=self.f_map.currentText(), time=self.f_time.currentText(),
weather=self.f_weather.currentText(),
scenario=self.f_scenario.currentText(),
temperature=self.f_temp.text().strip(),
length=self.f_length.text().strip())
doc.save(self.egg_path)
self.log.appendPlainText(
">> mission settings applied (map=%s time=%s weather=%s "
"length=%s) -- takes effect next round"
% (self.f_map.currentText(), self.f_time.currentText(),
self.f_weather.currentText(), self.f_length.text().strip()))
except Exception as e:
QMessageBox.warning(self, "Apply",
"Could not update the egg:\n%r" % e)
# ------------------------------------------------------------ egg file --
def _load_egg_into_ui(self):
self._seat_defaults.clear() # the table is about to be rebuilt
m = self.egg.mission()
for combo, key in ((self.f_map, "map"), (self.f_time, "time"),
(self.f_weather, "weather"),
(self.f_scenario, "scenario")):
if m.get(key):
combo.setCurrentText(m[key])
self.f_temp.setText(m.get("temperature") or "")
self.f_length.setText(m.get("length") or "")
self.table.setRowCount(0)
relay = self.mode.currentIndex() == 0
# LOCAL default: ONLY the operator's own seat (row 0) -- NOT every row.
# (Checking Local on all rows made "Launch local instances" spawn one
# full game client PER seat on this machine, which collided and
# crashed -- field report 2026-07-18.) Remote joiners stay unchecked.
for i, p in enumerate(self.egg.pilots()):
self._add_pilot_row(p, local=(relay and i == 0))
self._renumber_relay_tags()
self._apply_roster_controls_mode() # Seats spinner mirrors rows
self.egg_label.setText(os.path.basename(self.egg_path))
def _collect_egg(self):
"""UI -> self.egg (returns validation problems)."""
self.egg.set_mission(
map=self.f_map.currentText(), time=self.f_time.currentText(),
weather=self.f_weather.currentText(),
scenario=self.f_scenario.currentText(),
temperature=self.f_temp.text().strip(),
length=self.f_length.text().strip())
template = self._pilot_template()
pilots = [self._row_pilot(r, template)
for r in range(self.table.rowCount())]
self.egg.set_pilots(pilots)
# rasterize the callsigns into the egg's name-bitmap pages (what the
# 1995 console did when the operator typed a callsign)
self.egg.set_callsigns(eggmodel.make_callsigns(
[self._row_callsign(r) for r in range(self.table.rowCount())]))
return self.egg.validate(self.values)
def _egg_new(self):
self.egg = eggmodel.new_from_template()
self.egg_path = DEFAULT_EGG
self._load_egg_into_ui()
self._status("new mission from template")
def _egg_open(self):
path, _ = QFileDialog.getOpenFileName(
self, "Open mission egg", CONTENT, "Egg files (*.EGG *.egg)")
if path:
self.egg = eggmodel.EggDoc.load(path)
self.egg_path = path
self._load_egg_into_ui()
self._status("opened %s" % path)
def _egg_save(self):
problems = self._collect_egg()
if problems and not self._confirm_problems(problems, "Save anyway?"):
return False
self.egg.save(self.egg_path)
self.egg_label.setText(os.path.basename(self.egg_path))
self._status("saved %s" % self.egg_path)
return True
def _egg_save_as(self):
path, _ = QFileDialog.getSaveFileName(
self, "Save mission egg", self.egg_path, "Egg files (*.EGG *.egg)")
if path:
self.egg_path = path
self._egg_save()
def _validate_clicked(self):
problems = self._collect_egg()
if problems:
QMessageBox.warning(self, "Validation",
"\n".join("• %s" % p for p in problems))
else:
QMessageBox.information(self, "Validation",
"Mission is valid and launchable.")
def _confirm_problems(self, problems, question):
return QMessageBox.question(
self, "Validation problems",
"\n".join("• %s" % p for p in problems) + "\n\n" + question,
QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes
# ------------------------------------------------------------- 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). 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")):
self._start_remote_session(relay_host)
return
problems = self._collect_egg()
if problems:
QMessageBox.warning(self, "Cannot start", "\n".join(problems))
return
self.egg.save(self.egg_path)
relay = self.mode.currentIndex() == 0
tags = [p["address"] for p in self.egg.pilots()]
self.monitor = SessionMonitor(relay, tags)
args = [BTCONSOLE]
if relay:
args += ["--relay", str(self.f_port.value()), self.egg_path]
if self.f_manual.isChecked():
args += ["--manual-launch"]
# --reserve RETIRED (2026-07-22): it protected the operator's
# BT_SELF-pinned local seats from walk-up assignment, but local
# instances now request seats like every joiner -- reserving
# their seats locked THEM out ("ROSTER FULL" on the operator's
# own launch, field report). Seat order is first-come now; the
# trim-on-launch flow makes exact seats moot.
else:
# mesh: console dials each pod's CONSOLE port (= game port - 1)
args += [self.egg_path]
for tag in tags:
host, _, port = tag.rpartition(":")
args.append("%s:%d" % (host, int(port) - 1))
try: # fresh relay log per session
open(os.path.join(CONTENT, "operator_relay.log"), "w").close()
except OSError:
pass
self.console_proc = QProcess(self)
self.console_proc.setProcessChannelMode(QProcess.MergedChannels)
self.console_proc.readyReadStandardOutput.connect(self._console_output)
self.console_proc.finished.connect(self._console_finished)
self.console_proc.start(sys.executable, ["-u"] + args)
self.start_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self.rearm_btn.setEnabled(True)
self.restart_btn.setEnabled(True)
self.launch_local_btn.setEnabled(True)
self.launch_btn.setEnabled(False)
self.apply_btn.setEnabled(True)
self.log.appendPlainText("== session started (%s%s) ==" %
("relay" if relay else "mesh",
", manual launch" if relay
and self.f_manual.isChecked() else ""))
self._refresh_pod_status()
def _start_remote_session(self, host):
secret = self.f_secret.text().strip()
if not secret:
QMessageBox.warning(self, "Remote relay",
"Enter the relay host's secret\n"
"(content\\operator_secret.txt on that "
"machine).")
return
ctl_port = self.f_port.value() + 7 # CONTROL_PORT_OFFSET
self.monitor = SessionMonitor(True, [])
self.remote_link = RemoteRelayLink(host, ctl_port, secret)
self.remote_link.lineReceived.connect(self._ingest_line)
self.remote_link.closed.connect(self._remote_closed)
self.remote_link.start()
self.remote_link.send("get mission")
self.start_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self.rearm_btn.setEnabled(True)
self.restart_btn.setEnabled(True)
self.launch_local_btn.setEnabled(True)
self.launch_btn.setEnabled(False)
self.apply_btn.setEnabled(True)
self.log.appendPlainText("== REMOTE session: driving relay at "
"%s:%d (the relay stays up when you "
"disconnect) ==" % (host, ctl_port))
self._refresh_pod_status()
def _remote_closed(self, reason):
self.log.appendPlainText("== remote relay link closed: %s ==" % reason)
self.remote_link = None
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
self.rearm_btn.setEnabled(False)
self.restart_btn.setEnabled(False)
self.launch_btn.setEnabled(False)
self.end_btn.setEnabled(False)
self.apply_btn.setEnabled(False)
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
link.closed.disconnect(self._remote_closed)
link.stop()
self.log.appendPlainText("== disconnected from remote relay "
"(the relay keeps running) ==")
if self.console_proc:
self.console_proc.kill()
self.console_proc = None
self._stop_games()
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
self.rearm_btn.setEnabled(False)
self.restart_btn.setEnabled(False)
self.launch_btn.setEnabled(False)
self.end_btn.setEnabled(False)
self.launch_local_btn.setEnabled(False)
self.pod_status.setText("session not running")
self.apply_btn.setEnabled(False)
self.log.appendPlainText("== session stopped ==")
def _restart_session(self):
"""Next round: same mission/roster, fresh session. Players just
re-run their join script."""
self.log.appendPlainText("== restarting session ==")
self._stop_session()
self._start_session()
def _launch_mission(self):
"""Manual launch gate: send the relay its 'launch' command."""
if self._relay_send("launch"):
# Grey the button only once the command actually LEFT. The refresh
# tick re-derives this from the relay's own reports, so if the relay
# declines the press the button comes back rather than staying dead
# (which is what forced a session restart before 2026-07-26).
self.launch_btn.setEnabled(False)
self.end_btn.setEnabled(True)
def _relay_send(self, command):
"""Send an operator command and report HONESTLY whether it went.
The old call sites wrote to the QProcess and logged success
unconditionally -- but _console_finished leaves console_proc non-None,
so after the relay died every press logged ">> sent" into the void.
"""
if getattr(self, "remote_link", None):
self.remote_link.send(command)
self.log.appendPlainText(">> operator %s sent (remote)"
% command.upper())
return True
proc = self.console_proc
if proc is None or proc.state() != QProcess.Running:
self.log.appendPlainText(
"!! %s NOT sent -- the console/relay is not running "
"(press Start Session)" % command.upper())
return False
proc.write((command + "\n").encode())
self.log.appendPlainText(">> operator %s sent" % command.upper())
return True
def _rearm_round(self):
"""Ask the relay to clear a finished round's state without a restart."""
self._relay_send("rearm")
def _end_mission(self):
"""End the running mission now (the relay also auto-stops at the
egg's [mission] length -- the authentic console mission clock)."""
if self._relay_send("stop"):
self.end_sent = True
self.end_btn.setEnabled(False)
def _console_finished(self):
self.log.appendPlainText("== console/relay process exited ==")
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
self.rearm_btn.setEnabled(False)
def _console_output(self):
if not self.console_proc:
return
data = bytes(self.console_proc.readAllStandardOutput()).decode(
"utf-8", "replace")
# ALSO tee the relay/console output to a file -- the GUI log pane is
# not readable post-mortem, and every field bug so far needed the
# relay's own view to diagnose (2026-07-18).
try:
with open(os.path.join(CONTENT, "operator_relay.log"), "a",
encoding="utf-8") as f:
f.write(data)
except OSError:
pass
for line in data.splitlines():
self._ingest_line(line)
def _ingest_line(self, line):
"""One relay/console log line -> log pane + tee file + monitor.
Shared by the local child-process path and the remote link."""
if not line.strip():
return
if getattr(self, "remote_link", None):
# remote lines aren't captured by the child-process tee above
try:
with open(os.path.join(CONTENT, "operator_relay.log"), "a",
encoding="utf-8") as f:
f.write(line + "\n")
except OSError:
pass
self._maybe_fill_mission_fields(line)
self.log.appendPlainText(line)
if self.monitor and self.monitor.feed(line):
self._refresh_pod_status()
def _maybe_fill_mission_fields(self, line):
"""Prefill the mission form from the relay's 'get mission' reply."""
m = re.search(r"\[ctl\] mission map=(\S*) time=(\S*) "
r"weather=(\S*) scenario=(\S*) temperature=(\S*) "
r"length=(\S*)", line)
if not m:
return
for combo, value in ((self.f_map, m.group(1)),
(self.f_time, m.group(2)),
(self.f_weather, m.group(3)),
(self.f_scenario, m.group(4))):
if value:
combo.setCurrentText(value)
if m.group(5):
self.f_temp.setText(m.group(5))
if m.group(6):
self.f_length.setText(m.group(6))
def _refresh_pod_status(self):
if not self.monitor:
return
parts = []
for tag, state in self.monitor.state.items():
parts.append('<span style="color:%s">⬤</span> %s <i>%s</i>'
% (ST_COLORS[state], tag, state))
seated = sum(1 for s in self.monitor.state.values()
if s in (ST_SEATED, ST_REGISTERED, ST_READY))
ready_n = sum(1 for s in self.monitor.state.values() if s == ST_READY)
# END MISSION is PER ROUND, not per session. `end_sent` used to be
# cleared only by Start Session, so the button worked exactly once a
# night; and a stale press between rounds used to kill the next mission
# the instant it launched (the relay now refuses that too). This MUST
# sit outside the head-assignment chain below: as an `elif` it swallowed
# a branch and left `head` unbound -> UnboundLocalError on the first
# refresh after End Mission (review catch, 2026-07-26).
if not self.monitor.launched and getattr(self, "end_sent", False):
self.end_sent = False
if self.monitor.launched:
head = "LAUNCHED — mission running"
if (not self.end_btn.isEnabled()
and not getattr(self, "end_sent", False)
and (self.console_proc is not None
or getattr(self, "remote_link", None) is not None)):
self.end_btn.setEnabled(True)
elif self.monitor.held_status:
head = "STAGING — " + self.monitor.held_status
elif ready_n:
head = ("%d pod(s) LOADED+READY — LAUNCH starts them instantly"
% ready_n)
elif self.monitor.ready:
head = "ALL PODS READY — press LAUNCH MISSION"
elif seated:
head = ("%d player(s) seated (standing by) — LAUNCH starts "
"their loading; the mission fires itself when every "
"light is green" % seated)
elif self.monitor.relay_mode:
head = "eggs delivered: %d/%d" % (self.monitor.eggs,
len(self.monitor.tags))
else:
head = "mesh console driving pods"
self.pod_status.setText("<b>%s</b> &nbsp;&nbsp; %s"
% (head, " &nbsp; ".join(parts)))
# Reflect walk-up callsign/mech requests in the roster table -- AND put a
# seat back to its configured default when its player leaves.
#
# This used to only ever WRITE a name in: on a disconnect the relay pops
# seat_info, the loop hit `if not info: continue`, and the departed
# player's callsign sat in the roster for the rest of the session. The
# pilot light next to it correctly said "waiting", so the table and the
# light disagreed and the seat looked occupied when it was free
# (operator report 2026-07-26).
#
# Restoring the OPERATOR'S CONFIGURED value -- not a literal blank -- is
# deliberate: this cell is editable and is what gets written to the egg on
# Save, so stuffing a placeholder like "-- empty --" in here would put
# that placeholder into the mission file.
for r in range(self.table.rowCount()):
item = self.table.item(r, 0)
if item is None:
continue
tag = item.text().strip()
info = self.monitor.seat_info.get(tag)
cell = self.table.item(r, 1)
combo = self.table.cellWidget(r, 2)
if info:
# Occupied. On the FIRST refresh of this occupancy, stash what
# the operator had configured so we can put it back later. Doing
# it per-occupancy (rather than once at load) means an edit the
# operator makes while a seat is empty is respected. Keyed by
# TAG, not row index: rows shift when an egg is reloaded or the
# seat count changes, and a stale row-keyed stash would restore
# the WRONG name into whatever row now sits at that index -- a
# changed roster simply makes tag keys inert instead.
if tag not in self._seat_defaults:
self._seat_defaults[tag] = (
cell.text() if cell is not None else None,
combo.currentText() if combo is not None else None)
callsign, mech = info
else:
# Empty: restore the configured pilot and forget the stash, so
# 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:
combo.setCurrentText(mech)
# A REMOTE operator has no console_proc -- its command channel is the
# control socket -- so requiring one made LAUNCH (and END MISSION below)
# permanently dead in remote mode. Accept either channel.
have_relay = (self.console_proc is not None
or getattr(self, "remote_link", None) is not None)
self.launch_btn.setEnabled(
(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)
# -------------------------------------------------------- local launch --
def _pick_exe(self):
path, _ = QFileDialog.getOpenFileName(
self, "Locate btl4.exe", os.path.dirname(self.f_exe.text()),
"Executable (*.exe)")
if path:
self.f_exe.setText(path)
def _launch_local(self):
exe = self.f_exe.text()
if not os.path.isfile(exe):
QMessageBox.warning(self, "Launch", "btl4.exe not found:\n" + exe)
return
relay = self.mode.currentIndex() == 0
# Remote mode has no console_proc but IS a live session -- and the env
# build below already points BT_RELAY at remote_link.host, so this guard
# was the only thing making the feature unreachable there.
if (relay and self.console_proc is None
and getattr(self, "remote_link", None) is None):
QMessageBox.warning(
self, "Launch",
"The session is not running -- press Start Session first\n"
"(a local instance dials the relay and would fail without it).")
return
if not any(self._row_local(r) for r in range(self.table.rowCount())):
QMessageBox.warning(
self, "Launch",
"No roster row has 'Local' checked -- tick the Local box on\n"
"YOUR seat (the one you fly on this machine) and try again.")
return
pilots = self.egg.pilots()
launched = 0
for r in range(self.table.rowCount()):
if not self._row_local(r) or r >= len(pilots):
continue
tag = pilots[r]["address"]
env = QProcessEnvironment.systemEnvironment()
# ONE unified build (2026-07-22): the GLASS platform env is what
# arms the desktop input stack (PadRIO -> bindings.txt keymap,
# clickable cockpit). Without it the instance boots the DEV
# profile and the legacy arrow-drive bridge takes the keyboard
# (user-reported: "launched with the wrong keymap again").
env.insert("BT_PLATFORM", "glass")
# OPERATOR LOAD SPEED (2026-07-22, live finding): the game pins
# itself to CPU core 0 by default, which on the operator box is
# shared with the console/relay/desktop -- and the mission load
# drains frame-paced, so a starved frame rate multiplies load
# time (testers ~20s, operator 3-5 min). Spread the local
# instance across the UPPER cores instead.
if "BT_AFFINITY" not in os.environ:
env.insert("BT_AFFINITY", "0xF0")
if self.f_gauges.isChecked():
env.insert("BT_DEV_GAUGES", "1")
if self.f_inside.isChecked():
env.insert("BT_START_INSIDE", "1")
args = ["-egg", os.path.basename(self.egg_path)
if os.path.dirname(self.egg_path) == CONTENT
else self.egg_path]
if relay:
# WALK-UP FLOW (2026-07-22): a relay-mode local instance goes
# through the JOIN-GAME menu exactly like a join.bat player --
# callsign + mech picked in the menu, seat ASSIGNED by the
# relay (BT_SELF pinning is gone: it skipped the seat request
# that carries the choice). Zero-arg launch = the menu; it
# relaunches itself with -egg OPERATOR.EGG -net <BT_FE_JOINPORT>
# (distinct per instance -- two -net listeners can't share a
# port on one box).
relay_addr = "127.0.0.1"
if getattr(self, "remote_link", None):
relay_addr = self.remote_link.host
env.insert("BT_RELAY", "%s:%d" % (relay_addr,
self.f_port.value()))
env.insert("BT_FE_JOIN", "1")
env.insert("BT_FE_JOINPORT", str(1501 + 100 * r))
args = []
else:
host, _, port = tag.rpartition(":")
net_port = int(port) - 1
args += ["-net", str(net_port)]
# issue #33 forensics: append across the menu->game relaunch
# chain (each generation used to TRUNCATE the log -- the night-2
# crash loop left a 2-line file). Fresh file per launch press.
log_name = "operator_%d.log" % (r + 1)
try:
os.remove(os.path.join(CONTENT, log_name))
except OSError:
pass
env.insert("BT_LOG", log_name)
env.insert("BT_LOG_APPEND", "1")
proc = QProcess(self)
proc.setWorkingDirectory(CONTENT)
proc.setProcessEnvironment(env)
proc.start(exe, args)
self.game_procs.append(proc)
launched += 1
self.log.appendPlainText("launched %d local instance(s)" % launched)
def _stop_games(self):
for proc in self.game_procs:
if proc.state() != QProcess.NotRunning:
proc.kill()
self.game_procs = []
# ------------------------------------------------------ player scripts --
def _export_scripts(self):
problems = self._collect_egg()
if problems and not self._confirm_problems(problems, "Export anyway?"):
return
host = self.f_public.text().strip() or "YOUR.RELAY.HOST"
# LAN bat address: auto-discovery (BT_RELAY=auto) proved unreliable on
# some networks (field report 2026-07-20: a joiner had to hand-set
# BT_RELAY=<operator-lan-ip>:1500), so pin THIS operator PC's LAN IP.
# The UDP connect picks the default-route interface without sending a
# packet; falls back to "auto" only if detection fails.
def _detect_lan_ip():
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 1))
ip = s.getsockname()[0]
except Exception:
ip = ""
finally:
s.close()
return ip if (ip and not ip.startswith("127.")) else ""
out_dir = QFileDialog.getExistingDirectory(
self, "Export player launch scripts to…", REPO)
if not out_dir:
return
egg_name = os.path.basename(self.egg_path)
# ONE universal script per transport: the RELAY assigns seats
# first-come-first-served (no BT_SELF), so every player gets the SAME
# file -- who flies which mech is whoever sits down first, exactly
# like walking up to a pod.
# LOCATION GUARD (field report 2026-07-18: 'cannot find path specified'
# + dumps): the bat MUST sit in the extracted folder next to content\
# and build\. Running it from inside the zip (or after moving just the
# .bat) broke the relative cd with a cryptic error. Check first and
# give a clear message instead.
def guarded(header, sets, run, tail):
return ("@echo off\n" + header +
"cd /d %~dp0\n"
# ONE unified build (2026-07-21): every layer is in the one
# exe; BT_PLATFORM=glass arms the desktop cockpit (clickable
# buttons / trigger config / bindings.txt keymap).
"set BT_PLATFORM=glass\n"
"if not exist \"build\\Release\\btl4.exe\" goto badpath\n"
"if not exist \"content\\" + egg_name +
"\" goto badpath\n" +
sets +
"cd content\n" + run + tail +
"exit /b\n"
":badpath\n"
"echo.\n"
"echo ==================================================\n"
"echo ERROR: this file is not in the right place.\n"
"echo.\n"
"echo It must sit in the EXTRACTED game folder, next to\n"
"echo the 'content' and 'build' folders.\n"
"echo.\n"
"echo Fix: right-click the .zip - Extract All, open the\n"
"echo extracted folder, and run this file from THERE.\n"
"echo (Do NOT run it from inside the zip.)\n"
"echo ==================================================\n"
"echo.\n"
"pause\n")
net_tail = ("echo.\n"
"echo The game has exited. If you did NOT quit on purpose\n"
"echo (crash, or it never connected), send the operator the\n"
"echo file content\\join.log so they can see what "
"happened.\n"
"echo Otherwise the session may not be up yet -- try "
"again.\n"
"pause\n")
with open(os.path.join(out_dir, "join.bat"), "w", newline="\r\n") as f:
f.write(guarded(
"rem BT411 -- join %s's game (internet). Seat/mech assigned\n"
"rem by the operator. Keep this file next to content\\ + "
"build\\.\n" % host,
# BT_START_INSIDE + BT_DEV_GAUGES: the pod is a first-person
# cockpit with MFD gauges -- match it (field report: remote
# players booted 3rd-person + no gauges because join.bat set
# neither; view/gauges are CLIENT flags the relay can't reach).
"set BT_RELAY=%s:%d\nset BT_LOG=join.log\n"
"set BT_START_INSIDE=1\nset BT_DEV_GAUGES=1\n"
% (host, self.f_port.value()),
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n" % egg_name,
net_tail))
lan_ip = _detect_lan_ip()
lan_relay = ("%s:%d" % (lan_ip, self.f_port.value())) if lan_ip else "auto"
lan_hdr = (
("rem BT411 -- join the operator's LAN game.\n"
"rem BT_RELAY = the operator PC's LAN IP + console port. If that IP\n"
"rem changes, update it below (operator PC: run `ipconfig`).\n")
if lan_ip else
"rem BT411 -- join the operator's LAN game (auto-discovers).\n")
with open(os.path.join(out_dir, "join_lan.bat"), "w",
newline="\r\n") as f:
f.write(guarded(
lan_hdr +
"rem Joining over the INTERNET? Use join.bat instead.\n",
"set BT_RELAY=%s\nset BT_LOG=join.log\n"
"set BT_START_INSIDE=1\nset BT_DEV_GAUGES=1\n" % lan_relay,
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n" % egg_name,
net_tail))
with open(os.path.join(out_dir, "play_solo.bat"), "w",
newline="\r\n") as f:
f.write(guarded(
"rem BT411 -- single-player play: opens the MISSION MENU (pick "
"mech, map,\n"
"rem experience, weather, length; the menu returns after each "
"mission).\n",
"set BT_START_INSIDE=1\nset BT_DEV_GAUGES=1\n"
"set BT_LOG=join.log\nset BT_FE_SOLO=1\n",
"..\\build\\Release\\btl4.exe\n",
"echo.\necho The game has exited. If it closed unexpectedly,\n"
"echo send the operator content\\join.log.\npause\n"))
self.log.appendPlainText(
"exported join.bat + join_lan.bat + play_solo.bat to %s "
"(public host: %s) -- join bats are universal; the relay "
"assigns seats" % (out_dir, host))
# ------------------------------------------------------------- closing --
def closeEvent(self, event):
self._stop_session()
event.accept()
def dark_palette(app):
app.setStyle("Fusion")
p = QPalette()
bg, panel, text = QColor(37, 37, 40), QColor(28, 28, 30), QColor(220, 220, 220)
p.setColor(QPalette.Window, bg)
p.setColor(QPalette.WindowText, text)
p.setColor(QPalette.Base, panel)
p.setColor(QPalette.AlternateBase, bg)
p.setColor(QPalette.Text, text)
p.setColor(QPalette.Button, bg)
p.setColor(QPalette.ButtonText, text)
p.setColor(QPalette.Highlight, QColor(42, 130, 218))
p.setColor(QPalette.HighlightedText, Qt.white)
p.setColor(QPalette.ToolTipBase, panel)
p.setColor(QPalette.ToolTipText, text)
p.setColor(QPalette.PlaceholderText, QColor(120, 120, 120))
app.setPalette(p)
def main():
app = QApplication(sys.argv)
dark_palette(app)
win = Operator()
win.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())