Files
BT411/tools/btoperator.py
T
arcattackandClaude Fable 5 9f1bd8d6d4 Operator: tee relay/console output to content/operator_relay.log
Every field bug this session needed the relay's OWN view to diagnose,
but the app only shows it in the (post-mortem-unreadable) GUI log pane.
Now _console_output also appends to content/operator_relay.log (fresh
per session; stderr is merged so relay tracebacks are captured too).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:29:33 -05:00

837 lines
35 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 sys
from PySide6.QtCore import Qt, QProcess, QProcessEnvironment
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_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"}
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_READY = re.compile(r"WAITING FOR OPERATOR")
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 = ""
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_READY.search(line):
self.ready = True
changed = True
m = self.RE_RELAY_REG.search(line)
if m:
tag = self.host_tag(int(m.group(1)))
if tag:
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_RUN.search(line)
if m and int(m.group(1)) >= 2:
self.launched = True
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 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.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)
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()
add = QPushButton("Add pilot"); add.clicked.connect(self._add_pilot)
rem = QPushButton("Remove selected"); rem.clicked.connect(self._remove_pilot)
self.export_btn = QPushButton("Export player scripts…")
self.export_btn.clicked.connect(self._export_scripts)
btns.addWidget(add); btns.addWidget(rem)
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.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)
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.addSpacing(20)
srow.addWidget(self.launch_local_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 _mode_changed(self, _index):
relay = self.mode.currentIndex() == 0
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())
# ------------------------------------------------------------ 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.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
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 the operator's LOCAL seats so a remote join.bat can't be
# assigned one before the (slow-to-boot) local instance claims it
# -- the seat-collision that dropped the operator's pod + stalled
# the launch (field report 2026-07-18).
local_tags = [tags[r] for r in range(self.table.rowCount())
if self._row_local(r) and r < len(tags)]
if local_tags:
args += ["--reserve", ",".join(local_tags)]
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.restart_btn.setEnabled(True)
self.launch_local_btn.setEnabled(True)
self.launch_btn.setEnabled(False)
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 _stop_session(self):
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.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.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.console_proc:
self.console_proc.write(b"launch\n")
self.launch_btn.setEnabled(False)
self.end_btn.setEnabled(True)
self.log.appendPlainText(">> operator LAUNCH sent")
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.console_proc:
self.console_proc.write(b"stop\n")
self.end_sent = True
self.end_btn.setEnabled(False)
self.log.appendPlainText(">> operator END MISSION sent")
def _console_finished(self):
self.log.appendPlainText("== console/relay process exited ==")
self.start_btn.setEnabled(True)
self.stop_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():
if line.strip():
self.log.appendPlainText(line)
if self.monitor and self.monitor.feed(line):
self._refresh_pod_status()
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))
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 self.monitor.ready:
head = "ALL PODS READY — press LAUNCH MISSION"
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)))
self.launch_btn.setEnabled(
self.monitor.ready 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()
if self.f_gauges.isChecked():
env.insert("BT_DEV_GAUGES", "1")
if self.f_inside.isChecked():
env.insert("BT_START_INSIDE", "1")
if relay:
env.insert("BT_RELAY", "127.0.0.1:%d" % self.f_port.value())
env.insert("BT_SELF", tag)
net_port = 1501
else:
host, _, port = tag.rpartition(":")
net_port = int(port) - 1
env.insert("BT_LOG", "operator_%d.log" % (r + 1))
proc = QProcess(self)
proc.setWorkingDirectory(CONTENT)
proc.setProcessEnvironment(env)
proc.start(exe, ["-egg", os.path.basename(self.egg_path)
if os.path.dirname(self.egg_path) == CONTENT
else self.egg_path,
"-net", str(net_port)])
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"
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.
tail = ("echo.\n"
"echo The game has exited. If you did NOT quit on purpose,\n"
"echo the session may not be running yet or the server was\n"
"echo unreachable -- check with the operator and try again.\n"
"pause\n")
with open(os.path.join(out_dir, "join.bat"), "w", newline="\r\n") as f:
f.write("@echo off\n"
"rem BT411 -- join %s's game (internet). Your seat/mech\n"
"rem is assigned automatically by the operator console.\n"
"set BT_RELAY=%s:%d\n"
"cd /d %%~dp0content\n"
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n"
% (host, host, self.f_port.value(), egg_name) + tail)
with open(os.path.join(out_dir, "join_lan.bat"), "w",
newline="\r\n") as f:
f.write("@echo off\n"
"rem BT411 -- join the game from the operator's OWN\n"
"rem network (LAN) -- finds the console automatically.\n"
"rem Joining over the INTERNET? Use join.bat instead.\n"
"set BT_RELAY=auto\n"
"cd /d %%~dp0content\n"
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n"
% egg_name + tail)
# solo practice: no server involved -- the game self-launches a
# single-player mission (no -net argument = no console wait)
with open(os.path.join(out_dir, "play_solo.bat"), "w",
newline="\r\n") as f:
f.write("@echo off\n"
"rem BT411 -- single-player practice mission (no network,\n"
"rem no operator needed). Edit the -egg name below to try\n"
"rem other maps (ARENA1/ARENA2/DBASE/FOGDAY/...).\n"
"set BT_START_INSIDE=1\n"
"cd /d %~dp0content\n"
"..\\build\\Release\\btl4.exe -egg ARENA1.EGG\n"
"echo.\n"
"echo The game has exited. If it closed unexpectedly,\n"
"echo send the operator the file content\\btl4.log.\n"
"pause\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())