Menu (btl4fe, modeled on RP412 RP_L4/RPL4FE.cpp): full catalogs as always-visible clickable LISTS in three columns with friendly names -- 8 maps, the canonical 8 certified mechs, colors, times, weather, lengths, modes -- bright/dim green with a selection chevron, double-buffered paint, real EDIT controls for pilot name + LAN peers (green-on-black via WM_CTLCOLOREDIT), framed LAUNCH / HOST STEAM / JOIN STEAM buttons, and the loadout persisted across the per-mission relaunch (fe_last.ini, gitignored implicitly by *.ini? no -- plain file beside the eggs). ENTER/ESC kept. Lobby room (RP412 RPL4LOBBY look): pilot roster with loadouts (mech, color), framed L A U N C H M I S S I O N (owner) + LEAVE LOBBY buttons, clickable. Dist: FLAT layout (the RP412 convention) -- ONE btl4.exe entry point at the folder root beside the content tree (the shape TeslaConsole manages), DLLs + launchers at root, content flattened from git-tracked files. Verified: the staged flat tree boots the Mission Console zero-arg from its own root; the click-driven menu launches a full marshaled mission (row click -> length=60 in the egg, LAUNCH click -> ladder -> 41+ ticks, fe_last.ini written). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
480 lines
13 KiB
C++
480 lines
13 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"
|
|
#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)
|
|
{
|
|
lobbyLogFile = fopen("marshal.log", "at"); // shares the marshal's file
|
|
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>
|
|
|
|
//###########################################################################
|
|
// 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)
|
|
{
|
|
//
|
|
// 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);
|
|
}
|
|
|
|
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);
|
|
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;
|
|
wsprintfW(line, L" %d. %-16s %hs, %hs%s", i + 1, name,
|
|
member.vehicle[0] ? member.vehicle : "mech",
|
|
member.color[0] ? member.color : "-",
|
|
member.isSelf ? L" (you)" : L"");
|
|
}
|
|
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,
|
|
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");
|
|
PublishSelf(pilot_name, vehicle, color);
|
|
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 ((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,
|
|
char *my_token_out, int my_token_capacity,
|
|
char *steam_map_out, int steam_map_capacity)
|
|
{
|
|
if (BTSteamNet_Install() != 0 || !BTSteamNet_Active())
|
|
{
|
|
LobbyLog("join: Steam transport unavailable");
|
|
return -1;
|
|
}
|
|
|
|
ISteamMatchmaking *matchmaking = SteamMatchmaking();
|
|
matchmaking->AddRequestLobbyListStringFilter(
|
|
"btl4", "1", 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)
|
|
{
|
|
LobbyLog("join: no btl4 lobby found");
|
|
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;
|
|
PublishSelf(pilot_name, vehicle, color);
|
|
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)
|
|
{
|
|
result = -1; // the host's map is missing us
|
|
}
|
|
}
|
|
SteamMatchmaking()->LeaveLobby(currentLobby);
|
|
return result;
|
|
}
|