Walk-up callsign/mech request: join menu -> relay -> the session egg

The 1995 front-desk conversation, over the internet.  join.bat/
join_lan.bat now open a JOIN-GAME menu (the FE in BT_FE_JOIN trim:
CALLSIGN edit + the 18-mech list + JOIN); the choice relaunches into the
normal join with BT_CALLSIGN/BT_MECH env, rides the relay SEAT_REQUEST
as {callsign NUL mech NUL} (empty payload = roster defaults, wire-
compatible), and the relay validates the mech tag, HOLDS egg delivery
until every pod's console pad is connected, rewrites the egg via
eggmodel (vehicle= + rasterized callsign bitmaps, graceful no-PySide6
fallback) and streams it to all pads -- so every player's egg copy
carries every player's callsign.

The hold is gated on CONSOLE-PAD count, not game-side registration: a
pod HELLOs only after parsing the egg's roster, so a registration gate
deadlocks (hit live in the first run; pods animate in WAITING FOR
MISSION ASSIGNMENT during the hold).

Verified 2-node e2e (env-injected requests): thor/vulture requested over
roster defaults bhk1/ava1 -> relay held 1/2, released at 2/2, rewrote
both seats (relay log), the delivered egg carries vehicle=thor/vulture +
name=VIPER/MONGOOSE, both pods launched and built [cyl] tables for both
requested mechs.  Join-menu layout screenshot-verified; the menu's
click-through relaunch awaits live human verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-22 13:58:51 -05:00
co-authored by Claude Opus 4.8
parent eb618fe9e6
commit fcedc046cf
9 changed files with 239 additions and 25 deletions
+16
View File
@@ -378,6 +378,22 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
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.
- **WALK-UP CALLSIGN/MECH REQUEST (2026-07-22) [T2]**: the 1995 front-desk conversation, over
the wire. join.bat/join_lan.bat set `BT_FE_JOIN=1` and launch ZERO-ARG -> the FE menu in JOIN
trim (btl4fe.cpp `FeJoinOnly()`: CALLSIGN edit + the 18-mech list + JOIN; screenshot-verified)
-> `BTFeLaunchJoinRelay` relaunches with the universal join args + `BT_CALLSIGN`/`BT_MECH` env
-> `RelayRequestSeat` sends them as the SEAT_REQUEST payload `{callsign NUL mech NUL}` (empty
= roster defaults, wire-compatible) -> the relay validates the mech tag (the 18 certified
codes), stores per-seat prefs, and **HOLDS egg delivery until every pod's console pad is
connected** -- NOT until game-side registration: a pod HELLOs only after parsing the egg
roster, so that gate DEADLOCKS (hit live, fixed; pods animate in WAITING FOR MISSION
ASSIGNMENT during the hold) -- then rewrites the egg via eggmodel (vehicle= + rasterized
callsign bitmaps; PySide6 absent -> vehicles still apply, bitmap fallback logged) and streams
it to all pads. VERIFIED 2-node e2e: requested thor/vulture over roster defaults bhk1/ava1 ->
relay held 1/2, released 2/2, rewrote both seats, LAST.EGG carries vehicle=thor/vulture +
name=VIPER/MONGOOSE, both pods launched with `[cyl] table 'thor'/'vulture'` live. The FE
menu->relaunch path itself: awaiting live human verification (env-injected path is what the
e2e run proves). BT_SELF pods skip the request (defaults).
- **PATIENT WALK-UP (2026-07-18) [T2]**: a pod dialed at a relay that isn't up yet no longer
aborts (release `Fail()` is a bare `abort()` = looked like a "crash", field report). Both the
seat request (`RelayRequestSeat`) and LAN discovery (`BT_RELAY=auto`) now RETRY (2s dials, 30
+25 -2
View File
@@ -1719,10 +1719,33 @@ Logical L4NetworkManager::RelayRequestSeat()
}
Logical got_seat = False;
Logical roster_full = False;
// CALLSIGN/MECH REQUEST (2026-07-22, the walk-up desk over the wire):
// the join menu (BT_FE_JOIN) stashes the player's picks in BT_CALLSIGN
// / BT_MECH; they ride the seat request as {callsign NUL mech NUL} and
// the relay writes them into the session egg before it releases eggs.
// Empty payload (no env) = the operator's roster defaults, as before.
char seat_payload[64];
int seat_payload_length = 0;
{
const char *callsign = getenv("BT_CALLSIGN");
const char *mech_tag = getenv("BT_MECH");
if ((callsign != NULL && callsign[0] != '\0')
|| (mech_tag != NULL && mech_tag[0] != '\0'))
{
seat_payload_length = _snprintf(seat_payload,
sizeof(seat_payload) - 1, "%s%c%s%c",
callsign != NULL ? callsign : "", 0,
mech_tag != NULL ? mech_tag : "", 0);
if (seat_payload_length < 0)
seat_payload_length = sizeof(seat_payload) - 1;
}
}
RelayTcpEnvelope request;
request.route = RELAY_ROUTE_SEAT_REQUEST;
request.length = 0;
if (RelaySendAll(seat_socket, (const char *)&request, sizeof(request)))
request.length = (unsigned int)seat_payload_length;
if (RelaySendAll(seat_socket, (const char *)&request, sizeof(request))
&& (seat_payload_length == 0
|| RelaySendAll(seat_socket, seat_payload, seat_payload_length)))
{
char reply[sizeof(RelayTcpEnvelope) + 80];
int have = 0;
+10
View File
@@ -457,6 +457,16 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
SetEnvironmentVariableA("BT_FE_EGG", NULL);
sprintf(fe_arguments, "-net %d -platform glass", fe_spec.consolePort);
break;
case BTFeLaunchJoinRelay:
// BT_FE_JOIN (join.bat): the callsign/mech request rides env
// into the relaunched pod; L4NET's SEAT_REQUEST carries it to
// the relay, which writes it into the session egg. Args = the
// universal join contract (join.bat's own OPERATOR.EGG/1501).
SetEnvironmentVariableA("BT_FE_EGG", NULL);
SetEnvironmentVariableA("BT_CALLSIGN", fe_spec.joinCallsign);
SetEnvironmentVariableA("BT_MECH", fe_spec.joinVehicle);
strcpy(fe_arguments, "-egg OPERATOR.EGG -net 1501 -platform glass");
break;
#ifdef BT_STEAM
case BTFeLaunchJoinSteam:
SetEnvironmentVariableA("BT_FE_EGG", NULL);
+63 -11
View File
@@ -588,6 +588,25 @@ static int
return s;
}
//
// BT_FE_JOIN=1 (join.bat / join_lan.bat): the JOIN-GAME menu -- the walk-up
// desk conversation of the 1995 centers, over the internet. Only CALLSIGN +
// MECH are the player's to pick (the operator owns the mission + the rest of
// the loadout); the choice rides the relay SEAT_REQUEST and the relay writes
// it into the session egg (btconsole.py).
//
static int
FeJoinOnly()
{
static int s = -1;
if (s < 0)
{
const char *e = getenv("BT_FE_JOIN");
s = (e != NULL && *e != '0') ? 1 : 0;
}
return s;
}
static int
GroupSize(int group)
{
@@ -719,6 +738,15 @@ static void
{
menu.itemCount = 0;
int y;
if (FeJoinOnly())
{
// JOIN trim: the mech list + the JOIN button; callsign is the edit
// control (repositioned in BTFrontEnd_Run). Everything else is the
// operator's call.
y = MenuTopY; AddGroup(GroupVehicle, MenuCol2X, &y);
AddButton(GroupLaunch, MenuCol5X, MenuClientH - 190, 220, 52);
return;
}
// col1: the mission
y = MenuTopY; AddGroup(GroupMap, MenuCol1X, &y);
AddGroup(GroupScenario, MenuCol1X, &y);
@@ -843,8 +871,10 @@ static void
HFONT old_font = (HFONT)SelectObject(dc, menu.titleFont);
RECT title_rect = { MenuCol1X, 14, client.right - 24, 54 };
DrawMenuText(dc, "BATTLETECH -- MISSION CONSOLE", &title_rect,
kGreenBright, DT_LEFT | DT_TOP | DT_SINGLELINE);
DrawMenuText(dc,
FeJoinOnly() ? "BATTLETECH -- JOIN GAME"
: "BATTLETECH -- MISSION CONSOLE",
&title_rect, kGreenBright, DT_LEFT | DT_TOP | DT_SINGLELINE);
SelectObject(dc, menu.textFont);
@@ -896,12 +926,15 @@ static void
// Edit labels (the controls themselves are child windows in col5).
//
RECT label = { MenuCol5X, MenuNameLabelY, MenuCol5X + 240, MenuNameLabelY + MenuRowH };
DrawMenuText(dc, "PILOT NAME", &label, kGreenDim,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
label.top = MenuPeersLabelY;
label.bottom = label.top + MenuRowH;
DrawMenuText(dc, "LAN PEERS (host: ip:port,...)", &label, kGreenDim,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
DrawMenuText(dc, FeJoinOnly() ? "CALLSIGN" : "PILOT NAME", &label,
kGreenDim, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
if (!FeJoinOnly())
{
label.top = MenuPeersLabelY;
label.bottom = label.top + MenuRowH;
DrawMenuText(dc, "LAN PEERS (host: ip:port,...)", &label, kGreenDim,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
//
// Buttons (framed, RP412 style).
@@ -911,7 +944,7 @@ static void
{
const MenuItem &item = menu.items[i];
const char *text =
(item.group == GroupLaunch) ? "L A U N C H" :
(item.group == GroupLaunch) ? (FeJoinOnly() ? "J O I N" : "L A U N C H") :
(item.group == GroupSteamHost) ? "HOST STEAM LOBBY" :
(item.group == GroupSteamJoin) ? "JOIN STEAM LOBBY" : NULL;
if (text == NULL)
@@ -926,7 +959,9 @@ static void
DeleteObject(frame);
RECT help = { MenuCol1X, client.bottom - 30, client.right - 24, client.bottom - 6 };
DrawMenuText(dc, "click to select ENTER = launch ESC = quit",
DrawMenuText(dc,
FeJoinOnly() ? "click to select ENTER = join ESC = quit"
: "click to select ENTER = launch ESC = quit",
&help, kGreenDim, DT_LEFT | DT_TOP | DT_SINGLELINE);
SelectObject(dc, old_font);
@@ -1037,7 +1072,8 @@ int
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX;
AdjustWindowRect(&frame_rect, style, FALSE);
menu.window = CreateWindowW(
L"BTFrontEndWnd", L"BattleTech - Mission Console",
L"BTFrontEndWnd",
FeJoinOnly() ? L"BattleTech - Join Game" : L"BattleTech - Mission Console",
style, 100, 80,
frame_rect.right - frame_rect.left, frame_rect.bottom - frame_rect.top,
NULL, NULL, GetModuleHandleW(NULL), NULL);
@@ -1067,6 +1103,10 @@ int
SendMessageW(menu.peersEdit, WM_SETFONT, (WPARAM)menu.textFont, TRUE);
SetWindowTextA(menu.nameEdit, pilot_name);
SetWindowTextA(menu.peersEdit, peers);
if (FeJoinOnly())
{
ShowWindow(menu.peersEdit, SW_HIDE); // operator's concern, not the joiner's
}
ShowWindow(menu.window, SW_SHOW);
SetForegroundWindow(menu.window);
@@ -1104,6 +1144,18 @@ int
//
// Resolve the launch spec + write the egg.
//
if (FeJoinOnly())
{
// JOIN GAME: no egg, no port math -- the relay owns the session. The
// callsign + mech request rides the SEAT_REQUEST payload (L4NET reads
// BT_CALLSIGN / BT_MECH, set by WinMain from this spec).
spec->mode = BTFeLaunchJoinRelay;
strncpy(spec->joinCallsign, pilot_name[0] ? pilot_name : "Pilot",
sizeof(spec->joinCallsign) - 1);
strncpy(spec->joinVehicle, kVehicles[menu.selection[GroupVehicle]].key,
sizeof(spec->joinVehicle) - 1);
return 0;
}
int console_port = kPorts[menu.selection[GroupPort]];
const RoleEntry &scenario = kScenarios[menu.selection[GroupScenario]];
BTFeMission mission;
+3
View File
@@ -55,6 +55,7 @@ enum BTFeLaunchMode
BTFeLaunchRawSolo, // plain -egg endless solo (renderer work)
BTFeLaunchHostLan, // marshal feeds self + remote pods
BTFeLaunchJoinLan, // plain -net pod; a remote host's marshal feeds us
BTFeLaunchJoinRelay, // BT_FE_JOIN: relay join with callsign/mech request
BTFeLaunchHostSteam, // (BT_STEAM) lobby host: marshal over the Steam wire
BTFeLaunchJoinSteam // (BT_STEAM) lobby member: -net pod on the Steam wire
};
@@ -68,6 +69,8 @@ struct BTFeLaunchSpec
int missionSeconds;
char steamMyToken[48]; // (BT_STEAM) my roster token ip
char steamMap[512]; // (BT_STEAM) ip=steamid64;...
char joinCallsign[32]; // (BT_FE_JOIN) requested callsign
char joinVehicle[16]; // (BT_FE_JOIN) requested mech tag
};
//
+6 -3
View File
@@ -6,15 +6,18 @@ Do NOT run anything from inside the zip. The .bat files must sit next
to the 'content' and 'build' folders.
TO JOIN AN INTERNET GAME: double-click join.bat
(waits for the operator's session if it's not up yet)
TO JOIN A LAN GAME: double-click join_lan.bat (operator's LAN)
-- pick your CALLSIGN and MECH, click JOIN (your choice is sent to
the operator; waits for the session if it's not up yet)
TO JOIN A LAN GAME: double-click join_lan.bat (operator's LAN;
same callsign/mech menu)
SINGLE-PLAYER: double-click play_solo.bat -- opens the
mission menu: pick your mech (all 18), map, experience level,
colours, weather and mission length, then launch
{STEAM}STEAM INTERNET PLAY: double-click play_steam.bat (EXPERIMENTAL --
{STEAM} needs the Steam client running; host or join from the menu)
Your seat and mech are assigned automatically by the operator.
Your seat is assigned automatically; you'll pilot the mech you picked
in the join menu (the operator can override it).
If the game closes unexpectedly, send the operator content\join.log.
AFTER A MULTIPLAYER SESSION: the game writes a small match report,
+4 -1
View File
@@ -13,7 +13,10 @@ set BT_LOG=join.log
set BT_START_INSIDE=1
set BT_DEV_GAUGES=1
cd content
..\build\Release\btl4.exe -egg OPERATOR.EGG -net 1501
rem JOIN-GAME menu (2026-07-22): pick your CALLSIGN + MECH, click JOIN --
rem your choice is sent to the operator and lands in the mission roster.
set BT_FE_JOIN=1
..\build\Release\btl4.exe
echo.
echo The game has exited. If you did NOT quit on purpose
echo (crash, or it never connected), send the operator the
+4 -1
View File
@@ -16,7 +16,10 @@ set BT_LOG=join.log
set BT_START_INSIDE=1
set BT_DEV_GAUGES=1
cd content
..\build\Release\btl4.exe -egg OPERATOR.EGG -net 1501
rem JOIN-GAME menu (2026-07-22): pick your CALLSIGN + MECH, click JOIN --
rem your choice is sent to the operator and lands in the mission roster.
set BT_FE_JOIN=1
..\build\Release\btl4.exe
echo.
echo The game has exited. If you did NOT quit on purpose
echo (crash, or it never connected), send the operator the
+108 -7
View File
@@ -61,6 +61,12 @@ BT_SELF=<their [pilots] entry>.
import os
import random
import selectors
# player callsign/mech seat requests mutate the session egg via eggmodel;
# import guarded so a stripped install still relays with roster defaults.
try:
import eggmodel
except ImportError:
eggmodel = None
import socket
import struct
import sys
@@ -220,6 +226,10 @@ 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
ROUTE_MATCHLOG = -9 # client->relay: matchlog upload (filename NUL + bytes)
# the 18 certified mech tags (mirror of the FE kVehicles catalog)
VEHICLE_TAGS = {"blkhawk", "loki", "bhk1", "madcat", "thor", "owens",
"own1", "thr1", "lok1", "mad1", "avatar", "ava1",
"sunder", "snd1", "vulture", "vul1", "lok2", "mad2"}
MATCHLOG_CAP = 8 * 1024 * 1024 # matchlog upload frame cap (client caps at 8MB)
SEAT_RESERVE_SECONDS = 60.0
HELLO_MAGIC = 0x31525442 # 'BTR1' little-endian
@@ -294,6 +304,15 @@ class Relay:
self.stop_sent = False
self.eggs_done_at = None
self.egg_bytes = egg_wire_bytes(open(egg_path, "rb").read())
self.egg_path = egg_path
# WALK-UP SEAT PREFS (2026-07-22): callsign/mech requested in the
# SEAT_REQUEST payload, keyed by assigned host id. Eggs are HELD
# until the roster completes so every pod's egg carries every
# player's callsign (an early pod's egg would otherwise miss late
# joiners' labels); pods animate in WAITING FOR MISSION ASSIGNMENT
# meanwhile.
self.seat_prefs = {}
self.eggs_released = False
self.roster = parse_egg_roster(egg_path)
self.expected_ids = set(range(FIRST_GAME_HOST_ID,
FIRST_GAME_HOST_ID + len(self.roster)))
@@ -389,13 +408,27 @@ class Relay:
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)
# Egg delivery: HELD until every pod's console pad is connected
# (walk-up seat prefs must be in every pod's egg copy). NOT gated on
# game-side registration -- a pod HELLOs only after it parses the
# egg's roster, so that gate would deadlock. Seat requests precede
# the console pad, so by the Nth pad every pref is already in.
if self.eggs_released:
self._send_egg(conn)
elif len(self.console_conns) >= len(self.roster):
self._release_eggs()
else:
print(f"[relay] console conn {addr[0]}:{addr[1]}: egg HELD "
f"({len(self.console_conns)}/{len(self.roster)} pods "
f"present)", flush=True)
def _send_egg(self, conn):
addr = conn.sock.getpeername()
conn.sock.setblocking(True)
try:
n = 0
for pkt in egg_packets(self.egg_bytes):
sock.sendall(pkt)
conn.sock.sendall(pkt)
n += 1
conn.egg_sent = True
print(f"[relay] console conn {addr[0]}:{addr[1]}: egg sent "
@@ -407,10 +440,60 @@ class Relay:
return
finally:
try:
sock.setblocking(False)
conn.sock.setblocking(False)
except OSError:
pass
def _release_eggs(self):
if self.eggs_released:
return
self.eggs_released = True
if self.seat_prefs:
self._apply_seat_prefs()
for conn in list(self.console_conns):
if not conn.egg_sent:
self._send_egg(conn)
def _apply_seat_prefs(self):
# Rewrite vehicle= + the callsign system from the walk-up prefs.
# eggmodel's rasterizer needs PySide6 (present when launched from the
# operator app); without it the vehicle still applies and the
# callsign falls back to the text name= (labels keep the roster
# default bitmaps).
if eggmodel is None:
print("[relay] eggmodel unavailable -- seat prefs NOT applied",
flush=True)
return
try:
doc = eggmodel.EggDoc.load(self.egg_path)
pilots = doc.pilots()
names = [p.get("name") or p["address"] for p in pilots]
for host_id, (callsign, mech) in sorted(self.seat_prefs.items()):
i = host_id - FIRST_GAME_HOST_ID
if not (0 <= i < len(pilots)):
continue
if mech:
doc.set_kv(pilots[i]["address"], "vehicle", mech)
if callsign:
names[i] = callsign
try:
doc.set_callsigns(eggmodel.make_callsigns(names))
except Exception as e:
print(f"[relay] callsign rasterizer unavailable ({e!r}) -- "
f"vehicles applied, callsign bitmaps unchanged",
flush=True)
self.egg_bytes = egg_wire_bytes(
doc.emit().encode("latin-1", "replace"))
for host_id, (callsign, mech) in sorted(self.seat_prefs.items()):
i = host_id - FIRST_GAME_HOST_ID
if 0 <= i < len(pilots):
print(f"[relay] seat {host_id} egg: "
f"vehicle={mech or pilots[i].get('vehicle')} "
f"callsign={callsign or names[i]!r}", flush=True)
except Exception as e:
print(f"[relay] seat-pref egg rewrite FAILED ({e!r}) -- "
f"serving the original egg", flush=True)
def _pods_ready(self):
"""REAL pods = console connections that ACKED the egg (scanners never
speak the protocol, so they can't hold a roster slot)."""
@@ -665,12 +748,28 @@ class Relay:
return
host_id, tag = assigned
self.seat_reservations[host_id] = now + SEAT_RESERVE_SECONDS
# WALK-UP PREFS: optional payload {callsign NUL mech NUL}
pref_note = ""
if payload:
parts = payload.split(b"\0")
callsign = parts[0].decode("ascii", "replace").strip()[:15]
mech = (parts[1].decode("ascii", "replace").strip().lower()
if len(parts) > 1 else "")
callsign = "".join(c for c in callsign
if c.isalnum() or c in " -_.")
if mech and mech not in VEHICLE_TAGS:
print(f"[relay] {conn.name()} requested unknown mech "
f"{mech!r} -- keeping the roster default", flush=True)
mech = ""
if callsign or mech:
self.seat_prefs[host_id] = (callsign, mech)
pref_note = f" callsign={callsign!r} mech={mech or '(default)'}"
payload_out = struct.pack("<i", host_id) + tag.encode() + b"\0"
self._send_raw(conn, struct.pack(ENV_FMT_TCP, ROUTE_SEAT_ASSIGN,
len(payload_out)) + payload_out)
print(f"[relay] {conn.name()} seat request -> assigned host "
f"{host_id} '{tag}' (reserved {SEAT_RESERVE_SECONDS:.0f}s)",
flush=True)
f"{host_id} '{tag}' (reserved {SEAT_RESERVE_SECONDS:.0f}s)"
f"{pref_note}", flush=True)
return
if conn.host_id is None:
# First frame MUST be a valid HELLO.
@@ -694,6 +793,8 @@ class Relay:
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)
if len(self.by_host) >= len(self.roster):
self._release_eggs() # fallback; normally already fired
# PEER_UP exchange: newcomer learns everyone; everyone learns newcomer.
for other_id in sorted(self.by_host):
if other_id != host_id: