#!/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 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): # 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_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(' %s %s' % (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("%s    %s" % (head, "   ".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() # 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") 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" # 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())