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:
co-authored by
Claude Fable 5
parent
43fa53d542
commit
1436d27ac6
+16
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 --
|
||||
|
||||
|
||||
Reference in New Issue
Block a user