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
+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