Steam lobby: host, join, room, and launch into a marshaled race

RPL4LOBBY implements the multiplayer front door on ISteamMatchmaking.
The setup menu grows HOST STEAM RACE / JOIN STEAM RACE buttons when
the Steam wire is live; hosting creates a tagged public lobby, joining
finds one. Every member publishes FakeIP + fake ports + persona +
loadout as member data; the room screen lists members (host marked)
and gives the owner a launch button.

Launching writes a nonced go-roster into lobby data. Each pod
registers every peer with the Steam transport (two-port peer table:
engine console/game ports map to Steam fake ports on connect) and
enters the race: the owner through the hosted-race path - it builds
the multi-pilot egg from real personas and loadouts and its console
marshals everyone - and members as network pods that boot straight
into WaitingForEgg for the owner to feed over the wire.

The lobby outlives races: members loop back through WinMain into the
room (no local console needed - MissionCompleted is waived for member
races), and the owner returns to the room after its results screen.
Leaving the lobby clears the hosted-race priming.

Verified on this box: menu buttons appear under RP412STEAM=1, hosting
creates a lobby on the Steam backend, the room runs and leaves back to
the menu; single-player cycling and the LAN hosted race both still
pass. Full three-account mesh test is next, on real hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-12 21:26:31 -05:00
co-authored by Claude Fable 5
parent c66cb00a7e
commit 5892af318e
8 changed files with 1191 additions and 172 deletions
+309 -125
View File
@@ -3,6 +3,7 @@
#include "rpl4fe.h"
#include "rpl4console.h"
#include "rpl4lobby.h"
#include <stdio.h>
#include <string>
@@ -125,6 +126,8 @@ namespace
GroupWeather,
GroupLength,
GroupLaunch,
GroupSteamHost, // buttons, not selections (Steam builds only)
GroupSteamJoin,
GroupCount
};
@@ -150,6 +153,7 @@ namespace
HBRUSH editBrush;
Logical launched;
Logical closed;
int steamAction; // 1 = host lobby, 2 = join lobby
};
FEState *gFE = NULL;
@@ -178,12 +182,36 @@ namespace
{
char address[64]; // mesh (game port) address for [pilots]
char name[32];
const char *vehicle;
const char *color;
char vehicle[24];
char color[16];
char badge[24];
};
// lobby-fed override (real personas + loadouts); empty = env parsing
FEHostedPilot gHostedPilots[maxExtraPilots];
int gHostedPilotCount = 0;
char gHostedOwnerAddress[64] = "";
int CollectExtraPilots(const FEState *fe, ExtraPilot *extras, char *owner_address)
{
if (gHostedPilotCount > 0)
{
sprintf(owner_address, "%s:1502", gHostedOwnerAddress);
for (int i = 0; i < gHostedPilotCount; ++i)
{
strcpy(extras[i].address, gHostedPilots[i].address);
strcpy(extras[i].name,
gHostedPilots[i].name[0] ? gHostedPilots[i].name : "Pilot");
strcpy(extras[i].vehicle,
gHostedPilots[i].vehicle[0] ? gHostedPilots[i].vehicle : "speck");
strcpy(extras[i].color,
gHostedPilots[i].color[0] ? gHostedPilots[i].color : "Red");
strcpy(extras[i].badge,
gHostedPilots[i].badge[0] ? gHostedPilots[i].badge : "None");
}
return gHostedPilotCount;
}
const char *host_pods = getenv("RP412HOSTPODS");
if (host_pods == NULL || host_pods[0] == '\0')
{
@@ -233,10 +261,11 @@ namespace
ExtraPilot *pilot = &extras[extra_count];
sprintf(pilot->address, "%s:%d", entry, console_port + 1);
sprintf(pilot->name, "PILOT %d", extra_count + 2);
pilot->vehicle = kVehicles[
(fe->selection[GroupVehicle] + extra_count + 1) % FE_COUNT(kVehicles)].key;
pilot->color = kColors[
(fe->selection[GroupColor] + extra_count + 1) % FE_COUNT(kColors)].key;
strcpy(pilot->vehicle, kVehicles[
(fe->selection[GroupVehicle] + extra_count + 1) % FE_COUNT(kVehicles)].key);
strcpy(pilot->color, kColors[
(fe->selection[GroupColor] + extra_count + 1) % FE_COUNT(kColors)].key);
strcpy(pilot->badge, "None");
++extra_count;
}
return extra_count;
@@ -304,6 +333,26 @@ namespace
launch->rect.right = col3 + col_w;
launch->rect.bottom = client_h - row_h;
// Steam lobby buttons (only when the Steam wire is live)
if (RPL4Lobby_Available())
{
FEItem *host = &fe->items[fe->itemCount++];
host->group = GroupSteamHost;
host->index = 0;
host->rect.left = col3;
host->rect.top = client_h - 6 * row_h;
host->rect.right = col3 + col_w;
host->rect.bottom = client_h - 5 * row_h;
FEItem *join = &fe->items[fe->itemCount++];
join->group = GroupSteamJoin;
join->index = 0;
join->rect.left = col3;
join->rect.top = client_h - (9 * row_h) / 2;
join->rect.right = col3 + col_w;
join->rect.bottom = client_h - (7 * row_h) / 2;
}
// pilot name edit sits at the top of column 3
if (fe->nameEdit != NULL)
{
@@ -339,6 +388,8 @@ namespace
case GroupWeather: return kWeather[index].name;
case GroupLength: return kLengths[index].name;
case GroupLaunch: return "L A U N C H R A C E";
case GroupSteamHost: return "HOST STEAM RACE";
case GroupSteamJoin: return "JOIN STEAM RACE";
}
return "";
}
@@ -377,7 +428,7 @@ namespace
for (int i = 0; i < fe->itemCount; ++i)
{
const FEItem *item = &fe->items[i];
if (item->group != previous_group && item->group != GroupLaunch)
if (item->group != previous_group && item->group < GroupLaunch)
{
previous_group = item->group;
RECT header = item->rect;
@@ -389,11 +440,11 @@ namespace
}
Logical selected =
(item->group == GroupLaunch) ||
(item->group >= GroupLaunch) ||
(fe->selection[item->group] == item->index);
RECT row = item->rect;
if (item->group == GroupLaunch)
if (item->group >= GroupLaunch)
{
HBRUSH launch_brush = CreateSolidBrush(kGreenBright);
FrameRect(mem, &row, launch_brush);
@@ -462,6 +513,11 @@ namespace
// SendMessage never passes through the queue)
PostMessageA(fe->menuWindow, WM_NULL, 0, 0);
}
else if (item->group == GroupSteamHost || item->group == GroupSteamJoin)
{
fe->steamAction = (item->group == GroupSteamHost) ? 1 : 2;
PostMessageA(fe->menuWindow, WM_NULL, 0, 0);
}
else
{
fe->selection[item->group] = item->index;
@@ -673,7 +729,8 @@ namespace
egg += line;
sprintf(line, "color=%s\n", extras[p].color);
egg += line;
egg += "badge=None\n";
sprintf(line, "badge=%s\n", extras[p].badge);
egg += line;
}
egg += "[largebitmap]\n";
@@ -734,13 +791,14 @@ namespace
//############################ RPL4FrontEnd_Run ##########################
//########################################################################
Logical
RPL4FrontEnd_Run(
HINSTANCE instance,
HWND main_window,
char *egg_path_out,
int egg_path_size
)
static int gLastLaunchMode = FELaunchSingle;
//---------------------------------------------------------------
// Build frontend.egg from the persisted loadout (the menu persists
// on every exit; lobby launches can happen without a live menu)
//---------------------------------------------------------------
static Logical
BuildEggFromPersisted(char *egg_path_out, int egg_path_size)
{
FEState fe;
memset(&fe, 0, sizeof(fe));
@@ -753,138 +811,225 @@ Logical
{
fe.selection[GroupLength] = 2; // 5:00
}
gFE = &fe;
gLastMissionSeconds = kLengths[fe.selection[GroupLength]].seconds;
//---------------------------------------------------------------
// Window class + fonts
//---------------------------------------------------------------
static Logical class_registered = False;
if (!class_registered)
std::string egg;
egg.reserve(16384);
BuildEggText(&fe, egg);
strncpy(egg_path_out, "frontend.egg", egg_path_size - 1);
egg_path_out[egg_path_size - 1] = '\0';
FILE *file = fopen(egg_path_out, "wb");
if (file == NULL)
{
class_registered = True;
WNDCLASSA window_class;
memset(&window_class, 0, sizeof(window_class));
window_class.style = CS_HREDRAW | CS_VREDRAW;
window_class.lpfnWndProc = FrontEndWndProc;
window_class.hInstance = instance;
window_class.hCursor = LoadCursor(NULL, IDC_ARROW);
window_class.hbrBackground = (HBRUSH) GetStockObject(BLACK_BRUSH);
window_class.lpszClassName = "RPFrontEnd";
RegisterClassA(&window_class);
}
RECT client;
GetClientRect(main_window, &client);
fe.menuWindow = CreateWindowExA(
0, "RPFrontEnd", "",
WS_CHILD | WS_VISIBLE,
0, 0, client.right, client.bottom,
main_window, NULL, instance, NULL);
if (fe.menuWindow == NULL)
{
gFE = NULL;
DEBUG_STREAM << "FrontEnd: could not write " << egg_path_out
<< "\n" << std::flush;
return False;
}
fwrite(egg.data(), 1, egg.size(), file);
fclose(file);
DEBUG_STREAM << "FrontEnd: wrote " << egg_path_out << " ("
<< (int) egg.size() << " bytes)\n" << std::flush;
return True;
}
int text_h = client.bottom / 40;
if (text_h < 16) text_h = 16;
if (text_h > 24) text_h = 24;
fe.textFont = CreateFontA(-text_h, 0, 0, 0, FW_NORMAL,
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_DEFAULT_PRECIS,
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_MODERN,
"Consolas");
fe.titleFont = CreateFontA(-(text_h * 2), 0, 0, 0, FW_BOLD,
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_DEFAULT_PRECIS,
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_MODERN,
"Consolas");
fe.editBrush = CreateSolidBrush(kBlack);
fe.nameEdit = CreateWindowExA(
0, "EDIT", fe.pilotName,
WS_CHILD | WS_VISIBLE | WS_BORDER | ES_AUTOHSCROLL,
0, 0, 10, 10,
fe.menuWindow, NULL, instance, NULL);
SendMessageA(fe.nameEdit, WM_SETFONT, (WPARAM) fe.textFont, TRUE);
SendMessageA(fe.nameEdit, EM_SETLIMITTEXT, sizeof(fe.pilotName) - 2, 0);
LayoutMenu(&fe, client.right, client.bottom);
InvalidateRect(fe.menuWindow, NULL, TRUE);
Logical
RPL4FrontEnd_Run(
HINSTANCE instance,
HWND main_window,
char *egg_path_out,
int egg_path_size
)
{
gLastLaunchMode = FELaunchSingle;
//---------------------------------------------------------------
// Modal loop until the player launches or closes the window
// Coming back from a race while still in a lobby: straight to
// the room (the lobby outlives races - single binary payoff)
//---------------------------------------------------------------
MSG msg;
while (!fe.launched && !fe.closed)
if (RPL4Lobby_InRoom())
{
BOOL result = GetMessageA(&msg, NULL, 0, 0);
if (result <= 0)
int outcome = RPL4Lobby_Room(instance, main_window);
if (outcome == LobbyRoomClosed)
{
fe.closed = True;
break;
return False;
}
if (msg.message == WM_QUIT ||
(msg.message == WM_SYSCOMMAND && msg.wParam == SC_CLOSE))
if (outcome == LobbyLaunchMember)
{
fe.closed = True;
gLastLaunchMode = FELaunchMember;
egg_path_out[0] = '\0';
return True;
}
// closing the main window ends the front end too
if (msg.message == WM_CLOSE && msg.hwnd == main_window)
if (outcome == LobbyLaunchHost)
{
fe.closed = True;
}
TranslateMessage(&msg);
DispatchMessageA(&msg);
if (!IsWindow(main_window))
{
fe.closed = True;
gLastLaunchMode = FELaunchHost;
return BuildEggFromPersisted(egg_path_out, egg_path_size);
}
// LobbyRoomLeft: fall through to the setup menu
}
Logical launched = fe.launched;
if (launched)
for (;;)
{
gLastMissionSeconds = kLengths[fe.selection[GroupLength]].seconds;
GetWindowTextA(fe.nameEdit, fe.pilotName, sizeof(fe.pilotName) - 1);
if (fe.pilotName[0] == '\0')
FEState fe;
memset(&fe, 0, sizeof(fe));
strcpy(fe.pilotName, gLastPilotName);
if (gHavePersist)
{
strcpy(fe.pilotName, "Pilot");
}
strcpy(gLastPilotName, fe.pilotName);
memcpy(gPersistSelection, fe.selection, sizeof(gPersistSelection));
gHavePersist = True;
std::string egg;
egg.reserve(16384);
BuildEggText(&fe, egg);
strncpy(egg_path_out, "frontend.egg", egg_path_size - 1);
egg_path_out[egg_path_size - 1] = '\0';
FILE *file = fopen(egg_path_out, "wb");
if (file != NULL)
{
fwrite(egg.data(), 1, egg.size(), file);
fclose(file);
DEBUG_STREAM << "FrontEnd: wrote " << egg_path_out << " ("
<< (int) egg.size() << " bytes)\n" << std::flush;
memcpy(fe.selection, gPersistSelection, sizeof(fe.selection));
}
else
{
DEBUG_STREAM << "FrontEnd: could not write " << egg_path_out
<< "\n" << std::flush;
launched = False;
fe.selection[GroupLength] = 2; // 5:00
}
gFE = &fe;
//---------------------------------------------------------------
// Window class + fonts
//---------------------------------------------------------------
static Logical class_registered = False;
if (!class_registered)
{
class_registered = True;
WNDCLASSA window_class;
memset(&window_class, 0, sizeof(window_class));
window_class.style = CS_HREDRAW | CS_VREDRAW;
window_class.lpfnWndProc = FrontEndWndProc;
window_class.hInstance = instance;
window_class.hCursor = LoadCursor(NULL, IDC_ARROW);
window_class.hbrBackground = (HBRUSH) GetStockObject(BLACK_BRUSH);
window_class.lpszClassName = "RPFrontEnd";
RegisterClassA(&window_class);
}
RECT client;
GetClientRect(main_window, &client);
fe.menuWindow = CreateWindowExA(
0, "RPFrontEnd", "",
WS_CHILD | WS_VISIBLE,
0, 0, client.right, client.bottom,
main_window, NULL, instance, NULL);
if (fe.menuWindow == NULL)
{
gFE = NULL;
return False;
}
int text_h = client.bottom / 40;
if (text_h < 16) text_h = 16;
if (text_h > 24) text_h = 24;
fe.textFont = CreateFontA(-text_h, 0, 0, 0, FW_NORMAL,
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_DEFAULT_PRECIS,
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_MODERN,
"Consolas");
fe.titleFont = CreateFontA(-(text_h * 2), 0, 0, 0, FW_BOLD,
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_DEFAULT_PRECIS,
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_MODERN,
"Consolas");
fe.editBrush = CreateSolidBrush(kBlack);
fe.nameEdit = CreateWindowExA(
0, "EDIT", fe.pilotName,
WS_CHILD | WS_VISIBLE | WS_BORDER | ES_AUTOHSCROLL,
0, 0, 10, 10,
fe.menuWindow, NULL, instance, NULL);
SendMessageA(fe.nameEdit, WM_SETFONT, (WPARAM) fe.textFont, TRUE);
SendMessageA(fe.nameEdit, EM_SETLIMITTEXT, sizeof(fe.pilotName) - 2, 0);
LayoutMenu(&fe, client.right, client.bottom);
InvalidateRect(fe.menuWindow, NULL, TRUE);
//---------------------------------------------------------------
// Modal loop until launch, lobby button, or close
//---------------------------------------------------------------
MSG msg;
while (!fe.launched && !fe.closed && fe.steamAction == 0)
{
BOOL result = GetMessageA(&msg, NULL, 0, 0);
if (result <= 0)
{
fe.closed = True;
break;
}
if (msg.message == WM_QUIT ||
(msg.message == WM_SYSCOMMAND && msg.wParam == SC_CLOSE))
{
fe.closed = True;
}
// closing the main window ends the front end too
if (msg.message == WM_CLOSE && msg.hwnd == main_window)
{
fe.closed = True;
}
TranslateMessage(&msg);
DispatchMessageA(&msg);
if (!IsWindow(main_window))
{
fe.closed = True;
}
}
//---------------------------------------------------------------
// Harvest the loadout whichever way we leave the menu - the
// lobby publishes it as member data, launches build from it
//---------------------------------------------------------------
if (!fe.closed)
{
GetWindowTextA(fe.nameEdit, fe.pilotName, sizeof(fe.pilotName) - 1);
if (fe.pilotName[0] == '\0')
{
strcpy(fe.pilotName, "Pilot");
}
strcpy(gLastPilotName, fe.pilotName);
memcpy(gPersistSelection, fe.selection, sizeof(gPersistSelection));
gHavePersist = True;
}
Logical launched = fe.launched;
Logical closed = fe.closed;
int steam_action = fe.steamAction;
DestroyWindow(fe.menuWindow);
DeleteObject(fe.textFont);
DeleteObject(fe.titleFont);
DeleteObject(fe.editBrush);
gFE = NULL;
if (closed)
{
return False;
}
if (launched)
{
// plain launch: single player, or a LAN-hosted race when
// RP412HOSTPODS is set in the environment
return BuildEggFromPersisted(egg_path_out, egg_path_size);
}
//---------------------------------------------------------------
// Steam lobby (host or join), then back here if they leave
//---------------------------------------------------------------
int outcome = (steam_action == 1)
? RPL4Lobby_Host(instance, main_window)
: RPL4Lobby_Join(instance, main_window);
if (outcome == LobbyRoomClosed)
{
return False;
}
if (outcome == LobbyLaunchMember)
{
gLastLaunchMode = FELaunchMember;
egg_path_out[0] = '\0';
return True;
}
if (outcome == LobbyLaunchHost)
{
gLastLaunchMode = FELaunchHost;
return BuildEggFromPersisted(egg_path_out, egg_path_size);
}
// LobbyRoomLeft: show the menu again
}
DestroyWindow(fe.menuWindow);
DeleteObject(fe.textFont);
DeleteObject(fe.titleFont);
DeleteObject(fe.editBrush);
gFE = NULL;
return launched;
}
int
@@ -899,6 +1044,45 @@ const char *
return gLastPilotNamesCsv;
}
int
RPL4FrontEnd_LastLaunchMode()
{
return gLastLaunchMode;
}
void
RPL4FrontEnd_GetLoadout(char *vehicle, char *color, char *badge)
{
int v = gHavePersist ? gPersistSelection[GroupVehicle] : 0;
int c = gHavePersist ? gPersistSelection[GroupColor] : 0;
int b = gHavePersist ? gPersistSelection[GroupBadge] : 0;
strcpy(vehicle, kVehicles[v].key);
strcpy(color, kColors[c].key);
strcpy(badge, kBadges[b].key);
}
void
RPL4FrontEnd_SetHostedPilots(
const char *owner_address,
const FEHostedPilot *pilots,
int count
)
{
if (count > maxExtraPilots)
{
count = maxExtraPilots;
}
gHostedPilotCount = (pilots != NULL) ? count : 0;
for (int i = 0; i < gHostedPilotCount; ++i)
{
gHostedPilots[i] = pilots[i];
}
strncpy(gHostedOwnerAddress,
(owner_address != NULL) ? owner_address : "",
sizeof(gHostedOwnerAddress) - 1);
gHostedOwnerAddress[sizeof(gHostedOwnerAddress) - 1] = '\0';
}
//########################################################################
//######################## RPL4FrontEnd_ShowResults ######################
//########################################################################