diff --git a/context/multiplayer.md b/context/multiplayer.md index 9d1be40..7941a04 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -265,8 +265,10 @@ Base fns in every subsystem vtable: slot 6 = 41bd34 (Read/apply), slot 7 = 41c50 Opt-in relay/dispatcher topology so pods play over the internet behind NAT with no port forwarding, WHILE the authentic distributed-authority simulation is UNTOUCHED (each pod still masters its own mech; peers stay replicants). Env-gated: **`BT_RELAY=:`** -(host may be a DNS name; game port = +1; UDP = same number) + **`BT_SELF=`** (NIC matching can't identify a pod across NAT). BT_RELAY unset ⇒ every relay branch +(host may be a DNS name; game port = +1; UDP = same number). Identity: **`BT_SELF=`** claims a specific seat (NIC matching can't identify a pod across NAT); +BT_SELF absent/`auto` ⇒ the RELAY assigns the seat (see RELAY-ASSIGNED SEATS below) — players +never need to know a player number. BT_RELAY unset ⇒ every relay branch is dead ⇒ the classic mesh is byte-for-byte unchanged (mesh smoke-tested un-regressed each phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`. - **Topology**: every pod makes ONE outbound TCP connection to the relay (an extension of @@ -311,13 +313,28 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`. 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. +- **RELAY-ASSIGNED SEATS (2026-07-18) [T2]**: no BT_SELF ⇒ the pod's NetworkManager ctor makes a + short throwaway TCP dial to the relay GAME port and sends `SEAT_REQUEST` (route −6, empty); + the relay reserves the lowest roster seat that is neither claimed (registered HELLO) nor + reserved (60s window, so simultaneous joiners can't race onto one seat) and answers + `SEAT_ASSIGN` (route −7, `{int32 hostID}` + NUL-terminated `[pilots]` tag) — the tag becomes + `relaySelf` and everything downstream (egg self-match in StartConnecting, HELLO) proceeds + exactly as if BT_SELF had been set. Roster full ⇒ `SEAT_FULL` (route −8) ⇒ clean + `Fail()`. A real HELLO claiming the seat pops the reservation; a pod drop (PEER_DOWN) frees + the seat for the next joiner. First-come-first-served — the arcade walk-up-to-a-pod model. + Client: `L4NetworkManager::RelayRequestSeat` (L4NET.CPP); relay: `_handle_game_frame` + SEAT_REQUEST branch (btconsole.py). Verified: 8/8 stub tests (distinct seats, FULL on + exhaustion, claim clears reserve, duplicate-HELLO refused) + 2-node localhost e2e with NO + BT_SELF on either pod → both seated, full ladder, launch, UDP flowing. - **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. + console/relay stdout, one-click local instance launcher, and a single universal + `join.bat` (+ `join_lan.bat` with `BT_RELAY=auto`) export — no BT_SELF inside, every player + gets the SAME file, the relay seats them (`players/`). 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; or a playit.gg-style tunnel in front of the relay, host-side only). diff --git a/engine/MUNGA_L4/L4NET.CPP b/engine/MUNGA_L4/L4NET.CPP index 89ded59..c7058f1 100644 --- a/engine/MUNGA_L4/L4NET.CPP +++ b/engine/MUNGA_L4/L4NET.CPP @@ -84,7 +84,10 @@ enum RELAY_ROUTE_HELLO = -2, // first frame: magic + our hostID RELAY_ROUTE_PEER_UP = -3, // relay->pod: hostID registered RELAY_ROUTE_PEER_DOWN = -4, // relay->pod: hostID gone - RELAY_ROUTE_UDP_ACK = -5 // relay->pod: UDP HELLO acknowledged + RELAY_ROUTE_UDP_ACK = -5, // relay->pod: UDP HELLO acknowledged + RELAY_ROUTE_SEAT_REQUEST= -6, // pod->relay: assign me a free seat + RELAY_ROUTE_SEAT_ASSIGN = -7, // relay->pod: int32 hostID + NUL tag + RELAY_ROUTE_SEAT_FULL = -8 // relay->pod: roster full }; #define RELAY_HELLO_MAGIC 0x31525442 // 'BTR1' little-endian // LAN auto-discovery (BT_RELAY=auto): probe broadcast on this UDP port; the @@ -388,12 +391,25 @@ L4NetworkManager::L4NetworkManager(): relayGameAddress.sin_port = htons((unsigned short)(ntohs(relayConsoleAddress.sin_port) + 1)); + // + // Identity: an explicit BT_SELF claims a specific seat; absent + // (or "auto"), the RELAY assigns the next free roster seat -- + // players never need to know a player number. + // const char *self_env = getenv("BT_SELF"); - if (self_env == NULL || self_env[0] == '\0') + if (self_env == NULL || self_env[0] == '\0' + || _stricmp(self_env, "auto") == 0) { - Fail("BT_RELAY requires BT_SELF=\n"); + if (!RelayRequestSeat()) + { + Fail("relay seat request failed (roster full or relay " + "unreachable)\n"); + } + } + else + { + Str_Copy(relaySelf, self_env, sizeof(relaySelf)); } - Str_Copy(relaySelf, self_env, sizeof(relaySelf)); relayMode = True; DEBUG_STREAM << "[relay] mode ON: relay " << inet_ntoa(relayConsoleAddress.sin_addr) << ":" @@ -1335,6 +1351,84 @@ void L4NetworkManager::HostDisconnectedMessageHandler(HostDisconnectedMessage* H // D1 RELAY MODE support units. Everything below is dead unless BT_RELAY set. //############################################################################# +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// RelayRequestSeat -- SERVER-ASSIGNED SEATS: no BT_SELF means "give me any +// free seat". A short throwaway connection to the relay game port sends +// SEAT_REQUEST; the relay reserves the lowest unclaimed roster seat and +// answers SEAT_ASSIGN {int32 hostID, NUL-terminated tag}. The tag becomes +// relaySelf and everything downstream (egg self-match, HELLO) proceeds as if +// BT_SELF had been set. SEAT_FULL / timeout => False. +// +Logical L4NetworkManager::RelayRequestSeat() +{ + SOCKET seat_socket = RelayDialTcp(relayGameAddress, 60); + if (seat_socket == INVALID_SOCKET) + { + return False; + } + Logical got_seat = False; + RelayTcpEnvelope request; + request.route = RELAY_ROUTE_SEAT_REQUEST; + request.length = 0; + if (RelaySendAll(seat_socket, (const char *)&request, sizeof(request))) + { + char reply[sizeof(RelayTcpEnvelope) + 80]; + int have = 0; + unsigned long deadline = GetTickCount() + 10000UL; + while (GetTickCount() < deadline && !got_seat) + { + fd_set read_set; + FD_ZERO(&read_set); + FD_SET(seat_socket, &read_set); + timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 200000; + if (select(0, &read_set, NULL, NULL, &tv) != 1) + { + continue; + } + int received = recv(seat_socket, reply + have, + (int)sizeof(reply) - have, 0); + if (received <= 0) + { + break; + } + have += received; + if (have < (int)sizeof(RelayTcpEnvelope)) + { + continue; + } + RelayTcpEnvelope *envelope = (RelayTcpEnvelope *)reply; + if (envelope->route == RELAY_ROUTE_SEAT_FULL) + { + DEBUG_STREAM << "[relay] seat request: ROSTER FULL\n" + << std::flush; + break; + } + if (envelope->route != RELAY_ROUTE_SEAT_ASSIGN) + { + break; + } + if (have < (int)(sizeof(RelayTcpEnvelope) + envelope->length)) + { + continue; // partial payload; keep reading + } + const char *tag = reply + sizeof(RelayTcpEnvelope) + 4; + int tag_max = (int)envelope->length - 4; + if (tag_max > 0 && tag_max < (int)sizeof(relaySelf)) + { + memcpy(relaySelf, tag, tag_max); + relaySelf[tag_max] = '\0'; + DEBUG_STREAM << "[relay] seat ASSIGNED: '" << relaySelf + << "'\n" << std::flush; + got_seat = True; + } + } + } + closesocket(seat_socket); + return got_seat; +} + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // RelayDialTcp -- outbound TCP dial with a BOUNDED retry window (unlike // OpenConnection's unbounded 10061 busy-loop), then nonblocking + NODELAY. diff --git a/engine/MUNGA_L4/L4NET.H b/engine/MUNGA_L4/L4NET.H index f47d230..2e7ccfb 100644 --- a/engine/MUNGA_L4/L4NET.H +++ b/engine/MUNGA_L4/L4NET.H @@ -338,6 +338,8 @@ private: // Logical RelayDiscover(SOCKADDR_IN *console_endpoint); + Logical + RelayRequestSeat(); SOCKET RelayDialTcp(const SOCKADDR_IN &endpoint, int timeout_seconds); Logical diff --git a/players/join.bat b/players/join.bat new file mode 100644 index 0000000..be7e127 --- /dev/null +++ b/players/join.bat @@ -0,0 +1,6 @@ +@echo off +rem BT411 -- join 107.202.218.169's game (internet). Your seat/mech +rem is assigned automatically by the operator console. +set BT_RELAY=107.202.218.169:1500 +cd /d %~dp0content +..\build\Release\btl4.exe -egg OPERATOR.EGG -net 1501 diff --git a/players/join_lan.bat b/players/join_lan.bat new file mode 100644 index 0000000..6e4aab8 --- /dev/null +++ b/players/join_lan.bat @@ -0,0 +1,6 @@ +@echo off +rem BT411 -- join the LAN game. Finds the operator +rem console AND your seat automatically. +set BT_RELAY=auto +cd /d %~dp0content +..\build\Release\btl4.exe -egg OPERATOR.EGG -net 1501 diff --git a/tools/btconsole.py b/tools/btconsole.py index 0853f8b..0e19e55 100644 --- a/tools/btconsole.py +++ b/tools/btconsole.py @@ -184,6 +184,10 @@ ROUTE_HELLO = -2 ROUTE_PEER_UP = -3 ROUTE_PEER_DOWN = -4 ROUTE_UDP_ACK = -5 +ROUTE_SEAT_REQUEST = -6 # client->relay: assign me a free roster seat +ROUTE_SEAT_ASSIGN = -7 # relay->client: int32 hostID + NUL-terminated tag +ROUTE_SEAT_FULL = -8 # relay->client: no free seats +SEAT_RESERVE_SECONDS = 60.0 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 @@ -257,6 +261,7 @@ class Relay: self.console_conns = [] self.game_conns = [] # RelayGameConn (incl. unregistered) self.by_host = {} # hostID -> RelayGameConn + self.seat_reservations = {} # hostID -> expiry (auto-seats) self.udp_endpoint = {} # hostID -> (addr, port) self.udp_sock = None self.launch_at = None @@ -472,6 +477,37 @@ class Relay: return def _handle_game_frame(self, conn, route, payload): + if conn.host_id is None and route == ROUTE_SEAT_REQUEST: + # SERVER-ASSIGNED SEATS: hand out the lowest roster seat that is + # neither claimed (by_host) nor reserved; reserve it briefly so + # two simultaneous joiners can't race onto the same seat. The + # requesting connection closes after the reply; the pod re-dials + # and claims the seat with a normal HELLO. + now = time.time() + self.seat_reservations = {h: t for h, t in + self.seat_reservations.items() if t > now} + assigned = None + for i, tag in enumerate(self.roster): + host_id = FIRST_GAME_HOST_ID + i + if host_id in self.by_host or host_id in self.seat_reservations: + continue + assigned = (host_id, tag) + break + if assigned is None: + self._send_raw(conn, struct.pack(ENV_FMT_TCP, + ROUTE_SEAT_FULL, 0)) + print(f"[relay] {conn.name()} seat request: ROSTER FULL", + flush=True) + return + host_id, tag = assigned + self.seat_reservations[host_id] = now + SEAT_RESERVE_SECONDS + payload_out = struct.pack(" assigned host " + f"{host_id} '{tag}' (reserved {SEAT_RESERVE_SECONDS:.0f}s)", + flush=True) + return if conn.host_id is None: # First frame MUST be a valid HELLO. if route != ROUTE_HELLO or len(payload) < 8: @@ -491,6 +527,7 @@ class Relay: return conn.host_id = host_id self.by_host[host_id] = conn + self.seat_reservations.pop(host_id, None) # claim clears reserve 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. diff --git a/tools/btoperator.py b/tools/btoperator.py index 5247526..af14c40 100644 --- a/tools/btoperator.py +++ b/tools/btoperator.py @@ -675,34 +675,31 @@ class Operator(QMainWindow): 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" - "rem BT411 -- join %s's game as player %d (%s)\n" - "set BT_RELAY=%s:%d\n" - "set BT_SELF=%s\n" - "cd /d %%~dp0content\n" - "..\\build\\Release\\btl4.exe -egg %s -net 1501\n" - % (host, i + 1, p.get("vehicle") or "?", - host, self.f_port.value(), p["address"], - 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)) + # ONE universal script per transport: the RELAY assigns seats + # first-come-first-served (no BT_SELF), so every player gets the SAME + # file -- who flies which mech is whoever sits down first, exactly + # like walking up to a pod. + with open(os.path.join(out_dir, "join.bat"), "w", newline="\r\n") as f: + f.write("@echo off\n" + "rem BT411 -- join %s's game (internet). Your seat/mech\n" + "rem is assigned automatically by the operator console.\n" + "set BT_RELAY=%s:%d\n" + "cd /d %%~dp0content\n" + "..\\build\\Release\\btl4.exe -egg %s -net 1501\n" + % (host, host, self.f_port.value(), egg_name)) + with open(os.path.join(out_dir, "join_lan.bat"), "w", + newline="\r\n") as f: + f.write("@echo off\n" + "rem BT411 -- join the LAN game. Finds the operator\n" + "rem console AND your seat automatically.\n" + "set BT_RELAY=auto\n" + "cd /d %%~dp0content\n" + "..\\build\\Release\\btl4.exe -egg %s -net 1501\n" + % egg_name) self.log.appendPlainText( - "exported %d player script pair(s) (internet + _lan) to %s " - "(public host: %s)" % (len(self.egg.pilots()), out_dir, host)) + "exported join.bat + join_lan.bat to %s (public host: %s) -- " + "one file for everyone; the relay assigns seats" + % (out_dir, host)) # ------------------------------------------------------------- closing --