diff --git a/.gitignore b/.gitignore index 2c54099..abc7bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # build output /build/ +/build-steam/ *.obj *.pdb *.ilk diff --git a/CMakeLists.txt b/CMakeLists.txt index ae3f161..ea2af69 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -272,6 +272,7 @@ add_library(bt410_l4 STATIC "game/reconstructed/btl4app.cpp" "game/reconstructed/btl4fe.cpp" "game/reconstructed/btl4console.cpp" + "game/reconstructed/btl4lobby.cpp" "game/reconstructed/btl4galm.cpp" "game/reconstructed/btl4gau2.cpp" "game/reconstructed/btl4gau3.cpp" @@ -336,6 +337,12 @@ target_include_directories(bt410_l4 BEFORE PRIVATE "${DXSDK}/Include") target_compile_definitions(bt410_l4 PRIVATE ${BT_DEFS}) target_compile_options(bt410_l4 PRIVATE ${BT_OPTS}) +# --- Steam lobby (btl4lobby.cpp) lives in this lib; under BT412_STEAM it needs +# the gate define + the Steamworks headers (wrapped #pragma pack(push,8)). --- +if(BT412_STEAM) + target_compile_definitions(bt410_l4 PRIVATE BT412_STEAM) + target_include_directories(bt410_l4 PRIVATE "${STEAMWORKS}/public") +endif() #===========================================================================# # Executable: btl4.exe (WinMain launcher + game lib + engine lib + D3D9/audio) diff --git a/context/steamification.md b/context/steamification.md index b361d3a..575a977 100644 --- a/context/steamification.md +++ b/context/steamification.md @@ -129,7 +129,8 @@ Verified after merge: clean build; solo front-end mode; loopback MP through the `NetTransport_Get()`); (2) `BT412_STEAM=ON` config + build (compile-check of the Steam transport TU against the vendored SDK; a live Steam session needs the Phase 6 lobby + 3 machines). -- **Phase 5 (front end) — FOUNDATION DONE 2026-07-14; marshal/menu/results IN PROGRESS.** +- **Phase 5 (front end) — DONE 2026-07-16 (5a–5d).** Interactive menu + local egg builder + + in-process marshal + results + single-binary loop all landed and boot-verified. Landed the local **mission-egg builder** — the core "single-player without the operator console" capability: - `game/reconstructed/btl4fe.{hpp,cpp}`: catalogs (8 maps, 8 mechs + variants, @@ -146,14 +147,44 @@ Verified after merge: clean build; solo front-end mode; loopback MP through the Verified: zero-argument launch → `[frontend] built frontend.egg` → mission loads and runs (ticks, gait, targeting); the generated egg is structurally identical to MP.EGG (32×32/16×16 name bitmaps with real glyph pixels, all sections present). - **Remaining in Phase 5** (each substantial, deferred): interactive on-screen catalog - menu (RP412 `RPL4FE` is 1536 lines of render-loop UI); the in-process **LocalConsole - marshal** thread (mission clock + `StopMissionMessage` at expiry + single-binary - menu→race→results→menu loop); and the **score-intake + results screen**, which is - genuinely NEW for BT — BT has no working mission-end flow (mission-review msgID 0x18 - is a stub, `scoreAward=0`); v1 should collect kills/deaths from the live Comm-MFD - `pilotList` tally at stop (see [[gauges-hud]], [[multiplayer]]). -- Phases 6–7: Steam lobby (`btl4lobby`) + packaging remain. + **Completed since (2026-07-16):** + - Interactive on-screen catalog **menu** (green-on-black GDI, `btl4fe.cpp`): map / mech / + color / time / weather / length groups + pilot-name edit, framed LAUNCH (and, under + Steam, HOST / JOIN) buttons. Zero-arg launch shows it; a chosen LAUNCH builds the egg. + - In-process **LocalConsole marshal** (`btl4console.{hpp,cpp}`): NOT a thread — it runs on + the engine's per-frame hook (`gPerFrameHook`, added to `engine/MUNGA/APPMGR.*`, called on + the game thread in `RunMissions`), so its `application->Dispatch(StopMissionMessage)` is + safe. Owns the mission clock and stops the race at the chosen `length=`; snapshots + per-pilot kills/deaths at the stop (`BTResolveRosterPilot`/`BTPilotKills`/`BTPilotDeaths`). + - **Score intake + results screen**: BT's own kills/deaths model (no `gConsoleScoreSink` — + that is RP-layer; BT has no working mission-end wire flow, msgID 0x18 stays a stub). A + GDI "MISSION COMPLETE" scoreboard (`BTFrontEnd_ShowResults`) reads the marshal snapshot + (`BTLocalConsole_GetResult`/`GetResultName`). + - **Single-binary loop** = RELAUNCH per mission (`CreateProcessW` after the marshal stop), + NOT an in-process `for(;;)` — in-process re-init crashes on the stale reconstructed + `gBT*` globals (cdb-traced to `BTL4VideoRenderer::MakeEntityRenderables` → + `gBTTerrainEntity`). Verified: 3 distinct PIDs across the menu→race→results→menu cycle. + - WinMain launch-mode branch (`BTFrontEnd_LastLaunchMode()`): 0 solo, 1 lobby host, 2 + lobby member (see Phase 6). +- **Phase 6 (Steam lobby) — CODE LANDED 2026-07-16; live 3-machine test DEFERRED.** + `game/reconstructed/btl4lobby.{hpp,cpp}` (ported from RP412 `RPL4LOBBY`) builds both gates + — stubs without `BT412_STEAM`, full `ISteamMatchmaking` room under it (both configs + link clean). What works: the room screen; member data (`ip`/`cp`/`gp`/`nm`/`vh`/`cl`/`bd` = + FakeIP + fake console/game ports + persona + mech/color/badge); the nonced `go` launch + roster → `SteamNetTransport_RegisterPeer` for every peer; `Push`/`PullRaceResults` over + lobby data (`res` key) rebuilding the shared score sheet via `BTLocalConsole_ClearResults`/ + `InjectResult`. The menu offers HOST/JOIN when `BTLobby_Available()` (Steam transport up, + installed by WinMain on `BT412STEAM` env). A host launch bakes every member into the egg's + `[pilots]` (mesh `FakeIP:1502` addresses, round-robin drop zones) via + `BTFrontEnd_SetHostedPilots`; a member enters as a network pod on `:1501` + (`SetNetworkCommonFlatAddress` + `gConsoleLossEndsMission=True`). + **Deferred / not built:** the host→member **wire egg-feed marshal** (`InstallNetworkRace` — + the owner streaming eggs + RunMission to remote pods, as `tools/btconsole.py` does). Without + it a member 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 (the + single-binary limitation — everyone returns to a fresh menu, not the room). + `docs/STEAM-3-MACHINE-TEST.md` for BT is not yet authored. +- Phase 7: packaging (`pack-dist.ps1`), KeyLight, release — remain. ## The seams (what plugs in where) [T0 unless noted] diff --git a/docs/BT412-ROADMAP.md b/docs/BT412-ROADMAP.md index 653903b..a97ffb5 100644 --- a/docs/BT412-ROADMAP.md +++ b/docs/BT412-ROADMAP.md @@ -151,15 +151,19 @@ identical-to-baseline → take RP412's file verbatim, diverged → hand-apply th fresh menu; `-egg DEV.EGG` stays unmarshaled (dev shortcut); btconsole.py external feed still works; marshaled 2-instance loopback collects both score lines. -> **Status (2026-07-14): FOUNDATION DONE (5a egg builder); 5b/5c/5d remain.** The local -> egg builder (`game/reconstructed/btl4fe.*`) + front-end mode ship: a zero-argument -> launch builds `frontend.egg` from the default loadout (verified structurally identical -> to MP.EGG, GDI-rendered plasma name bitmaps) and boots a playable mission. Remaining: -> the interactive on-screen catalog menu, the in-process LocalConsole marshal (mission -> clock + StopMission at expiry + single-binary loop), and the score-intake/results -> screen — the last is NEW work (BT has no mission-end flow: msgID 0x18 is a stub, -> `scoreAward=0`; collect kills/deaths from the live Comm-MFD `pilotList` at stop). -> Details in `context/steamification.md`. +> **Status (2026-07-16): DONE (5a–5d).** The zero-argument launch shows the interactive +> green-on-black catalog menu (`game/reconstructed/btl4fe.*`: map/mech/color/time/weather/ +> length + pilot name), builds `frontend.egg` (verified structurally identical to MP.EGG, +> GDI-rendered plasma name bitmaps), and boots a playable mission. The in-process +> LocalConsole marshal (`game/reconstructed/btl4console.*`) owns the mission clock off the +> engine per-frame hook (`gPerFrameHook`, added to `APPMGR`) and dispatches +> `Application::StopMissionMessage` at the chosen length; it snapshots per-pilot +> kills/deaths at the stop (BT's own score model — msgID 0x18 stays a stub). The +> single-binary loop is a RELAUNCH per mission (`CreateProcessW`; avoids the stale +> `gBT*`-global re-init crash), showing a GDI "MISSION COMPLETE" scoreboard between races. +> WinMain (`game/btl4main.cpp`) wires front-end-mode detection, the marshal arm, the +> results screen + relaunch, and the launch-mode branch (solo/host/member). Details in +> `context/steamification.md`. ## Phase 6 — Steam lobby (Workstream C.2) @@ -174,6 +178,22 @@ identical-to-baseline → take RP412's file verbatim, diverged → hand-apply th kills/deaths → stop → results everywhere → rematch. Known MP fidelity gaps (peer warp spheres, missile clusters, PNAME/PLACE billboards) do not block. +> **Status (2026-07-16): CODE LANDED; live 3-machine test DEFERRED (no Steam + multi-machine +> here).** `game/reconstructed/btl4lobby.*` ships (compiled both gates: stubs without +> `BT412_STEAM`, full ISteamMatchmaking room under it). Built: the room screen (green-on-black, +> like the menu), member data exchange (FakeIP + fake console/game ports + persona + +> mech/color/badge), the nonced "go" launch roster → `SteamNetTransport_RegisterPeer` all peers, +> and Push/PullRaceResults over lobby data. The menu shows HOST/JOIN when `BTLobby_Available()` +> (Steam transport up); a launch routes through `BTFrontEnd_LastLaunchMode()`: **host** builds the +> egg with every member as a `[pilots]` entry + owns the marshal clock, **member** enters as a +> network pod on `:1501` (`SetNetworkCommonFlatAddress` + `gConsoleLossEndsMission`). WinMain +> installs the Steam transport on `BT412STEAM` env. **Remaining / deferred:** the host→member +> **wire egg-feed marshal** (`InstallNetworkRace` — the owner streaming eggs to remote pods) is +> NOT built; a member currently waits for a console connection that only that marshal provides, so +> a live host+member race needs it. The lobby OBJECT does not survive the per-mission relaunch +> (documented single-binary limitation — everyone lands on a fresh menu, not back in the room). +> `docs/STEAM-3-MACHINE-TEST.md` for BT is not yet authored. Details in `context/steamification.md`. + ## Phase 7 — Polish, packaging, release - KeyLight RGB mirror: `L4KEYLIGHT.*` verbatim (C++/WinRT TU — per-file C++17 props), diff --git a/game/btl4main.cpp b/game/btl4main.cpp index 7b3b2f4..8b87205 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -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(); diff --git a/game/fwd/l4steamtransport.hpp b/game/fwd/l4steamtransport.hpp new file mode 100644 index 0000000..bf4bf71 --- /dev/null +++ b/game/fwd/l4steamtransport.hpp @@ -0,0 +1 @@ +#include "../../engine/MUNGA_L4/L4STEAMTRANSPORT.h" diff --git a/game/reconstructed/btl4console.cpp b/game/reconstructed/btl4console.cpp index 722c459..0a4af5e 100644 --- a/game/reconstructed/btl4console.cpp +++ b/game/reconstructed/btl4console.cpp @@ -9,6 +9,8 @@ #include // Application, application, DEBUG_STREAM #pragma hdrstop +#include // 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; diff --git a/game/reconstructed/btl4console.hpp b/game/reconstructed/btl4console.hpp index f1c0a91..45cad1c 100644 --- a/game/reconstructed/btl4console.hpp +++ b/game/reconstructed/btl4console.hpp @@ -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(); diff --git a/game/reconstructed/btl4fe.cpp b/game/reconstructed/btl4fe.cpp index 7003d69..ecdeffa 100644 --- a/game/reconstructed/btl4fe.cpp +++ b/game/reconstructed/btl4fe.cpp @@ -18,11 +18,42 @@ #include #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); diff --git a/game/reconstructed/btl4fe.hpp b/game/reconstructed/btl4fe.hpp index ab54289..583662c 100644 --- a/game/reconstructed/btl4fe.hpp +++ b/game/reconstructed/btl4fe.hpp @@ -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 diff --git a/game/reconstructed/btl4lobby.cpp b/game/reconstructed/btl4lobby.cpp new file mode 100644 index 0000000..b3e1d66 --- /dev/null +++ b/game/reconstructed/btl4lobby.cpp @@ -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 +#include + +#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 +#include + +// 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 createResult; + CCallResult listResult; + CCallResult 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 diff --git a/game/reconstructed/btl4lobby.hpp b/game/reconstructed/btl4lobby.hpp new file mode 100644 index 0000000..13a3076 --- /dev/null +++ b/game/reconstructed/btl4lobby.hpp @@ -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