#!/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=: + BT_SELF=). 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") # 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 = 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 # 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.") 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(' %s %s' % (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("%s    %s" % (head, "   ".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. if r not in self._seat_defaults: self._seat_defaults[r] = ( 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. callsign, mech = self._seat_defaults.pop(r, (None, None)) 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) 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 # (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=: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())