Remote operator control channel + THE teardown crash root-caused and fixed
REMOTE OPERATORS (dev-channel ask): the relay grows a control TCP listener (console port + 7) speaking a line protocol -- AUTH <secret> (auto-generated content\operator_secret.txt), then launch / stop / ping / get mission / set key=value;... The relay's timestamped log stream tees to the authenticated operator (bounded per-conn buffers -- a stalled operator can NEVER stall the relay; overflow = drop), with a 400-line history replay on connect so mid-session operators see current state. One operator at a time; a new AUTH displaces the old. stdin commands unchanged (rigs/GUI local mode untouched). The operator GUI gains a 'Relay host' field: blank = today's local flow byte-for-byte (child relay dies with the window); a hostname = drive that machine's relay remotely (launch/stop/apply-settings over the wire, roster rebuilt from the teed log, local-instance joins point at the remote relay). Only the relay host needs port forwarding. Scripted protocol test: auth reject/accept, takeover, get/set, ping, history replay, and a FULL 2-node mission launched and stopped entirely over the socket. CRASH FIX (the layout-shifting heisenbug -- and almost certainly issue #35): the new crash self-report finally captured its 24-frame stack: InterestManager::OrphanInterestOrigin -> RemoveUninterestingEntity -> DestroyEntityAudioObjects -> {Static,Dynamic}3DPatchSource:: IsAudioSourceClipped, AV reading NULL+0x38 -- the unguarded chain GetMissionPlayer()->GetPlayerVehicle()->GetSimulationState() (the authentic burning-mech death-silence check) hit a NULL vehicle during MP teardown windows. Both sites now null-guard; burning semantics preserved whenever the vehicle exists. Also live-confirms that interest-based entity teardown RUNS in MP (the #38 paint mechanism). Regression: full combat round + control-channel drive, zero crashes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b710a848c2
commit
210bdb05ee
@@ -1189,13 +1189,31 @@ Dynamic3DPatchSource::Dynamic3DPatchSource(
|
|||||||
Logical
|
Logical
|
||||||
Dynamic3DPatchSource::IsAudioSourceClipped(AudioHead *audio_head)
|
Dynamic3DPatchSource::IsAudioSourceClipped(AudioHead *audio_head)
|
||||||
{
|
{
|
||||||
if (AudioSource::IsAudioSourceClipped(audio_head) || l4_application->GetMissionPlayer()->GetPlayerVehicle()->GetSimulationState() == VTV::BurningState)
|
if (AudioSource::IsAudioSourceClipped(audio_head))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
} else
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
//
|
||||||
|
// NULL-GUARD (2026-07-24, the layout-shifting teardown crash finally
|
||||||
|
// stack-captured: InterestManager::OrphanInterestOrigin ->
|
||||||
|
// RemoveUninterestingEntity -> DestroyEntityAudioObjects -> here, AV
|
||||||
|
// reading NULL+0x38). The binary could assume the mission player
|
||||||
|
// always has a vehicle (in a pod you always do); the port's MP join/
|
||||||
|
// teardown/respawn windows briefly leave it NULL. The burning check
|
||||||
|
// (death-silence: your burning mech clips world audio) keeps its
|
||||||
|
// authentic behavior whenever the vehicle exists.
|
||||||
|
//
|
||||||
|
{
|
||||||
|
Player *mission_player = l4_application->GetMissionPlayer();
|
||||||
|
if (mission_player != 0
|
||||||
|
&& mission_player->GetPlayerVehicle() != 0
|
||||||
|
&& mission_player->GetPlayerVehicle()->GetSimulationState()
|
||||||
|
== VTV::BurningState)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -1593,13 +1611,31 @@ Static3DPatchSource::Static3DPatchSource(
|
|||||||
|
|
||||||
Logical Static3DPatchSource::IsAudioSourceClipped(AudioHead *audio_head)
|
Logical Static3DPatchSource::IsAudioSourceClipped(AudioHead *audio_head)
|
||||||
{
|
{
|
||||||
if (AudioSource::IsAudioSourceClipped(audio_head) || l4_application->GetMissionPlayer()->GetPlayerVehicle()->GetSimulationState() == VTV::BurningState)
|
if (AudioSource::IsAudioSourceClipped(audio_head))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
} else
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
//
|
||||||
|
// NULL-GUARD (2026-07-24, the layout-shifting teardown crash finally
|
||||||
|
// stack-captured: InterestManager::OrphanInterestOrigin ->
|
||||||
|
// RemoveUninterestingEntity -> DestroyEntityAudioObjects -> here, AV
|
||||||
|
// reading NULL+0x38). The binary could assume the mission player
|
||||||
|
// always has a vehicle (in a pod you always do); the port's MP join/
|
||||||
|
// teardown/respawn windows briefly leave it NULL. The burning check
|
||||||
|
// (death-silence: your burning mech clips world audio) keeps its
|
||||||
|
// authentic behavior whenever the vehicle exists.
|
||||||
|
//
|
||||||
|
{
|
||||||
|
Player *mission_player = l4_application->GetMissionPlayer();
|
||||||
|
if (mission_player != 0
|
||||||
|
&& mission_player->GetPlayerVehicle() != 0
|
||||||
|
&& mission_player->GetPlayerVehicle()->GetSimulationState()
|
||||||
|
== VTV::BurningState)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
+276
-1
@@ -58,6 +58,7 @@ BT_SELF=<their [pilots] entry>.
|
|||||||
inbound datagram and forwards verbatim; unknown target endpoint falls
|
inbound datagram and forwards verbatim; unknown target endpoint falls
|
||||||
back to wrapping the frame onto the target's game TCP connection.
|
back to wrapping the frame onto the target's game TCP connection.
|
||||||
"""
|
"""
|
||||||
|
import collections
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import selectors
|
import selectors
|
||||||
@@ -241,6 +242,63 @@ ABORT_SETTLE_SECONDS = 8.0 # issue #33: egg-release hold after a round abort
|
|||||||
# 'Crimson'; caught live when a test egg authored color=Red).
|
# 'Crimson'; caught live when a test egg authored color=Red).
|
||||||
VALID_CAMO_COLORS = {"Black", "Brown", "Crimson", "Green", "Grey", "Tan",
|
VALID_CAMO_COLORS = {"Black", "Brown", "Crimson", "Green", "Grey", "Tan",
|
||||||
"White"}
|
"White"}
|
||||||
|
|
||||||
|
# REMOTE OPERATOR CONTROL (2026-07-24): a TCP line protocol so an operator
|
||||||
|
# GUI on ANOTHER machine can drive this relay (only the relay host needs
|
||||||
|
# port forwarding). Additive: stdin commands keep working unchanged.
|
||||||
|
# client -> relay: AUTH <secret> | launch | stop | ping |
|
||||||
|
# get mission | set key=value;key=value
|
||||||
|
# relay -> client: "OK ..." on auth, then the relay's own timestamped
|
||||||
|
# log stream (the GUI already parses those lines).
|
||||||
|
# One authenticated operator at a time -- a new AUTH displaces the old.
|
||||||
|
# A slow/stalled operator NEVER blocks the relay: writes are buffered per
|
||||||
|
# connection and the connection is dropped if the buffer overflows.
|
||||||
|
CONTROL_PORT_OFFSET = 7 # control port = console port + 7
|
||||||
|
CONTROL_AUTH_TIMEOUT = 10.0 # unauthenticated sockets die after this
|
||||||
|
CONTROL_OUTBUF_MAX = 262144 # slow-reader cutoff (bytes)
|
||||||
|
CONTROL_SET_KEYS = {"map", "time", "weather", "scenario", "temperature",
|
||||||
|
"length"} # whitelist for the 'set' command
|
||||||
|
|
||||||
|
def control_secret():
|
||||||
|
"""Shared secret for control-channel auth, persisted next to the eggs.
|
||||||
|
Auto-generated on first use; the operator shares it out-of-band."""
|
||||||
|
import secrets as _secrets
|
||||||
|
# The relay always runs with cwd = content\ (operator GUI and all rigs
|
||||||
|
# set it); the secret lives beside the eggs it guards.
|
||||||
|
path = "operator_secret.txt"
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="ascii") as f:
|
||||||
|
value = f.read().strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
value = _secrets.token_hex(16)
|
||||||
|
try:
|
||||||
|
with open(path, "w", encoding="ascii") as f:
|
||||||
|
f.write(value + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level sink list consulted by _StampedOut (the wrapper is installed
|
||||||
|
# before any Relay exists). Guarded by a lock: the stdin thread prints too.
|
||||||
|
# _CONTROL_HISTORY keeps the last N log lines so an operator who connects
|
||||||
|
# MID-SESSION gets recent state replayed (their GUI rebuilds the roster from
|
||||||
|
# parsed lines -- without replay it would start blank).
|
||||||
|
_CONTROL_SINKS = []
|
||||||
|
_CONTROL_LOCK = threading.Lock()
|
||||||
|
_CONTROL_HISTORY = collections.deque(maxlen=400)
|
||||||
|
|
||||||
|
|
||||||
|
def _control_tee(text):
|
||||||
|
"""Best-effort fan-out of log text to authenticated control conns."""
|
||||||
|
data = text.encode("utf-8", "replace")
|
||||||
|
with _CONTROL_LOCK:
|
||||||
|
_CONTROL_HISTORY.append(data)
|
||||||
|
for conn in list(_CONTROL_SINKS):
|
||||||
|
conn.queue_out(data)
|
||||||
HELLO_MAGIC = 0x31525442 # 'BTR1' little-endian
|
HELLO_MAGIC = 0x31525442 # 'BTR1' little-endian
|
||||||
FRAME_CAP = 1600 # NETWORKMANAGER_BUFFER_SIZE (NETWORK.h:89)
|
FRAME_CAP = 1600 # NETWORKMANAGER_BUFFER_SIZE (NETWORK.h:89)
|
||||||
FIRST_GAME_HOST_ID = 2 # FirstLegalHostID(1) == the console; pods 2..N+1
|
FIRST_GAME_HOST_ID = 2 # FirstLegalHostID(1) == the console; pods 2..N+1
|
||||||
@@ -307,6 +365,43 @@ class RelayConsoleConn:
|
|||||||
# count toward the launch gate; scanners never speak the protocol.
|
# count toward the launch gate; scanners never speak the protocol.
|
||||||
|
|
||||||
|
|
||||||
|
class RelayControlConn:
|
||||||
|
"""One operator control connection. All sends are buffered/non-blocking
|
||||||
|
so a stalled remote operator can never stall the relay."""
|
||||||
|
|
||||||
|
def __init__(self, sock, addr):
|
||||||
|
self.sock = sock
|
||||||
|
self.addr = addr
|
||||||
|
self.buf = b""
|
||||||
|
self.outbuf = b""
|
||||||
|
self.authed = False
|
||||||
|
self.connected_at = time.time()
|
||||||
|
self.dead = False
|
||||||
|
|
||||||
|
def name(self):
|
||||||
|
return "%s:%d" % (self.addr[0], self.addr[1])
|
||||||
|
|
||||||
|
def queue_out(self, data):
|
||||||
|
if self.dead or not self.authed:
|
||||||
|
return
|
||||||
|
self.outbuf += data
|
||||||
|
if len(self.outbuf) > CONTROL_OUTBUF_MAX:
|
||||||
|
self.dead = True # slow reader: cut it loose
|
||||||
|
return
|
||||||
|
self.flush_out()
|
||||||
|
|
||||||
|
def flush_out(self):
|
||||||
|
if self.dead or not self.outbuf:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
n = self.sock.send(self.outbuf)
|
||||||
|
self.outbuf = self.outbuf[n:]
|
||||||
|
except (BlockingIOError, InterruptedError):
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
self.dead = True
|
||||||
|
|
||||||
|
|
||||||
class Relay:
|
class Relay:
|
||||||
def __init__(self, console_port, egg_path, bind_addr, udp_drop_pct,
|
def __init__(self, console_port, egg_path, bind_addr, udp_drop_pct,
|
||||||
manual_launch=False, reserved_tags=None):
|
manual_launch=False, reserved_tags=None):
|
||||||
@@ -380,6 +475,8 @@ class Relay:
|
|||||||
self.stats = {"tcp_rx": 0, "tcp_tx": 0, "udp_rx": 0, "udp_tx": 0,
|
self.stats = {"tcp_rx": 0, "tcp_tx": 0, "udp_rx": 0, "udp_tx": 0,
|
||||||
"udp_dropped": 0, "udp_tcp_fallback": 0}
|
"udp_dropped": 0, "udp_tcp_fallback": 0}
|
||||||
self.stats_at = time.time() + STATS_PERIOD
|
self.stats_at = time.time() + STATS_PERIOD
|
||||||
|
self.ctl_conns = [] # RelayControlConn list
|
||||||
|
self.ctl_secret = control_secret()
|
||||||
|
|
||||||
# ---------------- lifecycle ----------------
|
# ---------------- lifecycle ----------------
|
||||||
|
|
||||||
@@ -394,6 +491,18 @@ class Relay:
|
|||||||
self.sel.register(con_l, selectors.EVENT_READ, ("con_listen", None))
|
self.sel.register(con_l, selectors.EVENT_READ, ("con_listen", None))
|
||||||
self.sel.register(game_l, selectors.EVENT_READ, ("game_listen", None))
|
self.sel.register(game_l, selectors.EVENT_READ, ("game_listen", None))
|
||||||
self.sel.register(self.udp_sock, selectors.EVENT_READ, ("udp", None))
|
self.sel.register(self.udp_sock, selectors.EVENT_READ, ("udp", None))
|
||||||
|
# Remote-operator control channel (best-effort; the game relay must
|
||||||
|
# come up even if this port is taken).
|
||||||
|
self.ctl_port = self.console_port + CONTROL_PORT_OFFSET
|
||||||
|
try:
|
||||||
|
ctl_l = self._listener(self.ctl_port)
|
||||||
|
self.sel.register(ctl_l, selectors.EVENT_READ, ("ctl_listen", None))
|
||||||
|
print(f"[ctl] operator control on port {self.ctl_port} "
|
||||||
|
f"(secret in content\\operator_secret.txt)", flush=True)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"[ctl] control port {self.ctl_port} unavailable ({e!r}) "
|
||||||
|
f"-- remote operators disabled; stdin still works",
|
||||||
|
flush=True)
|
||||||
# LAN auto-discovery responder (best-effort: another relay may own it).
|
# LAN auto-discovery responder (best-effort: another relay may own it).
|
||||||
self.disc_sock = None
|
self.disc_sock = None
|
||||||
try:
|
try:
|
||||||
@@ -427,6 +536,11 @@ class Relay:
|
|||||||
self._udp_read()
|
self._udp_read()
|
||||||
elif kind == "disc":
|
elif kind == "disc":
|
||||||
self._disc_read()
|
self._disc_read()
|
||||||
|
elif kind == "ctl_listen":
|
||||||
|
self._ctl_accept(key.fileobj)
|
||||||
|
elif kind == "ctl":
|
||||||
|
self._ctl_read(obj)
|
||||||
|
self._tick_control()
|
||||||
self._tick_launch()
|
self._tick_launch()
|
||||||
self._tick_stop()
|
self._tick_stop()
|
||||||
self._tick_settle()
|
self._tick_settle()
|
||||||
@@ -1281,6 +1395,162 @@ class Relay:
|
|||||||
|
|
||||||
# ---------------- stats ----------------
|
# ---------------- stats ----------------
|
||||||
|
|
||||||
|
# ---------------- remote operator control ----------------
|
||||||
|
|
||||||
|
def _ctl_accept(self, listener):
|
||||||
|
try:
|
||||||
|
sock, addr = listener.accept()
|
||||||
|
sock.setblocking(False)
|
||||||
|
conn = RelayControlConn(sock, addr)
|
||||||
|
self.ctl_conns.append(conn)
|
||||||
|
self.sel.register(sock, selectors.EVENT_READ, ("ctl", conn))
|
||||||
|
print(f"[ctl] operator connection from {conn.name()} "
|
||||||
|
f"(awaiting AUTH)", flush=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _ctl_drop(self, conn, why):
|
||||||
|
try:
|
||||||
|
self.sel.unregister(conn.sock)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
with _CONTROL_LOCK:
|
||||||
|
if conn in _CONTROL_SINKS:
|
||||||
|
_CONTROL_SINKS.remove(conn)
|
||||||
|
if conn in self.ctl_conns:
|
||||||
|
self.ctl_conns.remove(conn)
|
||||||
|
if conn.authed or why != "auth timeout":
|
||||||
|
print(f"[ctl] {conn.name()} dropped: {why}", flush=True)
|
||||||
|
|
||||||
|
def _ctl_read(self, conn):
|
||||||
|
try:
|
||||||
|
data = conn.sock.recv(4096)
|
||||||
|
except (BlockingIOError, InterruptedError):
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
self._ctl_drop(conn, "closed")
|
||||||
|
return
|
||||||
|
if not data:
|
||||||
|
self._ctl_drop(conn, "closed")
|
||||||
|
return
|
||||||
|
conn.buf += data
|
||||||
|
if len(conn.buf) > 8192:
|
||||||
|
self._ctl_drop(conn, "oversize command")
|
||||||
|
return
|
||||||
|
while b"\n" in conn.buf:
|
||||||
|
line, conn.buf = conn.buf.split(b"\n", 1)
|
||||||
|
try:
|
||||||
|
self._ctl_command(conn, line.decode("utf-8", "replace").strip())
|
||||||
|
except Exception as e: # a control bug must NEVER
|
||||||
|
print(f"[ctl] command error: {e!r}", flush=True) # kill games
|
||||||
|
|
||||||
|
def _ctl_command(self, conn, line):
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
if not conn.authed:
|
||||||
|
parts = line.split(None, 1)
|
||||||
|
if len(parts) == 2 and parts[0] == "AUTH" \
|
||||||
|
and parts[1].strip() == self.ctl_secret:
|
||||||
|
# one operator at a time: the newest displaces the rest
|
||||||
|
for other in list(self.ctl_conns):
|
||||||
|
if other is not conn and other.authed:
|
||||||
|
other.queue_out(b"[ctl] displaced by "
|
||||||
|
+ conn.name().encode() + b"\n")
|
||||||
|
other.flush_out()
|
||||||
|
self._ctl_drop(other, "displaced by new operator")
|
||||||
|
conn.authed = True
|
||||||
|
try:
|
||||||
|
conn.sock.send(("OK bt-relay-control 1 console=%d\n"
|
||||||
|
% self.console_port).encode())
|
||||||
|
except OSError:
|
||||||
|
conn.dead = True
|
||||||
|
# replay recent history (bounded), THEN subscribe live --
|
||||||
|
# under the lock so no line is lost or duplicated between
|
||||||
|
# the replay and the live stream
|
||||||
|
with _CONTROL_LOCK:
|
||||||
|
conn.queue_out(b"[ctl] --- session history replay ---\n")
|
||||||
|
for past in _CONTROL_HISTORY:
|
||||||
|
conn.queue_out(past)
|
||||||
|
conn.queue_out(b"[ctl] --- live ---\n")
|
||||||
|
_CONTROL_SINKS.append(conn)
|
||||||
|
print(f"[ctl] operator AUTHENTICATED: {conn.name()} "
|
||||||
|
f"(driving this relay)", flush=True)
|
||||||
|
else:
|
||||||
|
self._ctl_drop(conn, "bad auth")
|
||||||
|
return
|
||||||
|
cmd = line.split(None, 1)[0].lower()
|
||||||
|
if cmd == "launch":
|
||||||
|
print(f"[ctl] LAUNCH from {conn.name()}", flush=True)
|
||||||
|
self.launch_requested = True
|
||||||
|
elif cmd == "stop":
|
||||||
|
print(f"[ctl] STOP from {conn.name()}", flush=True)
|
||||||
|
self.stop_requested = True
|
||||||
|
elif cmd == "ping":
|
||||||
|
conn.queue_out(b"[ctl] pong\n")
|
||||||
|
elif cmd == "get":
|
||||||
|
self._ctl_get_mission(conn)
|
||||||
|
elif cmd == "set":
|
||||||
|
self._ctl_set_mission(conn, line[3:].strip())
|
||||||
|
else:
|
||||||
|
conn.queue_out(("[ctl] unknown command: %s\n" % cmd).encode())
|
||||||
|
|
||||||
|
def _ctl_get_mission(self, conn):
|
||||||
|
try:
|
||||||
|
doc = eggmodel.EggDoc.load(self.orig_egg_path)
|
||||||
|
m = doc.mission()
|
||||||
|
print("[ctl] mission map=%s time=%s weather=%s scenario=%s "
|
||||||
|
"temperature=%s length=%s seats=%d"
|
||||||
|
% (m.get("map", ""), m.get("time", ""),
|
||||||
|
m.get("weather", ""), m.get("scenario", ""),
|
||||||
|
m.get("temperature", ""), m.get("length", ""),
|
||||||
|
len(self.roster)), flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ctl] get mission failed: {e!r}", flush=True)
|
||||||
|
|
||||||
|
def _ctl_set_mission(self, conn, spec):
|
||||||
|
if eggmodel is None:
|
||||||
|
print("[ctl] set rejected: eggmodel unavailable", flush=True)
|
||||||
|
return
|
||||||
|
kw = {}
|
||||||
|
for pair in spec.split(";"):
|
||||||
|
pair = pair.strip()
|
||||||
|
if not pair or "=" not in pair:
|
||||||
|
continue
|
||||||
|
k, v = pair.split("=", 1)
|
||||||
|
k = k.strip().lower()
|
||||||
|
v = v.strip()
|
||||||
|
if k in CONTROL_SET_KEYS and v:
|
||||||
|
kw[k] = v
|
||||||
|
if not kw:
|
||||||
|
print("[ctl] set: nothing applied (allowed keys: %s)"
|
||||||
|
% ",".join(sorted(CONTROL_SET_KEYS)), flush=True)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
doc = eggmodel.EggDoc.load(self.orig_egg_path)
|
||||||
|
doc.set_mission(**kw)
|
||||||
|
doc.save(self.orig_egg_path)
|
||||||
|
print("[ctl] mission settings applied by %s: %s -- takes effect "
|
||||||
|
"next round" % (conn.name(),
|
||||||
|
" ".join("%s=%s" % kv for kv in
|
||||||
|
sorted(kw.items()))), flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ctl] set failed: {e!r}", flush=True)
|
||||||
|
|
||||||
|
def _tick_control(self):
|
||||||
|
now = time.time()
|
||||||
|
for conn in list(self.ctl_conns):
|
||||||
|
if conn.dead:
|
||||||
|
self._ctl_drop(conn, "send buffer overflow/socket error")
|
||||||
|
elif (not conn.authed
|
||||||
|
and now - conn.connected_at > CONTROL_AUTH_TIMEOUT):
|
||||||
|
self._ctl_drop(conn, "auth timeout")
|
||||||
|
else:
|
||||||
|
conn.flush_out()
|
||||||
|
|
||||||
def _tick_settle(self):
|
def _tick_settle(self):
|
||||||
# issue #33: the post-abort settle hold blocks egg release while the
|
# issue #33: the post-abort settle hold blocks egg release while the
|
||||||
# rejoin flapping calms; once it expires, re-run the release check
|
# rejoin flapping calms; once it expires, re-run the release check
|
||||||
@@ -1380,7 +1650,12 @@ class _StampedOut:
|
|||||||
out.append(ch)
|
out.append(ch)
|
||||||
if ch == "\n":
|
if ch == "\n":
|
||||||
self._at_line_start = True
|
self._at_line_start = True
|
||||||
self._inner.write("".join(out))
|
joined = "".join(out)
|
||||||
|
self._inner.write(joined)
|
||||||
|
try:
|
||||||
|
_control_tee(joined)
|
||||||
|
except Exception:
|
||||||
|
pass # the tee must never kill logs
|
||||||
|
|
||||||
def flush(self):
|
def flush(self):
|
||||||
self._inner.flush()
|
self._inner.flush()
|
||||||
|
|||||||
+202
-5
@@ -20,9 +20,11 @@ with the CLI and the test harnesses.
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QProcess, QProcessEnvironment
|
from PySide6.QtCore import Qt, QProcess, QProcessEnvironment, QThread, Signal
|
||||||
from PySide6.QtGui import QColor, QFont, QPalette, QAction
|
from PySide6.QtGui import QColor, QFont, QPalette, QAction
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication, QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox,
|
QApplication, QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox,
|
||||||
@@ -195,6 +197,69 @@ class SessionMonitor:
|
|||||||
# Main window
|
# 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):
|
class Operator(QMainWindow):
|
||||||
COLS = ["Identity / Address", "Callsign", "Vehicle", "Color", "Badge",
|
COLS = ["Identity / Address", "Callsign", "Vehicle", "Color", "Badge",
|
||||||
"Patch", "Experience", "Local"]
|
"Patch", "Experience", "Local"]
|
||||||
@@ -207,6 +272,7 @@ class Operator(QMainWindow):
|
|||||||
self.egg = eggmodel.new_from_template()
|
self.egg = eggmodel.new_from_template()
|
||||||
self.egg_path = DEFAULT_EGG
|
self.egg_path = DEFAULT_EGG
|
||||||
self.console_proc = None
|
self.console_proc = None
|
||||||
|
self.remote_link = None
|
||||||
self.game_procs = []
|
self.game_procs = []
|
||||||
self.monitor = None
|
self.monitor = None
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
@@ -226,6 +292,29 @@ class Operator(QMainWindow):
|
|||||||
self.mode.addItems(["Relay (internet)", "Mesh (LAN legacy)"])
|
self.mode.addItems(["Relay (internet)", "Mesh (LAN legacy)"])
|
||||||
self.mode.currentIndexChanged.connect(self._mode_changed)
|
self.mode.currentIndexChanged.connect(self._mode_changed)
|
||||||
top.addWidget(self.mode)
|
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)
|
top.addSpacing(24)
|
||||||
for label, slot in (("New", self._egg_new), ("Open…", self._egg_open),
|
for label, slot in (("New", self._egg_new), ("Open…", self._egg_open),
|
||||||
("Save", self._egg_save),
|
("Save", self._egg_save),
|
||||||
@@ -547,6 +636,18 @@ class Operator(QMainWindow):
|
|||||||
"""Write the mission-parameter fields into the running session's egg
|
"""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
|
file; the relay re-reads it at the next round's egg release, so the
|
||||||
seated players get the new arena/params WITHOUT rejoining."""
|
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:
|
try:
|
||||||
doc = eggmodel.EggDoc.load(self.egg_path)
|
doc = eggmodel.EggDoc.load(self.egg_path)
|
||||||
doc.set_mission(
|
doc.set_mission(
|
||||||
@@ -656,6 +757,11 @@ class Operator(QMainWindow):
|
|||||||
|
|
||||||
def _start_session(self):
|
def _start_session(self):
|
||||||
self.end_sent = False
|
self.end_sent = False
|
||||||
|
relay_host = self.f_relayhost.text().strip()
|
||||||
|
if (self.mode.currentIndex() == 0 and relay_host
|
||||||
|
and relay_host.lower() not in ("localhost", "127.0.0.1")):
|
||||||
|
self._start_remote_session(relay_host)
|
||||||
|
return
|
||||||
problems = self._collect_egg()
|
problems = self._collect_egg()
|
||||||
if problems:
|
if problems:
|
||||||
QMessageBox.warning(self, "Cannot start", "\n".join(problems))
|
QMessageBox.warning(self, "Cannot start", "\n".join(problems))
|
||||||
@@ -703,7 +809,51 @@ class Operator(QMainWindow):
|
|||||||
and self.f_manual.isChecked() else ""))
|
and self.f_manual.isChecked() else ""))
|
||||||
self._refresh_pod_status()
|
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.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.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):
|
def _stop_session(self):
|
||||||
|
if getattr(self, "remote_link", None):
|
||||||
|
link = self.remote_link
|
||||||
|
self.remote_link = None # closed-signal becomes a no-op
|
||||||
|
link.closed.disconnect(self._remote_closed)
|
||||||
|
link.stop()
|
||||||
|
self.log.appendPlainText("== disconnected from remote relay "
|
||||||
|
"(the relay keeps running) ==")
|
||||||
if self.console_proc:
|
if self.console_proc:
|
||||||
self.console_proc.kill()
|
self.console_proc.kill()
|
||||||
self.console_proc = None
|
self.console_proc = None
|
||||||
@@ -727,7 +877,12 @@ class Operator(QMainWindow):
|
|||||||
|
|
||||||
def _launch_mission(self):
|
def _launch_mission(self):
|
||||||
"""Manual launch gate: send the relay its 'launch' command."""
|
"""Manual launch gate: send the relay its 'launch' command."""
|
||||||
if self.console_proc:
|
if getattr(self, "remote_link", None):
|
||||||
|
self.remote_link.send("launch")
|
||||||
|
self.launch_btn.setEnabled(False)
|
||||||
|
self.end_btn.setEnabled(True)
|
||||||
|
self.log.appendPlainText(">> operator LAUNCH sent (remote)")
|
||||||
|
elif self.console_proc:
|
||||||
self.console_proc.write(b"launch\n")
|
self.console_proc.write(b"launch\n")
|
||||||
self.launch_btn.setEnabled(False)
|
self.launch_btn.setEnabled(False)
|
||||||
self.end_btn.setEnabled(True)
|
self.end_btn.setEnabled(True)
|
||||||
@@ -736,7 +891,12 @@ class Operator(QMainWindow):
|
|||||||
def _end_mission(self):
|
def _end_mission(self):
|
||||||
"""End the running mission now (the relay also auto-stops at the
|
"""End the running mission now (the relay also auto-stops at the
|
||||||
egg's [mission] length -- the authentic console mission clock)."""
|
egg's [mission] length -- the authentic console mission clock)."""
|
||||||
if self.console_proc:
|
if getattr(self, "remote_link", None):
|
||||||
|
self.remote_link.send("stop")
|
||||||
|
self.end_sent = True
|
||||||
|
self.end_btn.setEnabled(False)
|
||||||
|
self.log.appendPlainText(">> operator END MISSION sent (remote)")
|
||||||
|
elif self.console_proc:
|
||||||
self.console_proc.write(b"stop\n")
|
self.console_proc.write(b"stop\n")
|
||||||
self.end_sent = True
|
self.end_sent = True
|
||||||
self.end_btn.setEnabled(False)
|
self.end_btn.setEnabled(False)
|
||||||
@@ -762,11 +922,44 @@ class Operator(QMainWindow):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
for line in data.splitlines():
|
for line in data.splitlines():
|
||||||
if line.strip():
|
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)
|
self.log.appendPlainText(line)
|
||||||
if self.monitor and self.monitor.feed(line):
|
if self.monitor and self.monitor.feed(line):
|
||||||
self._refresh_pod_status()
|
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):
|
def _refresh_pod_status(self):
|
||||||
if not self.monitor:
|
if not self.monitor:
|
||||||
return
|
return
|
||||||
@@ -886,7 +1079,11 @@ class Operator(QMainWindow):
|
|||||||
# relaunches itself with -egg OPERATOR.EGG -net <BT_FE_JOINPORT>
|
# relaunches itself with -egg OPERATOR.EGG -net <BT_FE_JOINPORT>
|
||||||
# (distinct per instance -- two -net listeners can't share a
|
# (distinct per instance -- two -net listeners can't share a
|
||||||
# port on one box).
|
# port on one box).
|
||||||
env.insert("BT_RELAY", "127.0.0.1:%d" % self.f_port.value())
|
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_JOIN", "1")
|
||||||
env.insert("BT_FE_JOINPORT", str(1501 + 100 * r))
|
env.insert("BT_FE_JOINPORT", str(1501 + 100 * r))
|
||||||
args = []
|
args = []
|
||||||
|
|||||||
Reference in New Issue
Block a user