Files
BT411/tools/btoperator.py
T
arcattackandClaude Opus 5 4736cba1ca relay: LAUNCH is never silently inert again -- fix the post-round wedge (operator report)
THE SYMPTOM: "after a game ends I push LAUNCH and nothing happens until I reset
the session entirely or reboot the console."  The operator also observed the
mirror image, which is the tell: with a STABLE group, relaunching worked fine
several times in a row.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:21:24 -05:00

1332 lines
58 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\)")
RE_RELAY_REG = re.compile(r"game\[.* host=(\d+)\] REGISTERED")
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")
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")
# 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
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 = self.host_tag(int(m.group(1)))
if tag and self.state.get(tag) != ST_READY:
self.state[tag] = ST_REGISTERED
changed = True
m = self.RE_RELAY_DOWN.search(line)
if m:
tag = self.host_tag(int(m.group(1)))
if tag and self.state.get(tag) != ST_IDLE:
self.state[tag] = ST_IDLE
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
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.")
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):
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 _start_session(self):
self.end_sent = False
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):
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)
if self.monitor.launched:
head = "LAUNCHED — mission running"
if (self.console_proc and not self.end_btn.isEnabled()
and not getattr(self, "end_sent", False)):
self.end_btn.setEnabled(True)
elif getattr(self, "end_sent", False):
# 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).
self.end_sent = False
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
for r in range(self.table.rowCount()):
item = self.table.item(r, 0)
if item is None:
continue
info = self.monitor.seat_info.get(item.text().strip())
if not info:
continue
callsign, mech = info
if callsign and self.table.item(r, 1) is not None \
and self.table.item(r, 1).text() != callsign:
self.table.item(r, 1).setText(callsign)
combo = self.table.cellWidget(r, 2)
if mech and combo is not None and combo.currentText() != mech:
combo.setCurrentText(mech)
self.launch_btn.setEnabled(
(self.monitor.ready or (self.monitor.relay_mode and seated > 0))
and not self.monitor.launched
and self.console_proc is not None)
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
if relay and self.console_proc 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())