Net: Steam lobby (btl4lobby) + launch-mode wiring (Workstream C.2)

Port RP412's RPL4LOBBY to BT: an ISteamMatchmaking room stands in for the
arcade Site-Management screen. Compiled both gates -- stubs without
BT412_STEAM, the full room under it (both configs build + link clean).

Lobby (game/reconstructed/btl4lobby.*):
- Room screen (green-on-black, like the menu), owner = console.
- Member data: FakeIP + fake console/game ports + persona + mech/color/badge
  (ip/cp/gp/nm/vh/cl/bd keys).
- Nonced "go" launch roster -> SteamNetTransport_RegisterPeer for every peer.
- Push/PullRaceResults over lobby data rebuild the shared score sheet.

Front end (btl4fe.*): HOST/JOIN buttons when BTLobby_Available() (Steam
transport up); the menu loop exits on a steam action and BTFrontEnd_Run
routes the lobby outcome -- host builds the egg with every member as a
[pilots] mesh entry (BTFrontEnd_SetHostedPilots), member returns launch
mode 2. Results screen prefers the marshal's collated result names.

Marshal (btl4console.*): add GetResultName / ClearResults / InjectResult so
the lobby owner can refill the sheet from the collated wire scores; Result
gains a name field.

WinMain (btl4main.cpp): install the Steam transport on BT412STEAM env
(before the front end, so the lobby is offered); branch on
BTFrontEnd_LastLaunchMode() -- host owns the marshal clock + joins the mesh,
member enters as a network pod on :1501 (SetNetworkCommonFlatAddress +
gConsoleLossEndsMission); Push (host) / Pull (member) results, then relaunch.

CMake: btl4lobby.cpp joins bt410_l4; that lib gets BT412_STEAM + the
Steamworks include under the gate. build-steam/ gitignored.

Deferred (untestable here -- needs Steam + multiple machines): the
host->member wire egg-feed marshal (InstallNetworkRace); a member currently
waits for a console connection nothing supplies, so a live host+member race
is blocked on it. The lobby object does not survive the per-mission
relaunch. docs/STEAM-3-MACHINE-TEST.md for BT not yet authored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-16 23:18:36 -05:00
co-authored by Claude Opus 4.8
parent 2f5870e486
commit a2949166ea
12 changed files with 769 additions and 31 deletions
+56 -5
View File
@@ -368,6 +368,21 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
std::cout << "BattleTech v4.10 (reconstructed port)" << std::endl << std::flush;
// STEAM NETWORKING (BT412_STEAM build + BT412STEAM env): bring the Steam
// transport up BEFORE the front end so the lobby is available (its HOST/JOIN
// buttons appear only when a FakeIP was allocated) and the race mesh routes
// over SDR/FakeIP. Any failure silently leaves the process on Winsock TCP.
#ifdef BT412_STEAM
if (getenv("BT412STEAM") != NULL && atoi(getenv("BT412STEAM")) != 0)
{
extern Logical SteamNetTransport_Install();
if (SteamNetTransport_Install())
std::cout << "[steam] transport up -- lobby available" << std::endl << std::flush;
else
std::cout << "[steam] transport unavailable -- TCP fallback" << std::endl << std::flush;
}
#endif
// CPU pin (timing stability). BT_AFFINITY overrides the mask; =0 disables --
// required for multi-instance runs (two instances pinned to core 0 starve
// each other, and the L4NET connect-retry loop busy-waits).
@@ -530,13 +545,17 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
//=======================================================================//
extern int BTFrontEnd_Run();
extern int BTFrontEnd_LastMissionSeconds();
extern int BTFrontEnd_LastLaunchMode();
extern void BTFrontEnd_ShowResults();
extern void BTLocalConsole_Install(int mission_seconds);
extern int BTLocalConsole_MissionCompleted();
extern void BTLobby_PushRaceResults();
extern void BTLobby_PullRaceResults();
int launch_mode = 0; // 0 solo, 1 lobby host, 2 lobby member
if (front_end_mode)
{
ShowWindow(hWnd, SW_HIDE); // the menu is its own window
ShowWindow(hWnd, SW_HIDE); // the menu (and lobby room) are their own windows
if (!BTFrontEnd_Run())
{
std::cout << "[frontend] menu closed -- exiting" << std::endl << std::flush;
@@ -545,12 +564,33 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
}
ShowWindow(hWnd, SW_SHOW);
launch_mode = BTFrontEnd_LastLaunchMode();
int secs = BTFrontEnd_LastMissionSeconds();
const char *ov = getenv("BT_MISSION_SECONDS");
if (ov != 0) secs = atoi(ov);
BTLocalConsole_Install(secs);
std::cout << "[frontend] menu LAUNCH -> marshal armed (" << secs << "s)"
<< std::endl << std::flush;
if (launch_mode == 2)
{
// LOBBY MEMBER: no local egg -- the mission arrives over the wire
// from the host (the lobby already registered the mesh peers).
// Become a network pod on the session port; the host's marshal owns
// the clock, and console-loss ends our mission when the host stops.
L4Application::SetNetworkCommonFlatAddress(1501);
gConsoleLossEndsMission = True;
std::cout << "[frontend] lobby MEMBER -> network pod on :1501"
<< std::endl << std::flush;
}
else
{
// SOLO or LOBBY HOST: the egg was built locally and the in-process
// marshal owns the mission clock (the host is also the console). A
// host additionally joins the mesh on the session port.
if (launch_mode == 1)
L4Application::SetNetworkCommonFlatAddress(1501);
BTLocalConsole_Install(secs);
std::cout << "[frontend] menu LAUNCH (" << (launch_mode == 1 ? "host" : "solo")
<< ") -> marshal armed (" << secs << "s)" << std::endl << std::flush;
}
}
{
@@ -606,8 +646,19 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
// restarted the pod per mission. A `-egg`/`-net` pass, or a mission that
// ended some other way (window closed), just exits.
//-------------------------------------------------------------------//
if (front_end_mode && BTLocalConsole_MissionCompleted() && IsWindow(hWnd))
// The mission ran (not the menu-closed early return) if we front-end-moded
// and the window still lives (a user window-close destroys hWnd -> quit, no
// relaunch). Solo/host end via the local marshal; a member ends when the
// host stops the race (console-loss), so it has no local completion flag.
bool mission_ran = front_end_mode && IsWindow(hWnd)
&& (launch_mode == 2 || BTLocalConsole_MissionCompleted());
if (mission_ran)
{
// Distribute (host) or collect (member) the shared score sheet over the
// lobby so every pod shows the same board; solo needs neither.
if (launch_mode == 1) BTLobby_PushRaceResults();
else if (launch_mode == 2) BTLobby_PullRaceResults();
// Scoreboard first (kills/deaths snapshotted at the stop), then relaunch.
ShowWindow(hWnd, SW_HIDE);
BTFrontEnd_ShowResults();
+1
View File
@@ -0,0 +1 @@
#include "../../engine/MUNGA_L4/L4STEAMTRANSPORT.h"
+31 -1
View File
@@ -9,6 +9,8 @@
#include <bt.hpp> // Application, application, DEBUG_STREAM
#pragma hdrstop
#include <string.h> // strncpy (result names from the wire)
#include "appmsg.hpp" // full Application::StopMissionMessage definition
#include "appmgr.hpp" // gPerFrameHook
#include "btl4console.hpp"
@@ -31,7 +33,7 @@ namespace
// Final scores snapshotted at the stop (roster order; slot 0 = local).
enum { maxResults = 8 };
struct Result { int kills, deaths; };
struct Result { int kills, deaths; char name[24]; };
Result gResults[maxResults];
int gResultCount = 0;
@@ -45,6 +47,7 @@ namespace
break; // past the last occupied roster slot
gResults[gResultCount].kills = BTPilotKills(pilot);
gResults[gResultCount].deaths = BTPilotDeaths(pilot);
gResults[gResultCount].name[0] = 0; // filled by the lobby (owner) if networked
++gResultCount;
}
DEBUG_STREAM << "[marshal] snapshot " << gResultCount << " pilot score(s)\n" << std::flush;
@@ -136,6 +139,33 @@ int BTLocalConsole_GetResult(int index, int *kills, int *deaths)
return 1;
}
const char *BTLocalConsole_GetResultName(int index)
{
if (index < 0 || index >= gResultCount)
return "";
return gResults[index].name;
}
// The lobby rebuilds the sheet from wire data (owner collates every pod's
// scores) before showing the shared results, so it needs to clear + refill.
void BTLocalConsole_ClearResults()
{
gResultCount = 0;
}
void BTLocalConsole_InjectResult(int slot, int kills, int deaths, const char *name)
{
if (slot < 0 || slot >= maxResults)
return;
gResults[slot].kills = kills;
gResults[slot].deaths = deaths;
gResults[slot].name[0] = 0;
if (name) { strncpy(gResults[slot].name, name, sizeof(gResults[slot].name) - 1);
gResults[slot].name[sizeof(gResults[slot].name) - 1] = 0; }
if (slot >= gResultCount)
gResultCount = slot + 1;
}
void BTLocalConsole_Uninstall()
{
gPerFrameHook = 0;
+6
View File
@@ -24,6 +24,12 @@ int BTLocalConsole_MissionCompleted();
// Final scores snapshotted at the stop (roster order; slot 0 = local pilot).
int BTLocalConsole_ResultCount();
int BTLocalConsole_GetResult(int index, int *kills, int *deaths);
const char *BTLocalConsole_GetResultName(int index);
// Lobby (owner) rebuilds the sheet from the collated wire scores before the
// shared results screen: clear, then inject each pod's name/kills/deaths.
void BTLocalConsole_ClearResults();
void BTLocalConsole_InjectResult(int slot, int kills, int deaths, const char *name);
// Drop the marshal + clear the per-frame hook.
void BTLocalConsole_Uninstall();
+101 -7
View File
@@ -18,11 +18,42 @@
#include <string.h>
#include "btl4fe.hpp"
#include "btl4lobby.hpp" // BTLobby_Host/Join + LobbyOutcome
#include "l4app.hpp" // L4Application::SetEggNotationFileName
// Mission length (seconds) chosen on the last LAUNCH; WinMain arms the marshal.
static int gLastMissionSeconds = 300;
static char gLastPilotName[24] = "Pilot";
static char gLastVehicle[16] = "bhk1";
static char gLastColor[16] = "White";
static char gLastBadge[8] = "VGL";
// Lobby-fed hosted pilots (the owner's egg gains one [pilots] entry each).
static FEHostedPilot gHostedPilots[8];
static int gHostedPilotCount = 0;
static char gHostedOwnerAddress[64] = "";
// Launch mode from the last LAUNCH: 0 single, 1 host (lobby), 2 member (lobby).
static int gLastLaunchMode = 0;
void BTFrontEnd_GetLoadout(char *vehicle, char *color, char *badge)
{
strcpy(vehicle, gLastVehicle);
strcpy(color, gLastColor);
strcpy(badge, gLastBadge);
}
void BTFrontEnd_SetHostedPilots(const char *owner_address,
const FEHostedPilot *pilots, int count)
{
if (owner_address == 0 || count <= 0) { gHostedPilotCount = 0; gHostedOwnerAddress[0] = '\0'; return; }
strncpy(gHostedOwnerAddress, owner_address, sizeof(gHostedOwnerAddress) - 1);
if (count > 8) count = 8;
for (int i = 0; i < count; ++i) gHostedPilots[i] = pilots[i];
gHostedPilotCount = count;
}
int BTFrontEnd_LastLaunchMode() { return gLastLaunchMode; }
//---------------------------------------------------------------------------//
// Catalogs -- the BattleTech content the console's RPConfig.xml named.
@@ -370,7 +401,7 @@ const LenEntry kMenuLengths[] = { { 180,"3:00" }, { 300,"5:00" }, { 600,"10:00"
#define MENU_COUNT(t) ((int)(sizeof(t)/sizeof((t)[0])))
enum MenuGroup { GMap=0, GMech, GColor, GTime, GWeather, GLength, GLaunch, GCount };
enum MenuGroup { GMap=0, GMech, GColor, GTime, GWeather, GLength, GLaunch, GHost, GJoin, GCount };
const char *GroupTitle(int g) {
switch (g) {
@@ -393,6 +424,8 @@ const char *ItemName(int g, int i) {
case GColor: return kMenuColors[i].name; case GTime: return kMenuTimes[i].name;
case GWeather: return kMenuWeather[i].name; case GLength: return kMenuLengths[i].name;
case GLaunch: return "L A U N C H";
case GHost: return "HOST STEAM RACE";
case GJoin: return "JOIN STEAM RACE";
}
return "";
}
@@ -406,6 +439,7 @@ struct MenuState {
HFONT textFont, titleFont;
HBRUSH editBrush;
int launched, closed;
int steamAction; // 0 none, 1 host, 2 join
};
MenuState *gMenu = NULL;
@@ -446,6 +480,20 @@ void LayoutMenu(MenuState *m, int cw, int ch) {
launch->rect.left = col3; launch->rect.top = ch - 3 * row_h;
launch->rect.right = col3 + col_w; launch->rect.bottom = ch - row_h;
// Steam HOST / JOIN buttons (only when the Steam transport is up).
// (BTLobby_Available is declared globally in btl4lobby.hpp -- a block-scope
// `extern` HERE would bind it to this anonymous namespace instead.)
if (BTLobby_Available()) {
MenuItem *host = &m->items[m->itemCount++];
host->group = GHost; host->index = 0;
host->rect.left = col3; host->rect.top = ch - 6 * row_h;
host->rect.right = col3 + col_w; host->rect.bottom = ch - 5 * row_h;
MenuItem *join = &m->items[m->itemCount++];
join->group = GJoin; join->index = 0;
join->rect.left = col3; join->rect.top = ch - (9 * row_h) / 2;
join->rect.right = col3 + col_w; join->rect.bottom = ch - (7 * row_h) / 2;
}
if (m->nameEdit != NULL)
MoveWindow(m->nameEdit, col3, top - row_h / 4, col_w, row_h, TRUE);
}
@@ -476,7 +524,7 @@ void PaintMenu(MenuState *m) {
DrawTextA(mem, GroupTitle(it->group), -1, &h, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
RECT row = it->rect;
if (it->group == GLaunch) {
if (it->group >= GLaunch) { // LAUNCH / HOST / JOIN -- framed buttons
HBRUSH b = CreateSolidBrush(kGreenBright); FrameRect(mem, &row, b); DeleteObject(b);
SetTextColor(mem, kGreenBright);
DrawTextA(mem, ItemName(it->group, it->index), -1, &row, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
@@ -509,6 +557,9 @@ void MenuClick(MenuState *m, int x, int y) {
if (x >= it->rect.left && x < it->rect.right && y >= it->rect.top && y < it->rect.bottom) {
if (it->group == GLaunch) {
m->launched = 1; PostMessageA(m->window, WM_NULL, 0, 0);
} else if (it->group == GHost || it->group == GJoin) {
m->steamAction = (it->group == GHost) ? 1 : 2;
PostMessageA(m->window, WM_NULL, 0, 0);
} else {
m->selection[it->group] = it->index;
InvalidateRect(m->window, NULL, FALSE);
@@ -595,7 +646,7 @@ int BTFrontEnd_Run()
SetForegroundWindow(m.window);
MSG msg;
while (!m.launched && !m.closed) {
while (!m.launched && !m.closed && !m.steamAction) {
BOOL r = GetMessageA(&msg, NULL, 0, 0);
if (r <= 0) { m.closed = 1; break; }
// Enter = LAUNCH, Escape = quit -- intercepted at the loop level so
@@ -615,17 +666,36 @@ int BTFrontEnd_Run()
GetWindowTextA(m.nameEdit, pilotName, sizeof(pilotName) - 1);
if (pilotName[0] == '\0') strcpy(pilotName, "Pilot");
}
int launched = m.launched, closed = m.closed;
int launched = m.launched, closed = m.closed, steamAction = m.steamAction;
int selMap = m.selection[GMap], selMech = m.selection[GMech], selColor = m.selection[GColor];
int selTime = m.selection[GTime], selWx = m.selection[GWeather], selLen = m.selection[GLength];
// Remember the loadout so the lobby can publish it as member data.
strncpy(gLastPilotName, pilotName, sizeof(gLastPilotName) - 1);
strncpy(gLastVehicle, kMenuMechs[selMech].key, sizeof(gLastVehicle) - 1);
strncpy(gLastColor, kMenuColors[selColor].key, sizeof(gLastColor) - 1);
DestroyWindow(m.window);
DeleteObject(m.textFont); DeleteObject(m.titleFont); DeleteObject(m.editBrush);
gMenu = NULL;
if (closed || !launched)
if (closed)
return 0;
// Steam HOST / JOIN: run the lobby room; act on its outcome.
if (steamAction) {
// BTLobby_Host/Join are declared in btl4lobby.hpp (global scope).
int outcome = (steamAction == 1) ? BTLobby_Host(instance, 0) : BTLobby_Join(instance, 0);
if (outcome == LobbyRoomClosed) return 0;
if (outcome == LobbyLaunchMember) { gLastLaunchMode = 2; return 1; } // -net pod (no egg)
if (outcome == LobbyLaunchHost) { gLastLaunchMode = 1; } // build the egg below
else return BTFrontEnd_Run(); // left the room -> menu again
}
else {
if (!launched) return 0;
gLastLaunchMode = 0;
}
// Build the mission from the selections.
BTFeMission mission;
BTFeMission_Default(&mission);
@@ -634,7 +704,27 @@ int BTFrontEnd_Run()
strcpy(mission.weather, kMenuWeather[selWx].key);
mission.lengthSeconds = kMenuLengths[selLen].seconds;
strcpy(mission.pilots[0].name, pilotName);
strncpy(gLastPilotName, pilotName, sizeof(gLastPilotName) - 1);
// Host (lobby): append every lobby member as a [pilots] entry so the
// owner's egg carries the whole mesh (drop zones one..eight round-robin).
if (gHostedPilotCount > 0) {
static const char *const dz[8] = { "one","two","three","four","five","six","seven","eight" };
for (int i = 0; i < gHostedPilotCount && mission.pilotCount < 8; ++i) {
BTFePilot *p = &mission.pilots[mission.pilotCount];
memset(p, 0, sizeof(*p));
strncpy(p->address, gHostedPilots[i].address, sizeof(p->address)-1);
strncpy(p->name, gHostedPilots[i].name, sizeof(p->name)-1);
strncpy(p->vehicle, gHostedPilots[i].vehicle[0]?gHostedPilots[i].vehicle:"bhk1", sizeof(p->vehicle)-1);
strncpy(p->color, gHostedPilots[i].color[0]?gHostedPilots[i].color:"Red", sizeof(p->color)-1);
strcpy(p->badge, "VGL"); strcpy(p->patch, "Yellow");
strncpy(p->dropzone, dz[mission.pilotCount % 8], sizeof(p->dropzone)-1);
p->bitmapindex = mission.pilotCount + 1; p->advancedDamage = 1; p->loadzones = 1;
mission.pilotCount++;
}
// the owner is pilot 0; its mesh address is the FakeIP:1502
if (gHostedOwnerAddress[0])
sprintf(mission.pilots[0].address, "%s:1502", gHostedOwnerAddress);
}
strcpy(mission.pilots[0].vehicle, kMenuMechs[selMech].key);
strcpy(mission.pilots[0].color, kMenuColors[selColor].key);
@@ -661,6 +751,7 @@ namespace { HWND gResultsWin = NULL; int gResultsDone = 0; }
extern int BTLocalConsole_ResultCount();
extern int BTLocalConsole_GetResult(int index, int *kills, int *deaths);
extern const char *BTLocalConsole_GetResultName(int index);
namespace {
LRESULT CALLBACK ResultsWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
@@ -694,7 +785,10 @@ LRESULT CALLBACK ResultsWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
if (n <= 0) n = 1; // always show the local pilot line
for (int i = 0; i < n; ++i) {
int k = 0, d = 0; BTLocalConsole_GetResult(i, &k, &d);
char name[24]; if (i == 0) strncpy(name, gLastPilotName, sizeof(name)-1), name[sizeof(name)-1]=0;
char name[24];
const char *rn = BTLocalConsole_GetResultName(i); // set when the lobby collated the sheet
if (rn && rn[0]) { strncpy(name, rn, sizeof(name)-1); name[sizeof(name)-1]=0; }
else if (i == 0) { strncpy(name, gLastPilotName, sizeof(name)-1); name[sizeof(name)-1]=0; }
else sprintf(name, "Pilot %d", i + 1);
char kb[8], db[8]; sprintf(kb, "%d", k); sprintf(db, "%d", d);
SetTextColor(mem, i == 0 ? gB : gD);
+20
View File
@@ -76,4 +76,24 @@ int BTFrontEnd_LastMissionSeconds();
// Show the post-mission scoreboard (marshal snapshot) until dismissed.
void BTFrontEnd_ShowResults();
//---------------------------------------------------------------------------//
// Steam lobby glue (used by btl4lobby): the local pilot's loadout the lobby
// publishes as member data, and the hosted-pilot override the lobby feeds so
// the owner's egg carries every member as a pilot.
//---------------------------------------------------------------------------//
struct FEHostedPilot
{
char address[64]; // mesh (game-port) address for [pilots]
char name[32];
char vehicle[24];
char color[16];
char badge[24];
};
void BTFrontEnd_GetLoadout(char *vehicle, char *color, char *badge);
void BTFrontEnd_SetHostedPilots(const char *owner_address,
const FEHostedPilot *pilots, int count);
// The last LAUNCH's mode: 0 single-player, 1 lobby host, 2 lobby member.
int BTFrontEnd_LastLaunchMode();
#endif // BTL4FE_HPP
+432
View File
@@ -0,0 +1,432 @@
//===========================================================================//
// btl4lobby.cpp -- BT412 Steam lobby (see btl4lobby.hpp).
//
// Ported from RP412 RPL4LOBBY: the ISteamMatchmaking room, member loadout
// exchange (FakeIP + ports + persona + mech/color/badge), the launch roster,
// and the post-race score sheet. Compiled to stubs without BT412_STEAM.
//===========================================================================//
#define WIN32_LEAN_AND_MEAN
#define _WIN32_WINNT 0x0500
#define WINVER 0x0500
#include <windows.h>
#include <stdio.h>
#include "btl4lobby.hpp"
#ifndef BT412_STEAM
//---------------------------------------------------------------------------//
// No Steam SDK: the lobby is unavailable; single-player + LAN -net unchanged.
//---------------------------------------------------------------------------//
int BTLobby_Available() { return 0; }
int BTLobby_InRoom() { return 0; }
int BTLobby_Host(void *, void *) { return LobbyRoomLeft; }
int BTLobby_Join(void *, void *) { return LobbyRoomLeft; }
int BTLobby_Room(void *, void *) { return LobbyRoomLeft; }
void BTLobby_PushRaceResults() { }
void BTLobby_PullRaceResults() { }
#else
#include "btl4fe.hpp" // BTFrontEnd_GetLoadout / SetHostedPilots / FEHostedPilot
#include "btl4console.hpp" // result API for the score sheet
#include "l4steamtransport.hpp" // SteamNetTransport_* (via the fwd shim)
#pragma pack(push, 8)
#include "steam/steam_api.h"
#pragma pack(pop)
#include <string.h>
#include <iostream>
// The marshal result API (kills/deaths per roster slot; slot 0 = local).
extern int BTLocalConsole_ResultCount();
extern int BTLocalConsole_GetResult(int index, int *kills, int *deaths);
extern void BTLocalConsole_ClearResults();
extern void BTLocalConsole_InjectResult(int slot, int kills, int deaths, const char *name);
extern const char *BTLocalConsole_GetResultName(int slot);
// (The remote-pod network-race marshal -- collating every pod's scores over the
// wire -- is deferred; the owner publishes its local snapshot for now.)
namespace
{
enum { kMaxLobbyMembers = 8 };
const char kLobbyTagKey[] = "bt412";
const char kGoKey[] = "go";
const char kResultsKey[] = "res";
CSteamID gLobby;
bool gInLobby = false;
int gLastGoNonce = 0;
int gShownResultsNonce = 0;
// async call-result plumbing
bool gCallDone = false, 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 *r, bool io) {
gCallOK = !io && r->m_eResult == k_EResultOK;
gCallLobby = CSteamID(r->m_ulSteamIDLobby); gCallDone = true;
}
void OnList(LobbyMatchList_t *r, bool io) {
gCallOK = !io && r->m_nLobbiesMatching > 0;
if (gCallOK) gCallLobby = SteamMatchmaking()->GetLobbyByIndex(0);
gCallDone = true;
}
void OnEnter(LobbyEnter_t *r, bool io) {
gCallOK = !io && r->m_EChatRoomEnterResponse == k_EChatRoomEnterResponseSuccess;
gCallLobby = CSteamID(r->m_ulSteamIDLobby); gCallDone = true;
}
};
LobbyCalls gCalls;
bool WaitForCall(int timeout_ms) {
DWORD deadline = GetTickCount() + timeout_ms;
while (!gCallDone) {
SteamAPI_RunCallbacks();
if ((LONG)(GetTickCount() - deadline) >= 0) return false;
Sleep(50);
}
return gCallOK;
}
void SanitizeName(const char *in, char *out, int out_size) {
int n = 0;
for (int i = 0; in[i] && 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");
}
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);
char name[32]; SanitizeName(SteamFriends()->GetPersonaName(), name, sizeof(name));
SteamMatchmaking()->SetLobbyMemberData(gLobby, "nm", name);
char vehicle[24], color[16], badge[24];
BTFrontEnd_GetLoadout(vehicle, color, badge);
SteamMatchmaking()->SetLobbyMemberData(gLobby, "vh", vehicle);
SteamMatchmaking()->SetLobbyMemberData(gLobby, "cl", color);
SteamMatchmaking()->SetLobbyMemberData(gLobby, "bd", badge);
}
bool IsOwner() {
return gInLobby && SteamMatchmaking()->GetLobbyOwner(gLobby) == SteamUser()->GetSteamID();
}
struct MemberInfo {
CSteamID id;
char ip[32]; int consolePort, gamePort;
char name[32], vehicle[24], color[16], badge[24];
bool published;
};
int CollectMembers(MemberInfo *members) {
int count = SteamMatchmaking()->GetNumLobbyMembers(gLobby);
if (count > kMaxLobbyMembers) count = kMaxLobbyMembers;
for (int i = 0; i < count; ++i) {
MemberInfo *m = &members[i]; memset(m, 0, sizeof(*m));
m->id = SteamMatchmaking()->GetLobbyMemberByIndex(gLobby, i);
strncpy(m->ip, SteamMatchmaking()->GetLobbyMemberData(gLobby, m->id, "ip"), sizeof(m->ip)-1);
m->consolePort = atoi(SteamMatchmaking()->GetLobbyMemberData(gLobby, m->id, "cp"));
m->gamePort = atoi(SteamMatchmaking()->GetLobbyMemberData(gLobby, m->id, "gp"));
strncpy(m->name, SteamMatchmaking()->GetLobbyMemberData(gLobby, m->id, "nm"), sizeof(m->name)-1);
strncpy(m->vehicle, SteamMatchmaking()->GetLobbyMemberData(gLobby, m->id, "vh"), sizeof(m->vehicle)-1);
strncpy(m->color, SteamMatchmaking()->GetLobbyMemberData(gLobby, m->id, "cl"), sizeof(m->color)-1);
strncpy(m->badge, SteamMatchmaking()->GetLobbyMemberData(gLobby, m->id, "bd"), sizeof(m->badge)-1);
m->published = m->ip[0] && m->consolePort > 0 && m->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 the hosted-race env (BT412HOSTPODS/ADDR/PORT) + the
// hosted-pilot loadouts the front end feeds into the egg.
void PrimeHostedRace(MemberInfo *members, int count) {
static char pods_env[512], addr_env[64], 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 *p = &pilots[pilot_count++]; memset(p, 0, sizeof(*p));
sprintf(p->address, "%s:1502", members[i].ip);
strncpy(p->name, members[i].name, sizeof(p->name)-1);
strncpy(p->vehicle, members[i].vehicle, sizeof(p->vehicle)-1);
strncpy(p->color, members[i].color, sizeof(p->color)-1);
strncpy(p->badge, members[i].badge, sizeof(p->badge)-1);
if (pods[0]) strcat(pods, ",");
strcat(pods, members[i].ip); strcat(pods, ":1501");
}
BTFrontEnd_SetHostedPilots(SteamNetTransport_GetFakeAddressString(), pilots, pilot_count);
sprintf(pods_env, "BT412HOSTPODS=%s", pods); putenv(pods_env);
sprintf(addr_env, "BT412HOSTADDR=%s", SteamNetTransport_GetFakeAddressString()); putenv(addr_env);
strcpy(port_env, "BT412HOSTPORT=1501"); putenv(port_env);
}
//-----------------------------------------------------------------------//
// The room screen (green-on-black, like the menu).
//-----------------------------------------------------------------------//
const COLORREF kGreenBright = RGB(64,255,64), kGreenDim = RGB(24,140,24);
struct RoomState {
HWND window; HFONT textFont, titleFont;
RECT launchRect, leaveRect;
bool launchClicked, leaveClicked, closed;
MemberInfo members[kMaxLobbyMembers]; int memberCount;
};
RoomState *gRoom = NULL;
void PaintRoom(RoomState *room) {
PAINTSTRUCT ps; HDC hdc = BeginPaint(room->window, &ps);
RECT cl; GetClientRect(room->window, &cl);
HDC mem = CreateCompatibleDC(hdc);
HBITMAP surf = CreateCompatibleBitmap(hdc, cl.right, cl.bottom);
HBITMAP old = (HBITMAP) SelectObject(mem, surf);
FillRect(mem, &cl, (HBRUSH) GetStockObject(BLACK_BRUSH)); SetBkMode(mem, TRANSPARENT);
SelectObject(mem, room->titleFont); SetTextColor(mem, kGreenBright);
RECT title = cl; title.top = cl.bottom/28; title.bottom = title.top + cl.bottom/10;
DrawTextA(mem, "STEAM RACE LOBBY", -1, &title, DT_CENTER|DT_TOP|DT_SINGLELINE);
SelectObject(mem, room->textFont);
int row_h = cl.bottom/16, top = cl.bottom/4, left = cl.right/4, right = 3*cl.right/4;
char text[128];
for (int i = 0; i < room->memberCount; ++i) {
MemberInfo *m = &room->members[i];
RECT row; row.left=left; row.right=right; row.top=top+i*row_h; row.bottom=row.top+row_h;
bool owner_row = SteamMatchmaking()->GetLobbyOwner(gLobby) == m->id;
SetTextColor(mem, m->published ? kGreenBright : kGreenDim);
sprintf(text, "%s%s", m->name[0]?m->name:"(joining...)", owner_row?" [HOST]":"");
DrawTextA(mem, text, -1, &row, DT_LEFT|DT_VCENTER|DT_SINGLELINE);
if (m->vehicle[0]) { sprintf(text, "%s / %s", m->vehicle, m->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);
HBRUSH frame = CreateSolidBrush(kGreenBright);
if (IsOwner()) { FrameRect(mem, &room->launchRect, frame); SetTextColor(mem, kGreenBright);
DrawTextA(mem, "L A U N C H R A C 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, cl.right, cl.bottom, mem, 0,0, SRCCOPY);
SelectObject(mem, old); DeleteObject(surf); DeleteDC(mem);
EndPaint(room->window, &ps);
}
LRESULT CALLBACK RoomWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
RoomState *room = gRoom;
switch (msg) {
case WM_PAINT: if (room) { PaintRoom(room); return 0; } break;
case WM_LBUTTONDOWN:
if (room) { POINT pt = {(int)(short)LOWORD(lp),(int)(short)HIWORD(lp)};
if (IsOwner() && PtInRect(&room->launchRect, pt)) room->launchClicked = true;
else if (PtInRect(&room->leaveRect, pt)) room->leaveClicked = true;
return 0; }
break;
case WM_ERASEBKGND: return 1;
}
return DefWindowProcA(hwnd, msg, wp, lp);
}
int RunRoom(HINSTANCE instance, HWND main_window) {
static bool reg = false;
if (!reg) { reg = true;
WNDCLASSA wc; memset(&wc,0,sizeof(wc));
wc.style=CS_HREDRAW|CS_VREDRAW; wc.lpfnWndProc=RoomWndProc; wc.hInstance=instance;
wc.hCursor=LoadCursor(NULL,IDC_ARROW); wc.hbrBackground=(HBRUSH)GetStockObject(BLACK_BRUSH);
wc.lpszClassName="BTLobbyRoom"; RegisterClassA(&wc);
}
int W = 1000, H = 720;
int sx = (GetSystemMetrics(SM_CXSCREEN)-W)/2, sy = (GetSystemMetrics(SM_CYSCREEN)-H)/2;
RoomState room; memset(&room, 0, sizeof(room));
room.window = CreateWindowExA(0, "BTLobbyRoom", "BattleTech",
WS_OVERLAPPEDWINDOW|WS_VISIBLE, sx<0?0:sx, sy<0?0:sy, W, H, NULL, NULL, instance, NULL);
if (room.window == NULL) return LobbyRoomClosed;
RECT cl; GetClientRect(room.window, &cl);
int th = cl.bottom/40; if (th<16) th=16; if (th>24) th=24;
room.textFont = CreateFontA(-(th*3/2),0,0,0,FW_NORMAL,0,0,0,ANSI_CHARSET,OUT_DEFAULT_PRECIS,
CLIP_DEFAULT_PRECIS,ANTIALIASED_QUALITY,DEFAULT_PITCH|FF_MODERN,"Consolas");
room.titleFont = CreateFontA(-(th*2),0,0,0,FW_BOLD,0,0,0,ANSI_CHARSET,OUT_DEFAULT_PRECIS,
CLIP_DEFAULT_PRECIS,ANTIALIASED_QUALITY,DEFAULT_PITCH|FF_MODERN,"Consolas");
int row_h = cl.bottom/36; if (row_h<18) row_h=18; if (row_h>30) row_h=30;
int col_w = cl.right/4;
room.launchRect.left=(cl.right-col_w)/2; room.launchRect.right=room.launchRect.left+col_w;
room.launchRect.top=cl.bottom-6*row_h; room.launchRect.bottom=room.launchRect.top+2*row_h;
room.leaveRect.left=(cl.right-col_w/2)/2; room.leaveRect.right=room.leaveRect.left+col_w/2;
room.leaveRect.top=cl.bottom-3*row_h; room.leaveRect.bottom=cl.bottom-row_h;
gRoom = &room;
PublishMemberData();
SetForegroundWindow(room.window);
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 (room.closed) { outcome = LobbyRoomClosed; break; }
if (room.leaveClicked) {
SteamMatchmaking()->LeaveLobby(gLobby); gInLobby = false;
BTFrontEnd_SetHostedPilots(NULL, NULL, 0);
static char clr[] = "BT412HOSTPODS="; putenv(clr);
outcome = LobbyRoomLeft; break;
}
if (room.launchClicked) {
room.launchClicked = false;
room.memberCount = CollectMembers(room.members);
bool all_pub = true;
for (int i = 0; i < room.memberCount; ++i) if (!room.members[i].published) all_pub = false;
if (all_pub && room.memberCount >= 1) {
++gLastGoNonce;
char go[900]; sprintf(go, "%d:", gLastGoNonce);
for (int i = 0; i < room.memberCount; ++i) {
char e[96]; sprintf(e, "%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(e) < sizeof(go)) strcat(go, e);
}
SteamMatchmaking()->SetLobbyData(gLobby, kGoKey, go);
RegisterRoster(room.members, room.memberCount);
PrimeHostedRace(room.members, room.memberCount);
outcome = LobbyLaunchHost; break;
}
}
if (!IsOwner()) {
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
if (go && go[0]) {
int nonce = atoi(go);
if (nonce > gLastGoNonce) {
gLastGoNonce = nonce;
SteamNetTransport_SetEnginePorts(1501);
const char *c = strchr(go, ':'); c = c ? c+1 : go;
while (*c) {
char ip[32]; int cpp=0, gpp=0; unsigned __int64 sid=0; int n=0;
while (*c && *c!='|' && n<(int)sizeof(ip)-1) ip[n++]=*c++;
ip[n]='\0';
if (*c=='|') { cpp=atoi(++c); while (*c && *c!='|') ++c; }
if (*c=='|') { gpp=atoi(++c); while (*c && *c!='|' && *c!=';') ++c; }
if (*c=='|') sid=_strtoui64(++c, NULL, 10);
while (*c && *c!=';') ++c; if (*c==';') ++c;
if (ip[0] && cpp>0 && gpp>0) SteamNetTransport_RegisterPeer(ip, cpp, gpp, sid);
}
outcome = LobbyLaunchMember; break;
}
}
}
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;
}
} // namespace
//===========================================================================//
// Public API
//===========================================================================//
int BTLobby_Available() { return SteamNetTransport_GetFakeAddressString()[0] != '\0'; }
int BTLobby_InRoom() { return gInLobby ? 1 : 0; }
int BTLobby_Host(void *instance, void *main_window) {
gCallDone = false;
SteamAPICall_t call = SteamMatchmaking()->CreateLobby(k_ELobbyTypePublic, kMaxLobbyMembers);
gCalls.createResult.Set(call, &gCalls, &LobbyCalls::OnCreated);
if (!WaitForCall(15000)) return LobbyRoomLeft;
gLobby = gCallLobby; gInLobby = true; gLastGoNonce = 0;
SteamMatchmaking()->SetLobbyData(gLobby, kLobbyTagKey, "1");
SteamMatchmaking()->SetLobbyData(gLobby, kGoKey, "");
return RunRoom((HINSTANCE)instance, (HWND)main_window);
}
int BTLobby_Join(void *instance, void *main_window) {
gCallDone = false;
SteamMatchmaking()->AddRequestLobbyListStringFilter(kLobbyTagKey, "1", k_ELobbyComparisonEqual);
SteamMatchmaking()->AddRequestLobbyListDistanceFilter(k_ELobbyDistanceFilterWorldwide);
SteamAPICall_t call = SteamMatchmaking()->RequestLobbyList();
gCalls.listResult.Set(call, &gCalls, &LobbyCalls::OnList);
if (!WaitForCall(15000)) return LobbyRoomLeft;
CSteamID target = gCallLobby;
gCallDone = false;
call = SteamMatchmaking()->JoinLobby(target);
gCalls.enterResult.Set(call, &gCalls, &LobbyCalls::OnEnter);
if (!WaitForCall(15000)) return LobbyRoomLeft;
gLobby = gCallLobby; gInLobby = true;
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
gLastGoNonce = go ? atoi(go) : 0;
return RunRoom((HINSTANCE)instance, (HWND)main_window);
}
int BTLobby_Room(void *instance, void *main_window) {
if (!gInLobby) return LobbyRoomLeft;
return RunRoom((HINSTANCE)instance, (HWND)main_window);
}
void BTLobby_PushRaceResults() {
if (!gInLobby || !IsOwner() || BTLocalConsole_ResultCount() == 0) return;
SteamAPI_RunCallbacks();
char sheet[700]; sprintf(sheet, "%d:", gLastGoNonce);
for (int i = 0; i < BTLocalConsole_ResultCount(); ++i) {
int kills = 0, deaths = 0; BTLocalConsole_GetResult(i, &kills, &deaths);
const char *name = BTLocalConsole_GetResultName(i);
char e[96]; sprintf(e, "%d|%d|%d|%.30s;", i, kills, deaths, name ? name : "");
if (strlen(sheet)+strlen(e) < sizeof(sheet)) strcat(sheet, e);
}
SteamMatchmaking()->SetLobbyData(gLobby, kResultsKey, sheet);
gShownResultsNonce = gLastGoNonce;
}
void BTLobby_PullRaceResults() {
if (!gInLobby || IsOwner() || gLastGoNonce == gShownResultsNonce) return;
const char *sheet = NULL; DWORD deadline = GetTickCount() + 8000;
for (;;) {
SteamAPI_RunCallbacks();
sheet = SteamMatchmaking()->GetLobbyData(gLobby, kResultsKey);
if (sheet && sheet[0] && atoi(sheet) == gLastGoNonce) break;
if ((LONG)(GetTickCount() - deadline) >= 0) return;
Sleep(100);
}
gShownResultsNonce = gLastGoNonce;
BTLocalConsole_ClearResults();
const char *c = strchr(sheet, ':'); c = c ? c+1 : sheet;
while (*c) {
int slot = atoi(c), kills = 0, deaths = 0; char name[32] = "";
while (*c && *c!='|' && *c!=';') ++c;
if (*c=='|') { kills=atoi(++c); while (*c && *c!='|' && *c!=';') ++c; }
if (*c=='|') { deaths=atoi(++c); while (*c && *c!='|' && *c!=';') ++c; }
if (*c=='|') { ++c; int n=0; while (*c && *c!=';' && n<(int)sizeof(name)-1) name[n++]=*c++; name[n]='\0'; }
while (*c && *c!=';') ++c; if (*c==';') ++c;
BTLocalConsole_InjectResult(slot, kills, deaths, name);
}
}
#endif // BT412_STEAM
+45
View File
@@ -0,0 +1,45 @@
//===========================================================================//
// btl4lobby.hpp -- BT412 Steam lobby (the internet-multiplayer front door).
//
// An ISteamMatchmaking lobby stands in for the arcade's Site Management screen
// (see context/steamification.md). Every member publishes its FakeIP + fake
// console/game ports + persona + loadout (mech/color/badge) as lobby member
// data; the lobby OWNER is the console. Launching writes a "go" roster into
// lobby data -- each pod registers every peer with the Steam transport and
// enters the race: the owner through the hosted-race marshal (it builds the
// egg and marshals the members over the wire), the members as network pods
// waiting for the owner's console connection. The lobby OBJECT outlives races
// (the single-binary payoff -- after a race everyone lands back in the room).
//
// Compiled to stubs without BT412_STEAM (single-player + LAN -net unaffected).
//===========================================================================//
#ifndef BTL4LOBBY_HPP
#define BTL4LOBBY_HPP
enum BTLobbyOutcome
{
LobbyRoomLeft = 0, // player left the lobby -> back to the menu
LobbyRoomClosed, // window went away -> quit
LobbyLaunchHost, // owner launched: hosted-race path is primed
LobbyLaunchMember // owner launched: enter the race as a network pod
};
// True when the Steam transport is up (the lobby UI is offered in the menu).
int BTLobby_Available();
// True while we sit in a lobby (races return to the room).
int BTLobby_InRoom();
// Create a lobby / find-and-join one, then run the room screen.
int BTLobby_Host(void *instance, void *main_window);
int BTLobby_Join(void *instance, void *main_window);
// Re-enter the room of the lobby we are already in (post-race).
int BTLobby_Room(void *instance, void *main_window);
// Post-race score sheet: the owner publishes its console's results into lobby
// data; members pull them so the same results screen shows everywhere.
void BTLobby_PushRaceResults();
void BTLobby_PullRaceResults();
#endif // BTL4LOBBY_HPP