Files
RP412/RP_L4/RPL4LOBBY.cpp
T
CydandClaude Opus 5 5c7e218c98 A room cannot mix builds, and the log stops hiding things
Three guards, all of them paid for by an actual wasted afternoon.

THE BUILD GUARD. kNetRevision is hand-maintained and only bumped when
someone judges that a change alters the simulation, so two different builds
normally carry the same revision and will race each other happily - which
is how a 4.12.184 machine and a 4.12.187 machine sat in the same room all
day. The exact build now travels as lobby data and member data, and it is
checked in two places:

  - on JOIN, before the room is ever entered, with a dialog naming both
    builds. The owner's launch check would have caught it eventually, but
    only after everyone had picked a loadout and pressed go, and all it can
    do then is decline to start - which reads as the host's button being
    broken.
  - at LAUNCH, beside the revision check, so a member seated before the
    guard existed still cannot race.

The room screen shows BUILD <version> against an offending row, because a
host is owed the reason as well as the refusal. An absent value counts as a
mismatch: a build older than the key cannot be trusted to match. Both
guards stay - the revision still refuses a mix known to simulate
differently even where the build strings agree.

THE DUPLICATE KEY WARNING. environ.ini is applied line by line, so a second
copy of a key silently beats the first. A TARGETFPS added at the top of the
file was overridden by the one the template ships further down, and the test
it was written for looked as though it had failed rather than never having
run. Now: "TARGETFPS is set twice - line 1 and line 8; the LATER one wins",
naming the key and both lines, because which and where is the whole value.

THE MISSING FACTS. TARGETFPS appeared nowhere in the log, so no run could
be checked afterwards against what it was actually asked for, and
interpolation only announced itself when switched off - there was no way to
confirm from a log that it was on. Both are now stated outright, with a
warning when a frame target differs from the physics rate AND interpolation
is off, which is the combination that steps.

Verified: the duplicate warning names lines 1 and 8 of a file carrying both,
the frame line reads "144 fps, drawing on exact physics steps" with the
mismatch note when interpolation is off, and "60 fps, drawing interpolated
across the physics step" with no note when it is on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:58:27 -05:00

1320 lines
41 KiB
C++

#include "..\munga_l4\mungal4.h"
#pragma hdrstop
#include "rpl4lobby.h"
#ifndef RP412_STEAM
//########################################################################
// No Steam SDK in this build: the lobby is unavailable, everything
// else (single player, LAN -net races) works as always.
//########################################################################
Logical RPL4Lobby_Available() { return False; }
Logical RPL4Lobby_Configured() { return False; }
Logical RPL4Lobby_InRoom() { return False; }
int RPL4Lobby_Host(HINSTANCE, HWND) { return LobbyRoomLeft; }
int RPL4Lobby_Join(HINSTANCE, HWND) { return LobbyRoomLeft; }
int RPL4Lobby_Room(HINSTANCE, HWND) { return LobbyRoomLeft; }
void RPL4Lobby_PushRaceResults() { }
void RPL4Lobby_PullRaceResults() { }
#else
#include "rpl4fe.h"
#include "rpl4console.h"
#include "rpl4build.h" // generated: RP412_VERSION, for the build guard
#include "..\munga_l4\l4steamtransport.h"
#pragma pack(push, 8)
#include "steam/steam_api.h"
#pragma pack(pop)
#include <stdio.h>
//########################################################################
// Lobby state - one lobby at a time, alive across races
//########################################################################
namespace
{
// A full grid is eight pods, as the pod hall ran it. The egg builder
// carries the owner plus maxExtraPilots (8), so eight members fit
// with room to spare.
enum { kMaxLobbyMembers = 8 };
const char kLobbyTagKey[] = "rp412";
const char kGoKey[] = "go";
CSteamID gLobby;
Logical gInLobby = False;
int gLastGoNonce = 0; // launches we already answered
int gShownResultsNonce = 0; // score sheets we already displayed
const char kResultsKey[] = "res";
const char kScenarioKey[] = "sc";
//-------------------------------------------------------------------
// Simulation protocol revision. Bump this whenever a change makes
// two builds simulate the same mission differently - it is not the
// wire format alone. Map entity ownership is dealt by advancing a
// shared cursor once per map entity, so anything that changes which
// entities are dealt at all silently desynchronizes who owns what.
//
// 2 - doorframes became local Hermit clockwork and are no longer
// dealt, which shifts every subsequent map entity's owner
// 1 - the 3-machine verified Steam build
//-------------------------------------------------------------------
const char kNetRevision[] = "2";
const char kNetRevKey[] = "nr";
// this member picked Live Cam rather than a grid slot
const char kCamKey[] = "cam";
//
// The exact build, so a room cannot mix them.
//
// kNetRevision above is hand-maintained and only bumped when someone
// decides a change alters the simulation - which means two DIFFERENT
// builds normally carry the same revision and will happily race each
// other. That is fine when the difference really is cosmetic and
// disastrous when the judgement was wrong, and it is not a judgement
// anyone should have to make correctly every time. The patch number is
// the repository's commit count, so this compares the actual binary.
//
// Both guards stay: the revision still refuses a mix that is known to
// simulate differently even between builds that agree here, which
// matters for anyone hand-editing a version.
//
const char kBuildKey[] = "bld";
// the owner's mission setup, shown to everyone in the room
const char kMapKey[] = "mp";
const char kTimeKey[] = "td";
const char kWeatherKey[] = "wx";
const char kLengthKey[] = "ln";
// async call-result plumbing
Logical gCallDone = False;
Logical gCallOK = False;
CSteamID gCallLobby;
class LobbyCalls
{
public:
CCallResult<LobbyCalls, LobbyCreated_t> createResult;
CCallResult<LobbyCalls, LobbyMatchList_t> listResult;
CCallResult<LobbyCalls, LobbyEnter_t> enterResult;
void OnCreated(LobbyCreated_t *result, bool io_failure)
{
gCallOK = !io_failure && result->m_eResult == k_EResultOK;
gCallLobby = CSteamID(result->m_ulSteamIDLobby);
gCallDone = True;
}
void OnList(LobbyMatchList_t *result, bool io_failure)
{
gCallOK = !io_failure && result->m_nLobbiesMatching > 0;
if (gCallOK)
{
gCallLobby = SteamMatchmaking()->GetLobbyByIndex(0);
}
gCallDone = True;
}
void OnEnter(LobbyEnter_t *result, bool io_failure)
{
gCallOK = !io_failure &&
result->m_EChatRoomEnterResponse == k_EChatRoomEnterResponseSuccess;
gCallLobby = CSteamID(result->m_ulSteamIDLobby);
gCallDone = True;
}
};
LobbyCalls gCalls;
Logical WaitForCall(int timeout_ms)
{
DWORD deadline = GetTickCount() + timeout_ms;
while (!gCallDone)
{
SteamAPI_RunCallbacks();
if ((LONG)(GetTickCount() - deadline) >= 0)
{
return False;
}
Sleep(50);
}
return gCallOK;
}
//---------------------------------------------------------------
// Member data: what every pod publishes on entering the room
//---------------------------------------------------------------
void SanitizeName(const char *in, char *out, int out_size)
{
int n = 0;
for (int i = 0; in[i] != '\0' && n < out_size - 1; ++i)
{
char c = in[i];
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == ' ' || c == '-' || c == '_')
{
out[n++] = c;
}
}
out[n] = '\0';
if (n == 0)
{
strcpy(out, "Pilot");
}
}
Logical IsOwner()
{
return gInLobby &&
SteamMatchmaking()->GetLobbyOwner(gLobby) == SteamUser()->GetSteamID();
}
void PublishMemberData()
{
char value[64];
SteamMatchmaking()->SetLobbyMemberData(gLobby, "ip",
SteamNetTransport_GetFakeAddressString());
sprintf(value, "%d", SteamNetTransport_GetFakeConsolePort());
SteamMatchmaking()->SetLobbyMemberData(gLobby, "cp", value);
sprintf(value, "%d", SteamNetTransport_GetFakeGamePort());
SteamMatchmaking()->SetLobbyMemberData(gLobby, "gp", value);
//
// The name in the room is the CALLSIGN from the setup screen, not
// the Steam persona. Publishing the persona meant the box a player
// types their name into had no effect on anything they could see -
// and because it never came from a file, wiping the install
// directory did not shake it loose either.
//
// The persona is kept as the fallback for somebody who has never
// set a callsign: better to appear as yourself than as "Pilot".
//
char name[32];
const char *callsign = RPL4FrontEnd_Callsign();
if (callsign != NULL && callsign[0] != '\0' &&
strcmp(callsign, "Pilot") != 0)
{
SanitizeName(callsign, name, sizeof(name));
}
else
{
SanitizeName(SteamFriends()->GetPersonaName(), name, sizeof(name));
}
SteamMatchmaking()->SetLobbyMemberData(gLobby, "nm", name);
char vehicle[24], color[16], badge[24];
RPL4FrontEnd_GetLoadout(vehicle, color, badge);
SteamMatchmaking()->SetLobbyMemberData(gLobby, "vh", vehicle);
SteamMatchmaking()->SetLobbyMemberData(gLobby, "cl", color);
SteamMatchmaking()->SetLobbyMemberData(gLobby, "bd", badge);
//
// Live Cam. The loadout above still goes out unchanged - the pick
// is a role, not a vehicle, and it is only acted on for the host,
// so a member's own row must go on showing what it will really
// fly. The room screen reads this against the owner's row.
//
SteamMatchmaking()->SetLobbyMemberData(gLobby, kCamKey,
RPL4FrontEnd_IsCameraRole() ? "1" : "0");
// football: this member's own team and position pick
SteamMatchmaking()->SetLobbyMemberData(gLobby, "tm",
RPL4FrontEnd_TeamKey(RPL4FrontEnd_GetTeamIndex()));
SteamMatchmaking()->SetLobbyMemberData(gLobby, "ps",
RPL4FrontEnd_PositionKey(RPL4FrontEnd_GetPositionIndex()));
// what this build simulates like, so a mismatched room cannot launch
SteamMatchmaking()->SetLobbyMemberData(gLobby, kNetRevKey, kNetRevision);
// and which build it actually IS - see kBuildKey
SteamMatchmaking()->SetLobbyMemberData(gLobby, kBuildKey, RP412_VERSION);
//---------------------------------------------------------------
// Only the owner's menu decides the mission, so the owner also
// publishes what it picked: the scenario (members need it to know
// whether their team/position pick matters) and the setup
// everyone is about to fly into.
//---------------------------------------------------------------
if (IsOwner())
{
// members check these before they act on the owner's go, and
// a joiner checks the build before it even sits down
SteamMatchmaking()->SetLobbyData(gLobby, kNetRevKey, kNetRevision);
SteamMatchmaking()->SetLobbyData(gLobby, kBuildKey, RP412_VERSION);
SteamMatchmaking()->SetLobbyData(gLobby, kScenarioKey,
RPL4FrontEnd_IsFootballSelected() ? "football" : "race");
SteamMatchmaking()->SetLobbyData(gLobby, kMapKey,
RPL4FrontEnd_SelectedMapName());
SteamMatchmaking()->SetLobbyData(gLobby, kTimeKey,
RPL4FrontEnd_SelectedTimeName());
SteamMatchmaking()->SetLobbyData(gLobby, kWeatherKey,
RPL4FrontEnd_SelectedWeatherName());
SteamMatchmaking()->SetLobbyData(gLobby, kLengthKey,
RPL4FrontEnd_SelectedLengthName());
}
}
Logical FootballLobby()
{
if (!gInLobby)
{
return False;
}
const char *scenario = SteamMatchmaking()->GetLobbyData(gLobby, kScenarioKey);
return (scenario != NULL && strcmp(scenario, "football") == 0);
}
//-------------------------------------------------------------------
// Lobby data read back as a string, empty when the owner has not
// published it yet (a member can be in the room a beat before the
// owner's first publish lands).
//-------------------------------------------------------------------
const char *LobbyText(const char *key)
{
if (!gInLobby)
{
return "";
}
const char *text = SteamMatchmaking()->GetLobbyData(gLobby, key);
return (text != NULL) ? text : "";
}
//---------------------------------------------------------------
// Launch: roster into lobby data, peers into the transport, and
// the hosted-race path primed on the owner
//---------------------------------------------------------------
struct MemberInfo
{
CSteamID id;
char ip[32];
int consolePort;
int gamePort;
char name[32];
char vehicle[24];
char color[16];
char badge[24];
char team[32]; // football pick
char position[16];
char netRev[8]; // simulation protocol revision
char build[24]; // the exact build, see kBuildKey
Logical camera; // picked Live Cam (acted on for the host)
Logical published;
};
int CollectMembers(MemberInfo *members)
{
int count = SteamMatchmaking()->GetNumLobbyMembers(gLobby);
if (count > kMaxLobbyMembers)
{
count = kMaxLobbyMembers;
}
for (int i = 0; i < count; ++i)
{
MemberInfo *member = &members[i];
memset(member, 0, sizeof(*member));
member->id = SteamMatchmaking()->GetLobbyMemberByIndex(gLobby, i);
strncpy(member->ip,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "ip"),
sizeof(member->ip) - 1);
member->consolePort = atoi(
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "cp"));
member->gamePort = atoi(
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "gp"));
strncpy(member->name,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "nm"),
sizeof(member->name) - 1);
strncpy(member->vehicle,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "vh"),
sizeof(member->vehicle) - 1);
strncpy(member->color,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "cl"),
sizeof(member->color) - 1);
strncpy(member->badge,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "bd"),
sizeof(member->badge) - 1);
strncpy(member->team,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "tm"),
sizeof(member->team) - 1);
strncpy(member->position,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "ps"),
sizeof(member->position) - 1);
strncpy(member->netRev,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kNetRevKey),
sizeof(member->netRev) - 1);
strncpy(member->build,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kBuildKey),
sizeof(member->build) - 1);
member->camera = (atoi(
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kCamKey)) != 0)
? True : False;
member->published =
member->ip[0] != '\0' && member->consolePort > 0 && member->gamePort > 0;
}
return count;
}
void RegisterRoster(MemberInfo *members, int count)
{
SteamNetTransport_SetEnginePorts(1501);
for (int i = 0; i < count; ++i)
{
if (members[i].published)
{
SteamNetTransport_RegisterPeer(
members[i].ip, members[i].consolePort, members[i].gamePort,
members[i].id.ConvertToUint64());
}
}
}
// Owner side: prime RPL4FE + WinMain's hosted-race path
void PrimeHostedRace(MemberInfo *members, int count)
{
static char pods_env[512];
static char addr_env[64];
static char port_env[32];
CSteamID self = SteamUser()->GetSteamID();
FEHostedPilot pilots[kMaxLobbyMembers];
int pilot_count = 0;
char pods[512] = "";
for (int i = 0; i < count; ++i)
{
if (members[i].id == self || !members[i].published)
{
continue;
}
FEHostedPilot *pilot = &pilots[pilot_count++];
memset(pilot, 0, sizeof(*pilot));
sprintf(pilot->address, "%s:1502", members[i].ip);
strncpy(pilot->name, members[i].name, sizeof(pilot->name) - 1);
strncpy(pilot->vehicle, members[i].vehicle, sizeof(pilot->vehicle) - 1);
strncpy(pilot->color, members[i].color, sizeof(pilot->color) - 1);
strncpy(pilot->badge, members[i].badge, sizeof(pilot->badge) - 1);
strncpy(pilot->team, members[i].team, sizeof(pilot->team) - 1);
strncpy(pilot->position, members[i].position, sizeof(pilot->position) - 1);
if (pods[0] != '\0')
{
strcat(pods, ",");
}
strcat(pods, members[i].ip);
strcat(pods, ":1501");
}
RPL4FrontEnd_SetHostedPilots(
SteamNetTransport_GetFakeAddressString(), pilots, pilot_count);
sprintf(pods_env, "RP412HOSTPODS=%s", pods);
putenv(pods_env);
sprintf(addr_env, "RP412HOSTADDR=%s", SteamNetTransport_GetFakeAddressString());
putenv(addr_env);
strcpy(port_env, "RP412HOSTPORT=1501");
putenv(port_env);
}
//---------------------------------------------------------------
// The room screen (front-end style: green on black)
//---------------------------------------------------------------
const COLORREF kGreenBright = RGB(64, 255, 64);
const COLORREF kGreenDim = RGB(24, 140, 24);
struct RoomState
{
HWND window;
HFONT textFont;
HFONT titleFont;
RECT launchRect; // owner only
RECT leaveRect;
RECT teamRect; // football: cycle my team
RECT positionRect; // football: cycle my position
Logical launchClicked;
Logical leaveClicked;
Logical pickChanged;
Logical closed;
MemberInfo members[kMaxLobbyMembers];
int memberCount;
};
RoomState *gRoom = NULL;
void PaintRoom(RoomState *room)
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(room->window, &ps);
RECT client;
GetClientRect(room->window, &client);
HDC mem = CreateCompatibleDC(hdc);
HBITMAP surface = CreateCompatibleBitmap(hdc, client.right, client.bottom);
HBITMAP old_surface = (HBITMAP) SelectObject(mem, surface);
FillRect(mem, &client, (HBRUSH) GetStockObject(BLACK_BRUSH));
SetBkMode(mem, TRANSPARENT);
SelectObject(mem, room->titleFont);
SetTextColor(mem, kGreenBright);
RECT title = client;
title.top = client.bottom / 28;
title.bottom = title.top + client.bottom / 10;
DrawTextA(mem,
FootballLobby() ? "STEAM FOOTBALL LOBBY" : "STEAM RACE LOBBY",
-1, &title, DT_CENTER | DT_TOP | DT_SINGLELINE);
SelectObject(mem, room->textFont);
// wider than the old middle-half: the right column now carries
// full catalog names, and in football three of them
int left = client.right / 6;
int right = client.right - client.right / 6;
char text[128];
//---------------------------------------------------------------
// The buttons are laid out upward from the bottom edge while the
// roster runs down from the top, so everything between the title
// and the topmost button has to share one band.
//---------------------------------------------------------------
int ceiling = client.bottom;
if (FootballLobby() && room->teamRect.top > 0)
{
ceiling = room->teamRect.top;
}
else if (IsOwner() && room->launchRect.top > 0)
{
ceiling = room->launchRect.top;
}
else if (room->leaveRect.top > 0)
{
ceiling = room->leaveRect.top;
}
//---------------------------------------------------------------
// Size the rows to that band rather than to a fixed fraction of
// the window: a full lobby is eight players, and eight rows at a
// sixteenth each would run off the bottom of every window we
// support. Counted in half rows, the band holds
//
// setup lines + gap + kMaxLobbyMembers rows + gap + hint
//
// so the row height falls out of the space actually available.
// It never grows past the old sixteenth (a two-player lobby
// should not have circus-sized rows) and never shrinks below the
// font, which is what would actually break - overlapping text.
//---------------------------------------------------------------
// tmHeight already includes the font's own leading, so a row that
// tall cannot clip or collide - asking for more than that just
// costs setup lines the room would rather keep.
TEXTMETRICA metrics;
GetTextMetricsA(mem, &metrics);
int min_row = metrics.tmHeight + 2;
int max_row = client.bottom / 16;
int setup_top = title.bottom + client.bottom / 64;
int band = ceiling - setup_top;
int setup_lines = 2;
int row_h = 0;
for (;;)
{
// halves: setup, half gap, roster, half gap, hint
int halves = 2 * setup_lines + 1 + 2 * kMaxLobbyMembers + 1 + 2;
row_h = (2 * band) / halves;
if (row_h >= min_row || setup_lines == 0)
{
break;
}
--setup_lines; // buy room back from the setup lines
}
if (row_h > max_row) row_h = max_row;
if (row_h < 12) row_h = 12;
//---------------------------------------------------------------
// On a window too short to give eight rows the room the standard
// font wants, draw this block smaller rather than letting the
// rows overlap each other or run under the buttons. Nobody plays
// at that size, but a squeezed roster still has to be readable.
//---------------------------------------------------------------
HFONT squeezed = NULL;
HFONT previous = NULL;
if (row_h < min_row)
{
squeezed = CreateFontA(-(row_h * 2 / 3), 0, 0, 0, FW_NORMAL,
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_TT_PRECIS,
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
FIXED_PITCH | FF_MODERN, "Consolas");
if (squeezed != NULL)
{
previous = (HFONT) SelectObject(mem, squeezed);
}
}
int top = setup_top;
const char *map_name = LobbyText(kMapKey);
if (map_name[0] != '\0' && setup_lines > 0)
{
RECT setup;
setup.left = client.right / 12;
setup.right = client.right - client.right / 12;
setup.top = setup_top;
setup.bottom = setup.top + row_h;
SetTextColor(mem, kGreenBright);
DrawTextA(mem, map_name, -1, &setup,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
//-----------------------------------------------------------
// Conditions on the line below. Game length is the host's
// pick too, and the one people ask about first.
//-----------------------------------------------------------
const char *time_name = LobbyText(kTimeKey);
const char *weather_name = LobbyText(kWeatherKey);
const char *length_name = LobbyText(kLengthKey);
text[0] = '\0';
if (time_name[0] != '\0')
{
strcat(text, time_name);
}
if (weather_name[0] != '\0')
{
if (text[0] != '\0') strcat(text, " - ");
strcat(text, weather_name);
}
if (length_name[0] != '\0')
{
if (text[0] != '\0') strcat(text, " - ");
strcat(text, length_name);
}
if (text[0] != '\0' && setup_lines > 1)
{
setup.top = setup.bottom;
setup.bottom = setup.top + row_h;
SetTextColor(mem, kGreenDim);
DrawTextA(mem, text, -1, &setup,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
}
if (setup.bottom + row_h / 2 > top)
{
top = setup.bottom + row_h / 2;
}
}
for (int i = 0; i < room->memberCount; ++i)
{
MemberInfo *member = &room->members[i];
RECT row;
row.left = left;
row.right = right;
row.top = top + i * row_h;
row.bottom = row.top + row_h;
Logical is_owner_row =
SteamMatchmaking()->GetLobbyOwner(gLobby) == member->id;
SetTextColor(mem, member->published ? kGreenBright : kGreenDim);
sprintf(text, "%s%s", member->name[0] ? member->name : "(joining...)",
is_owner_row ? " [HOST]" : "");
DrawTextA(mem, text, -1, &row, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
//-----------------------------------------------------------
// The right column is what this player brings. Football is a
// team sheet - team colors and position - but the VTV still
// decides how they play it, so name it there too. Keys
// travel on the wire; the catalogs turn them back into
// names, so nobody reads "bttlbrg".
//-----------------------------------------------------------
//
// A Live Cam host brings no vehicle, so its row says the role
// instead of a loadout. Only the owner's row: the host writes
// the egg, so only the host's pick is acted on, and a member
// who set Live Cam is still going to race - saying otherwise
// here would be the room screen lying about the grid.
//
//
// A build the room cannot race with is worth saying before
// anything about loadouts. A joiner is now turned away at the
// door, so this should only ever show for a member who was
// already seated when the guard arrived - but the launch check
// still refuses on it, and a host is owed the reason.
//
if (member->published && member->build[0] != '\0' &&
strcmp(member->build, RP412_VERSION) != 0)
{
sprintf(text, "BUILD %s", member->build);
DrawTextA(mem, text, -1, &row,
DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
}
else if (member->camera && is_owner_row)
{
DrawTextA(mem, "LIVE CAM", -1, &row,
DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
}
else if (FootballLobby())
{
if (member->team[0] != '\0')
{
sprintf(text, "%s - %s - %s",
RPL4FrontEnd_VehicleNameForKey(member->vehicle),
RPL4FrontEnd_TeamNameForKey(member->team),
RPL4FrontEnd_PositionNameForKey(member->position));
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
}
}
else if (member->vehicle[0] != '\0')
{
sprintf(text, "%s - %s",
RPL4FrontEnd_VehicleNameForKey(member->vehicle),
member->color);
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
}
}
SetTextColor(mem, kGreenDim);
RECT hint;
hint.left = left;
hint.right = right;
hint.top = top + kMaxLobbyMembers * row_h + row_h / 2;
hint.bottom = hint.top + row_h;
DrawTextA(mem,
IsOwner() ? "You are the console: launch when everyone is in."
: "Waiting for the host to launch...",
-1, &hint, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
// back to the room font for the buttons, which have their own room
if (squeezed != NULL)
{
SelectObject(mem, previous);
DeleteObject(squeezed);
}
HBRUSH frame = CreateSolidBrush(kGreenBright);
//
// Football: my own team and position, click to cycle
//
if (FootballLobby())
{
FrameRect(mem, &room->teamRect, frame);
SetTextColor(mem, kGreenBright);
sprintf(text, "MY TEAM: %s",
RPL4FrontEnd_TeamName(RPL4FrontEnd_GetTeamIndex()));
DrawTextA(mem, text, -1, &room->teamRect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
FrameRect(mem, &room->positionRect, frame);
sprintf(text, "MY POSITION: %s",
RPL4FrontEnd_PositionName(RPL4FrontEnd_GetPositionIndex()));
DrawTextA(mem, text, -1, &room->positionRect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
}
if (IsOwner())
{
FrameRect(mem, &room->launchRect, frame);
SetTextColor(mem, kGreenBright);
DrawTextA(mem, "L A U N C H G A M E", -1, &room->launchRect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
}
FrameRect(mem, &room->leaveRect, frame);
SetTextColor(mem, kGreenBright);
DrawTextA(mem, "LEAVE LOBBY", -1, &room->leaveRect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
DeleteObject(frame);
BitBlt(hdc, 0, 0, client.right, client.bottom, mem, 0, 0, SRCCOPY);
SelectObject(mem, old_surface);
DeleteObject(surface);
DeleteDC(mem);
EndPaint(room->window, &ps);
}
LRESULT CALLBACK
RoomWndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
RoomState *room = gRoom;
switch (message)
{
case WM_PAINT:
if (room != NULL)
{
PaintRoom(room);
return 0;
}
break;
case WM_LBUTTONDOWN:
if (room != NULL)
{
POINT point = { (int)(short) LOWORD(lParam), (int)(short) HIWORD(lParam) };
if (IsOwner() && PtInRect(&room->launchRect, point))
{
room->launchClicked = True;
}
else if (PtInRect(&room->leaveRect, point))
{
room->leaveClicked = True;
}
else if (FootballLobby() && PtInRect(&room->teamRect, point))
{
RPL4FrontEnd_SetTeamIndex(
(RPL4FrontEnd_GetTeamIndex() + 1) % RPL4FrontEnd_TeamCount());
room->pickChanged = True;
}
else if (FootballLobby() && PtInRect(&room->positionRect, point))
{
RPL4FrontEnd_SetPositionIndex(
(RPL4FrontEnd_GetPositionIndex() + 1) % RPL4FrontEnd_PositionCount());
room->pickChanged = True;
}
return 0;
}
break;
case WM_ERASEBKGND:
return 1;
}
return DefWindowProcA(hwnd, message, wParam, lParam);
}
//---------------------------------------------------------------
// The room loop: pump Steam + Windows, watch for launch/leave
//---------------------------------------------------------------
int RunRoom(HINSTANCE instance, HWND main_window)
{
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 = RoomWndProc;
window_class.hInstance = instance;
window_class.hCursor = LoadCursor(NULL, IDC_ARROW);
window_class.hbrBackground = (HBRUSH) GetStockObject(BLACK_BRUSH);
window_class.lpszClassName = "RPLobby";
RegisterClassA(&window_class);
}
RECT client;
GetClientRect(main_window, &client);
RoomState room;
memset(&room, 0, sizeof(room));
room.window = CreateWindowExA(
0, "RPLobby", "",
WS_CHILD | WS_VISIBLE,
0, 0, client.right, client.bottom,
main_window, NULL, instance, NULL);
if (room.window == NULL)
{
return LobbyRoomClosed;
}
int text_h = client.bottom / 40;
if (text_h < 16) text_h = 16;
if (text_h > 24) text_h = 24;
room.textFont = CreateFontA(-(text_h * 3 / 2), 0, 0, 0, FW_NORMAL,
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_DEFAULT_PRECIS,
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_MODERN,
"Consolas");
room.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");
int row_h = client.bottom / 36;
if (row_h < 18) row_h = 18;
if (row_h > 30) row_h = 30;
int col_w = client.right / 4;
room.launchRect.left = (client.right - col_w) / 2;
room.launchRect.right = room.launchRect.left + col_w;
room.launchRect.top = client.bottom - 6 * row_h;
room.launchRect.bottom = room.launchRect.top + 2 * row_h;
room.leaveRect.left = (client.right - col_w / 2) / 2;
room.leaveRect.right = room.leaveRect.left + col_w / 2;
room.leaveRect.top = client.bottom - 3 * row_h;
room.leaveRect.bottom = client.bottom - row_h;
// football pick buttons, above the launch button
room.teamRect.left = (client.right - col_w) / 2;
room.teamRect.right = room.teamRect.left + col_w;
room.teamRect.top = client.bottom - 12 * row_h;
room.teamRect.bottom = room.teamRect.top + 2 * row_h;
room.positionRect.left = room.teamRect.left;
room.positionRect.right = room.teamRect.right;
room.positionRect.top = client.bottom - 9 * row_h;
room.positionRect.bottom = room.positionRect.top + 2 * row_h;
gRoom = &room;
PublishMemberData();
int outcome = -1;
DWORD last_refresh = 0;
while (outcome < 0)
{
MSG msg;
while (PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE))
{
if (msg.message == WM_QUIT)
{
room.closed = True;
}
TranslateMessage(&msg);
DispatchMessageA(&msg);
}
SteamAPI_RunCallbacks();
if (!IsWindow(main_window) || room.closed)
{
outcome = LobbyRoomClosed;
break;
}
//
// A pick changed: republish so everyone's roster updates
//
if (room.pickChanged)
{
room.pickChanged = False;
PublishMemberData();
room.memberCount = CollectMembers(room.members);
InvalidateRect(room.window, NULL, FALSE);
RedrawWindow(room.window, NULL, NULL, RDW_UPDATENOW);
}
if (room.leaveClicked)
{
SteamMatchmaking()->LeaveLobby(gLobby);
gInLobby = False;
// plain menu launches must not inherit lobby hosting
RPL4FrontEnd_SetHostedPilots(NULL, NULL, 0);
static char clear_env[] = "RP412HOSTPODS=";
putenv(clear_env);
outcome = LobbyRoomLeft;
break;
}
//
// Owner launch: roster into lobby data + local prime
//
if (room.launchClicked)
{
room.launchClicked = False;
room.memberCount = CollectMembers(room.members);
Logical all_published = True;
Logical all_same_build = True;
for (int i = 0; i < room.memberCount; ++i)
{
if (!room.members[i].published)
{
all_published = False;
}
if (strcmp(room.members[i].netRev, kNetRevision) != 0)
{
all_same_build = False;
DEBUG_STREAM << "Lobby: " << room.members[i].name
<< " simulates like rev '" << room.members[i].netRev
<< "', we are rev '" << kNetRevision << "'\n" << std::flush;
}
//
// The exact build too. An empty string is an older
// build that predates the key and cannot be trusted to
// match either.
//
if (strcmp(room.members[i].build, RP412_VERSION) != 0)
{
all_same_build = False;
DEBUG_STREAM << "Lobby: " << room.members[i].name
<< " is build '"
<< (room.members[i].build[0] ? room.members[i].build : "(older)")
<< "', we are '" << RP412_VERSION << "'\n" << std::flush;
}
}
if (all_published && all_same_build && room.memberCount >= 1)
{
++gLastGoNonce;
char go[800];
sprintf(go, "%d:", gLastGoNonce);
for (int i = 0; i < room.memberCount; ++i)
{
char entry[96];
sprintf(entry, "%s|%d|%d|%I64u;", room.members[i].ip,
room.members[i].consolePort, room.members[i].gamePort,
room.members[i].id.ConvertToUint64());
if (strlen(go) + strlen(entry) < sizeof(go))
{
strcat(go, entry);
}
}
SteamMatchmaking()->SetLobbyData(gLobby, kGoKey, go);
RegisterRoster(room.members, room.memberCount);
PrimeHostedRace(room.members, room.memberCount);
outcome = LobbyLaunchHost;
break;
}
DEBUG_STREAM << "Lobby: not everyone is ready yet\n" << std::flush;
}
//
// Member: watch for the owner's go signal
//
if (!IsOwner())
{
//
// A room whose owner simulates differently than we do would
// desynchronize silently rather than fail, so sit the race out
// instead of flying into it.
//
const char *owner_rev = LobbyText(kNetRevKey);
if (owner_rev[0] != '\0' &&
strcmp(owner_rev, kNetRevision) != 0)
{
DEBUG_STREAM << "Lobby: owner simulates like rev '"
<< owner_rev << "', we are rev '" << kNetRevision
<< "' - not launching\n" << std::flush;
outcome = LobbyRoomLeft;
SteamMatchmaking()->LeaveLobby(gLobby);
gInLobby = False;
break;
}
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
if (go != NULL && go[0] != '\0')
{
int nonce = atoi(go);
if (nonce > gLastGoNonce)
{
gLastGoNonce = nonce;
// register every rostered peer with the transport
SteamNetTransport_SetEnginePorts(1501);
const char *cursor = strchr(go, ':');
cursor = (cursor != NULL) ? cursor + 1 : go;
while (*cursor != '\0')
{
char ip[32];
int console_port = 0, game_port = 0;
unsigned __int64 steam_id = 0;
int n = 0;
while (cursor[n] != '\0' && cursor[n] != '|' &&
n < (int) sizeof(ip) - 1)
{
ip[n] = cursor[n];
++n;
}
ip[n] = '\0';
cursor += n;
if (*cursor == '|')
{
console_port = atoi(++cursor);
while (*cursor != '\0' && *cursor != '|') ++cursor;
}
if (*cursor == '|')
{
game_port = atoi(++cursor);
while (*cursor != '\0' && *cursor != '|' && *cursor != ';') ++cursor;
}
if (*cursor == '|')
{
steam_id = _strtoui64(++cursor, NULL, 10);
}
while (*cursor != '\0' && *cursor != ';') ++cursor;
if (*cursor == ';') ++cursor;
if (ip[0] != '\0' && console_port > 0 && game_port > 0)
{
SteamNetTransport_RegisterPeer(
ip, console_port, game_port, steam_id);
}
}
outcome = LobbyLaunchMember;
break;
}
}
}
//
// Refresh the member list once a second
//
DWORD now = GetTickCount();
if ((LONG)(now - last_refresh) >= 1000)
{
last_refresh = now;
room.memberCount = CollectMembers(room.members);
InvalidateRect(room.window, NULL, FALSE);
RedrawWindow(room.window, NULL, NULL, RDW_UPDATENOW);
}
Sleep(50);
}
DestroyWindow(room.window);
DeleteObject(room.textFont);
DeleteObject(room.titleFont);
gRoom = NULL;
return outcome;
}
}
//########################################################################
// Public API
//########################################################################
Logical
RPL4Lobby_Available()
{
return SteamNetTransport_GetFakeAddressString()[0] != '\0';
}
//
// The environ.ini switch, read the same way RPL4.CPP reads it to decide
// whether to install the transport at all. Says nothing about whether the
// Steam client was actually there.
//
Logical
RPL4Lobby_Configured()
{
const char *steam_switch = getenv("RP412STEAM");
return (steam_switch != NULL && atoi(steam_switch) != 0) ? True : False;
}
Logical
RPL4Lobby_InRoom()
{
return gInLobby;
}
//
// Host and Join are the two places that reach Steam without the
// transport having got there first, so they carry their own guard.
// steam_api.dll is delay-loaded and calling into it when it is absent
// raises the helper's fatal exception - the menu already greys these on
// Available(), but a dead DLL must not depend on the UI for safety.
//
int
RPL4Lobby_Host(HINSTANCE instance, HWND main_window)
{
if (!SteamNetTransport_ClientLibraryPresent())
{
return LobbyRoomLeft;
}
gCallDone = False;
SteamAPICall_t call =
SteamMatchmaking()->CreateLobby(k_ELobbyTypePublic, kMaxLobbyMembers);
gCalls.createResult.Set(call, &gCalls, &LobbyCalls::OnCreated);
if (!WaitForCall(15000))
{
DEBUG_STREAM << "Lobby: CreateLobby failed\n" << std::flush;
return LobbyRoomLeft;
}
gLobby = gCallLobby;
gInLobby = True;
gLastGoNonce = 0;
SteamMatchmaking()->SetLobbyData(gLobby, kLobbyTagKey, "1");
SteamMatchmaking()->SetLobbyData(gLobby, kGoKey, "");
DEBUG_STREAM << "Lobby: hosting " << gLobby.ConvertToUint64() << "\n" << std::flush;
return RunRoom(instance, main_window);
}
int
RPL4Lobby_Join(HINSTANCE instance, HWND main_window)
{
if (!SteamNetTransport_ClientLibraryPresent())
{
return LobbyRoomLeft;
}
gCallDone = False;
SteamMatchmaking()->AddRequestLobbyListStringFilter(
kLobbyTagKey, "1", k_ELobbyComparisonEqual);
// the default lobby search is distance-filtered (roughly same
// region) - RP412 races are worldwide
SteamMatchmaking()->AddRequestLobbyListDistanceFilter(
k_ELobbyDistanceFilterWorldwide);
SteamAPICall_t call = SteamMatchmaking()->RequestLobbyList();
gCalls.listResult.Set(call, &gCalls, &LobbyCalls::OnList);
if (!WaitForCall(15000))
{
DEBUG_STREAM << "Lobby: no open race lobby found\n" << std::flush;
return LobbyRoomLeft;
}
CSteamID target = gCallLobby;
gCallDone = False;
call = SteamMatchmaking()->JoinLobby(target);
gCalls.enterResult.Set(call, &gCalls, &LobbyCalls::OnEnter);
if (!WaitForCall(15000))
{
DEBUG_STREAM << "Lobby: join failed\n" << std::flush;
return LobbyRoomLeft;
}
gLobby = gCallLobby;
gInLobby = True;
//
// Refuse a room running a different build, and refuse it HERE rather
// than at launch. The owner's launch check would catch it, but only
// after everyone has picked a loadout and pressed go, and all it can do
// then is silently decline to start - which reads as the host's button
// being broken. Far better to say so on the way in.
//
// The owner publishes its build as lobby data, so this costs one read.
// An empty string means a host older than the key, which is equally a
// mismatch: it cannot be trusted to be this build.
//
{
const char *host_build = SteamMatchmaking()->GetLobbyData(gLobby, kBuildKey);
if (host_build == NULL || strcmp(host_build, RP412_VERSION) != 0)
{
DEBUG_STREAM << "Lobby: room is build '"
<< ((host_build != NULL && host_build[0]) ? host_build : "(older)")
<< "', we are '" << RP412_VERSION
<< "' - leaving, everyone must run the same build\n" << std::flush;
SteamMatchmaking()->LeaveLobby(gLobby);
gInLobby = False;
//
// Say so on screen, not just in the log. A player who is bounced
// out of a room with no explanation will try again, and again,
// and then report that joining is broken - which is precisely
// the afternoon this guard exists to prevent.
//
char said[256];
sprintf(said,
"That race is running Red Planet %s.\n"
"You are running %s.\n\n"
"Everyone in a race has to be on the same build - the\n"
"simulation has to agree exactly. Swap to a matching\n"
"build and join again.",
(host_build != NULL && host_build[0]) ? host_build : "an older build",
RP412_VERSION);
MessageBoxA(main_window, said, "Different build",
MB_OK | MB_ICONINFORMATION);
return LobbyRoomLeft;
}
}
// answer only launches newer than anything already in the lobby
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
gLastGoNonce = (go != NULL) ? atoi(go) : 0;
DEBUG_STREAM << "Lobby: joined " << gLobby.ConvertToUint64() << "\n" << std::flush;
return RunRoom(instance, main_window);
}
int
RPL4Lobby_Room(HINSTANCE instance, HWND main_window)
{
if (!gInLobby)
{
return LobbyRoomLeft;
}
return RunRoom(instance, main_window);
}
void
RPL4Lobby_PushRaceResults()
{
if (!gInLobby || !IsOwner() || RPL4LocalConsole_ResultCount() == 0)
{
return;
}
SteamAPI_RunCallbacks();
char sheet[600];
sprintf(sheet, "%d:", gLastGoNonce);
for (int i = 0; i < RPL4LocalConsole_ResultCount(); ++i)
{
int host_ID, score;
if (!RPL4LocalConsole_GetResult(i, &host_ID, &score))
{
continue;
}
const char *name = RPL4LocalConsole_GetResultName(host_ID);
char entry[80];
sprintf(entry, "%d|%d|%.30s;", host_ID, score,
(name != NULL) ? name : "");
if (strlen(sheet) + strlen(entry) < sizeof(sheet))
{
strcat(sheet, entry);
}
}
SteamMatchmaking()->SetLobbyData(gLobby, kResultsKey, sheet);
gShownResultsNonce = gLastGoNonce; // the owner sees its own screen
DEBUG_STREAM << "Lobby: results published (" << sheet << ")\n" << std::flush;
}
void
RPL4Lobby_PullRaceResults()
{
if (!gInLobby || IsOwner() || gLastGoNonce == gShownResultsNonce)
{
return;
}
//
// The owner publishes right after its race teardown - ours may
// finish first, so give the sheet a moment to arrive
//
const char *sheet = NULL;
DWORD deadline = GetTickCount() + 8 * 1000;
for (;;)
{
SteamAPI_RunCallbacks();
sheet = SteamMatchmaking()->GetLobbyData(gLobby, kResultsKey);
if (sheet != NULL && sheet[0] != '\0' && atoi(sheet) == gLastGoNonce)
{
break;
}
if ((LONG)(GetTickCount() - deadline) >= 0)
{
return; // no sheet for this race - skip the screen
}
Sleep(100);
}
gShownResultsNonce = gLastGoNonce;
RPL4LocalConsole_ClearResults();
const char *cursor = strchr(sheet, ':');
cursor = (cursor != NULL) ? cursor + 1 : sheet;
while (*cursor != '\0')
{
int host_ID = atoi(cursor);
int score = 0;
char name[32] = "";
while (*cursor != '\0' && *cursor != '|' && *cursor != ';') ++cursor;
if (*cursor == '|')
{
score = atoi(++cursor);
while (*cursor != '\0' && *cursor != '|' && *cursor != ';') ++cursor;
}
if (*cursor == '|')
{
++cursor;
int n = 0;
while (cursor[n] != '\0' && cursor[n] != ';' &&
n < (int) sizeof(name) - 1)
{
name[n] = cursor[n];
++n;
}
name[n] = '\0';
cursor += n;
}
while (*cursor != '\0' && *cursor != ';') ++cursor;
if (*cursor == ';') ++cursor;
if (host_ID > 0)
{
RPL4LocalConsole_InjectResult(host_ID, score, name);
}
}
DEBUG_STREAM << "Lobby: results pulled for race " << gLastGoNonce << "\n" << std::flush;
}
#endif // RP412_STEAM