Conn Man crashed twice in an Owens on 2026-07-27, kept playing, and by the time
we asked for his logs they no longer existed. Not a mystery: every launcher did
if exist content\X.old.log del content\X.old.log
if exist content\X.log ren content\X.log X.old.log
which keeps TWO generations. He relaunched more than twice, so his own bat
deleted both crash sessions. The player who crashes is exactly the player who
relaunches immediately, so the retention window was aimed at the wrong case.
THE LOG. BT_LOG set still means "use this path verbatim" -- that contract is
load-bearing (mp_a.log/mp_b.log for 2-node runs from one cwd, btoperator's
operator_N.log, every scratchpad/mp_*.sh that greps the file it named) and is
untouched. Only when BT_LOG is UNSET does the exe now name the file itself:
content\<stem>_YYYYMMDD.log, appended, one per day, no rotation window to fall
out of. The stem (solo/join/steam/joyconfig) comes from the gates the bat
already sets, so solo still cannot overwrite the MULTIPLAYER log.
Self-named files append UNCONDITIONALLY. The default open mode is ios::out --
TRUNCATE -- and the operator GUI's exported bats never set BT_LOG_APPEND, so
they were truncating already; leaving append implicit would have let the second
launch of the day erase the morning.
IDENTITY, because these arrive from many players at once and Discord renames
half of them to message.txt (most of the night-5 evidence had to be
re-identified by hand):
===== BT411 SESSION build=4.11.603 (d64d75f+) machine=... user=...
callsign=Conn Man mode=solo pid=13192 local=... log=... =====
Also the session separator inside a per-day file, and it puts build+hash ABOVE
any [crash] block so btl4+0xNNNN offsets stay matchable to a PDB across builds.
SIZE. Never delete, but stay sendable: past 8 MB the next launch rolls to
<stem>_YYYYMMDD.1.log. Checked only at open, so a live session is never split.
CONSOLIDATION. launch_report.txt is gone, folded into lastrun_<stem>.txt (one
launch record: the exe appends its block at first breath, the bat appends the
exit line). Per-stem because one shared lastrun.txt let a second launcher's
`del` destroy the first launch's block. Its ABSENCE after a run is now the
#41 "never reached WinMain" probe -- the old "if not exist X.log" test cannot
work against a per-day file, which survives earlier launches, and would have
reported every healthy run as blocked by antivirus. marshal.log folded into
the day log too; it keeps its own handle and gained a CRITICAL_SECTION because
it runs on a worker thread while the engine log stream is single-threaded.
play_steam.bat gained a launch bracket it never had -- which is why a Steam
player killed before WinMain previously left no evidence at all.
_putenv_s, not SetEnvironmentVariableA, to pin the resolved name: MSVC's CRT
keeps its own environment copy, so getenv() in-process does NOT see a
SetEnvironmentVariableA write (measured). btl4console/btl4lobby resolve the
day log via getenv, so with the Win32-only call they silently fell back to
marshal.log and the fold never happened.
logfile.open() is now checked -- a failed open used to rebind cout to a dead
buffer, silently dropping the header, [boot] and any crash stack while
lastrun still claimed log=<name>.
Verified: append across sessions with distinct pids, crash block landing under
its own header, BT_LOG verbatim unmangled, an inherited BT_LOG cleared by the
bats, roll-over at 8 MB, and both forensic verdicts (exe ran / exe blocked).
Console auto-retrieval is unaffected -- it uploads the matchlog, a different
file, and a crashed round never reaches the upload call anyway.
Known gaps, deliberately not in this commit: the setlocal hoist for gate
leakage when two bats share one console (dev-only), and btoperator's exported
bats still lack the lastrun bracket -- do not press Export on a shipped zip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1485 lines
67 KiB
Python
1485 lines
67 KiB
Python
#!/usr/bin/env python
|
|
"""btoperator.py -- BT411 OPERATOR CONSOLE (PySide6).
|
|
|
|
The system-operator station the 1995 archive lost, recreated: build missions
|
|
with validated dropdowns (every value read live from BTL4.RES via eggmodel),
|
|
run the console/relay, watch pods arrive, and launch games -- LAN mesh or
|
|
internet relay -- plus one-click local instances for testing.
|
|
|
|
python tools/btoperator.py
|
|
|
|
Modes:
|
|
Relay (internet) -- runs `btconsole.py --relay`; pods dial OUT to this box
|
|
(players: BT_RELAY=<host>:<port> + BT_SELF=<tag>).
|
|
Mesh (LAN legacy) -- runs the classic dial-out console against each pod's
|
|
-net port (authentic cabinet topology).
|
|
|
|
The egg/protocol logic stays headless in eggmodel.py / btconsole.py; this file
|
|
is only UI + process management (QProcess), so the tested wire code is shared
|
|
with the CLI and the test harnesses.
|
|
"""
|
|
import os
|
|
import re
|
|
import socket
|
|
import sys
|
|
import threading
|
|
|
|
from PySide6.QtCore import (Qt, QProcess, QProcessEnvironment, QThread, QTimer,
|
|
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")
|
|
|
|
def rotate_log(path):
|
|
"""Keep the PREVIOUS run's log as <path>.1 instead of destroying it.
|
|
|
|
2026-07-26 games night: the crash self-report block and the whole 23:08
|
|
session's relay log were both lost to truncation minutes after they were
|
|
written -- Start Session zeroed operator_relay.log and Launch-instances
|
|
os.remove()'d the pod log. One generation of history is cheap insurance.
|
|
"""
|
|
try:
|
|
if os.path.exists(path):
|
|
prev = path + ".1"
|
|
if os.path.exists(prev):
|
|
os.remove(prev)
|
|
os.replace(path, prev)
|
|
except OSError:
|
|
pass # a locked log must never block a session
|
|
|
|
DEFAULT_EXE = os.path.join(REPO, "build", "Release", "btl4.exe")
|
|
DEFAULT_EGG = os.path.join(CONTENT, "OPERATOR.EGG")
|
|
BTCONSOLE = os.path.join(HERE, "btconsole.py")
|
|
|
|
sys.path.insert(0, HERE)
|
|
import eggmodel # noqa: E402
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pilot state model (parsed live from console/relay stdout)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
ST_SEATED = "seated"
|
|
ST_READY = "ready"
|
|
ST_IDLE, ST_EGG, ST_REGISTERED, ST_LAUNCHED = (
|
|
"waiting", "egg sent", "registered", "LAUNCHED")
|
|
ST_COLORS = {ST_IDLE: "#666666", ST_EGG: "#b58900",
|
|
ST_REGISTERED: "#2aa198", ST_LAUNCHED: "#33cc55",
|
|
ST_SEATED: "#268bd2", ST_READY: "#8be000"}
|
|
|
|
|
|
class SessionMonitor:
|
|
"""Turns btconsole/relay stdout lines into per-pilot state + stats."""
|
|
|
|
RE_RELAY_EGG = re.compile(r"pod ACK from .*?\((\d+)/(\d+) ready\)")
|
|
# REGISTERED/dropped carry an explicit tag='...' since 2026-07-26: the
|
|
# positional hostID->tag fallback is computed against the UNTRIMMED egg
|
|
# roster, so after a LAUNCH trim remaps seat ids it names the wrong row.
|
|
# Prefer the relay's own tag; keep the fallback for old logs.
|
|
RE_RELAY_REG = re.compile(
|
|
r"game\[.* host=(\d+)\] REGISTERED(?:.*?tag='([^']*)')?")
|
|
RE_RELAY_RUN = re.compile(r"RunMission #(\d+) sent")
|
|
RE_RELAY_STAT = re.compile(r"\[relay-stats\] (.*)")
|
|
RE_RELAY_DOWN = re.compile(
|
|
r"game\[.* host=(\d+)\] dropped(?:.*?tag='([^']*)')?")
|
|
RE_RELAY_WAITOP = re.compile(r"WAITING FOR OPERATOR")
|
|
RE_RELAY_SEAT = re.compile(
|
|
r"PLAYER (\d+) SEATED \(host \d+\) tag='([^']*)' "
|
|
r"callsign='([^']*)' mech='([^']*)'")
|
|
RE_RELAY_FREED = re.compile(
|
|
r"PLAYER (\d+) LEFT \(host \d+\) tag='([^']*)'")
|
|
RE_RELAY_READY = re.compile(
|
|
r"PLAYER (\d+) READY \(host \d+\) tag='([^']*)'")
|
|
RE_RELAY_HELD = re.compile(
|
|
r"launch HELD -- still loading: (.+) \((\d+)/(\d+) ready\)")
|
|
RE_RELAY_RESET = re.compile(r"round RESET")
|
|
# The relay announces its roster at startup and replays that line to a
|
|
# newly AUTHed operator. A REMOTE operator has no egg loaded, so this is
|
|
# the only way it can learn the tags -- without them `state` stayed empty,
|
|
# every pilot light was invisible and `seated` was always 0.
|
|
RE_RELAY_ROSTER = re.compile(
|
|
r"roster: (\d+) pilot\(s\) -> hostIDs \[[^\]]*\]: \[(.*)\]")
|
|
# A ROUND HAS ENDED / THE LAUNCHER RE-ARMED. Before these, `launched`
|
|
# could only be cleared by "WAITING FOR OPERATOR" (needs every seat of the
|
|
# last round to re-ACK) or "round RESET" (needs every pod gone). A games
|
|
# night sits between those -- one player does not come back -- so the
|
|
# button stayed greyed out and the operator had to restart the session
|
|
# (report 2026-07-25). StopMission is the honest "the mission is over"
|
|
# signal, and RE-ARM is the relay saying the launcher is ready again.
|
|
RE_RELAY_REARM = re.compile(r"RE-ARM for a new round")
|
|
RE_RELAY_STOPPED = re.compile(r"StopMission sent")
|
|
RE_MESH_EGG = re.compile(r"\[([^\]]+)\] egg sent")
|
|
RE_MESH_RUN = re.compile(r"\[([^\]]+)\] RunMission #(\d+) sent")
|
|
|
|
def __init__(self, relay_mode, tags):
|
|
self.relay_mode = relay_mode
|
|
self.tags = tags # roster order == hostID order
|
|
self.state = {t: ST_IDLE for t in tags}
|
|
self.eggs = 0
|
|
self.ready = False # manual mode: awaiting operator
|
|
self.launched = False
|
|
self.stats = ""
|
|
self.seat_info = {} # tag -> (callsign, mech) requests
|
|
self.held_status = None # "PLAYER 2, 3 (2/4 ready)" while staging
|
|
|
|
def host_tag(self, host_id):
|
|
i = host_id - 2 # hostIDs 2..N+1 in egg order
|
|
return self.tags[i] if 0 <= i < len(self.tags) else None
|
|
|
|
def feed(self, line):
|
|
"""Consume one log line; return True if any state changed."""
|
|
changed = False
|
|
if self.relay_mode:
|
|
m = self.RE_RELAY_EGG.search(line)
|
|
if m:
|
|
self.eggs = int(m.group(1))
|
|
changed = True
|
|
m = self.RE_RELAY_ROSTER.search(line)
|
|
if m:
|
|
tags = re.findall(r"'([^']*)'", m.group(2))
|
|
if tags and tags != self.tags:
|
|
# keep any state we already know for a tag that survives
|
|
self.tags = tags
|
|
self.state = {t: self.state.get(t, ST_IDLE) for t in tags}
|
|
changed = True
|
|
if self.RE_RELAY_WAITOP.search(line):
|
|
# WAITING FOR OPERATOR = pods are ready to launch. Reset
|
|
# `launched` so the LAUNCH button re-enables -- this fires
|
|
# again for a NEW mission after the pods rejoin (back-to-back
|
|
# missions, 2026-07-18), not only the first time.
|
|
self.ready = True
|
|
self.launched = False
|
|
changed = True
|
|
m = self.RE_RELAY_SEAT.search(line)
|
|
if m:
|
|
tag = m.group(2)
|
|
if tag in self.state:
|
|
self.state[tag] = ST_SEATED
|
|
self.seat_info[tag] = (m.group(3), m.group(4))
|
|
changed = True
|
|
m = self.RE_RELAY_FREED.search(line)
|
|
if m:
|
|
tag = m.group(2)
|
|
if tag in self.state:
|
|
self.state[tag] = ST_IDLE
|
|
self.seat_info.pop(tag, None)
|
|
changed = True
|
|
m = self.RE_RELAY_READY.search(line)
|
|
if m:
|
|
tag = m.group(2)
|
|
if tag in self.state:
|
|
self.state[tag] = ST_READY
|
|
changed = True
|
|
m = self.RE_RELAY_REG.search(line)
|
|
if m:
|
|
tag = m.group(2) or self.host_tag(int(m.group(1)))
|
|
if tag in self.state and self.state.get(tag) != ST_READY:
|
|
self.state[tag] = ST_REGISTERED
|
|
changed = True
|
|
m = self.RE_RELAY_DOWN.search(line)
|
|
if m:
|
|
tag = m.group(2) or self.host_tag(int(m.group(1)))
|
|
if tag in self.state and self.state.get(tag) != ST_IDLE:
|
|
self.state[tag] = ST_IDLE
|
|
# ALSO forget the walk-up identity. "PLAYER n LEFT" -- the
|
|
# other line that clears a seat -- is only printed by the
|
|
# relay when the player never CLAIMED its seat, so a
|
|
# registered player dropping mid-session produced only this
|
|
# `dropped` line. Without the pop, seat_info kept their
|
|
# callsign and the roster showed a departed pilot as still
|
|
# sitting there (operator report 2026-07-26).
|
|
self.seat_info.pop(tag, None)
|
|
changed = True
|
|
m = self.RE_RELAY_HELD.search(line)
|
|
if m:
|
|
self.held_status = "%s still loading (%s/%s ready)" % (
|
|
m.group(1), m.group(2), m.group(3))
|
|
changed = True
|
|
if (self.RE_RELAY_REARM.search(line)
|
|
or self.RE_RELAY_STOPPED.search(line)):
|
|
# The round is over (or the relay re-armed): the LAUNCH button
|
|
# must come back WITHOUT needing a full-roster re-ACK.
|
|
self.launched = False
|
|
self.held_status = None
|
|
changed = True
|
|
if self.RE_RELAY_RESET.search(line):
|
|
# BETWEEN ROUNDS: re-arm the console for the next launch. The
|
|
# old re-arm rode "WAITING FOR OPERATOR" which only prints on a
|
|
# FULL-roster ACK -- in launch-with-whoever mode the button
|
|
# stayed dead after round 1 (field report 2026-07-23).
|
|
self.launched = False
|
|
self.ready = False
|
|
self.held_status = None
|
|
changed = True
|
|
m = self.RE_RELAY_RUN.search(line)
|
|
if m and int(m.group(1)) >= 2:
|
|
self.launched = True
|
|
self.held_status = None
|
|
for t in self.state:
|
|
if self.state[t] == ST_REGISTERED:
|
|
self.state[t] = ST_LAUNCHED
|
|
changed = True
|
|
m = self.RE_RELAY_STAT.search(line)
|
|
if m:
|
|
self.stats = m.group(1)
|
|
changed = True
|
|
else:
|
|
m = self.RE_MESH_EGG.search(line)
|
|
if m:
|
|
# mesh log key = host:consolePort; roster tag = host:gamePort
|
|
tag = self._mesh_tag(m.group(1))
|
|
if tag:
|
|
self.state[tag] = ST_EGG
|
|
changed = True
|
|
m = self.RE_MESH_RUN.search(line)
|
|
if m and int(m.group(2)) >= 2:
|
|
tag = self._mesh_tag(m.group(1))
|
|
if tag:
|
|
self.state[tag] = ST_LAUNCHED
|
|
self.launched = True
|
|
changed = True
|
|
return changed
|
|
|
|
def _mesh_tag(self, console_hostport):
|
|
host, _, port = console_hostport.rpartition(":")
|
|
try:
|
|
game = "%s:%d" % (host, int(port) + 1)
|
|
except ValueError:
|
|
return None
|
|
return game if game in self.state else None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main window
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class RemoteRelayLink(QThread):
|
|
"""Operator control-channel client (2026-07-24): drives a RELAY RUNNING
|
|
ON ANOTHER MACHINE over the btconsole control protocol (AUTH, then the
|
|
relay's log stream in; launch/stop/set commands out). The reader runs
|
|
in this thread; send() is called from the GUI thread -- socket sendall
|
|
on small lines is safe cross-thread, guarded anyway."""
|
|
|
|
lineReceived = Signal(str)
|
|
closed = Signal(str)
|
|
|
|
def __init__(self, host, port, secret):
|
|
super().__init__()
|
|
self.host = host
|
|
self.port = port
|
|
self.secret = secret
|
|
self.sock = None
|
|
self._send_lock = threading.Lock()
|
|
self._stopping = False
|
|
|
|
def run(self):
|
|
try:
|
|
self.sock = socket.create_connection((self.host, self.port),
|
|
timeout=8)
|
|
self.sock.sendall(("AUTH %s\n" % self.secret).encode())
|
|
self.sock.settimeout(None)
|
|
buf = b""
|
|
while not self._stopping:
|
|
data = self.sock.recv(4096)
|
|
if not data:
|
|
break
|
|
buf += data
|
|
while b"\n" in buf:
|
|
line, buf = buf.split(b"\n", 1)
|
|
text = line.decode("utf-8", "replace").rstrip()
|
|
if text:
|
|
self.lineReceived.emit(text)
|
|
self.closed.emit("connection closed by relay"
|
|
if not self._stopping else "disconnected")
|
|
except Exception as e:
|
|
self.closed.emit("connect/read failed: %r" % e)
|
|
|
|
def send(self, text):
|
|
with self._send_lock:
|
|
if self.sock is None:
|
|
return False
|
|
try:
|
|
self.sock.sendall((text.rstrip() + "\n").encode())
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
def stop(self):
|
|
self._stopping = True
|
|
with self._send_lock:
|
|
if self.sock is not None:
|
|
try:
|
|
self.sock.close()
|
|
except OSError:
|
|
pass
|
|
self.sock = None
|
|
|
|
|
|
class Operator(QMainWindow):
|
|
COLS = ["Identity / Address", "Callsign", "Vehicle", "Color", "Badge",
|
|
"Patch", "Experience", "Local"]
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle("BT411 Operator Console")
|
|
self.resize(1180, 760)
|
|
self.values = eggmodel.ValueSets()
|
|
self.egg = eggmodel.new_from_template()
|
|
self.egg_path = DEFAULT_EGG
|
|
self.console_proc = None
|
|
self.remote_link = None
|
|
# row -> (configured callsign, configured mech), stashed while that seat
|
|
# is occupied by a walk-up player and restored when it empties, so a
|
|
# departed pilot's name does not sit in the roster (report 2026-07-26).
|
|
self._seat_defaults = {}
|
|
self.game_procs = []
|
|
self.monitor = None
|
|
self._build_ui()
|
|
self._load_egg_into_ui()
|
|
|
|
# ------------------------------------------------------------------ UI --
|
|
|
|
def _build_ui(self):
|
|
central = QWidget()
|
|
self.setCentralWidget(central)
|
|
root = QVBoxLayout(central)
|
|
|
|
# -- top bar: mode + egg file actions
|
|
top = QHBoxLayout()
|
|
top.addWidget(QLabel("Mode:"))
|
|
self.mode = QComboBox()
|
|
self.mode.addItems(["Relay (internet)", "Mesh (LAN legacy)"])
|
|
self.mode.currentIndexChanged.connect(self._mode_changed)
|
|
top.addWidget(self.mode)
|
|
# REMOTE OPERATOR (2026-07-24): leave blank to run the relay HERE
|
|
# (unchanged local flow -- relay dies with this window). Enter a
|
|
# host to DRIVE a relay running on that machine instead (needs its
|
|
# content\operator_secret.txt value; only the relay host forwards
|
|
# ports).
|
|
top.addSpacing(12)
|
|
top.addWidget(QLabel("Relay host:"))
|
|
self.f_relayhost = QLineEdit()
|
|
self.f_relayhost.setPlaceholderText("blank = run relay here")
|
|
self.f_relayhost.setFixedWidth(150)
|
|
self.f_relayhost.setToolTip(
|
|
"Blank/localhost: start the relay on this machine (it stops when "
|
|
"you close this window).\nA hostname/IP: connect to a relay "
|
|
"already running there and operate it remotely.")
|
|
top.addWidget(self.f_relayhost)
|
|
top.addWidget(QLabel("Secret:"))
|
|
self.f_secret = QLineEdit()
|
|
self.f_secret.setPlaceholderText("remote only")
|
|
self.f_secret.setFixedWidth(120)
|
|
self.f_secret.setToolTip(
|
|
"The relay host's content\\operator_secret.txt value "
|
|
"(auto-created there on first relay start).")
|
|
top.addWidget(self.f_secret)
|
|
top.addSpacing(24)
|
|
for label, slot in (("New", self._egg_new), ("Open…", self._egg_open),
|
|
("Save", self._egg_save),
|
|
("Save As…", self._egg_save_as)):
|
|
b = QPushButton(label)
|
|
b.clicked.connect(slot)
|
|
top.addWidget(b)
|
|
self.egg_label = QLabel(os.path.basename(self.egg_path))
|
|
self.egg_label.setStyleSheet("color:#888;")
|
|
top.addWidget(self.egg_label)
|
|
top.addStretch(1)
|
|
self.validate_btn = QPushButton("Validate")
|
|
self.validate_btn.clicked.connect(self._validate_clicked)
|
|
top.addWidget(self.validate_btn)
|
|
root.addLayout(top)
|
|
|
|
split = QSplitter(Qt.Vertical)
|
|
root.addWidget(split, 1)
|
|
|
|
upper = QWidget()
|
|
upper_l = QHBoxLayout(upper)
|
|
upper_l.setContentsMargins(0, 0, 0, 0)
|
|
split.addWidget(upper)
|
|
|
|
# -- mission + network forms
|
|
left = QVBoxLayout()
|
|
upper_l.addLayout(left, 0)
|
|
|
|
mission_box = QGroupBox("Mission")
|
|
form = QFormLayout(mission_box)
|
|
self.f_map = QComboBox(); self.f_map.addItems(self.values.maps)
|
|
self.f_time = QComboBox(); self.f_time.addItems(self.values.times)
|
|
self.f_weather = QComboBox(); self.f_weather.addItems(self.values.weathers)
|
|
self.f_scenario = QComboBox(); self.f_scenario.addItems(self.values.scenarios)
|
|
self.f_temp = QLineEdit(); self.f_temp.setPlaceholderText("e.g. 25.0")
|
|
self.f_length = QLineEdit(); self.f_length.setPlaceholderText("seconds; blank = unlimited")
|
|
form.addRow("Map", self.f_map)
|
|
form.addRow("Time", self.f_time)
|
|
form.addRow("Weather", self.f_weather)
|
|
form.addRow("Scenario", self.f_scenario)
|
|
form.addRow("Temperature", self.f_temp)
|
|
form.addRow("Game length", self.f_length)
|
|
left.addWidget(mission_box)
|
|
|
|
net_box = QGroupBox("Network")
|
|
nform = QFormLayout(net_box)
|
|
self.f_port = QSpinBox()
|
|
self.f_port.setRange(1024, 65000)
|
|
self.f_port.setValue(1500)
|
|
self.f_public = QLineEdit()
|
|
self.f_public.setPlaceholderText("public hostname for player scripts")
|
|
self.f_manual = QCheckBox("Manual launch (wait for my Launch button)")
|
|
self.f_manual.setChecked(True)
|
|
nform.addRow("Console port", self.f_port)
|
|
nform.addRow("Public host", self.f_public)
|
|
nform.addRow(self.f_manual)
|
|
left.addWidget(net_box)
|
|
|
|
exe_box = QGroupBox("Local game")
|
|
eform = QFormLayout(exe_box)
|
|
self.f_exe = QLineEdit(DEFAULT_EXE)
|
|
pick = QPushButton("…")
|
|
pick.setFixedWidth(28)
|
|
pick.clicked.connect(self._pick_exe)
|
|
exe_row = QHBoxLayout()
|
|
exe_row.addWidget(self.f_exe, 1)
|
|
exe_row.addWidget(pick)
|
|
eform.addRow("btl4.exe", exe_row)
|
|
self.f_gauges = QCheckBox("Dev gauges"); self.f_gauges.setChecked(True)
|
|
self.f_inside = QCheckBox("Start in cockpit"); self.f_inside.setChecked(True)
|
|
opt_row = QHBoxLayout()
|
|
opt_row.addWidget(self.f_gauges)
|
|
opt_row.addWidget(self.f_inside)
|
|
eform.addRow(opt_row)
|
|
left.addWidget(exe_box)
|
|
left.addStretch(1)
|
|
|
|
# -- roster
|
|
roster_box = QGroupBox("Pilot roster (hostIDs assigned in this order)")
|
|
rl = QVBoxLayout(roster_box)
|
|
self.table = QTableWidget(0, len(self.COLS))
|
|
self.table.setHorizontalHeaderLabels(self.COLS)
|
|
self.table.horizontalHeader().setSectionResizeMode(
|
|
0, QHeaderView.Stretch)
|
|
self.table.verticalHeader().setVisible(True)
|
|
rl.addWidget(self.table, 1)
|
|
btns = QHBoxLayout()
|
|
# RELAY mode (2026-07-22): rows are just CAPACITY + per-seat defaults
|
|
# -- joiners auto-populate them live and launch trims to whoever is
|
|
# seated, so hand add/remove is busywork. A Seats spinner maintains
|
|
# the row count; Add/Remove stay for MESH mode (explicit addresses).
|
|
self.seats_label = QLabel("Seats:")
|
|
self.seats_spin = QSpinBox()
|
|
self.seats_spin.setRange(1, 8)
|
|
self.seats_spin.valueChanged.connect(self._set_seat_count)
|
|
self.add_btn = QPushButton("Add pilot")
|
|
self.add_btn.clicked.connect(self._add_pilot)
|
|
self.rem_btn = QPushButton("Remove selected")
|
|
self.rem_btn.clicked.connect(self._remove_pilot)
|
|
self.export_btn = QPushButton("Export player scripts…")
|
|
self.export_btn.clicked.connect(self._export_scripts)
|
|
btns.addWidget(self.seats_label); btns.addWidget(self.seats_spin)
|
|
btns.addWidget(self.add_btn); btns.addWidget(self.rem_btn)
|
|
btns.addStretch(1); btns.addWidget(self.export_btn)
|
|
rl.addLayout(btns)
|
|
upper_l.addWidget(roster_box, 1)
|
|
|
|
# -- session panel
|
|
lower = QWidget()
|
|
low = QVBoxLayout(lower)
|
|
low.setContentsMargins(0, 0, 0, 0)
|
|
split.addWidget(lower)
|
|
srow = QHBoxLayout()
|
|
self.start_btn = QPushButton("▶ Start session")
|
|
self.start_btn.setStyleSheet("font-weight:bold;")
|
|
self.start_btn.clicked.connect(self._start_session)
|
|
self.stop_btn = QPushButton("■ Stop session")
|
|
self.stop_btn.clicked.connect(self._stop_session)
|
|
self.stop_btn.setEnabled(False)
|
|
self.restart_btn = QPushButton("↻ Restart session")
|
|
self.restart_btn.clicked.connect(self._restart_session)
|
|
self.restart_btn.setEnabled(False)
|
|
self.apply_btn = QPushButton("Apply mission settings")
|
|
self.apply_btn.setToolTip(
|
|
"Write arena/time/weather/length changes into the running "
|
|
"session -- takes effect on the NEXT round (players keep their "
|
|
"seats between rounds)")
|
|
self.apply_btn.clicked.connect(self._apply_mission_settings)
|
|
self.apply_btn.setEnabled(False)
|
|
self.launch_btn = QPushButton("🚀 LAUNCH MISSION")
|
|
self.launch_btn.setStyleSheet(
|
|
"font-weight:bold; color:#33cc55; padding:4px 14px;")
|
|
self.launch_btn.clicked.connect(self._launch_mission)
|
|
self.launch_btn.setEnabled(False)
|
|
self.end_btn = QPushButton("⏹ END MISSION")
|
|
self.end_btn.setStyleSheet(
|
|
"font-weight:bold; color:#cc5533; padding:4px 14px;")
|
|
self.end_btn.clicked.connect(self._end_mission)
|
|
self.end_btn.setEnabled(False)
|
|
# ESCAPE HATCH (2026-07-26). When the launcher latched after a round,
|
|
# restarting the whole session was the ONLY way out -- which also
|
|
# disconnected every player who was still waiting. This asks the relay
|
|
# to clear just the round state and keep the seats.
|
|
self.rearm_btn = QPushButton("↻ Re-arm")
|
|
self.rearm_btn.setToolTip(
|
|
"Clear the finished round's state and re-open the launcher, "
|
|
"keeping everyone who is already connected. "
|
|
"Use this if LAUNCH looks dead after a round instead of "
|
|
"restarting the session. Between rounds only: refused while "
|
|
"a mission is running or a launch is firing.")
|
|
self.rearm_btn.clicked.connect(self._rearm_round)
|
|
self.rearm_btn.setEnabled(False)
|
|
self.launch_local_btn = QPushButton("Launch local instances")
|
|
self.launch_local_btn.clicked.connect(self._launch_local)
|
|
self.launch_local_btn.setEnabled(False)
|
|
self.stop_games_btn = QPushButton("Stop games")
|
|
self.stop_games_btn.clicked.connect(self._stop_games)
|
|
srow.addWidget(self.start_btn)
|
|
srow.addWidget(self.stop_btn)
|
|
srow.addWidget(self.restart_btn)
|
|
srow.addSpacing(20)
|
|
srow.addWidget(self.launch_btn)
|
|
srow.addWidget(self.end_btn)
|
|
srow.addWidget(self.rearm_btn)
|
|
srow.addSpacing(20)
|
|
srow.addWidget(self.launch_local_btn)
|
|
srow.addWidget(self.apply_btn)
|
|
srow.addWidget(self.stop_games_btn)
|
|
srow.addStretch(1)
|
|
self.stats_label = QLabel("")
|
|
self.stats_label.setStyleSheet("color:#7a9;")
|
|
srow.addWidget(self.stats_label)
|
|
low.addLayout(srow)
|
|
|
|
self.pod_status = QLabel("session not running")
|
|
self.pod_status.setTextFormat(Qt.RichText)
|
|
low.addWidget(self.pod_status)
|
|
|
|
self.log = QPlainTextEdit()
|
|
self.log.setReadOnly(True)
|
|
self.log.setMaximumBlockCount(2000)
|
|
self.log.setFont(QFont("Consolas", 9))
|
|
low.addWidget(self.log, 1)
|
|
|
|
split.setSizes([440, 300])
|
|
self.setStatusBar(QStatusBar())
|
|
self._status("ready — values loaded live from BTL4.RES: "
|
|
f"{len(self.values.maps)} maps, "
|
|
f"{len(self.values.vehicles)} vehicles, "
|
|
f"{len(self.values.colors)} colors")
|
|
|
|
def _status(self, text):
|
|
self.statusBar().showMessage(text)
|
|
|
|
# -------------------------------------------------------------- roster --
|
|
|
|
def _combo(self, items, current):
|
|
c = QComboBox()
|
|
c.addItems(items)
|
|
if current in items:
|
|
c.setCurrentText(current)
|
|
elif current:
|
|
c.insertItem(0, current)
|
|
c.setCurrentIndex(0)
|
|
return c
|
|
|
|
def _add_pilot_row(self, pilot, local=False):
|
|
r = self.table.rowCount()
|
|
self.table.insertRow(r)
|
|
addr = QTableWidgetItem(pilot.get("address", ""))
|
|
if self.mode.currentIndex() == 0: # relay tags are auto-managed
|
|
addr.setFlags(addr.flags() & ~Qt.ItemIsEditable)
|
|
self.table.setItem(r, 0, addr)
|
|
# callsign: free text, becomes the name BITMAPS in the egg on save
|
|
# (the 1995 console's job -- see eggmodel.set_callsigns)
|
|
self.table.setItem(r, 1, QTableWidgetItem(
|
|
pilot.get("name") or "PLAYER%d" % (r + 1)))
|
|
self.table.setCellWidget(r, 2, self._combo(
|
|
self.values.vehicles, pilot.get("vehicle") or "bhk1"))
|
|
self.table.setCellWidget(r, 3, self._combo(
|
|
self.values.colors, pilot.get("color") or "White"))
|
|
self.table.setCellWidget(r, 4, self._combo(
|
|
self.values.badges, pilot.get("badge") or "VGL"))
|
|
self.table.setCellWidget(r, 5, self._combo(
|
|
self.values.patches, pilot.get("patch") or "Yellow"))
|
|
self.table.setCellWidget(r, 6, self._combo(
|
|
self.values.experiences, pilot.get("experience") or "expert"))
|
|
chk = QCheckBox()
|
|
chk.setChecked(local)
|
|
wrap = QWidget()
|
|
wl = QHBoxLayout(wrap)
|
|
wl.setContentsMargins(0, 0, 0, 0)
|
|
wl.setAlignment(Qt.AlignCenter)
|
|
wl.addWidget(chk)
|
|
self.table.setCellWidget(r, 7, wrap)
|
|
|
|
def _row_pilot(self, r, template):
|
|
"""Read one roster row back into a pilot dict."""
|
|
p = dict(template)
|
|
p["address"] = self.table.item(r, 0).text().strip()
|
|
p["name"] = self._row_callsign(r)
|
|
p["vehicle"] = self.table.cellWidget(r, 2).currentText()
|
|
p["color"] = self.table.cellWidget(r, 3).currentText()
|
|
p["badge"] = self.table.cellWidget(r, 4).currentText()
|
|
p["patch"] = self.table.cellWidget(r, 5).currentText()
|
|
p["experience"] = self.table.cellWidget(r, 6).currentText()
|
|
p["bitmapindex"] = str(r + 1)
|
|
return p
|
|
|
|
def _row_callsign(self, r):
|
|
item = self.table.item(r, 1)
|
|
text = item.text().strip() if item else ""
|
|
return text or "PLAYER%d" % (r + 1)
|
|
|
|
def _row_local(self, r):
|
|
wrap = self.table.cellWidget(r, 7)
|
|
return wrap.findChild(QCheckBox).isChecked() if wrap else False
|
|
|
|
def _pilot_template(self):
|
|
pilots = self.egg.pilots()
|
|
if pilots:
|
|
t = dict(pilots[0])
|
|
else:
|
|
t = {"hostType": "0", "advancedDamage": "1", "loadzones": "1",
|
|
"name": "Pilot", "experience": "expert", "badge": "VGL",
|
|
"patch": "Yellow", "role": "Role::Default",
|
|
"dropzone": "one", "vehicle": "bhk1", "vehicleValue": "1000",
|
|
"color": "White"}
|
|
t.pop("address", None)
|
|
return t
|
|
|
|
def _add_pilot(self):
|
|
n = self.table.rowCount() + 1
|
|
if self.mode.currentIndex() == 0:
|
|
address = eggmodel.relay_tags(n)[-1]
|
|
else:
|
|
address = "192.168.1.%d:1502" % (99 + n)
|
|
p = self._pilot_template()
|
|
p["address"] = address
|
|
p.pop("name", None) # new row gets the PLAYERn default callsign
|
|
# a NEWLY-added pilot is a remote joiner by default (Local only on the
|
|
# operator's own seat, normally row 0 which the egg load already sets)
|
|
self._add_pilot_row(p, local=(self.mode.currentIndex() == 0
|
|
and self.table.rowCount() == 0))
|
|
self._renumber_relay_tags()
|
|
|
|
def _remove_pilot(self):
|
|
r = self.table.currentRow()
|
|
if r >= 0:
|
|
self.table.removeRow(r)
|
|
self._renumber_relay_tags()
|
|
|
|
def _renumber_relay_tags(self):
|
|
if self.mode.currentIndex() != 0:
|
|
return
|
|
tags = eggmodel.relay_tags(self.table.rowCount())
|
|
for r, tag in enumerate(tags):
|
|
self.table.item(r, 0).setText(tag)
|
|
|
|
def _set_seat_count(self, n):
|
|
if self.mode.currentIndex() != 0:
|
|
return
|
|
while self.table.rowCount() < n:
|
|
self._add_pilot()
|
|
while self.table.rowCount() > n:
|
|
self.table.removeRow(self.table.rowCount() - 1)
|
|
self._renumber_relay_tags()
|
|
|
|
def _apply_roster_controls_mode(self):
|
|
relay = self.mode.currentIndex() == 0
|
|
self.seats_label.setVisible(relay)
|
|
self.seats_spin.setVisible(relay)
|
|
self.add_btn.setVisible(not relay)
|
|
self.rem_btn.setVisible(not relay)
|
|
if relay:
|
|
self.seats_spin.blockSignals(True)
|
|
self.seats_spin.setValue(self.table.rowCount())
|
|
self.seats_spin.blockSignals(False)
|
|
|
|
def _mode_changed(self, _index):
|
|
relay = self.mode.currentIndex() == 0
|
|
self._apply_roster_controls_mode()
|
|
for r in range(self.table.rowCount()):
|
|
item = self.table.item(r, 0)
|
|
flags = item.flags()
|
|
item.setFlags((flags & ~Qt.ItemIsEditable) if relay
|
|
else (flags | Qt.ItemIsEditable))
|
|
if relay:
|
|
self._renumber_relay_tags()
|
|
self._status("mode: %s" % self.mode.currentText())
|
|
|
|
def _apply_mission_settings(self):
|
|
"""Write the mission-parameter fields into the running session's egg
|
|
file; the relay re-reads it at the next round's egg release, so the
|
|
seated players get the new arena/params WITHOUT rejoining."""
|
|
if getattr(self, "remote_link", None):
|
|
spec = ("set map=%s;time=%s;weather=%s;scenario=%s;"
|
|
"temperature=%s;length=%s"
|
|
% (self.f_map.currentText(), self.f_time.currentText(),
|
|
self.f_weather.currentText(),
|
|
self.f_scenario.currentText(),
|
|
self.f_temp.text().strip(),
|
|
self.f_length.text().strip()))
|
|
self.remote_link.send(spec)
|
|
self.log.appendPlainText(">> mission settings sent to the remote "
|
|
"relay -- takes effect next round")
|
|
return
|
|
try:
|
|
doc = eggmodel.EggDoc.load(self.egg_path)
|
|
doc.set_mission(
|
|
map=self.f_map.currentText(), time=self.f_time.currentText(),
|
|
weather=self.f_weather.currentText(),
|
|
scenario=self.f_scenario.currentText(),
|
|
temperature=self.f_temp.text().strip(),
|
|
length=self.f_length.text().strip())
|
|
doc.save(self.egg_path)
|
|
self.log.appendPlainText(
|
|
">> mission settings applied (map=%s time=%s weather=%s "
|
|
"length=%s) -- takes effect next round"
|
|
% (self.f_map.currentText(), self.f_time.currentText(),
|
|
self.f_weather.currentText(), self.f_length.text().strip()))
|
|
except Exception as e:
|
|
QMessageBox.warning(self, "Apply",
|
|
"Could not update the egg:\n%r" % e)
|
|
|
|
# ------------------------------------------------------------ egg file --
|
|
|
|
def _load_egg_into_ui(self):
|
|
self._seat_defaults.clear() # the table is about to be rebuilt
|
|
m = self.egg.mission()
|
|
for combo, key in ((self.f_map, "map"), (self.f_time, "time"),
|
|
(self.f_weather, "weather"),
|
|
(self.f_scenario, "scenario")):
|
|
if m.get(key):
|
|
combo.setCurrentText(m[key])
|
|
self.f_temp.setText(m.get("temperature") or "")
|
|
self.f_length.setText(m.get("length") or "")
|
|
self.table.setRowCount(0)
|
|
relay = self.mode.currentIndex() == 0
|
|
# LOCAL default: ONLY the operator's own seat (row 0) -- NOT every row.
|
|
# (Checking Local on all rows made "Launch local instances" spawn one
|
|
# full game client PER seat on this machine, which collided and
|
|
# crashed -- field report 2026-07-18.) Remote joiners stay unchecked.
|
|
for i, p in enumerate(self.egg.pilots()):
|
|
self._add_pilot_row(p, local=(relay and i == 0))
|
|
self._renumber_relay_tags()
|
|
self._apply_roster_controls_mode() # Seats spinner mirrors rows
|
|
self.egg_label.setText(os.path.basename(self.egg_path))
|
|
|
|
def _collect_egg(self):
|
|
"""UI -> self.egg (returns validation problems)."""
|
|
self.egg.set_mission(
|
|
map=self.f_map.currentText(), time=self.f_time.currentText(),
|
|
weather=self.f_weather.currentText(),
|
|
scenario=self.f_scenario.currentText(),
|
|
temperature=self.f_temp.text().strip(),
|
|
length=self.f_length.text().strip())
|
|
template = self._pilot_template()
|
|
pilots = [self._row_pilot(r, template)
|
|
for r in range(self.table.rowCount())]
|
|
self.egg.set_pilots(pilots)
|
|
# rasterize the callsigns into the egg's name-bitmap pages (what the
|
|
# 1995 console did when the operator typed a callsign)
|
|
self.egg.set_callsigns(eggmodel.make_callsigns(
|
|
[self._row_callsign(r) for r in range(self.table.rowCount())]))
|
|
return self.egg.validate(self.values)
|
|
|
|
def _egg_new(self):
|
|
self.egg = eggmodel.new_from_template()
|
|
self.egg_path = DEFAULT_EGG
|
|
self._load_egg_into_ui()
|
|
self._status("new mission from template")
|
|
|
|
def _egg_open(self):
|
|
path, _ = QFileDialog.getOpenFileName(
|
|
self, "Open mission egg", CONTENT, "Egg files (*.EGG *.egg)")
|
|
if path:
|
|
self.egg = eggmodel.EggDoc.load(path)
|
|
self.egg_path = path
|
|
self._load_egg_into_ui()
|
|
self._status("opened %s" % path)
|
|
|
|
def _egg_save(self):
|
|
problems = self._collect_egg()
|
|
if problems and not self._confirm_problems(problems, "Save anyway?"):
|
|
return False
|
|
self.egg.save(self.egg_path)
|
|
self.egg_label.setText(os.path.basename(self.egg_path))
|
|
self._status("saved %s" % self.egg_path)
|
|
return True
|
|
|
|
def _egg_save_as(self):
|
|
path, _ = QFileDialog.getSaveFileName(
|
|
self, "Save mission egg", self.egg_path, "Egg files (*.EGG *.egg)")
|
|
if path:
|
|
self.egg_path = path
|
|
self._egg_save()
|
|
|
|
def _validate_clicked(self):
|
|
problems = self._collect_egg()
|
|
if problems:
|
|
QMessageBox.warning(self, "Validation",
|
|
"\n".join("• %s" % p for p in problems))
|
|
else:
|
|
QMessageBox.information(self, "Validation",
|
|
"Mission is valid and launchable.")
|
|
|
|
def _confirm_problems(self, problems, question):
|
|
return QMessageBox.question(
|
|
self, "Validation problems",
|
|
"\n".join("• %s" % p for p in problems) + "\n\n" + question,
|
|
QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes
|
|
|
|
# ------------------------------------------------------------- session --
|
|
|
|
def _restore_seat_defaults(self):
|
|
"""Write every stashed configured (callsign, mech) back into its row and
|
|
clear the stash. MUST run before anything snapshots or saves the table:
|
|
Restart Session used to _collect_egg() with a departed walk-up's name
|
|
still in the cell, baking it into the next session's egg (name= AND the
|
|
rasterized bitmaps) and then re-capturing it as the seat's 'configured
|
|
default' -- the reported roster bug returning through the egg file
|
|
(review 2026-07-26)."""
|
|
for tag, (callsign, mech) in list(self._seat_defaults.items()):
|
|
for r in range(self.table.rowCount()):
|
|
item = self.table.item(r, 0)
|
|
if item is None or item.text().strip() != tag:
|
|
continue
|
|
cell = self.table.item(r, 1)
|
|
if callsign is not None and cell is not None:
|
|
cell.setText(callsign)
|
|
combo = self.table.cellWidget(r, 2)
|
|
if mech is not None and combo is not None:
|
|
combo.setCurrentText(mech)
|
|
break
|
|
self._seat_defaults.clear()
|
|
|
|
def _start_session(self):
|
|
self.end_sent = False
|
|
# A session stopped while a seat was OCCUPIED leaves its stash entry
|
|
# behind (the seat never emptied, so it was never popped). Write those
|
|
# configured values BACK into the cells before _collect_egg can bake a
|
|
# walk-up's name into the egg, then start clean.
|
|
self._restore_seat_defaults()
|
|
relay_host = self.f_relayhost.text().strip()
|
|
if (self.mode.currentIndex() == 0 and relay_host
|
|
and relay_host.lower() not in ("localhost", "127.0.0.1")):
|
|
self._start_remote_session(relay_host)
|
|
return
|
|
problems = self._collect_egg()
|
|
if problems:
|
|
QMessageBox.warning(self, "Cannot start", "\n".join(problems))
|
|
return
|
|
self.egg.save(self.egg_path)
|
|
relay = self.mode.currentIndex() == 0
|
|
tags = [p["address"] for p in self.egg.pilots()]
|
|
self.monitor = SessionMonitor(relay, tags)
|
|
|
|
args = [BTCONSOLE]
|
|
if relay:
|
|
args += ["--relay", str(self.f_port.value()), self.egg_path]
|
|
if self.f_manual.isChecked():
|
|
args += ["--manual-launch"]
|
|
# --reserve RETIRED (2026-07-22): it protected the operator's
|
|
# BT_SELF-pinned local seats from walk-up assignment, but local
|
|
# instances now request seats like every joiner -- reserving
|
|
# their seats locked THEM out ("ROSTER FULL" on the operator's
|
|
# own launch, field report). Seat order is first-come now; the
|
|
# trim-on-launch flow makes exact seats moot.
|
|
else:
|
|
# mesh: console dials each pod's CONSOLE port (= game port - 1)
|
|
args += [self.egg_path]
|
|
for tag in tags:
|
|
host, _, port = tag.rpartition(":")
|
|
args.append("%s:%d" % (host, int(port) - 1))
|
|
# fresh relay log per session -- the OLD session survives as .1
|
|
rotate_log(os.path.join(CONTENT, "operator_relay.log"))
|
|
self.console_proc = QProcess(self)
|
|
self.console_proc.setProcessChannelMode(QProcess.MergedChannels)
|
|
self.console_proc.readyReadStandardOutput.connect(self._console_output)
|
|
self.console_proc.finished.connect(self._console_finished)
|
|
self.console_proc.start(sys.executable, ["-u"] + args)
|
|
self.start_btn.setEnabled(False)
|
|
self.stop_btn.setEnabled(True)
|
|
self.rearm_btn.setEnabled(True)
|
|
self.restart_btn.setEnabled(True)
|
|
self.launch_local_btn.setEnabled(True)
|
|
self.launch_btn.setEnabled(False)
|
|
self.apply_btn.setEnabled(True)
|
|
self.log.appendPlainText("== session started (%s%s) ==" %
|
|
("relay" if relay else "mesh",
|
|
", manual launch" if relay
|
|
and self.f_manual.isChecked() else ""))
|
|
self._refresh_pod_status()
|
|
|
|
def _start_remote_session(self, host):
|
|
secret = self.f_secret.text().strip()
|
|
if not secret:
|
|
QMessageBox.warning(self, "Remote relay",
|
|
"Enter the relay host's secret\n"
|
|
"(content\\operator_secret.txt on that "
|
|
"machine).")
|
|
return
|
|
ctl_port = self.f_port.value() + 7 # CONTROL_PORT_OFFSET
|
|
self.monitor = SessionMonitor(True, [])
|
|
self.remote_link = RemoteRelayLink(host, ctl_port, secret)
|
|
self.remote_link.lineReceived.connect(self._ingest_line)
|
|
self.remote_link.closed.connect(self._remote_closed)
|
|
self.remote_link.start()
|
|
self.remote_link.send("get mission")
|
|
self.start_btn.setEnabled(False)
|
|
self.stop_btn.setEnabled(True)
|
|
self.rearm_btn.setEnabled(True)
|
|
self.restart_btn.setEnabled(True)
|
|
self.launch_local_btn.setEnabled(True)
|
|
self.launch_btn.setEnabled(False)
|
|
self.apply_btn.setEnabled(True)
|
|
self.log.appendPlainText("== REMOTE session: driving relay at "
|
|
"%s:%d (the relay stays up when you "
|
|
"disconnect) ==" % (host, ctl_port))
|
|
self._refresh_pod_status()
|
|
|
|
def _remote_closed(self, reason):
|
|
self.log.appendPlainText("== remote relay link closed: %s ==" % reason)
|
|
self.remote_link = None
|
|
self.start_btn.setEnabled(True)
|
|
self.stop_btn.setEnabled(False)
|
|
self.rearm_btn.setEnabled(False)
|
|
self.restart_btn.setEnabled(False)
|
|
self.launch_btn.setEnabled(False)
|
|
self.end_btn.setEnabled(False)
|
|
self.apply_btn.setEnabled(False)
|
|
self.pod_status.setText("remote link closed")
|
|
|
|
def _stop_session(self):
|
|
self._restore_seat_defaults() # departed walk-up names must not outlive the session
|
|
if getattr(self, "remote_link", None):
|
|
link = self.remote_link
|
|
self.remote_link = None # closed-signal becomes a no-op
|
|
link.closed.disconnect(self._remote_closed)
|
|
link.stop()
|
|
self.log.appendPlainText("== disconnected from remote relay "
|
|
"(the relay keeps running) ==")
|
|
if self.console_proc:
|
|
self.console_proc.kill()
|
|
self.console_proc = None
|
|
self._stop_games()
|
|
self.start_btn.setEnabled(True)
|
|
self.stop_btn.setEnabled(False)
|
|
self.rearm_btn.setEnabled(False)
|
|
self.restart_btn.setEnabled(False)
|
|
self.launch_btn.setEnabled(False)
|
|
self.end_btn.setEnabled(False)
|
|
self.launch_local_btn.setEnabled(False)
|
|
self.pod_status.setText("session not running")
|
|
self.apply_btn.setEnabled(False)
|
|
self.log.appendPlainText("== session stopped ==")
|
|
|
|
def _restart_session(self):
|
|
"""Next round: same mission/roster, fresh session. Players just
|
|
re-run their join script."""
|
|
if getattr(self, "remote_link", None):
|
|
# PARKED RELAY: we do not own that process, so tearing down our
|
|
# own link would only disconnect us (the old behaviour, which
|
|
# looked like "Restart did nothing"). Ask the relay itself to
|
|
# restart -- its supervisor relaunches it, re-reading the egg, so
|
|
# a roster resize or a wedge is recoverable from the road. Then
|
|
# reconnect once it is back up.
|
|
host = self.f_relayhost.text().strip()
|
|
self.log.appendPlainText(
|
|
"== asking the parked relay at %s to RESTART "
|
|
"(reconnecting in a few seconds) ==" % host)
|
|
self._relay_send("restart")
|
|
self._stop_session()
|
|
QTimer.singleShot(6000, self._start_session)
|
|
return
|
|
self.log.appendPlainText("== restarting session ==")
|
|
self._stop_session()
|
|
self._start_session()
|
|
|
|
def _launch_mission(self):
|
|
"""Manual launch gate: send the relay its 'launch' command."""
|
|
if self._relay_send("launch"):
|
|
# Grey the button only once the command actually LEFT. The refresh
|
|
# tick re-derives this from the relay's own reports, so if the relay
|
|
# declines the press the button comes back rather than staying dead
|
|
# (which is what forced a session restart before 2026-07-26).
|
|
self.launch_btn.setEnabled(False)
|
|
self.end_btn.setEnabled(True)
|
|
|
|
def _relay_send(self, command):
|
|
"""Send an operator command and report HONESTLY whether it went.
|
|
|
|
The old call sites wrote to the QProcess and logged success
|
|
unconditionally -- but _console_finished leaves console_proc non-None,
|
|
so after the relay died every press logged ">> sent" into the void.
|
|
"""
|
|
if getattr(self, "remote_link", None):
|
|
self.remote_link.send(command)
|
|
self.log.appendPlainText(">> operator %s sent (remote)"
|
|
% command.upper())
|
|
return True
|
|
proc = self.console_proc
|
|
if proc is None or proc.state() != QProcess.Running:
|
|
self.log.appendPlainText(
|
|
"!! %s NOT sent -- the console/relay is not running "
|
|
"(press Start Session)" % command.upper())
|
|
return False
|
|
proc.write((command + "\n").encode())
|
|
self.log.appendPlainText(">> operator %s sent" % command.upper())
|
|
return True
|
|
|
|
def _rearm_round(self):
|
|
"""Ask the relay to clear a finished round's state without a restart."""
|
|
self._relay_send("rearm")
|
|
|
|
def _end_mission(self):
|
|
"""End the running mission now (the relay also auto-stops at the
|
|
egg's [mission] length -- the authentic console mission clock)."""
|
|
if self._relay_send("stop"):
|
|
self.end_sent = True
|
|
self.end_btn.setEnabled(False)
|
|
|
|
def _console_finished(self):
|
|
self.log.appendPlainText("== console/relay process exited ==")
|
|
self.start_btn.setEnabled(True)
|
|
self.stop_btn.setEnabled(False)
|
|
self.rearm_btn.setEnabled(False)
|
|
|
|
def _console_output(self):
|
|
if not self.console_proc:
|
|
return
|
|
data = bytes(self.console_proc.readAllStandardOutput()).decode(
|
|
"utf-8", "replace")
|
|
# ALSO tee the relay/console output to a file -- the GUI log pane is
|
|
# not readable post-mortem, and every field bug so far needed the
|
|
# relay's own view to diagnose (2026-07-18).
|
|
try:
|
|
with open(os.path.join(CONTENT, "operator_relay.log"), "a",
|
|
encoding="utf-8") as f:
|
|
f.write(data)
|
|
except OSError:
|
|
pass
|
|
for line in data.splitlines():
|
|
self._ingest_line(line)
|
|
|
|
def _ingest_line(self, line):
|
|
"""One relay/console log line -> log pane + tee file + monitor.
|
|
Shared by the local child-process path and the remote link."""
|
|
if not line.strip():
|
|
return
|
|
if getattr(self, "remote_link", None):
|
|
# remote lines aren't captured by the child-process tee above
|
|
try:
|
|
with open(os.path.join(CONTENT, "operator_relay.log"), "a",
|
|
encoding="utf-8") as f:
|
|
f.write(line + "\n")
|
|
except OSError:
|
|
pass
|
|
self._maybe_fill_mission_fields(line)
|
|
self.log.appendPlainText(line)
|
|
if self.monitor and self.monitor.feed(line):
|
|
self._refresh_pod_status()
|
|
|
|
def _maybe_fill_mission_fields(self, line):
|
|
"""Prefill the mission form from the relay's 'get mission' reply."""
|
|
m = re.search(r"\[ctl\] mission map=(\S*) time=(\S*) "
|
|
r"weather=(\S*) scenario=(\S*) temperature=(\S*) "
|
|
r"length=(\S*)", line)
|
|
if not m:
|
|
return
|
|
for combo, value in ((self.f_map, m.group(1)),
|
|
(self.f_time, m.group(2)),
|
|
(self.f_weather, m.group(3)),
|
|
(self.f_scenario, m.group(4))):
|
|
if value:
|
|
combo.setCurrentText(value)
|
|
if m.group(5):
|
|
self.f_temp.setText(m.group(5))
|
|
if m.group(6):
|
|
self.f_length.setText(m.group(6))
|
|
|
|
def _refresh_pod_status(self):
|
|
if not self.monitor:
|
|
return
|
|
parts = []
|
|
for tag, state in self.monitor.state.items():
|
|
parts.append('<span style="color:%s">⬤</span> %s <i>%s</i>'
|
|
% (ST_COLORS[state], tag, state))
|
|
seated = sum(1 for s in self.monitor.state.values()
|
|
if s in (ST_SEATED, ST_REGISTERED, ST_READY))
|
|
ready_n = sum(1 for s in self.monitor.state.values() if s == ST_READY)
|
|
# END MISSION is PER ROUND, not per session. `end_sent` used to be
|
|
# cleared only by Start Session, so the button worked exactly once a
|
|
# night; and a stale press between rounds used to kill the next mission
|
|
# the instant it launched (the relay now refuses that too). This MUST
|
|
# sit outside the head-assignment chain below: as an `elif` it swallowed
|
|
# a branch and left `head` unbound -> UnboundLocalError on the first
|
|
# refresh after End Mission (review catch, 2026-07-26).
|
|
if not self.monitor.launched and getattr(self, "end_sent", False):
|
|
self.end_sent = False
|
|
|
|
if self.monitor.launched:
|
|
head = "LAUNCHED — mission running"
|
|
if (not self.end_btn.isEnabled()
|
|
and not getattr(self, "end_sent", False)
|
|
and (self.console_proc is not None
|
|
or getattr(self, "remote_link", None) is not None)):
|
|
self.end_btn.setEnabled(True)
|
|
elif self.monitor.held_status:
|
|
head = "STAGING — " + self.monitor.held_status
|
|
elif ready_n:
|
|
head = ("%d pod(s) LOADED+READY — LAUNCH starts them instantly"
|
|
% ready_n)
|
|
elif self.monitor.ready:
|
|
head = "ALL PODS READY — press LAUNCH MISSION"
|
|
elif seated:
|
|
head = ("%d player(s) seated (standing by) — LAUNCH starts "
|
|
"their loading; the mission fires itself when every "
|
|
"light is green" % seated)
|
|
elif self.monitor.relay_mode:
|
|
head = "eggs delivered: %d/%d" % (self.monitor.eggs,
|
|
len(self.monitor.tags))
|
|
else:
|
|
head = "mesh console driving pods"
|
|
self.pod_status.setText("<b>%s</b> %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. Keyed by
|
|
# TAG, not row index: rows shift when an egg is reloaded or the
|
|
# seat count changes, and a stale row-keyed stash would restore
|
|
# the WRONG name into whatever row now sits at that index -- a
|
|
# changed roster simply makes tag keys inert instead.
|
|
if tag not in self._seat_defaults:
|
|
self._seat_defaults[tag] = (
|
|
cell.text() if cell is not None else None,
|
|
combo.currentText() if combo is not None else None)
|
|
callsign, mech = info
|
|
else:
|
|
# Empty: restore the configured pilot and forget the stash, so
|
|
# the next player to take this seat re-snapshots. Restore on
|
|
# `is not None`: an operator-BLANKED cell is a real configured
|
|
# value, and skipping it as falsy left the departed name up and
|
|
# then re-captured it as the default (review 2026-07-26).
|
|
callsign, mech = self._seat_defaults.pop(tag, (None, None))
|
|
if callsign is not None and cell is not None and cell.text() != callsign:
|
|
cell.setText(callsign)
|
|
if mech is not None and combo is not None and combo.currentText() != mech:
|
|
combo.setCurrentText(mech)
|
|
continue
|
|
if callsign and cell is not None and cell.text() != callsign:
|
|
cell.setText(callsign)
|
|
if mech and combo is not None and combo.currentText() != mech:
|
|
combo.setCurrentText(mech)
|
|
# A REMOTE operator has no console_proc -- its command channel is the
|
|
# control socket -- so requiring one made LAUNCH (and END MISSION below)
|
|
# permanently dead in remote mode. Accept either channel.
|
|
have_relay = (self.console_proc is not None
|
|
or getattr(self, "remote_link", None) is not None)
|
|
self.launch_btn.setEnabled(
|
|
(self.monitor.ready or (self.monitor.relay_mode and seated > 0))
|
|
and not self.monitor.launched
|
|
and have_relay)
|
|
# Re-arm is refused by the relay mid-mission/mid-pair; grey it too so the
|
|
# button beside END MISSION does not invite the press (review 2026-07-26).
|
|
self.rearm_btn.setEnabled(have_relay and not self.monitor.launched)
|
|
if self.monitor.stats:
|
|
self.stats_label.setText(self.monitor.stats)
|
|
|
|
# -------------------------------------------------------- local launch --
|
|
|
|
def _pick_exe(self):
|
|
path, _ = QFileDialog.getOpenFileName(
|
|
self, "Locate btl4.exe", os.path.dirname(self.f_exe.text()),
|
|
"Executable (*.exe)")
|
|
if path:
|
|
self.f_exe.setText(path)
|
|
|
|
def _launch_local(self):
|
|
exe = self.f_exe.text()
|
|
if not os.path.isfile(exe):
|
|
QMessageBox.warning(self, "Launch", "btl4.exe not found:\n" + exe)
|
|
return
|
|
relay = self.mode.currentIndex() == 0
|
|
# Remote mode has no console_proc but IS a live session -- and the env
|
|
# build below already points BT_RELAY at remote_link.host, so this guard
|
|
# was the only thing making the feature unreachable there.
|
|
if (relay and self.console_proc is None
|
|
and getattr(self, "remote_link", None) is None):
|
|
QMessageBox.warning(
|
|
self, "Launch",
|
|
"The session is not running -- press Start Session first\n"
|
|
"(a local instance dials the relay and would fail without it).")
|
|
return
|
|
if not any(self._row_local(r) for r in range(self.table.rowCount())):
|
|
QMessageBox.warning(
|
|
self, "Launch",
|
|
"No roster row has 'Local' checked -- tick the Local box on\n"
|
|
"YOUR seat (the one you fly on this machine) and try again.")
|
|
return
|
|
pilots = self.egg.pilots()
|
|
launched = 0
|
|
for r in range(self.table.rowCount()):
|
|
if not self._row_local(r) or r >= len(pilots):
|
|
continue
|
|
tag = pilots[r]["address"]
|
|
env = QProcessEnvironment.systemEnvironment()
|
|
# ONE unified build (2026-07-22): the GLASS platform env is what
|
|
# arms the desktop input stack (PadRIO -> bindings.txt keymap,
|
|
# clickable cockpit). Without it the instance boots the DEV
|
|
# profile and the legacy arrow-drive bridge takes the keyboard
|
|
# (user-reported: "launched with the wrong keymap again").
|
|
env.insert("BT_PLATFORM", "glass")
|
|
# OPERATOR LOAD SPEED (2026-07-22, live finding): the game pins
|
|
# itself to CPU core 0 by default, which on the operator box is
|
|
# shared with the console/relay/desktop -- and the mission load
|
|
# drains frame-paced, so a starved frame rate multiplies load
|
|
# time (testers ~20s, operator 3-5 min). Spread the local
|
|
# instance across the UPPER cores instead.
|
|
if "BT_AFFINITY" not in os.environ:
|
|
env.insert("BT_AFFINITY", "0xF0")
|
|
if self.f_gauges.isChecked():
|
|
env.insert("BT_DEV_GAUGES", "1")
|
|
if self.f_inside.isChecked():
|
|
env.insert("BT_START_INSIDE", "1")
|
|
args = ["-egg", os.path.basename(self.egg_path)
|
|
if os.path.dirname(self.egg_path) == CONTENT
|
|
else self.egg_path]
|
|
if relay:
|
|
# WALK-UP FLOW (2026-07-22): a relay-mode local instance goes
|
|
# through the JOIN-GAME menu exactly like a join.bat player --
|
|
# callsign + mech picked in the menu, seat ASSIGNED by the
|
|
# relay (BT_SELF pinning is gone: it skipped the seat request
|
|
# that carries the choice). Zero-arg launch = the menu; it
|
|
# relaunches itself with -egg OPERATOR.EGG -net <BT_FE_JOINPORT>
|
|
# (distinct per instance -- two -net listeners can't share a
|
|
# port on one box).
|
|
relay_addr = "127.0.0.1"
|
|
if getattr(self, "remote_link", None):
|
|
relay_addr = self.remote_link.host
|
|
env.insert("BT_RELAY", "%s:%d" % (relay_addr,
|
|
self.f_port.value()))
|
|
env.insert("BT_FE_JOIN", "1")
|
|
env.insert("BT_FE_JOINPORT", str(1501 + 100 * r))
|
|
args = []
|
|
else:
|
|
host, _, port = tag.rpartition(":")
|
|
net_port = int(port) - 1
|
|
args += ["-net", str(net_port)]
|
|
# issue #33 forensics: append across the menu->game relaunch
|
|
# chain (each generation used to TRUNCATE the log -- the night-2
|
|
# crash loop left a 2-line file). Fresh file per launch press.
|
|
log_name = "operator_%d.log" % (r + 1)
|
|
rotate_log(os.path.join(CONTENT, log_name))
|
|
env.insert("BT_LOG", log_name)
|
|
env.insert("BT_LOG_APPEND", "1")
|
|
proc = QProcess(self)
|
|
proc.setWorkingDirectory(CONTENT)
|
|
proc.setProcessEnvironment(env)
|
|
proc.start(exe, args)
|
|
self.game_procs.append(proc)
|
|
launched += 1
|
|
self.log.appendPlainText("launched %d local instance(s)" % launched)
|
|
|
|
def _stop_games(self):
|
|
for proc in self.game_procs:
|
|
if proc.state() != QProcess.NotRunning:
|
|
proc.kill()
|
|
self.game_procs = []
|
|
|
|
# ------------------------------------------------------ player scripts --
|
|
|
|
def _export_scripts(self):
|
|
problems = self._collect_egg()
|
|
if problems and not self._confirm_problems(problems, "Export anyway?"):
|
|
return
|
|
host = self.f_public.text().strip() or "YOUR.RELAY.HOST"
|
|
|
|
# LAN bat address: auto-discovery (BT_RELAY=auto) proved unreliable on
|
|
# some networks (field report 2026-07-20: a joiner had to hand-set
|
|
# BT_RELAY=<operator-lan-ip>:1500), so pin THIS operator PC's LAN IP.
|
|
# The UDP connect picks the default-route interface without sending a
|
|
# packet; falls back to "auto" only if detection fails.
|
|
def _detect_lan_ip():
|
|
import socket
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
s.connect(("8.8.8.8", 1))
|
|
ip = s.getsockname()[0]
|
|
except Exception:
|
|
ip = ""
|
|
finally:
|
|
s.close()
|
|
return ip if (ip and not ip.startswith("127.")) else ""
|
|
|
|
out_dir = QFileDialog.getExistingDirectory(
|
|
self, "Export player launch scripts to…", REPO)
|
|
if not out_dir:
|
|
return
|
|
egg_name = os.path.basename(self.egg_path)
|
|
# ONE universal script per transport: the RELAY assigns seats
|
|
# first-come-first-served (no BT_SELF), so every player gets the SAME
|
|
# file -- who flies which mech is whoever sits down first, exactly
|
|
# like walking up to a pod.
|
|
# LOCATION GUARD (field report 2026-07-18: 'cannot find path specified'
|
|
# + dumps): the bat MUST sit in the extracted folder next to content\
|
|
# and build\. Running it from inside the zip (or after moving just the
|
|
# .bat) broke the relative cd with a cryptic error. Check first and
|
|
# give a clear message instead.
|
|
def guarded(header, sets, run, tail):
|
|
return ("@echo off\n" + header +
|
|
"cd /d %~dp0\n"
|
|
# ONE unified build (2026-07-21): every layer is in the one
|
|
# exe; BT_PLATFORM=glass arms the desktop cockpit (clickable
|
|
# buttons / trigger config / bindings.txt keymap).
|
|
"set BT_PLATFORM=glass\n"
|
|
"if not exist \"build\\Release\\btl4.exe\" goto badpath\n"
|
|
"if not exist \"content\\" + egg_name +
|
|
"\" goto badpath\n" +
|
|
sets +
|
|
"cd content\n" + run + tail +
|
|
"exit /b\n"
|
|
":badpath\n"
|
|
"echo.\n"
|
|
"echo ==================================================\n"
|
|
"echo ERROR: this file is not in the right place.\n"
|
|
"echo.\n"
|
|
"echo It must sit in the EXTRACTED game folder, next to\n"
|
|
"echo the 'content' and 'build' folders.\n"
|
|
"echo.\n"
|
|
"echo Fix: right-click the .zip - Extract All, open the\n"
|
|
"echo extracted folder, and run this file from THERE.\n"
|
|
"echo (Do NOT run it from inside the zip.)\n"
|
|
"echo ==================================================\n"
|
|
"echo.\n"
|
|
"pause\n")
|
|
net_tail = ("echo.\n"
|
|
"echo The game has exited. If you did NOT quit on purpose\n"
|
|
"echo (crash, or it never connected), send the operator the\n"
|
|
"echo newest file content\\join_YYYYMMDD.log so they can "
|
|
"see what happened.\n"
|
|
"echo (one file per day, every session in it, each headed by "
|
|
"a BT411 SESSION line.)\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\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\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_FE_SOLO=1\n",
|
|
"..\\build\\Release\\btl4.exe\n",
|
|
"echo.\necho The game has exited. If it closed unexpectedly,\n"
|
|
# this bat sets BT_FE_SOLO, and btl4main tests BT_FE_SOLO before
|
|
# BT_FE_JOIN/BT_RELAY -- so the exe names it solo_, not join_.
|
|
"echo send the operator the newest content\\solo_YYYYMMDD.log\n"
|
|
"echo and content\\lastrun_solo.txt (send it even if there is no\n"
|
|
"echo log dated today -- that means it never started).\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())
|