D1 phase 7: LAN auto-discovery (BT_RELAY=auto) + operator LAN scripts

LAN players now find the game with zero configuration -- the 90s arcade
model (cabinets locate the operator station) restored:

- Client (L4NET RelayDiscover): BT_RELAY=auto broadcasts 'BTR1DISC' on
  udp/15999 (255.255.255.255 AND 127.0.0.1 -- broadcast doesn't reliably
  loop back on Windows; covers same-box sessions), 5 x 1s attempts; the
  answering relay's SOURCE IP + its advertised console port become the
  relay address.  Explicit <host>:<port> path unchanged; mesh untouched.
- Relay (btconsole.py): best-effort discovery responder on udp/15999
  answers 'BTR1HERE' + <u16 consolePort>; degrades gracefully (logged) if
  the port is taken.
- Operator app: Export player scripts now writes a join_as_playerN_lan.bat
  pair (BT_RELAY=auto) next to each internet script -- LAN guests
  double-click and are found; internet guests use the public-host script.

Verified 2-node: both pods BT_RELAY=auto -> probe answered through a real
interface (not just loopback) -> discovered 172.19.x.x:1500 -> full session
to RunningMission.  KB: multiplayer.md D1 section updated (+ the operator
console entry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-18 11:12:01 -05:00
co-authored by Claude Fable 5
parent 43fa53d542
commit 1436d27ac6
5 changed files with 208 additions and 32 deletions
+16 -2
View File
@@ -304,9 +304,23 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
MP_RELAY.EGG --bind 127.0.0.1`; pods `BT_RELAY=127.0.0.1:1500 BT_SELF=10.99.0.N:1502 btl4
-egg MP_RELAY.EGG -net 1501` (egg `[pilots]` = arbitrary unique tags). Diag: relay
`[relay-stats]` per-transport census; client `BT_NET_TRACE`.
- **LAN AUTO-DISCOVERY (2026-07-18) [T2]**: `BT_RELAY=auto` — the pod broadcasts `BTR1DISC` on
udp/15999 (broadcast + 127.0.0.1, 5×1s attempts); the relay answers `BTR1HERE`+<u16
consolePort>; the replier's source IP + advertised port become the relay address. Zero-config
for LAN players (the 90s cabinet model); broadcast never crosses routers so internet players
keep the explicit host:port. Verified 2-node: both pods `auto`-discovered through a real
interface and reached RunningMission. Relay side degrades gracefully if the discovery port is
taken.
- **OPERATOR CONSOLE (2026-07-18)**: `tools/btoperator.py` (PySide6) — the lost 1995 operator
station recreated: mission editor with ALL values validated live from BTL4.RES
(`tools/eggmodel.py`: maps=type14∩26, colors/badges/patches from the type-25 vehicletable,
vehicles from ModelLists), relay/mesh mode switch, live pod-status lights parsed from the
console/relay stdout, one-click local instance launcher, per-player `join_as_playerN.bat` (+
`_lan.bat` with `BT_RELAY=auto`) export. Wire logic stays in btconsole.py/eggmodel.py (GUI =
UI + QProcess only). E2E-verified: app-driven relay session → 2 local pods → LAUNCH observed.
- **STILL UNTESTED (config, not code)**: a real two-physical-machine / internet run (only the
relay's console+game ports need to be reachable inbound — one forwarded port total).
Optional future: LAN `BT_RELAY=auto` UDP-broadcast discovery.
relay's console+game ports need to be reachable inbound — one forwarded port total; or a
playit.gg-style tunnel in front of the relay, host-side only).
## Remaining (P6 phase 4 / Phase 7)
Pod-LAN config: **real-IP path VALIDATED (2026-07-15)** — an egg with the machine's real LAN IP in
+128 -27
View File
@@ -87,6 +87,11 @@ enum
RELAY_ROUTE_UDP_ACK = -5 // relay->pod: UDP HELLO acknowledged
};
#define RELAY_HELLO_MAGIC 0x31525442 // 'BTR1' little-endian
// LAN auto-discovery (BT_RELAY=auto): probe broadcast on this UDP port; the
// relay answers "BTR1HERE" + <u16 consolePort>. Must match btconsole.py.
#define RELAY_DISCOVERY_PORT 15999
#define RELAY_DISC_PROBE "BTR1DISC"
#define RELAY_DISC_REPLY "BTR1HERE"
struct RelayTcpEnvelope
{
int route;
@@ -336,35 +341,52 @@ L4NetworkManager::L4NetworkManager():
const char *relay_env = getenv("BT_RELAY");
if (relay_env != NULL && relay_env[0] != '\0')
{
char host_part[128];
int relay_port = 0;
const char *colon = strrchr(relay_env, ':');
if (colon == NULL || colon == relay_env
|| (relay_port = atoi(colon + 1)) <= 0
|| (colon - relay_env) >= (int)sizeof(host_part))
if (_stricmp(relay_env, "auto") == 0)
{
Fail("BT_RELAY must be <host>:<consolePort>\n");
}
memcpy(host_part, relay_env, colon - relay_env);
host_part[colon - relay_env] = '\0';
unsigned long relay_ip = inet_addr(host_part);
if (relay_ip == INADDR_NONE)
{
hostent *he = gethostbyname(host_part);
if (he == NULL || he->h_addr_list[0] == NULL)
//
// LAN AUTO-DISCOVERY: broadcast a probe; the relay answers
// with its console port; its source IP is the relay host.
// Zero-config for anyone on the operator's LAN (the 90s
// arcade model: cabinets find the operator station).
//
if (!RelayDiscover(&relayConsoleAddress))
{
DEBUG_STREAM << "ERROR: BT_RELAY host '" << host_part
<< "' did not resolve\n" << std::flush;
Fail("BT_RELAY host unresolvable\n");
Fail("BT_RELAY=auto: no relay answered on this LAN\n");
}
relay_ip = *(unsigned long *)he->h_addr_list[0];
}
relayConsoleAddress.sin_family = AF_INET;
relayConsoleAddress.sin_addr.S_un.S_addr = relay_ip;
relayConsoleAddress.sin_port = htons((unsigned short)relay_port);
else
{
char host_part[128];
int relay_port = 0;
const char *colon = strrchr(relay_env, ':');
if (colon == NULL || colon == relay_env
|| (relay_port = atoi(colon + 1)) <= 0
|| (colon - relay_env) >= (int)sizeof(host_part))
{
Fail("BT_RELAY must be <host>:<consolePort> or 'auto'\n");
}
memcpy(host_part, relay_env, colon - relay_env);
host_part[colon - relay_env] = '\0';
unsigned long relay_ip = inet_addr(host_part);
if (relay_ip == INADDR_NONE)
{
hostent *he = gethostbyname(host_part);
if (he == NULL || he->h_addr_list[0] == NULL)
{
DEBUG_STREAM << "ERROR: BT_RELAY host '" << host_part
<< "' did not resolve\n" << std::flush;
Fail("BT_RELAY host unresolvable\n");
}
relay_ip = *(unsigned long *)he->h_addr_list[0];
}
relayConsoleAddress.sin_family = AF_INET;
relayConsoleAddress.sin_addr.S_un.S_addr = relay_ip;
relayConsoleAddress.sin_port = htons((unsigned short)relay_port);
}
relayGameAddress = relayConsoleAddress;
relayGameAddress.sin_port = htons((unsigned short)(relay_port + 1));
relayGameAddress.sin_port =
htons((unsigned short)(ntohs(relayConsoleAddress.sin_port) + 1));
const char *self_env = getenv("BT_SELF");
if (self_env == NULL || self_env[0] == '\0')
@@ -373,13 +395,92 @@ L4NetworkManager::L4NetworkManager():
}
Str_Copy(relaySelf, self_env, sizeof(relaySelf));
relayMode = True;
DEBUG_STREAM << "[relay] mode ON: relay " << host_part << ":"
<< relay_port << " (game " << (relay_port + 1) << "), self='"
<< relaySelf << "'\n" << std::flush;
DEBUG_STREAM << "[relay] mode ON: relay "
<< inet_ntoa(relayConsoleAddress.sin_addr) << ":"
<< (int)ntohs(relayConsoleAddress.sin_port)
<< " (game " << (int)ntohs(relayGameAddress.sin_port)
<< "), self='" << relaySelf << "'\n" << std::flush;
}
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// RelayDiscover -- BT_RELAY=auto: find the relay on the LAN by UDP broadcast.
// Sends the probe to 255.255.255.255 AND 127.0.0.1 (same-box sessions --
// broadcast does not reliably loop back on Windows), ~5 attempts at 1s. The
// answering relay's source address + its advertised console port fill
// *console_endpoint.
//
Logical L4NetworkManager::RelayDiscover(SOCKADDR_IN *console_endpoint)
{
SOCKET probe_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (probe_socket == INVALID_SOCKET)
{
return False;
}
BOOL allow_broadcast = TRUE;
setsockopt(probe_socket, SOL_SOCKET, SO_BROADCAST,
(char *)&allow_broadcast, sizeof(allow_broadcast));
unsigned long enable = 1;
ioctlsocket(probe_socket, FIONBIO, &enable);
SOCKADDR_IN broadcast_addr, loopback_addr;
memset(&broadcast_addr, 0, sizeof(broadcast_addr));
broadcast_addr.sin_family = AF_INET;
broadcast_addr.sin_addr.S_un.S_addr = INADDR_BROADCAST;
broadcast_addr.sin_port = htons(RELAY_DISCOVERY_PORT);
loopback_addr = broadcast_addr;
loopback_addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
const int probe_len = (int)strlen(RELAY_DISC_PROBE);
const int reply_len = (int)strlen(RELAY_DISC_REPLY);
Logical found = False;
DEBUG_STREAM << "[relay] BT_RELAY=auto: probing the LAN (udp/"
<< RELAY_DISCOVERY_PORT << ")...\n" << std::flush;
for (int attempt = 0; attempt < 5 && !found; ++attempt)
{
sendto(probe_socket, RELAY_DISC_PROBE, probe_len, 0,
(const sockaddr *)&broadcast_addr, sizeof(broadcast_addr));
sendto(probe_socket, RELAY_DISC_PROBE, probe_len, 0,
(const sockaddr *)&loopback_addr, sizeof(loopback_addr));
unsigned long wait_until = GetTickCount() + 1000UL;
while (GetTickCount() < wait_until && !found)
{
fd_set read_set;
FD_ZERO(&read_set);
FD_SET(probe_socket, &read_set);
timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 100000;
if (select(0, &read_set, NULL, NULL, &tv) != 1)
{
continue;
}
char reply[32];
SOCKADDR_IN from;
int from_len = sizeof(from);
int received = recvfrom(probe_socket, reply, sizeof(reply), 0,
(sockaddr *)&from, &from_len);
if (received >= reply_len + 2
&& memcmp(reply, RELAY_DISC_REPLY, reply_len) == 0)
{
unsigned short console_port;
memcpy(&console_port, reply + reply_len, 2);
*console_endpoint = from;
console_endpoint->sin_port = htons(console_port);
DEBUG_STREAM << "[relay] discovered relay at "
<< inet_ntoa(from.sin_addr) << ":" << (int)console_port
<< "\n" << std::flush;
found = True;
}
}
}
closesocket(probe_socket);
return found;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// L4NetworkManager::MakeNotationFileEgg This routine creates the console remote
// host
+2
View File
@@ -336,6 +336,8 @@ private:
// unset => relayMode False => every relay branch is dead and the classic
// mesh is untouched.
//
Logical
RelayDiscover(SOCKADDR_IN *console_endpoint);
SOCKET
RelayDialTcp(const SOCKADDR_IN &endpoint, int timeout_seconds);
Logical
+45
View File
@@ -189,6 +189,15 @@ 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
# LAN auto-discovery (BT_RELAY=auto): pods broadcast DISC_PROBE on this UDP
# port; the relay answers DISC_REPLY + <H consolePort>. The pod combines the
# replier's source IP with the advertised port. Broadcast never crosses
# routers, so this is LAN-only by construction; internet players keep using
# the explicit host:port.
DISCOVERY_PORT = 15999
DISC_PROBE = b"BTR1DISC"
DISC_REPLY = b"BTR1HERE"
def parse_egg_roster(egg_path):
"""Count the [pilots] pilot= entries -> expected hostIDs 2..N+1 in egg order.
@@ -261,6 +270,21 @@ class Relay:
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))
# LAN auto-discovery responder (best-effort: another relay may own it).
self.disc_sock = None
try:
self.disc_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.disc_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.disc_sock.bind((self.bind_addr, DISCOVERY_PORT))
self.disc_sock.setblocking(False)
self.sel.register(self.disc_sock, selectors.EVENT_READ,
("disc", None))
print(f"[relay] LAN discovery answering on udp/{DISCOVERY_PORT} "
f"(pods may use BT_RELAY=auto)", flush=True)
except OSError as e:
print(f"[relay] LAN discovery unavailable ({e!r}) -- "
f"pods must use the explicit address", flush=True)
self.disc_sock = 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)
@@ -277,6 +301,8 @@ class Relay:
self._game_read(obj)
elif kind == "udp":
self._udp_read()
elif kind == "disc":
self._disc_read()
self._tick_launch()
self._tick_stats()
@@ -534,6 +560,25 @@ class Relay:
self._send_raw(target, env + frame)
self.stats["udp_tcp_fallback"] += 1
# ---------------- LAN auto-discovery ----------------
def _disc_read(self):
while True:
try:
data, endpoint = self.disc_sock.recvfrom(64)
except (BlockingIOError, InterruptedError, OSError):
return
if data == DISC_PROBE:
try:
self.disc_sock.sendto(
DISC_REPLY + struct.pack("<H", self.console_port),
endpoint)
print(f"[relay] discovery probe from "
f"{endpoint[0]}:{endpoint[1]} -> answered "
f"(console port {self.console_port})", flush=True)
except OSError:
pass
# ---------------- stats ----------------
def _tick_stats(self):
+17 -3
View File
@@ -625,7 +625,9 @@ class Operator(QMainWindow):
self, "Export player launch scripts to…", REPO)
if not out_dir:
return
egg_name = os.path.basename(self.egg_path)
for i, p in enumerate(self.egg.pilots()):
# Internet variant: explicit public host.
name = os.path.join(out_dir, "join_as_player%d.bat" % (i + 1))
with open(name, "w", newline="\r\n") as f:
f.write("@echo off\n"
@@ -636,10 +638,22 @@ class Operator(QMainWindow):
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n"
% (host, i + 1, p.get("vehicle") or "?",
host, self.f_port.value(), p["address"],
os.path.basename(self.egg_path)))
egg_name))
# LAN variant: zero-config auto-discovery (BT_RELAY=auto).
lan = os.path.join(out_dir, "join_as_player%d_lan.bat" % (i + 1))
with open(lan, "w", newline="\r\n") as f:
f.write("@echo off\n"
"rem BT411 -- join the LAN game as player %d (%s) --\n"
"rem finds the operator console automatically.\n"
"set BT_RELAY=auto\n"
"set BT_SELF=%s\n"
"cd /d %%~dp0content\n"
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n"
% (i + 1, p.get("vehicle") or "?", p["address"],
egg_name))
self.log.appendPlainText(
"exported %d player script(s) to %s (relay host: %s)"
% (len(self.egg.pilots()), out_dir, host))
"exported %d player script pair(s) (internet + _lan) to %s "
"(public host: %s)" % (len(self.egg.pilots()), out_dir, host))
# ------------------------------------------------------------- closing --