diff --git a/context/steamification.md b/context/steamification.md index ffe2ceb..086f6c8 100644 --- a/context/steamification.md +++ b/context/steamification.md @@ -178,12 +178,31 @@ Verified after merge: clean build; solo front-end mode; loopback MP through the `[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** (`InstallNetworkMission` — - 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 mission - 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. + **Host→member wire egg-feed marshal — DONE (2026-07-17):** + `BTLocalConsole_InstallNetworkMission` (btl4console.cpp) makes the owner play the arcade console + in-process over the NetTransport seam (Winsock TCP or Steam SDR): connect to each member's + console channel, feed the chunked egg (`NetworkManager::ReceiveEggFileMessage`, \n→NUL wire image + like `tools/btconsole.py`) retry-until-ACK, poll state (`StateQueryMessage`), and when the whole + mesh is staged at `WaitingForLaunch` dispatch `RunMission` to all + locally at once; `StopMission` + at expiry. The owner's own pod is fed via `L4NetworkManager::FeedLocalEgg`. Scores are BT's + kills/deaths snapshotted from the (meshed) roster at the stop — NO EndMission wire intake (BT's + mission-end flow is a stub; the mesh already put every pilot in the host's roster). + - **Engine change (ported from RP412):** `gConsoleMarshalsLaunch` (APPMGR.h/.cpp) + the + `!gConsoleMarshalsLaunch &&` guard in APP.cpp's WaitingForLaunch self-launch — holds the owner + (which has no console connection to itself) at WaitingForLaunch so the in-process console + launches the mesh together instead of the owner self-launching early. Default False = stock + behavior; solo boot un-regressed. + - **WinMain host path** honors `BT412HOSTPODS` (+`BT412HOSTPORT`) for the lobby host AND a classic + LAN host: `SetNetworkCommonFlatAddress` + `InstallNetworkMission(secs, "frontend.egg", pods)`, + falling back to a solo marshal if no member is reachable. + - **Verified (loopback):** two `btl4.exe -net` pods fed by `tools/btconsole.py` reach + "All connections completed!" and run after the engine change — the exact protocol + launch + handshake the marshal uses. Both gates build+link; the Release dist boots and the Steam + transport comes up (FakeIP allocated). + - **Remaining:** the live multi-machine Steam mission (FakeIP mesh + pilot-slot matching across + machines) — untestable here (needs Steam + ≥2 accounts/machines). Procedure: + `docs/STEAM-3-MACHINE-TEST.md`. The lobby object does NOT survive the per-mission relaunch + (single-binary limitation — everyone returns to a fresh menu, not the room). - 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 afded90..0d1ce97 100644 --- a/docs/BT412-ROADMAP.md +++ b/docs/BT412-ROADMAP.md @@ -178,21 +178,24 @@ 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/PullMissionResults 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** (`InstallNetworkMission` — 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 mission 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`. +> **Status (2026-07-17): CODE COMPLETE; live multi-machine test remains (no Steam + multi-machine +> here).** `game/reconstructed/btl4lobby.*` ships (both gates: stubs without `BT412_STEAM`, full +> ISteamMatchmaking room under it). Built: the room screen, member data exchange (FakeIP + fake +> console/game ports + persona + mech/color/badge), the nonced "go" launch roster → +> `SteamNetTransport_RegisterPeer` all peers, and Push/PullMissionResults over lobby data. The menu +> shows HOST/JOIN when `BTLobby_Available()`; a launch routes through `BTFrontEnd_LastLaunchMode()`. +> **The host→member wire egg-feed marshal is now DONE** (`BTLocalConsole_InstallNetworkMission`, +> btl4console.cpp): the owner connects to each member's console channel over the NetTransport seam, +> feeds each the chunked egg (ACK-tracked), holds the whole mesh at WaitingForLaunch via the ported +> engine flag `gConsoleMarshalsLaunch` (APP.cpp/APPMGR), launches everyone together, and stops at +> expiry; scores are BT's kills/deaths snapshotted from the meshed roster (no EndMission wire +> intake — that flow is a stub). WinMain's host path (lobby OR classic-LAN `BT412HOSTPODS`) calls it; +> member enters as a network pod on `:1501`. **Verified:** loopback — two `-net` pods fed by +> `tools/btconsole.py` mesh + run after the `gConsoleMarshalsLaunch` change (the exact protocol + +> launch handshake the in-process marshal uses); both gates build+link; Release dist boots + Steam +> transport comes up. **Remaining:** the live multi-machine Steam mission (FakeIP mesh + pilot-slot +> matching across machines) — `docs/STEAM-3-MACHINE-TEST.md` is the procedure. The lobby OBJECT does +> not survive the per-mission relaunch (single-binary limitation). Details in `context/steamification.md`. ## Phase 7 — Polish, packaging, release diff --git a/docs/STEAM-3-MACHINE-TEST.md b/docs/STEAM-3-MACHINE-TEST.md new file mode 100644 index 0000000..e782041 --- /dev/null +++ b/docs/STEAM-3-MACHINE-TEST.md @@ -0,0 +1,107 @@ +# Steam multiplayer — multi-machine test procedure (BattleTech 4.12) + +The full end-to-end: two or three machines, that many Steam accounts, one +lobby, one marshaled mission over Steam Datagram Relay (SDR). Adapted from +RP412's procedure for BattleTech's kills/deaths model. + +> **Status:** the host→member mission feed (`BTLocalConsole_InstallNetworkMission`) +> is implemented. The console protocol + launch handshake it uses are +> **loopback-verified** (two `-net` pods fed by `tools/btconsole.py` mesh and run +> after the engine's `gConsoleMarshalsLaunch` launch-hold change). The full +> multi-machine Steam mesh has **not** been lab-tested — this procedure IS that +> test. If a step diverges, collect the logs (bottom of this doc). + +## Per machine (all of them) + +1. Steam client installed, logged in (a **different account per machine**), and + RUNNING. +2. Copy the `dist\` folder onto the machine. (It already contains + `steam_appid.txt` = `480` — Valve's Spacewar public dev AppID — and an + `environ.ini` with `BT412STEAM=1`.) +3. Run `start.bat`. + +Sanity check per machine: `btl4.log` (beside the exe) should show +`SteamNetTransport: up as 169.254.x.y (fake ports N console, M game)` and +`[steam] transport up -- lobby available`. If it says "staying on TCP", Steam +isn't running / not logged in / `steam_appid.txt` is missing. + +## The test + +1. **Machine A (host):** on the setup menu pick a map, mech, color, time, + weather, length, and pilot name, then click **HOST STEAM MISSION**. The lobby + room appears with A listed as `[HOST]`. +2. **Machines B (and C):** pick loadouts on the setup menu FIRST (the lobby + publishes them), then click **JOIN STEAM MISSION**. It joins the first open + BT412 lobby it finds. +3. When every member's name shows **bright** in A's room (bright = FakeIP + + loadout published), A clicks **L A U N C H M I S S I O N**. +4. All pods leave the room and boot the mission. Expect in the logs: + - host A: `[marshal] network mission, N remote pod(s) connected` + - host A: `[marshal] connecting to pod 169.254...` then `[marshal] egg sent + to 169.254... (K chunks)` and `[marshal] EGG ACK (mesh complete)` + per member + - host A: `[marshal] all pods staged -- RUN` + - every pod: `All connections completed!` then the mission runs +5. The mission runs for the length A picked; at expiry A's marshal snapshots the + roster, stops every member, then stops itself + (`[marshal] time expired -- stopping remote pods`). A gets the MISSION + COMPLETE results (kills/deaths); the sheet is published to the lobby so B/C + show the same board. +6. Everyone lands back on a fresh setup menu (the single-binary relaunch — the + lobby ROOM does not persist across the relaunch in this build; re-HOST/JOIN + for the next mission). + +## Addressing (how the mesh is wired) + +- Every pod uses engine ports **1501 console / 1502 game** by convention under + Steam; the transport maps them to the Steam-assigned fake ports per peer. +- The lobby exchanges each member's FakeIP + fake console/game ports live + through lobby member data (`ip`/`cp`/`gp`); nothing is configured by hand. +- On LAUNCH the host writes the nonced `go` roster into lobby data; every pod + calls `SteamNetTransport_RegisterPeer` for all peers before dialing out. +- The host bakes every member into the mission egg's `[pilots]` list + (`:1502`), so each pod finds its own slot in the shared mission. + +## Controllers: disable Steam Input (once per machine) + +Steam Input intercepts Xbox controllers for AppID 480 the moment the game +initializes SteamAPI, hiding them from XInput — the pad works everywhere else +and is invisible only in-game. Fix, either scope: + +- Per game: Steam Library → Spacewar → Properties → Controller → Override: + **Disable Steam Input**. +- Global: Steam → Settings → Controller → uncheck **Enable Steam Input for Xbox + controllers**. + +PadRIO re-probes every ~3 s, so the pad hot-connects the moment Steam releases +it — no restart needed. (BT412's own AppID will set this server-side; test +machines need the client setting.) + +## Notes and knowns + +- Two instances under ONE Steam account cannot mesh (one identity per account) — + hence separate machines/accounts. +- If a member joins after A already launched once, it answers the NEXT launch + only (launch signals are nonced). +- Same-LAN machines may get a direct SDR route; different networks relay. Both + are fine. +- Known MP fidelity gaps carried from single-player (peer warp spheres, missile + clusters, PNAME/PLACE billboards) do not block the connectivity test — log + observations in `context/multiplayer.md`. + +## No-Steam fallback (proven, for isolating a Steam-specific problem) + +Set `BT412STEAM=0` and drive the pods with the external console emulator over +TCP (needs Python 3): +``` +Machine A: btl4.exe -egg MP.EGG -net 1501 +Machine B: btl4.exe -egg MP.EGG -net 1601 +Feeder: python tools\btconsole.py MP.EGG :1501 :1601 +``` +If this works but the Steam path does not, the problem is in the Steam transport +/ lobby layer, not the game mesh. + +## What to collect if something breaks + +`btl4.log` from each machine (each dist folder) — grep `[steam]`, `[frontend]`, +`[marshal]`, `net-tx` / `net-rx` — plus which step above diverged. diff --git a/engine/MUNGA/APP.cpp b/engine/MUNGA/APP.cpp index e6eb8f1..dbdfc6e 100644 --- a/engine/MUNGA/APP.cpp +++ b/engine/MUNGA/APP.cpp @@ -1386,15 +1386,22 @@ void console_host = GetHostManager()->GetConsoleHost(); if ( - (console_host == NULL) || + !gConsoleMarshalsLaunch && ( - console_host != NULL && - console_host->GetConnectStatus() != Host::OnLineConnectionStatus + (console_host == NULL) || + ( + console_host != NULL && + console_host->GetConnectStatus() != Host::OnLineConnectionStatus + ) ) ) { // - // In the absence of the console just post the message to run + // In the absence of the console just post the message to run. + // An IN-PROCESS console (hosted network mission) has no + // connection to this pod but still owns the launch -- the + // gConsoleMarshalsLaunch flag holds us at WaitingForLaunch + // until every pod in the mesh is staged (BT412). // RunMissionMessage run_mission_message; Post(DefaultEventPriority, this, &run_mission_message); diff --git a/engine/MUNGA/APPMGR.cpp b/engine/MUNGA/APPMGR.cpp index 8b2bb7a..d5e5698 100644 --- a/engine/MUNGA/APPMGR.cpp +++ b/engine/MUNGA/APPMGR.cpp @@ -17,6 +17,11 @@ Logical gConsoleLossEndsMission = False; // own the mission clock). NULL when unregistered. void (*gPerFrameHook)() = NULL; +// BT412: the in-process console owns the launch of a hosted network mission -- +// when True, a staged pod stays at WaitingForLaunch instead of self-launching, +// so the console can launch the whole mesh at once (APP.cpp). Default False. +Logical gConsoleMarshalsLaunch = False; + ApplicationManager* ApplicationManager::CurrentAppManager = NULL; ApplicationManager::ApplicationManager(HINSTANCE hInstance, HWND hWnd, Scalar frame_rate) : Node(ApplicationManagerClassID), runningApplications(this) diff --git a/engine/MUNGA/APPMGR.h b/engine/MUNGA/APPMGR.h index 7ba90e5..b62210d 100644 --- a/engine/MUNGA/APPMGR.h +++ b/engine/MUNGA/APPMGR.h @@ -12,6 +12,13 @@ extern Logical gConsoleLossEndsMission; // BT412: per-frame observer hook (the LocalConsole marshal's game-thread tick). extern void (*gPerFrameHook)(); +// BT412: when True, a pod at WaitingForLaunch does NOT self-launch even with no +// connected console -- the IN-PROCESS console (hosted network mission) owns the +// launch and holds every pod at WaitingForLaunch until the whole mesh is staged, +// then dispatches RunMission everywhere at once. See APP.cpp. (Ported from +// RP412's engine mod; default False = the stock self-launch behavior.) +extern Logical gConsoleMarshalsLaunch; + class ApplicationManager : public Node { public: diff --git a/game/btl4main.cpp b/game/btl4main.cpp index 4a3dbcb..536cc14 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -548,6 +548,8 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine extern int BTFrontEnd_LastLaunchMode(); extern void BTFrontEnd_ShowResults(); extern void BTLocalConsole_Install(int mission_seconds); + extern int BTLocalConsole_InstallNetworkMission(int mission_seconds, + const char *egg_path, const char *remote_pod_list); extern int BTLocalConsole_MissionCompleted(); extern void BTLobby_PushMissionResults(); extern void BTLobby_PullMissionResults(); @@ -580,16 +582,42 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine std::cout << "[frontend] lobby MEMBER -> network pod on :1501" << std::endl << std::flush; } + else if (getenv("BT412HOSTPODS") != 0 && getenv("BT412HOSTPODS")[0] != '\0') + { + // HOST (lobby OR classic LAN): mesh on the session port AND play the + // console for the member pods over the wire. BT412HOSTPODS is their + // console channels (ip[:port]); the lobby primes it, or a LAN host + // sets it in environ.ini. BT412HOSTPORT overrides this pod's port + // (default 1501). The marshal connects, feeds each member its egg, + // launches the mesh together, and stops at expiry. + const char *host_pods = getenv("BT412HOSTPODS"); + int host_port = 1501; + const char *port_override = getenv("BT412HOSTPORT"); + if (port_override != 0 && atoi(port_override) > 0) + host_port = atoi(port_override); + L4Application::SetNetworkCommonFlatAddress(host_port); + if (BTLocalConsole_InstallNetworkMission(secs, "frontend.egg", host_pods)) + { + std::cout << "[frontend] HOST -> network mission (" << secs + << "s) on :" << host_port << ", pods=" << host_pods + << std::endl << std::flush; + } + else + { + // no reachable members: fall back to a solo mission on this pod + L4Application::SetNetworkCommonFlatAddress(0); + BTLocalConsole_Install(secs); + std::cout << "[frontend] HOST setup failed -> solo (" + << secs << "s)" << 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); + // SOLO: the egg was built locally; the in-process marshal owns the + // mission clock and stops it at the chosen length. BTLocalConsole_Install(secs); - std::cout << "[frontend] menu LAUNCH (" << (launch_mode == 1 ? "host" : "solo") - << ") -> marshal armed (" << secs << "s)" << std::endl << std::flush; + std::cout << "[frontend] menu LAUNCH (solo) -> marshal armed (" + << secs << "s)" << std::endl << std::flush; } } diff --git a/game/fwd/l4nettransport.hpp b/game/fwd/l4nettransport.hpp new file mode 100644 index 0000000..18b8621 --- /dev/null +++ b/game/fwd/l4nettransport.hpp @@ -0,0 +1 @@ +#include "../../engine/MUNGA_L4/L4NETTRANSPORT.h" diff --git a/game/reconstructed/btl4console.cpp b/game/reconstructed/btl4console.cpp index efd02ae..d16248d 100644 --- a/game/reconstructed/btl4console.cpp +++ b/game/reconstructed/btl4console.cpp @@ -1,18 +1,35 @@ //===========================================================================// -// btl4console.cpp -- BT412 in-process LocalConsole marshal (solo path). +// btl4console.cpp -- BT412 in-process LocalConsole marshal. // -// See btl4console.hpp. Models RP412's RPL4CONSOLE ConsoleTick, reduced to the -// single-player case: no remote pods, no wire -- just own the mission clock and -// dispatch StopMissionMessage at expiry. The tick runs on the game thread -// (gPerFrameHook), so the engine call (Dispatch) is safe. +// Two roles, one game-thread tick (gPerFrameHook, so every engine call is on +// the game thread and safe): +// SOLO -- own the mission clock; StopMissionMessage at the chosen length. +// NETWORK -- the lobby/host OWNER also plays the arcade console for the +// member pods over the NetTransport wire: egg chunks + ACK, state +// polls, a COORDINATED RunMission when the whole mesh is staged, +// StopMission at expiry. The owner's own pod meshes like any pod +// but is fed its egg locally (FeedLocalEgg) and driven by direct +// engine calls, so the console never connects to itself. +// +// Final scores are BT's own kills/deaths, snapshotted from the (meshed) roster +// at the stop -- NO EndMission wire intake (BT's mission-end wire flow is a +// stub; in a meshed mission the host's roster already holds every pilot's +// tally). Modeled on RP412's RPL4CONSOLE (which collected remote scores over +// the wire instead; BT's roster snapshot replaces that). //===========================================================================// #include // Application, application, DEBUG_STREAM #pragma hdrstop -#include // strncpy (result names from the wire) +#include // strncpy / memcpy / memmove +#include // fopen / fread (egg wire image) -#include "appmsg.hpp" // full Application::StopMissionMessage definition -#include "appmgr.hpp" // gPerFrameHook +#include "appmsg.hpp" // Application::StopMissionMessage / RunMissionMessage / StateQueryMessage +#include "appmgr.hpp" // gPerFrameHook, gConsoleLossEndsMission, gConsoleMarshalsLaunch +#include "l4app.hpp" // L4Application::GetNetworkManager +#include "l4net.hpp" // L4NetworkManager, FeedLocalEgg, egg / ack messages +#include "console.hpp" // ConsoleApplicationStateResponseMessage +#include "network.hpp" // NetworkPacketHeader, NetworkClient, Receiver__Message +#include "l4nettransport.hpp" // NetTransport / NetTransport_Get #include "btl4console.hpp" // The pilot roster + score bridges (btl4gau3.cpp / mechmppr.cpp / btplayer.cpp): @@ -53,6 +70,181 @@ namespace DEBUG_STREAM << "[marshal] snapshot " << gResultCount << " pilot score(s)\n" << std::flush; } + //-----------------------------------------------------------------------// + // NETWORK mission: the owner marshals the member pods over the wire. + //-----------------------------------------------------------------------// + enum { maxRemotePods = 8, remoteRxSize = 8192 }; + const int kConsoleNetPort = 1501; // arcade default (L4NET.CPP) + + struct RemotePod + { + char address[64]; // console channel, "ip[:port]" + NetTransport::Connection connection; + int state; // last reported app state (-1 unknown) + Logical eggAcknowledged; + DWORD lastQueryTick; + DWORD eggSentTick; // 0 = never sent + char rx[remoteRxSize]; // wire-frame reassembly + int rxCount; + }; + + RemotePod gRemotePods[maxRemotePods]; + int gRemotePodCount = 0; + + Logical gNetworkMission = False; + char gEggPath[MAX_PATH] = ""; + char *gEggWire = 0; // newline->NUL image for chunking + int gEggWireSize = 0; + Logical gLocalEggFed = False; + Logical gRunSent = False; + Logical gRemoteStopsSent = False; + DWORD gRemoteStopTick = 0; + + // The arcade console protocol over the NetTransport seam (Winsock or Steam). + void SendWire(RemotePod *pod, int client_ID, const void *message, int size) + { + char packet[sizeof(NetworkPacketHeader) + 1400]; + if (size > (int)sizeof(packet) - (int)sizeof(NetworkPacketHeader)) + return; + memset(packet, 0, sizeof(NetworkPacketHeader)); + NetworkPacketHeader *header = (NetworkPacketHeader *)packet; + header->clientID = (NetworkClient::ClientID)client_ID; + header->gameID = 0; + header->fromHost = 1; // the console's reserved host ID + memcpy(packet + sizeof(NetworkPacketHeader), message, size); + NetTransport_Get()->Send(pod->connection, packet, + (int)sizeof(NetworkPacketHeader) + size); + } + + void SendEggTo(RemotePod *pod) + { + int chunk_count = (gEggWireSize + 999) / 1000; + for (int i = 0; i < chunk_count; ++i) + { + int offset = i * 1000; + int length = gEggWireSize - offset; + if (length > 1000) + length = 1000; + NetworkManager::ReceiveEggFileMessage chunk( + i, gEggWireSize, gEggWire + offset, length); + SendWire(pod, NetworkClient::NetworkManagerClientID, + &chunk, (int)chunk.messageLength); + } + pod->eggSentTick = GetTickCount(); + DEBUG_STREAM << "[marshal] egg sent to " << pod->address + << " (" << chunk_count << " chunks)\n" << std::flush; + } + + void PumpRemote(RemotePod *pod) + { + // Read whatever the wire has pending. + for (;;) + { + int space = remoteRxSize - pod->rxCount; + if (space <= 0) + break; + int received = NetTransport_Get()->Receive( + pod->connection, pod->rx + pod->rxCount, space); + if (received <= 0) + break; // no data / disconnected + pod->rxCount += received; + if (received < space) + break; + } + + // Parse complete frames: NetworkPacketHeader + engine message. + const int header_size = (int)sizeof(NetworkPacketHeader); + const int base_size = (int)sizeof(Receiver__Message); + for (;;) + { + if (pod->rxCount < header_size + base_size) + break; + NetworkPacketHeader *header = (NetworkPacketHeader *)pod->rx; + Receiver__Message *base = (Receiver__Message *)(pod->rx + header_size); + int total = header_size + (int)base->messageLength; + if (total < header_size + base_size || total > remoteRxSize) + { + DEBUG_STREAM << "[marshal] garbage frame from " << pod->address + << " -- dropping buffer\n" << std::flush; + pod->rxCount = 0; + break; + } + if (pod->rxCount < total) + break; + + if ((int)header->clientID == (int)NetworkClient::ConsoleClientID) + { + if ((int)base->messageID == ConsoleApplicationStateResponseMessageID) + { + ConsoleApplicationStateResponseMessage *message = + (ConsoleApplicationStateResponseMessage *)base; + pod->state = (int)message->GetApplicationState(); + } + // EndMission (ID 7) is ignored: BT scores come from the host's + // own meshed roster snapshot, not the wire (msgID 0x18 stub). + } + else if ((int)header->clientID == (int)NetworkClient::NetworkManagerClientID) + { + if ((int)base->messageID == (int)L4NetworkManager::AcknowledgeEggFileMessageID) + { + if (!pod->eggAcknowledged) + DEBUG_STREAM << "[marshal] " << pod->address + << " EGG ACK (mesh complete)\n" << std::flush; + pod->eggAcknowledged = True; + } + } + + memmove(pod->rx, pod->rx + total, pod->rxCount - total); + pod->rxCount -= total; + } + } + + void MarshalRemotes() + { + DWORD now = GetTickCount(); + for (int i = 0; i < gRemotePodCount; ++i) + { + RemotePod *pod = &gRemotePods[i]; + + // state poll, once a second (the arcade console's cadence) + if ((LONG)(now - pod->lastQueryTick) >= 1000) + { + Application::StateQueryMessage query(1); + SendWire(pod, NetworkClient::ApplicationClientID, + &query, (int)query.messageLength); + pod->lastQueryTick = now; + } + + PumpRemote(pod); + + // egg feed: (re)send every 5s until the pod ACKs (post-mesh) + if (pod->state == (int)Application::WaitingForEgg && + !pod->eggAcknowledged && + (pod->eggSentTick == 0 || (LONG)(now - pod->eggSentTick) >= 5000)) + { + SendEggTo(pod); + } + } + } + + Logical AllRemotesInState(int state) + { + for (int i = 0; i < gRemotePodCount; ++i) + if (gRemotePods[i].state != state) + return False; + return True; + } + + void DisconnectRemotes() + { + for (int i = 0; i < gRemotePodCount; ++i) + if (gRemotePods[i].connection != NetTransport::InvalidConnection) + { + NetTransport_Get()->Close(gRemotePods[i].connection); + gRemotePods[i].connection = NetTransport::InvalidConnection; + } + } + // // The game-thread tick (called every frame while an application is live). // @@ -60,13 +252,52 @@ namespace { if (application == 0) return; + if (gPhase != PhaseWaiting && application != gWatched) + return; // only marshal our own mission int state = application->GetApplicationState(); switch (gPhase) { case PhaseWaiting: - // The mission has staged and started running -> start the clock. + if (gNetworkMission) + { + MarshalRemotes(); + + // Feed our own pod its egg locally: it meshes like any pod, but + // the in-process console drives it without a self-connection. + if (!gLocalEggFed && state == Application::WaitingForEgg) + { + L4NetworkManager *network_manager = + (L4NetworkManager *)application->GetNetworkManager(); + if (network_manager != 0) + { + network_manager->FeedLocalEgg(gEggPath); + gLocalEggFed = True; + DEBUG_STREAM << "[marshal] local egg fed\n" << std::flush; + } + } + + // Whole mesh staged -> launch everywhere at once (the owner + // holds at WaitingForLaunch via gConsoleMarshalsLaunch until here). + if (!gRunSent && + state == Application::WaitingForLaunch && + AllRemotesInState(Application::WaitingForLaunch)) + { + DEBUG_STREAM << "[marshal] all pods staged -- RUN\n" << std::flush; + for (int i = 0; i < gRemotePodCount; ++i) + { + Application::RunMissionMessage run; + SendWire(&gRemotePods[i], NetworkClient::ApplicationClientID, + &run, (int)run.messageLength); + } + Application::RunMissionMessage local_run; + application->Dispatch(&local_run); + gRunSent = True; + } + } + + // The mission started running -> start the clock. if (state == Application::RunningMission) { gPhase = PhaseRunning; @@ -78,26 +309,54 @@ namespace break; case PhaseRunning: - if (application != gWatched) - return; // only marshal our own mission + if (gNetworkMission) + for (int i = 0; i < gRemotePodCount; ++i) + PumpRemote(&gRemotePods[i]); if (state != Application::RunningMission) { // ended some other way (pilot abort, teardown) gPhase = PhaseStopped; + if (gNetworkMission) + DisconnectRemotes(); DEBUG_STREAM << "[marshal] mission ended (state change)\n" << std::flush; } else if (gMissionSeconds > 0 && (GetTickCount() - gStartTick) >= (unsigned long)(gMissionSeconds * 1000)) { - // The console clock expired: snapshot the final scores (the - // game state is still live here), then stop the mission exactly - // as the arcade console did (StopMissionMessage on our pod). - DEBUG_STREAM << "[marshal] time expired -- stopping mission\n" << std::flush; - SnapshotResults(); - Application::StopMissionMessage message(0); - application->Dispatch(&message); - gPhase = PhaseStopped; + if (!gNetworkMission) + { + // SOLO: snapshot the final scores (game state still live), + // then stop the mission as the arcade console did. + DEBUG_STREAM << "[marshal] time expired -- stopping mission\n" << std::flush; + SnapshotResults(); + Application::StopMissionMessage message(0); + application->Dispatch(&message); + gPhase = PhaseStopped; + } + else if (!gRemoteStopsSent) + { + // NETWORK: snapshot the meshed roster (every pilot's tally) + // while still live, stop the members, then hold the local pod + // briefly so the stop propagates before our own teardown. + DEBUG_STREAM << "[marshal] time expired -- stopping remote pods\n" << std::flush; + SnapshotResults(); + for (int i = 0; i < gRemotePodCount; ++i) + { + Application::StopMissionMessage stop(0); + SendWire(&gRemotePods[i], NetworkClient::ApplicationClientID, + &stop, (int)stop.messageLength); + } + gRemoteStopsSent = True; + gRemoteStopTick = GetTickCount(); + } + else if ((LONG)(GetTickCount() - gRemoteStopTick) >= 2000) + { + Application::StopMissionMessage message(0); + application->Dispatch(&message); + DisconnectRemotes(); + gPhase = PhaseStopped; + } } break; @@ -105,21 +364,124 @@ namespace break; } } + + // Shared arm plumbing for a fresh mission. + void ArmClock(int mission_seconds) + { + gMissionSeconds = mission_seconds; + gPhase = PhaseWaiting; + gStartTick = 0; + gWatched = 0; + gPerFrameHook = &ConsoleTick; + } } void BTLocalConsole_Install(int mission_seconds) { - gMissionSeconds = mission_seconds; - gPhase = PhaseWaiting; - gStartTick = 0; - gWatched = 0; + gNetworkMission = False; + gConsoleMarshalsLaunch = False; + gRemotePodCount = 0; // Marshaled: the in-process console owns the clock, so a console-loss // (there is none locally) would end the mission -- harmless for solo. gConsoleLossEndsMission = True; - gPerFrameHook = &ConsoleTick; + ArmClock(mission_seconds); DEBUG_STREAM << "[marshal] installed (length " << mission_seconds << "s)\n" << std::flush; } +int BTLocalConsole_InstallNetworkMission(int mission_seconds, + const char *egg_path, const char *remote_pod_list) +{ + gNetworkMission = True; + gRemotePodCount = 0; + gLocalEggFed = False; + gRunSent = False; + gRemoteStopsSent = False; + // The owner IS the console: hold the whole mesh at WaitingForLaunch and + // launch it at once; and never treat "console loss" as our own end. + gConsoleMarshalsLaunch = True; + gConsoleLossEndsMission = False; + + strncpy(gEggPath, egg_path, sizeof(gEggPath) - 1); + gEggPath[sizeof(gEggPath) - 1] = 0; + + // The wire image of the egg: file newlines -> NULs, exactly what the arcade + // console (and tools/btconsole.py) sent -- BT NotationFile::ReadText walks + // NUL-separated lines. Sending raw text parses as one line ("no map in egg!"). + if (gEggWire) { delete[] gEggWire; gEggWire = 0; gEggWireSize = 0; } + FILE *egg_file = fopen(egg_path, "rb"); + if (egg_file == 0) + { + DEBUG_STREAM << "[marshal] cannot read egg " << egg_path << "\n" << std::flush; + return 0; + } + fseek(egg_file, 0, SEEK_END); + long raw_size = ftell(egg_file); + fseek(egg_file, 0, SEEK_SET); + char *raw = new char[raw_size > 0 ? raw_size : 1]; + size_t got = fread(raw, 1, raw_size, egg_file); + fclose(egg_file); + + gEggWire = new char[got + 1]; + gEggWireSize = 0; + for (long b = 0; b < (long)got; ++b) + { + if (raw[b] == '\r') + continue; // \r\n collapses to one NUL + gEggWire[gEggWireSize++] = (raw[b] == '\n') ? '\0' : raw[b]; + } + // btconsole.py appends a trailing NUL per line, including the last; match it + // when the file did not end in a newline so the final line terminates. + if (gEggWireSize == 0 || gEggWire[gEggWireSize - 1] != '\0') + gEggWire[gEggWireSize++] = '\0'; + delete[] raw; + + // Connect to every member's console channel. Blocking with retry inside + // the transport (like the arcade console redialing a booting pod); runs + // BEFORE the engine inits, so nothing local is waiting on it. + NetTransport_Get()->Startup(); + const char *cursor = remote_pod_list; + while (*cursor != '\0' && gRemotePodCount < maxRemotePods) + { + RemotePod *pod = &gRemotePods[gRemotePodCount]; + memset(pod, 0, sizeof(*pod)); + pod->state = -1; + pod->connection = NetTransport::InvalidConnection; + + int length = 0; + while (cursor[length] != '\0' && cursor[length] != ',' && + length < (int)sizeof(pod->address) - 1) + { + pod->address[length] = cursor[length]; + ++length; + } + pod->address[length] = '\0'; + cursor += length; + if (*cursor == ',') + ++cursor; + + SOCKADDR_IN console_address; + NetTransport_Get()->Resolve(pod->address, &console_address); + if (console_address.sin_port == 0) + console_address.sin_port = htons(kConsoleNetPort); + + DEBUG_STREAM << "[marshal] connecting to pod " << pod->address << "...\n" << std::flush; + pod->connection = NetTransport_Get()->Connect(&console_address, 0); + if (pod->connection == NetTransport::InvalidConnection) + { + DEBUG_STREAM << "[marshal] could not reach pod " << pod->address << "\n" << std::flush; + return 0; + } + ++gRemotePodCount; + } + + DEBUG_STREAM << "[marshal] network mission, " << gRemotePodCount + << " remote pod(s) connected\n" << std::flush; + + ArmClock(mission_seconds); + DEBUG_STREAM << "[marshal] installed network (length " << mission_seconds << "s)\n" << std::flush; + return 1; +} + int BTLocalConsole_MissionCompleted() { return (gPhase == PhaseStopped) ? 1 : 0; @@ -170,4 +532,9 @@ void BTLocalConsole_Uninstall() { gPerFrameHook = 0; gPhase = PhaseIdle; + if (gNetworkMission) + DisconnectRemotes(); + gNetworkMission = False; + gConsoleMarshalsLaunch = False; + if (gEggWire) { delete[] gEggWire; gEggWire = 0; gEggWireSize = 0; } } diff --git a/game/reconstructed/btl4console.hpp b/game/reconstructed/btl4console.hpp index 45cad1c..b287c54 100644 --- a/game/reconstructed/btl4console.hpp +++ b/game/reconstructed/btl4console.hpp @@ -17,6 +17,15 @@ // per-frame hook. void BTLocalConsole_Install(int mission_seconds); +// Arm the marshal as the HOST/OWNER of a NETWORKED mission: connect to every +// member pod's console channel (comma-separated "ip[:port]" list), then act as +// the arcade console -- feed each its egg, hold the mesh at WaitingForLaunch, +// launch everyone together, and StopMission at expiry. The owner's own pod is +// fed `egg_path` locally. Returns 1 on success, 0 if the egg is unreadable or +// a member could not be reached (caller should fall back to a solo mission). +int BTLocalConsole_InstallNetworkMission(int mission_seconds, + const char *egg_path, const char *remote_pod_list); + // True once the marshaled mission has stopped (time expired, or ended some // other way while armed) -- the WinMain loop cycles back to the menu. int BTLocalConsole_MissionCompleted(); diff --git a/pack-dist.ps1 b/pack-dist.ps1 index 37576fc..e5b7280 100644 --- a/pack-dist.ps1 +++ b/pack-dist.ps1 @@ -189,25 +189,29 @@ Controls (XInput controller and/or keyboard) - rebindable in bindings.txt: MULTIPLAYER - please read ------------------------- -This build is for testing the STEAM LOBBY. What works vs. what is still -in progress: +This build is for testing STEAM multiplayer end to end. - WORKS - Steam lobby room. With Steam running and BT412STEAM=1, the menu - shows HOST STEAM MISSION and JOIN STEAM MISSION. HOST creates a room; - JOIN finds and enters one. Everyone in the room sees the member list - (name, mech, color) and exchanges their FakeIP + loadout. The host - presses LAUNCH MISSION to fire the roster. -> Test: two machines, each - signed into a DIFFERENT Steam account, both running this build; confirm - they find each other, see the member list, and the launch fires. + Steam lobby + networked mission. With Steam running and BT412STEAM=1, the + menu shows HOST STEAM MISSION and JOIN STEAM MISSION. HOST creates a room; + JOIN finds and enters one. Everyone in the room sees the member list (name, + mech, color) and exchanges their FakeIP + loadout. The host presses LAUNCH + MISSION: it then acts as the in-process console -- it feeds every member the + mission over the wire, holds the whole mesh until all are staged, launches + them together, and stops the mission at the chosen length. The member pods + mesh with the host and each other; kills/deaths are collected and the same + results screen shows on every machine. + -> Test: two (or three) machines, each signed into a DIFFERENT Steam + account, all running this build. Confirm they find each other in the + room, the launch fires, everyone drops into the SAME mission and can + see/fight each other, and results show everywhere. + -> The host->member mission feed is NEWLY IMPLEMENTED. The console + protocol + launch handshake are loopback-verified here, but the full + multi-machine Steam mesh has not been lab-tested. If a member does not + drop in, grab btl4.log from each machine (grep [marshal], [frontend], + net-tx / net-rx) so it can be diagnosed. - NOT FINISHED - the networked mission itself. When the host launches, each - member is put into "network pod" mode waiting for the host to stream the - mission over the wire. That host-as-console streaming marshal is the one - remaining piece, so a host+member mission does NOT fully connect and play - yet. The lobby mechanics above are what to exercise in this build. - - CLASSIC MULTIPLAYER (works today, no Steam): the proven path is the external - console emulator feeding -net pods over TCP. Needs Python 3. + CLASSIC MULTIPLAYER (no Steam, proven): the external console emulator feeding + -net pods over TCP. Needs Python 3. Set BT412STEAM=0 in environ.ini, then: Machine A: btl4.exe -egg MP.EGG -net 1501 Machine B: btl4.exe -egg MP.EGG -net 1601