Files
BT411/game/glass/btl4lobby.cpp
T
Joe DiPrimaandClaude Fable 5 648f6b1675 Steam gate: rejected players now get a MESSAGE BOX, not a silent quit
A failed join returns 1 to the FE, which QUITS the exe -- so every
rejection so far was a log line plus "the game just closed" (#68's exact
complaint). New LobbyNotice() = same text in the day log (flattened, still
greppable) + blocking MessageBox. Wired to every join-side bail:

- BUILD MISMATCH: when the version-filtered search is empty, probe once
  without the version filter; if a lobby IS up, the box names the host's
  build vs ours (lobby data rides the list result -- no join needed).
  Post-entry verify mismatch gets the same box.
- NO LOBBY FOUND: probe empty too -> plain no-lobby box naming our build.
- STEAM UNAVAILABLE: transport install failed.
- LEFT BEHIND: host launched without us (no token in btl4map).

Old exes still exit silently on rejection -- nothing shipped today can
add text to a binary players already have; the host's roster marker +
REJECT log line remain the operator's view of those.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:42:01 -05:00

640 lines
20 KiB
C++

//###########################################################################
// btl4lobby -- the Steam lobby (BT_STEAM only; compiled only when the gate
// is on -- see CMakeLists.txt). Design: btl4lobby.hpp.
//###########################################################################
#include "btl4lobby.hpp"
#include <windows.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#pragma pack(push, 8)
#include "steam/steam_api.h"
#include "steam/isteammatchmaking.h"
#include <btversion.h> // BUILD GATE (#108 confound): exact-build lobbies
#pragma pack(pop)
//
// The engine-side transport surface (munga_engine).
//
extern int BTSteamNet_Install();
extern int BTSteamNet_Active();
extern unsigned long long BTSteamNet_MySteamID();
static FILE *lobbyLogFile = NULL;
static void
LobbyLog(const char *format, ...)
{
if (lobbyLogFile == NULL)
{
// Same target as MarshalLog (btl4console.cpp): the per-day log whose
// resolved name btl4main pinned into BT_LOG, so lobby lines land in the
// one file players are asked for. Old filename kept as the fallback.
const char *day_log = getenv("BT_LOG");
lobbyLogFile = fopen((day_log && *day_log) ? day_log : "marshal.log", "at");
if (lobbyLogFile == NULL)
{
return;
}
}
va_list arguments;
va_start(arguments, format);
fprintf(lobbyLogFile, "[lobby] ");
vfprintf(lobbyLogFile, format, arguments);
fprintf(lobbyLogFile, "\n");
fflush(lobbyLogFile);
va_end(arguments);
}
#include <stdarg.h>
//
// A rejection the PLAYER must see goes through LobbyNotice: the same text
// lands in the day log (newlines flattened so the line stays greppable)
// AND in a blocking message box. These paths are synchronous FE flows --
// a bare return here QUITS the exe, and #68 taught us a player bounced
// with only a log line reports "the game just closed".
//
static void
LobbyNotice(const char *format, ...)
{
char text[512];
va_list arguments;
va_start(arguments, format);
_vsnprintf(text, sizeof(text) - 1, format, arguments);
text[sizeof(text) - 1] = 0;
va_end(arguments);
{
char flat[512];
int n = 0;
for (const char *s = text; *s && n < 510; ++s)
{
if (*s == '\n')
{
if (n > 0 && flat[n - 1] == ' ')
continue;
flat[n++] = ' ';
}
else
{
flat[n++] = *s;
}
}
flat[n] = 0;
LobbyLog("NOTICE: %s", flat);
}
WCHAR wide[512];
int n = 0;
for (const char *s = text; *s && n < 511; ++s)
wide[n++] = (WCHAR)*s;
wide[n] = 0;
MessageBoxW(NULL, wide, L"BATTLETECH -- STEAM LOBBY",
MB_OK | MB_ICONWARNING | MB_SETFOREGROUND);
}
//###########################################################################
// Synchronous Steam call-result helper (manual polling -- no callback
// template machinery in this C-style TU).
//###########################################################################
static int
WaitApiCall(SteamAPICall_t call, void *result, int result_size,
int expected_callback_id, int timeout_ms)
{
ISteamUtils *utils = SteamUtils();
for (int waited = 0; waited < timeout_ms; waited += 50)
{
SteamAPI_RunCallbacks();
bool failed = false;
if (utils->IsAPICallCompleted(call, &failed))
{
if (failed)
{
return -1;
}
return utils->GetAPICallResult(call, result, result_size,
expected_callback_id, &failed) && !failed ? 0 : -1;
}
Sleep(50);
}
return -1;
}
//###########################################################################
// Member data
//###########################################################################
static CSteamID currentLobby;
static void
PublishSelf(const char *pilot_name, const char *vehicle, const char *color,
const char *experience)
{
//
// Identity is implicit (the member's SteamID); the roster TOKENS are
// minted by the host at GO time -- only the pilot identity publishes.
//
ISteamMatchmaking *matchmaking = SteamMatchmaking();
matchmaking->SetLobbyMemberData(currentLobby, "nm", pilot_name);
matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle);
matchmaking->SetLobbyMemberData(currentLobby, "cl", color);
matchmaking->SetLobbyMemberData(currentLobby, "xp", experience);
// BUILD GATE, member half: publish our exact build so the HOST can
// reject mismatches at GO time. This is what catches OLD exes -- they
// predate the joiner-side lobby filter, but they can't fake a "bv" key
// they never set.
matchmaking->SetLobbyMemberData(currentLobby, "bv", BT_VERSION_STRING);
}
static int
ReadMember(CSteamID lobby, int index, BTLobbyMember *out)
{
ISteamMatchmaking *matchmaking = SteamMatchmaking();
CSteamID member = matchmaking->GetLobbyMemberByIndex(lobby, index);
const char *published_name = matchmaking->GetLobbyMemberData(lobby, member, "nm");
if (published_name == NULL || published_name[0] == 0)
{
return -1; // not published yet
}
memset(out, 0, sizeof(*out));
out->steamID = member.ConvertToUint64();
out->consolePort = 1501; // token ports (see L4STEAMNET token table)
out->gamePort = 1502;
strncpy(out->name,
matchmaking->GetLobbyMemberData(lobby, member, "nm"),
sizeof(out->name) - 1);
strncpy(out->vehicle,
matchmaking->GetLobbyMemberData(lobby, member, "vh"),
sizeof(out->vehicle) - 1);
strncpy(out->color,
matchmaking->GetLobbyMemberData(lobby, member, "cl"),
sizeof(out->color) - 1);
{
// #116: experience rides the lobby too -- absent (an older build in
// the lobby) leaves it empty and the egg writer's "veteran" fallback
// applies, same as before this field existed.
const char *xp = matchmaking->GetLobbyMemberData(lobby, member, "xp");
if (xp != NULL)
strncpy(out->experience, xp, sizeof(out->experience) - 1);
}
out->isSelf = (member == SteamUser()->GetSteamID());
if (out->name[0] == 0)
{
strcpy(out->name, "Pilot");
}
return 0;
}
//###########################################################################
// The room screen -- the FE's green-on-black style; lists members, host
// ENTER = GO, ESC = cancel.
//###########################################################################
static int roomIsHost = 0;
static int roomResult = 0; // 0 pending, 1 go, -1 cancel
static RECT roomLaunchRect = { 140, 250, 420, 296 };
static RECT roomLeaveRect = { 140, 306, 300, 336 };
static void
PaintRoom(HWND window)
{
PAINTSTRUCT paint;
HDC dc = BeginPaint(window, &paint);
RECT client;
GetClientRect(window, &client);
HBRUSH background = CreateSolidBrush(RGB(4, 12, 4));
FillRect(dc, &client, background);
DeleteObject(background);
SetBkMode(dc, TRANSPARENT);
HFONT font = CreateFontW(-16, 0, 0, 0, FW_NORMAL, 0, 0, 0,
DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, FIXED_PITCH, L"Consolas");
HFONT old_font = (HFONT)SelectObject(dc, font);
SetTextColor(dc, RGB(90, 255, 90));
//
// The RP412 room look (RP_L4/RPL4LOBBY.cpp): pilot roster with
// loadouts, a framed LAUNCH button for the owner, LEAVE for all.
//
WCHAR line[160];
int y = 16;
wsprintfW(line, L"STEAM LOBBY -- %s",
roomIsHost ? L"YOU ARE HOSTING" : L"WAITING FOR THE HOST");
RECT row = { 24, y, client.right - 24, y + 26 };
DrawTextW(dc, line, -1, &row, DT_LEFT | DT_TOP | DT_SINGLELINE);
y += 44;
ISteamMatchmaking *matchmaking = SteamMatchmaking();
int count = matchmaking->GetNumLobbyMembers(currentLobby);
for (int i = 0; i < count && i < 8; ++i)
{
BTLobbyMember member;
WCHAR name[120];
if (ReadMember(currentLobby, i, &member) == 0)
{
int n = 0;
for (const char *s = member.name; *s && n < 60; ++s) name[n++] = (WCHAR)*s;
name[n] = 0;
// BUILD GATE: flag mismatched members in the roster so the host
// sees WHO won't launch before pressing GO (the log alone left
// the operator guessing on field nights).
const char *bv = matchmaking->GetLobbyMemberData(currentLobby,
matchmaking->GetLobbyMemberByIndex(currentLobby, i), "bv");
int build_ok = (bv != NULL && strcmp(bv, BT_VERSION_STRING) == 0);
wsprintfW(line, L" %d. %-16s %hs, %hs%s%s", i + 1, name,
member.vehicle[0] ? member.vehicle : "mech",
member.color[0] ? member.color : "-",
member.isSelf ? L" (you)" : L"",
build_ok ? L"" : L" [WRONG BUILD -- WILL NOT LAUNCH]");
}
else
{
wsprintfW(line, L" %d. (joining...)", i + 1);
}
RECT member_row = { 24, y, client.right - 24, y + 24 };
DrawTextW(dc, line, -1, &member_row, DT_LEFT | DT_TOP | DT_SINGLELINE);
y += 26;
}
//
// Framed buttons (hit rects shared with the WndProc).
//
HBRUSH frame = CreateSolidBrush(RGB(90, 255, 90));
if (roomIsHost)
{
FrameRect(dc, &roomLaunchRect, frame);
DrawTextW(dc, L"L A U N C H M I S S I O N", -1, &roomLaunchRect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
}
FrameRect(dc, &roomLeaveRect, frame);
DrawTextW(dc, L"LEAVE LOBBY", -1, &roomLeaveRect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
DeleteObject(frame);
SelectObject(dc, old_font);
DeleteObject(font);
EndPaint(window, &paint);
}
static LRESULT CALLBACK
RoomWndProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam)
{
switch (message)
{
case WM_PAINT:
PaintRoom(window);
return 0;
case WM_TIMER:
SteamAPI_RunCallbacks();
//
// Members leave when the host signals GO.
//
if (!roomIsHost)
{
const char *go = SteamMatchmaking()->GetLobbyData(currentLobby, "btl4go");
if (go != NULL && go[0] == '1')
{
roomResult = 1;
}
}
InvalidateRect(window, NULL, FALSE);
return 0;
case WM_KEYDOWN:
if (wparam == VK_RETURN && roomIsHost)
{
roomResult = 1;
}
else if (wparam == VK_ESCAPE)
{
roomResult = -1;
}
return 0;
case WM_LBUTTONDOWN:
{
int x = (int)(short)LOWORD(lparam);
int y = (int)(short)HIWORD(lparam);
if (roomIsHost &&
x >= roomLaunchRect.left && x < roomLaunchRect.right &&
y >= roomLaunchRect.top && y < roomLaunchRect.bottom)
{
roomResult = 1;
}
else if (x >= roomLeaveRect.left && x < roomLeaveRect.right &&
y >= roomLeaveRect.top && y < roomLeaveRect.bottom)
{
roomResult = -1;
}
}
return 0;
case WM_CLOSE:
roomResult = -1;
return 0;
}
return DefWindowProcW(window, message, wparam, lparam);
}
static int
RunRoom(int is_host)
{
roomIsHost = is_host;
roomResult = 0;
WNDCLASSW window_class;
memset(&window_class, 0, sizeof(window_class));
window_class.lpfnWndProc = RoomWndProc;
window_class.hInstance = GetModuleHandleW(NULL);
window_class.hCursor = LoadCursorW(NULL, (LPCWSTR)IDC_ARROW);
window_class.lpszClassName = L"BTLobbyRoomWnd";
RegisterClassW(&window_class);
HWND window = CreateWindowW(
L"BTLobbyRoomWnd", L"BattleTech - Steam Lobby",
WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU,
160, 160, 560, 360,
NULL, NULL, GetModuleHandleW(NULL), NULL);
if (window == NULL)
{
return -1;
}
SetTimer(window, 1, 250, NULL);
ShowWindow(window, SW_SHOW);
SetForegroundWindow(window);
MSG message;
while (roomResult == 0 && GetMessageW(&message, NULL, 0, 0))
{
TranslateMessage(&message);
DispatchMessageW(&message);
}
KillTimer(window, 1);
DestroyWindow(window);
return (roomResult == 1) ? 0 : 1;
}
//###########################################################################
// Host / member entries
//###########################################################################
int
BTLobby_HostAndRoom(
const char *pilot_name, const char *vehicle, const char *color,
const char *experience,
BTLobbyRoster *roster_out,
char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity)
{
memset(roster_out, 0, sizeof(*roster_out));
if (BTSteamNet_Install() != 0 || !BTSteamNet_Active())
{
LobbyLog("host: Steam transport unavailable");
return -1;
}
SteamAPICall_t call = SteamMatchmaking()->CreateLobby(k_ELobbyTypePublic, 8);
LobbyCreated_t created;
memset(&created, 0, sizeof(created));
if (WaitApiCall(call, &created, sizeof(created),
LobbyCreated_t::k_iCallback, 15000) != 0 ||
created.m_eResult != k_EResultOK)
{
LobbyLog("host: CreateLobby failed");
return -1;
}
currentLobby = created.m_ulSteamIDLobby;
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4", "1");
// BUILD GATE: stamp the host's exact build on the lobby. Joiners filter
// on equality (below) and verify after entry -- a mixed-build lobby
// silently corrupts raw-struct replication (the night-9 ghost confound:
// one stale-zip player desyncs one connection's stream). Same build or
// no entry.
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4ver", BT_VERSION_STRING);
PublishSelf(pilot_name, vehicle, color, experience);
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
if (RunRoom(1) != 0)
{
SteamMatchmaking()->LeaveLobby(currentLobby);
return 1;
}
//
// GO: snapshot the roster SELF FIRST (the egg's pilot order defines
// the connect topology; the host must be pilot 1), MINT the tokens,
// publish the token map, then signal.
//
ISteamMatchmaking *matchmaking = SteamMatchmaking();
int count = matchmaking->GetNumLobbyMembers(currentLobby);
for (int pass = 0; pass < 2; ++pass)
{
for (int i = 0; i < count && roster_out->memberCount < 8; ++i)
{
BTLobbyMember member;
if (ReadMember(currentLobby, i, &member) != 0)
{
continue;
}
if (!member.isSelf)
{
// BUILD GATE, host half: a member on a different zip (or an
// exe old enough to have no "bv" key at all) gets NO token.
// Omitted from btl4map, their client -- old builds included,
// the code predates the gate -- hits its own "the host's map
// is missing us" path and fails the join cleanly instead of
// desyncing the session (#108's stale-zip confound).
const char *bv = matchmaking->GetLobbyMemberData(
currentLobby, CSteamID(member.steamID), "bv");
if (bv == NULL || strcmp(bv, BT_VERSION_STRING) != 0)
{
LobbyLog("host: REJECT %s at GO -- their build [%s], "
"ours %s (no token minted; their join will fail)",
member.name, (bv && *bv) ? bv : "pre-gate/unknown",
BT_VERSION_STRING);
continue;
}
}
if ((pass == 0) == (member.isSelf != 0))
{
roster_out->members[roster_out->memberCount++] = member;
}
}
}
steam_map_out[0] = 0;
my_token_out[0] = 0;
for (int m = 0; m < roster_out->memberCount; ++m)
{
BTLobbyMember &member = roster_out->members[m];
sprintf(member.fakeAddress, "169.254.77.%d", m + 1);
char entry[96];
sprintf(entry, "%s%s=%llu", (m > 0) ? ";" : "",
member.fakeAddress, member.steamID);
if ((int)(strlen(steam_map_out) + strlen(entry) + 1)
< steam_map_capacity)
{
strcat(steam_map_out, entry);
}
if (member.isSelf)
{
strncpy(my_token_out, member.fakeAddress, my_token_capacity - 1);
my_token_out[my_token_capacity - 1] = 0;
}
}
matchmaking->SetLobbyData(currentLobby, "btl4map", steam_map_out);
matchmaking->SetLobbyData(currentLobby, "btl4go", "1");
SteamAPI_RunCallbacks();
LobbyLog("host: GO with %d member(s), map [%s]",
roster_out->memberCount, steam_map_out);
//
// The lobby will not survive the relaunch (documented); leave now so
// members do not see a ghost host.
//
Sleep(1500); // let the GO propagate
SteamMatchmaking()->LeaveLobby(currentLobby);
return 0;
}
int
BTLobby_JoinAndWait(
const char *pilot_name, const char *vehicle, const char *color,
const char *experience,
char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity)
{
if (BTSteamNet_Install() != 0 || !BTSteamNet_Active())
{
LobbyNotice("STEAM UNAVAILABLE\n\n"
"Could not reach Steam.\n"
"Is Steam running and logged in?");
return -1;
}
ISteamMatchmaking *matchmaking = SteamMatchmaking();
matchmaking->AddRequestLobbyListStringFilter(
"btl4", "1", k_ELobbyComparisonEqual);
// BUILD GATE: only lobbies stamped with OUR exact build are visible. A
// stale-zip player finds nothing -- and the log names the build so the
// "no lobby found" report is self-diagnosing (#68's silent-exit lesson).
matchmaking->AddRequestLobbyListStringFilter(
"btl4ver", BT_VERSION_STRING, k_ELobbyComparisonEqual);
SteamAPICall_t call = matchmaking->RequestLobbyList();
LobbyMatchList_t match_list;
memset(&match_list, 0, sizeof(match_list));
if (WaitApiCall(call, &match_list, sizeof(match_list),
LobbyMatchList_t::k_iCallback, 15000) != 0 ||
match_list.m_nLobbiesMatching == 0)
{
//
// Nothing on OUR build. Probe once WITHOUT the version filter so
// the rejection is specific: "the host is on a different zip"
// beats "no lobby" when one is in fact up. Lobby data rides back
// with the list result, readable without joining. (Filters only
// apply to the next request, so the probe re-adds the game key.)
//
matchmaking->AddRequestLobbyListStringFilter(
"btl4", "1", k_ELobbyComparisonEqual);
call = matchmaking->RequestLobbyList();
memset(&match_list, 0, sizeof(match_list));
if (WaitApiCall(call, &match_list, sizeof(match_list),
LobbyMatchList_t::k_iCallback, 8000) == 0 &&
match_list.m_nLobbiesMatching > 0)
{
const char *host_ver = matchmaking->GetLobbyData(
matchmaking->GetLobbyByIndex(0), "btl4ver");
LobbyNotice("BUILD MISMATCH\n\n"
"A lobby is up, but the host runs build %s\n"
"and this machine runs build %s.\n\n"
"Everyone must run the same zip to play together.",
(host_ver && *host_ver) ? host_ver : "(an older build)",
BT_VERSION_STRING);
}
else
{
LobbyNotice("NO LOBBY FOUND\n\n"
"No BattleTech lobby is up right now.\n"
"(This machine runs build %s.)", BT_VERSION_STRING);
}
return -1;
}
CSteamID lobby = matchmaking->GetLobbyByIndex(0);
call = matchmaking->JoinLobby(lobby);
LobbyEnter_t entered;
memset(&entered, 0, sizeof(entered));
if (WaitApiCall(call, &entered, sizeof(entered),
LobbyEnter_t::k_iCallback, 15000) != 0 ||
entered.m_EChatRoomEnterResponse != k_EChatRoomEnterResponseSuccess)
{
LobbyLog("join: JoinLobby failed");
return -1;
}
currentLobby = lobby;
// BUILD GATE belt-and-suspenders: the list filter covers discovery, but
// verify the entered lobby too (covers invite/direct joins and any
// filter drift). Mismatch = leave loudly, never play version-skewed.
{
const char *host_ver = matchmaking->GetLobbyData(currentLobby, "btl4ver");
if (host_ver == NULL || strcmp(host_ver, BT_VERSION_STRING) != 0)
{
matchmaking->LeaveLobby(currentLobby);
currentLobby = CSteamID();
LobbyNotice("BUILD MISMATCH\n\n"
"The host runs build %s\n"
"and this machine runs build %s.\n\n"
"Everyone must run the same zip to play together.",
(host_ver && *host_ver) ? host_ver : "(an older build)",
BT_VERSION_STRING);
return -1;
}
}
PublishSelf(pilot_name, vehicle, color, experience);
LobbyLog("join: in lobby, waiting for GO");
int result = RunRoom(0);
if (result == 0)
{
//
// GO: read the token map, find my own token by my SteamID.
//
const char *map_text = matchmaking->GetLobbyData(currentLobby, "btl4map");
steam_map_out[0] = 0;
my_token_out[0] = 0;
if (map_text != NULL)
{
strncpy(steam_map_out, map_text, steam_map_capacity - 1);
steam_map_out[steam_map_capacity - 1] = 0;
char needle[32];
sprintf(needle, "=%llu", BTSteamNet_MySteamID());
char map_copy[512];
strncpy(map_copy, map_text, sizeof(map_copy) - 1);
map_copy[sizeof(map_copy) - 1] = 0;
char *cursor = strtok(map_copy, ";");
while (cursor != NULL)
{
char *hit = strstr(cursor, needle);
if (hit != NULL && hit[strlen(needle)] == 0)
{
*hit = 0;
strncpy(my_token_out, cursor, my_token_capacity - 1);
my_token_out[my_token_capacity - 1] = 0;
}
cursor = strtok(NULL, ";");
}
}
LobbyLog("join: my token [%s], map [%s]", my_token_out, steam_map_out);
if (my_token_out[0] == 0)
{
// The host launched without us -- no seat in the mission map.
// (Same-build clients can only hit this via a full lobby or a
// publish race now; version skew is gated before entry.)
LobbyNotice("LEFT BEHIND\n\n"
"The host launched the mission without this machine\n"
"(no seat in the mission map).\n\n"
"Rejoin on the next launch. If this repeats, compare\n"
"builds: this machine runs %s.", BT_VERSION_STRING);
result = -1;
}
}
SteamMatchmaking()->LeaveLobby(currentLobby);
return result;
}