D1 phase 1: relay TCP core in btconsole.py (--relay mode)
The console emulator gains a relay/dispatcher mode for internet play (D1):
pods dial OUT to this process (NAT-friendly) instead of the console dialing
into each pod. Legacy dial-out mode is untouched (verified: an unmodified
2-node mesh session still forms end-to-end through it).
--relay <consolePort> <egg> [--bind ADDR] [--udp-drop PCT], single
selectors-based event loop:
- console listener (consolePort): streams the NUL-line egg to each pod on
connect; GLOBAL launch timer -- RunMission x2 to ALL pods at +20s/+4s after
the LAST pod has its egg (fixes the per-pod skew of the legacy tool).
- game TCP listener (consolePort+1): envelope router {route i32, length u32}.
First frame must be HELLO ('BTR1' + hostID validated against the egg
[pilots] roster); PEER_UP/PEER_DOWN exchange; route>=2 unicast (route
rewritten to sender), -1 broadcast-except-sender (the client sends each
broadcast ONCE -- kills the N-1x upload duplication). Frame cap 1600
(NETWORKMANAGER_BUFFER_SIZE); protocol violations drop the connection.
- game UDP socket (consolePort+1/udp): endpoint learned per-datagram
(NAT-rebind tolerant), HELLO->HELLO-ACK, verbatim forward by route,
TCP-wrap fallback when the target's UDP endpoint is unknown; --udp-drop
test hook for the robustness phase.
Verified: scratchpad/relay_stub_test.py (self-contained; spawns the relay +
stub clients) -- 24/24 checks pass: egg delivery, registration, PEER events,
unicast route-rewrite, broadcast fan-out (sender excluded), UDP ack/forward/
fallback, and all reject paths (bad magic, dup id, out-of-roster, oversize).
Plan: ~/.claude/plans/partitioned-snuggling-piglet.md (D1 relay + UDP).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e2c21c4db2
commit
bf8c2ffaae
+436
-1
@@ -28,11 +28,38 @@ AcknowledgeEggFile back on this socket. The console must STAY CONNECTED (a
|
||||
disconnect trips the pod's console-loss path, which also closes the game
|
||||
listener -- an engine bug).
|
||||
|
||||
Usage:
|
||||
Usage (legacy dial-out console -- the pod-authentic direction):
|
||||
python btconsole.py <egg-file> <host:port> [<host:port> ...]
|
||||
Example (two instances on one box, -net 1501 / -net 1601):
|
||||
python btconsole.py MP.EGG 127.0.0.1:1501 127.0.0.1:1601
|
||||
|
||||
RELAY MODE (D1 -- internet play; pods dial OUT to this process):
|
||||
python btconsole.py --relay <consolePort> <egg-file> [--bind ADDR] [--udp-drop PCT]
|
||||
Example:
|
||||
python btconsole.py --relay 1500 MP_RELAY.EGG
|
||||
The relay listens on <consolePort>/tcp (console protocol: streams the egg to each
|
||||
pod that connects, sends the LAUNCH pair once ALL pods have the egg), on
|
||||
<consolePort>+1/tcp (the GAME relay: envelope-framed frame router, see
|
||||
RelayEnvelope below), and on <consolePort>+1/udp (the unreliable channel
|
||||
forwarder). Pods run with BT_RELAY=<host>:<consolePort> and a unique
|
||||
BT_SELF=<their [pilots] entry>.
|
||||
|
||||
RelayEnvelope (game TCP): { int32 route; uint32 length; } + length payload bytes
|
||||
route >= 2 : client->relay: unicast to that hostID
|
||||
relay->client: the sender's hostID (frames also self-identify
|
||||
via NetworkPacketHeader.fromHost)
|
||||
route == -1: broadcast to every registered pod except the sender
|
||||
route == -2: HELLO (payload: int32 magic 'BTR1', int32 myHostID)
|
||||
route == -3: PEER_UP (payload: int32 hostID) relay->client only
|
||||
route == -4: PEER_DOWN (payload: int32 hostID) relay->client only
|
||||
UDP envelope: { int32 route; int32 fromHost; uint32 seq; } + frame
|
||||
route as above; -2 = HELLO/keepalive (no payload) -> relay answers -5
|
||||
(HELLO-ACK). The relay learns each pod's public endpoint from every
|
||||
inbound datagram and forwards verbatim; unknown target endpoint falls
|
||||
back to wrapping the frame onto the target's game TCP connection.
|
||||
"""
|
||||
import random
|
||||
import selectors
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
@@ -144,7 +171,415 @@ def serve_pod(hostport, egg_bytes):
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
#############################################################################
|
||||
# RELAY MODE (D1)
|
||||
#############################################################################
|
||||
|
||||
ENV_FMT_TCP = "<iI" # route, length
|
||||
ENV_FMT_UDP = "<iiI" # route, fromHost, seq
|
||||
ENV_TCP_SIZE = 8
|
||||
ENV_UDP_SIZE = 12
|
||||
ROUTE_BCAST = -1
|
||||
ROUTE_HELLO = -2
|
||||
ROUTE_PEER_UP = -3
|
||||
ROUTE_PEER_DOWN = -4
|
||||
ROUTE_UDP_ACK = -5
|
||||
HELLO_MAGIC = 0x31525442 # 'BTR1' little-endian
|
||||
FRAME_CAP = 1600 # NETWORKMANAGER_BUFFER_SIZE (NETWORK.h:89)
|
||||
FIRST_GAME_HOST_ID = 2 # FirstLegalHostID(1) == the console; pods 2..N+1
|
||||
STATS_PERIOD = 10.0
|
||||
|
||||
|
||||
def parse_egg_roster(egg_path):
|
||||
"""Count the [pilots] pilot= entries -> expected hostIDs 2..N+1 in egg order.
|
||||
The relay's ONLY game knowledge; it never parses game frames."""
|
||||
entries = []
|
||||
in_pilots = False
|
||||
for raw in open(egg_path, "rb").read().replace(b"\r\n", b"\n").split(b"\n"):
|
||||
line = raw.strip()
|
||||
if line.startswith(b"["):
|
||||
in_pilots = (line.lower() == b"[pilots]")
|
||||
continue
|
||||
if in_pilots and line.lower().startswith(b"pilot="):
|
||||
entries.append(line.split(b"=", 1)[1].decode("ascii", "replace"))
|
||||
return entries
|
||||
|
||||
|
||||
class RelayGameConn:
|
||||
"""One accepted game-TCP connection: ACCEPTED -> (HELLO) REGISTERED -> GONE."""
|
||||
def __init__(self, sock, addr):
|
||||
self.sock = sock
|
||||
self.addr = addr
|
||||
self.buf = b""
|
||||
self.host_id = None # None until HELLO registers it
|
||||
|
||||
def name(self):
|
||||
hid = self.host_id if self.host_id is not None else "?"
|
||||
return f"game[{self.addr[0]}:{self.addr[1]} host={hid}]"
|
||||
|
||||
|
||||
class RelayConsoleConn:
|
||||
"""One accepted console connection (legacy raw protocol, relay-terminated)."""
|
||||
def __init__(self, sock, addr):
|
||||
self.sock = sock
|
||||
self.addr = addr
|
||||
self.egg_sent = False
|
||||
|
||||
|
||||
class Relay:
|
||||
def __init__(self, console_port, egg_path, bind_addr, udp_drop_pct):
|
||||
self.console_port = console_port
|
||||
self.game_port = console_port + 1
|
||||
self.bind_addr = bind_addr
|
||||
self.udp_drop_pct = udp_drop_pct
|
||||
self.egg_bytes = egg_wire_bytes(open(egg_path, "rb").read())
|
||||
self.roster = parse_egg_roster(egg_path)
|
||||
self.expected_ids = set(range(FIRST_GAME_HOST_ID,
|
||||
FIRST_GAME_HOST_ID + len(self.roster)))
|
||||
self.sel = selectors.DefaultSelector()
|
||||
self.console_conns = []
|
||||
self.game_conns = [] # RelayGameConn (incl. unregistered)
|
||||
self.by_host = {} # hostID -> RelayGameConn
|
||||
self.udp_endpoint = {} # hostID -> (addr, port)
|
||||
self.udp_sock = None
|
||||
self.launch_at = None
|
||||
self.launches_sent = 0
|
||||
self.stats = {"tcp_rx": 0, "tcp_tx": 0, "udp_rx": 0, "udp_tx": 0,
|
||||
"udp_dropped": 0, "udp_tcp_fallback": 0}
|
||||
self.stats_at = time.time() + STATS_PERIOD
|
||||
|
||||
# ---------------- lifecycle ----------------
|
||||
|
||||
def run(self):
|
||||
print(f"[relay] roster: {len(self.roster)} pilot(s) -> hostIDs "
|
||||
f"{sorted(self.expected_ids)}: {self.roster}", flush=True)
|
||||
con_l = self._listener(self.console_port)
|
||||
game_l = self._listener(self.game_port)
|
||||
self.udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.udp_sock.bind((self.bind_addr, self.game_port))
|
||||
self.udp_sock.setblocking(False)
|
||||
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(self.udp_sock, selectors.EVENT_READ, ("udp", None))
|
||||
print(f"[relay] console {self.bind_addr}:{self.console_port} | game tcp/udp "
|
||||
f"{self.bind_addr}:{self.game_port} | udp-drop {self.udp_drop_pct}%",
|
||||
flush=True)
|
||||
while True:
|
||||
for key, _ in self.sel.select(timeout=0.5):
|
||||
kind, obj = key.data
|
||||
if kind == "con_listen":
|
||||
self._accept_console(key.fileobj)
|
||||
elif kind == "game_listen":
|
||||
self._accept_game(key.fileobj)
|
||||
elif kind == "console":
|
||||
self._console_read(obj)
|
||||
elif kind == "game":
|
||||
self._game_read(obj)
|
||||
elif kind == "udp":
|
||||
self._udp_read()
|
||||
self._tick_launch()
|
||||
self._tick_stats()
|
||||
|
||||
def _listener(self, port):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind((self.bind_addr, port))
|
||||
s.listen(16)
|
||||
s.setblocking(False)
|
||||
return s
|
||||
|
||||
# ---------------- console side (legacy protocol, relay-terminated) ----------------
|
||||
|
||||
def _accept_console(self, listener):
|
||||
sock, addr = listener.accept()
|
||||
sock.setblocking(False)
|
||||
conn = RelayConsoleConn(sock, addr)
|
||||
self.console_conns.append(conn)
|
||||
self.sel.register(sock, selectors.EVENT_READ, ("console", conn))
|
||||
# Stream the egg immediately (blocking sendall is fine: the pod's console
|
||||
# pad drains fast and the egg is a few KB).
|
||||
sock.setblocking(True)
|
||||
try:
|
||||
n = 0
|
||||
for pkt in egg_packets(self.egg_bytes):
|
||||
sock.sendall(pkt)
|
||||
n += 1
|
||||
conn.egg_sent = True
|
||||
print(f"[relay] console conn {addr[0]}:{addr[1]}: egg sent "
|
||||
f"({n} chunks); {self._eggs_out()}/{len(self.roster)} pods have it",
|
||||
flush=True)
|
||||
except OSError as e:
|
||||
print(f"[relay] console conn {addr[0]}:{addr[1]}: egg send failed {e!r}",
|
||||
flush=True)
|
||||
self._drop_console(conn)
|
||||
return
|
||||
finally:
|
||||
try:
|
||||
sock.setblocking(False)
|
||||
except OSError:
|
||||
pass
|
||||
# GLOBAL launch timer: arm/rearm when the LAST pod has its egg.
|
||||
if self._eggs_out() >= len(self.roster) and self.launches_sent == 0:
|
||||
self.launch_at = time.time() + LAUNCH_SETTLE_SECONDS
|
||||
print(f"[relay] all pods have the egg; LAUNCH pair in "
|
||||
f"{LAUNCH_SETTLE_SECONDS}s", flush=True)
|
||||
|
||||
def _eggs_out(self):
|
||||
return sum(1 for c in self.console_conns if c.egg_sent)
|
||||
|
||||
def _console_read(self, conn):
|
||||
try:
|
||||
data = conn.sock.recv(4096)
|
||||
except OSError:
|
||||
data = b""
|
||||
if not data:
|
||||
print(f"[relay] console conn {conn.addr[0]}:{conn.addr[1]} closed",
|
||||
flush=True)
|
||||
self._drop_console(conn)
|
||||
return
|
||||
if len(data) >= 24:
|
||||
(clid, _gid, fh, _ts) = struct.unpack_from("<iiii", data, 0)
|
||||
(mlen, mid) = struct.unpack_from("<Ii", data, 16)
|
||||
print(f"[relay] pod->console clientID={clid} fromHost={fh} msgID={mid} "
|
||||
f"len={mlen}", flush=True)
|
||||
|
||||
def _drop_console(self, conn):
|
||||
try:
|
||||
self.sel.unregister(conn.sock)
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
try:
|
||||
conn.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
if conn in self.console_conns:
|
||||
self.console_conns.remove(conn)
|
||||
|
||||
def _tick_launch(self):
|
||||
if self.launch_at is None or self.launches_sent >= 2:
|
||||
return
|
||||
if time.time() < self.launch_at:
|
||||
return
|
||||
pkt = run_mission_packet()
|
||||
for conn in list(self.console_conns):
|
||||
try:
|
||||
conn.sock.setblocking(True)
|
||||
conn.sock.sendall(pkt)
|
||||
conn.sock.setblocking(False)
|
||||
except OSError as e:
|
||||
print(f"[relay] launch send failed to {conn.addr}: {e!r}", flush=True)
|
||||
self.launches_sent += 1
|
||||
self.launch_at = time.time() + LAUNCH_STEP_SECONDS
|
||||
print(f"[relay] RunMission #{self.launches_sent} sent to "
|
||||
f"{len(self.console_conns)} pod(s)", flush=True)
|
||||
|
||||
# ---------------- game TCP side ----------------
|
||||
|
||||
def _accept_game(self, listener):
|
||||
sock, addr = listener.accept()
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
sock.setblocking(False)
|
||||
conn = RelayGameConn(sock, addr)
|
||||
self.game_conns.append(conn)
|
||||
self.sel.register(sock, selectors.EVENT_READ, ("game", conn))
|
||||
print(f"[relay] {conn.name()} accepted (awaiting HELLO)", flush=True)
|
||||
|
||||
def _game_read(self, conn):
|
||||
try:
|
||||
data = conn.sock.recv(8192)
|
||||
except OSError:
|
||||
data = b""
|
||||
if not data:
|
||||
self._drop_game(conn, "closed")
|
||||
return
|
||||
conn.buf += data
|
||||
while len(conn.buf) >= ENV_TCP_SIZE:
|
||||
route, length = struct.unpack_from(ENV_FMT_TCP, conn.buf, 0)
|
||||
if length > FRAME_CAP:
|
||||
self._drop_game(conn, f"oversize frame ({length})")
|
||||
return
|
||||
if len(conn.buf) < ENV_TCP_SIZE + length:
|
||||
break # partial; wait for more
|
||||
payload = conn.buf[ENV_TCP_SIZE:ENV_TCP_SIZE + length]
|
||||
conn.buf = conn.buf[ENV_TCP_SIZE + length:]
|
||||
self.stats["tcp_rx"] += 1
|
||||
self._handle_game_frame(conn, route, payload)
|
||||
if conn.sock.fileno() < 0: # dropped mid-loop
|
||||
return
|
||||
|
||||
def _handle_game_frame(self, conn, route, payload):
|
||||
if conn.host_id is None:
|
||||
# First frame MUST be a valid HELLO.
|
||||
if route != ROUTE_HELLO or len(payload) < 8:
|
||||
self._drop_game(conn, f"first frame not HELLO (route={route})")
|
||||
return
|
||||
magic, host_id = struct.unpack_from("<iI", payload, 0)
|
||||
host_id = int(host_id)
|
||||
if magic != HELLO_MAGIC:
|
||||
self._drop_game(conn, f"bad HELLO magic 0x{magic:x}")
|
||||
return
|
||||
if host_id not in self.expected_ids:
|
||||
self._drop_game(conn, f"HELLO hostID {host_id} not in roster "
|
||||
f"{sorted(self.expected_ids)}")
|
||||
return
|
||||
if host_id in self.by_host:
|
||||
self._drop_game(conn, f"HELLO hostID {host_id} already registered")
|
||||
return
|
||||
conn.host_id = host_id
|
||||
self.by_host[host_id] = conn
|
||||
print(f"[relay] {conn.name()} REGISTERED "
|
||||
f"({len(self.by_host)}/{len(self.roster)})", flush=True)
|
||||
# PEER_UP exchange: newcomer learns everyone; everyone learns newcomer.
|
||||
for other_id in sorted(self.by_host):
|
||||
if other_id != host_id:
|
||||
self._send_control(conn, ROUTE_PEER_UP, other_id)
|
||||
for other in self.by_host.values():
|
||||
if other is not conn:
|
||||
self._send_control(other, ROUTE_PEER_UP, host_id)
|
||||
return
|
||||
if route == ROUTE_BCAST:
|
||||
env = struct.pack(ENV_FMT_TCP, conn.host_id, len(payload))
|
||||
for other in list(self.by_host.values()):
|
||||
if other is not conn:
|
||||
self._send_raw(other, env + payload)
|
||||
elif route >= FIRST_GAME_HOST_ID:
|
||||
target = self.by_host.get(route)
|
||||
if target is None:
|
||||
return # peer gone; drop silently
|
||||
env = struct.pack(ENV_FMT_TCP, conn.host_id, len(payload))
|
||||
self._send_raw(target, env + payload)
|
||||
else:
|
||||
print(f"[relay] {conn.name()} bad route {route} -- ignored", flush=True)
|
||||
|
||||
def _send_control(self, conn, route, host_id):
|
||||
self._send_raw(conn, struct.pack(ENV_FMT_TCP, route, 4)
|
||||
+ struct.pack("<i", host_id))
|
||||
|
||||
def _send_raw(self, conn, data):
|
||||
try:
|
||||
conn.sock.setblocking(True)
|
||||
conn.sock.sendall(data)
|
||||
conn.sock.setblocking(False)
|
||||
self.stats["tcp_tx"] += 1
|
||||
except OSError as e:
|
||||
self._drop_game(conn, f"send failed {e!r}")
|
||||
|
||||
def _drop_game(self, conn, why):
|
||||
print(f"[relay] {conn.name()} dropped: {why}", flush=True)
|
||||
try:
|
||||
self.sel.unregister(conn.sock)
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
try:
|
||||
conn.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
if conn in self.game_conns:
|
||||
self.game_conns.remove(conn)
|
||||
if conn.host_id is not None and self.by_host.get(conn.host_id) is conn:
|
||||
del self.by_host[conn.host_id]
|
||||
self.udp_endpoint.pop(conn.host_id, None)
|
||||
for other in self.by_host.values():
|
||||
self._send_control(other, ROUTE_PEER_DOWN, conn.host_id)
|
||||
|
||||
# ---------------- game UDP side ----------------
|
||||
|
||||
def _udp_read(self):
|
||||
while True:
|
||||
try:
|
||||
data, endpoint = self.udp_sock.recvfrom(4096)
|
||||
except (BlockingIOError, InterruptedError):
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
if len(data) < ENV_UDP_SIZE:
|
||||
continue
|
||||
route, from_host, _seq = struct.unpack_from(ENV_FMT_UDP, data, 0)
|
||||
if from_host not in self.by_host:
|
||||
continue # not TCP-registered: drop
|
||||
self.stats["udp_rx"] += 1
|
||||
# NAT-rebind tolerant: every datagram refreshes the endpoint map.
|
||||
self.udp_endpoint[from_host] = endpoint
|
||||
if route == ROUTE_HELLO:
|
||||
ack = struct.pack(ENV_FMT_UDP, ROUTE_UDP_ACK, CONSOLE_HOST_ID, 0)
|
||||
try:
|
||||
self.udp_sock.sendto(ack, endpoint)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if route == ROUTE_BCAST:
|
||||
targets = [h for h in self.by_host if h != from_host]
|
||||
elif route >= FIRST_GAME_HOST_ID and route in self.by_host:
|
||||
targets = [route]
|
||||
else:
|
||||
continue
|
||||
for host_id in targets:
|
||||
if self.udp_drop_pct and random.random() * 100.0 < self.udp_drop_pct:
|
||||
self.stats["udp_dropped"] += 1
|
||||
continue
|
||||
ep = self.udp_endpoint.get(host_id)
|
||||
if ep is not None:
|
||||
try:
|
||||
self.udp_sock.sendto(data, ep) # forward VERBATIM
|
||||
self.stats["udp_tx"] += 1
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
# Target's UDP endpoint unknown/broken: wrap the frame onto its
|
||||
# game TCP connection (asymmetric-UDP-blockage fallback).
|
||||
frame = data[ENV_UDP_SIZE:]
|
||||
target = self.by_host.get(host_id)
|
||||
if target is not None and frame:
|
||||
env = struct.pack(ENV_FMT_TCP, from_host, len(frame))
|
||||
self._send_raw(target, env + frame)
|
||||
self.stats["udp_tcp_fallback"] += 1
|
||||
|
||||
# ---------------- stats ----------------
|
||||
|
||||
def _tick_stats(self):
|
||||
if time.time() < self.stats_at:
|
||||
return
|
||||
self.stats_at = time.time() + STATS_PERIOD
|
||||
s = self.stats
|
||||
print(f"[relay-stats] tcp rx/tx {s['tcp_rx']}/{s['tcp_tx']} | "
|
||||
f"udp rx/tx {s['udp_rx']}/{s['udp_tx']} "
|
||||
f"(dropped {s['udp_dropped']}, tcp-fallback {s['udp_tcp_fallback']}) | "
|
||||
f"registered {sorted(self.by_host)} udp-known {sorted(self.udp_endpoint)}",
|
||||
flush=True)
|
||||
|
||||
|
||||
def relay_main(argv):
|
||||
"""argv: --relay <consolePort> <egg-file> [--bind ADDR] [--udp-drop PCT]"""
|
||||
args = argv[:]
|
||||
bind_addr = "0.0.0.0"
|
||||
udp_drop = 0.0
|
||||
if "--bind" in args:
|
||||
i = args.index("--bind")
|
||||
bind_addr = args[i + 1]
|
||||
del args[i:i + 2]
|
||||
if "--udp-drop" in args:
|
||||
i = args.index("--udp-drop")
|
||||
udp_drop = float(args[i + 1])
|
||||
del args[i:i + 2]
|
||||
if len(args) != 2:
|
||||
print(__doc__)
|
||||
return 2
|
||||
console_port = int(args[0])
|
||||
egg_path = args[1]
|
||||
relay = Relay(console_port, egg_path, bind_addr, udp_drop)
|
||||
if not relay.roster:
|
||||
print(f"[relay] ERROR: no [pilots] entries in {egg_path}")
|
||||
return 2
|
||||
try:
|
||||
relay.run()
|
||||
except KeyboardInterrupt:
|
||||
print("[relay] shutting down")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "--relay":
|
||||
return relay_main(sys.argv[2:])
|
||||
if len(sys.argv) < 3:
|
||||
print(__doc__)
|
||||
return 2
|
||||
|
||||
Reference in New Issue
Block a user