diff --git a/context/multiplayer.md b/context/multiplayer.md
index beaf8c1..2c6ef3d 100644
--- a/context/multiplayer.md
+++ b/context/multiplayer.md
@@ -437,6 +437,17 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
from take 2's commit is UNPROVEN -- the measured fact is just that loads outlive any fixed
cap; the counter design is correct under either reading. Residual: WHY loads take 1-2+ min
on the operator box (vs ~30s earlier same day) is an open perf question ([loadobj] timings).
+- **READY LIGHT (2026-07-22) [T2, live-verified]**: the roster showed "registered" for a
+ still-loading pod and a ready one alike. When the app ladder reaches WaitingForLaunch
+ (APP.cpp CheckLoad transition) the pod sends ONE empty route **-10** frame on its live relay
+ game socket (`BTRelayNotifyReady`, L4NET; socket mirrored at HELLO via
+ `BTRelayRememberGameSocket`); the relay prints `SEAT n READY tag='...' (k/m loaded)` and the
+ GUI lights the seat bright-green (`ST_READY`) with a "N pod(s) LOADED+READY" banner. Chain
+ verified LIVE by the operator: blue(seated) -> teal(registered) -> green(ready) in order.
+ ALSO the load-speed "mystery" resolved: mission loads are ~30s uncontended; the 1-2+ minute
+ loads happened when assistant background test runs (game instances + relay + cdb) ran
+ CONCURRENTLY with the user's interactive sessions on the same box -- see the memory note;
+ don't run game-spawning tests while the user is testing.
- **ROUND RESET (2026-07-22) [T2]**: trim/prefs FINALIZE the egg for one round -- and that used
to be permanent for the relay's lifetime, so a REJOIN after a mission (or crash) was served
the stale finalized egg and its new callsign/mech request was recorded but never applied (the
diff --git a/engine/MUNGA/APP.cpp b/engine/MUNGA/APP.cpp
index 90996c1..eee79bf 100644
--- a/engine/MUNGA/APP.cpp
+++ b/engine/MUNGA/APP.cpp
@@ -1416,6 +1416,11 @@ void
Tell("Sent ready message to ourselves\n");
}
+ {
+ // tell the operator this pod finished loading (READY light)
+ extern void BTRelayNotifyReady(void);
+ BTRelayNotifyReady();
+ }
{
// relay launches that arrived MID-LOAD fire now (they could
// not enter the queue then -- see L4APP.cpp pend counter)
diff --git a/engine/MUNGA_L4/L4NET.CPP b/engine/MUNGA_L4/L4NET.CPP
index 45a4f32..7fed3a3 100644
--- a/engine/MUNGA_L4/L4NET.CPP
+++ b/engine/MUNGA_L4/L4NET.CPP
@@ -159,8 +159,10 @@ enum
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
- RELAY_ROUTE_MATCHLOG = -9 // pod->relay: matchlog upload
+ RELAY_ROUTE_MATCHLOG = -9, // pod->relay: matchlog upload
// (payload: filename NUL + file bytes)
+ RELAY_ROUTE_READY = -10 // pod->relay: mission load complete
+ // (operator's READY light; empty payload)
};
#define RELAY_HELLO_MAGIC 0x31525442 // 'BTR1' little-endian
// LAN auto-discovery (BT_RELAY=auto): probe broadcast on this UDP port; the
@@ -180,6 +182,7 @@ struct RelayUdpEnvelope
unsigned int sequence;
};
void BTRelayRememberGameAddress(const SOCKADDR_IN &game_address); // fwd (defined below)
+void BTRelayRememberGameSocket(SOCKET game_socket); // fwd (defined below)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// BTRelayWaitStatus -- progress text into the join.bat window during the
@@ -593,6 +596,50 @@ void BTRelayRememberGameAddress(const SOCKADDR_IN &game_address)
s_relayGameAddrCached = 1;
}
+//
+// READY LIGHT (2026-07-22): the operator's roster shows "registered" for a
+// still-loading pod and a ready one alike -- with 1-2 minute loads the
+// operator can't tell who they'd be waiting on. When the app ladder reaches
+// WaitingForLaunch (APP.cpp CheckLoadMessageHandler), the pod sends one
+// empty route -10 frame on its live relay game socket; the relay announces
+// SEAT n READY and the console GUI lights the seat green. Relay mode only;
+// silent no-op otherwise.
+//
+static SOCKET s_relayGameSocketForReady = INVALID_SOCKET;
+
+void BTRelayRememberGameSocket(SOCKET game_socket)
+{
+ s_relayGameSocketForReady = game_socket;
+}
+
+void BTRelayNotifyReady(void)
+{
+ static int s_ready_sent = 0;
+ if (s_ready_sent || s_relayGameSocketForReady == INVALID_SOCKET)
+ return;
+ s_ready_sent = 1;
+ RelayTcpEnvelope envelope;
+ envelope.route = RELAY_ROUTE_READY;
+ envelope.length = 0;
+ const char *data = (const char *)&envelope;
+ int done = 0, want = (int)sizeof(envelope);
+ unsigned long deadline = GetTickCount() + 2000UL;
+ while (done < want && GetTickCount() < deadline)
+ {
+ int sent = send(s_relayGameSocketForReady, data + done, want - done, 0);
+ if (sent > 0)
+ {
+ done += sent;
+ continue;
+ }
+ if (WSAGetLastError() != WSAEWOULDBLOCK)
+ break;
+ Sleep(10);
+ }
+ DEBUG_STREAM << "[launch] READY " << (done == want ? "sent" : "send FAILED")
+ << " to the relay" << std::endl << std::flush;
+}
+
void BTRelayUploadMatchLog(void)
{
extern const char *BTMatchLogPath();
@@ -2072,6 +2119,7 @@ void L4NetworkManager::ConnectRelayGame(HostID local_host_ID)
Fail("relay HELLO transmit failed\n");
}
relayLocalHostID = local_host_ID;
+ BTRelayRememberGameSocket(relayGameSocket); // READY-light notifier
DEBUG_STREAM << "[relay] game connection up, HELLO sent (hostID "
<< (int)local_host_ID << ")\n" << std::flush;
diff --git a/tools/btconsole.py b/tools/btconsole.py
index 0a069b1..881cb37 100644
--- a/tools/btconsole.py
+++ b/tools/btconsole.py
@@ -226,6 +226,7 @@ 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)
+ROUTE_READY = -10 # client->relay: mission load complete (READY light)
# 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",
@@ -925,6 +926,15 @@ class Relay:
if other is not conn:
self._send_control(other, ROUTE_PEER_UP, host_id)
return
+ if route == ROUTE_READY:
+ conn.ready = True
+ ready_count = sum(1 for c in self.by_host.values()
+ if getattr(c, "ready", False))
+ i = conn.host_id - FIRST_GAME_HOST_ID
+ tag = self.roster[i] if 0 <= i < len(self.roster) else "?"
+ print(f"[relay] SEAT {conn.host_id} READY tag='{tag}' "
+ f"({ready_count}/{len(self.by_host)} loaded)", flush=True)
+ return
if route == ROUTE_BCAST:
env = struct.pack(ENV_FMT_TCP, conn.host_id, len(payload))
for other in list(self.by_host.values()):
diff --git a/tools/btoperator.py b/tools/btoperator.py
index 98a1db3..e6e07ed 100644
--- a/tools/btoperator.py
+++ b/tools/btoperator.py
@@ -46,11 +46,12 @@ import eggmodel # noqa: E402
# ---------------------------------------------------------------------------
ST_SEATED = "seated"
+ST_READY = "ready"
ST_IDLE, ST_EGG, ST_REGISTERED, ST_LAUNCHED = (
"waiting", "egg sent", "registered", "LAUNCHED")
ST_COLORS = {ST_IDLE: "#666666", ST_EGG: "#b58900",
ST_REGISTERED: "#2aa198", ST_LAUNCHED: "#33cc55",
- ST_SEATED: "#268bd2"}
+ ST_SEATED: "#268bd2", ST_READY: "#8be000"}
class SessionMonitor:
@@ -65,6 +66,7 @@ class SessionMonitor:
RE_RELAY_SEAT = re.compile(
r"SEAT (\d+) PRESENT tag='([^']*)' callsign='([^']*)' mech='([^']*)'")
RE_RELAY_FREED = re.compile(r"SEAT (\d+) FREED tag='([^']*)'")
+ RE_RELAY_READY = re.compile(r"SEAT (\d+) READY tag='([^']*)'")
RE_MESH_EGG = re.compile(r"\[([^\]]+)\] egg sent")
RE_MESH_RUN = re.compile(r"\[([^\]]+)\] RunMission #(\d+) sent")
@@ -112,10 +114,16 @@ class SessionMonitor:
self.state[tag] = ST_IDLE
self.seat_info.pop(tag, None)
changed = True
+ m = self.RE_RELAY_READY.search(line)
+ if m:
+ tag = m.group(2)
+ if tag in self.state:
+ self.state[tag] = ST_READY
+ changed = True
m = self.RE_RELAY_REG.search(line)
if m:
tag = self.host_tag(int(m.group(1)))
- if tag:
+ if tag and self.state.get(tag) != ST_READY:
self.state[tag] = ST_REGISTERED
changed = True
m = self.RE_RELAY_DOWN.search(line)
@@ -713,11 +721,15 @@ class Operator(QMainWindow):
parts.append('⬤ %s %s'
% (ST_COLORS[state], tag, state))
seated = sum(1 for s in self.monitor.state.values() if s == ST_SEATED)
+ ready_n = sum(1 for s in self.monitor.state.values() if s == ST_READY)
if self.monitor.launched:
head = "LAUNCHED — mission running"
if (self.console_proc and not self.end_btn.isEnabled()
and not getattr(self, "end_sent", False)):
self.end_btn.setEnabled(True)
+ elif ready_n:
+ head = ("%d pod(s) LOADED+READY — LAUNCH starts them instantly"
+ % ready_n)
elif self.monitor.ready:
head = "ALL PODS READY — press LAUNCH MISSION"
elif seated: