Net: host->member mission marshal -- a Steam/LAN mission plays end to end

The lobby could stage a room but the launched mission never fed the members.
BTLocalConsole_InstallNetworkMission (btl4console.cpp) makes the host play the
arcade console in-process over the NetTransport seam (Winsock TCP or Steam SDR):

  - connect to each member's console channel (ip[:port] from BT412HOSTPODS);
  - feed each the chunked egg (NetworkManager::ReceiveEggFileMessage, \n->NUL
    wire image like tools/btconsole.py), resending until it ACKs;
  - poll member state (StateQueryMessage); when the whole mesh is staged at
    WaitingForLaunch, dispatch RunMission to every member + locally at once;
  - StopMission at expiry (members first, then self after a short grace);
  - scores are BT's kills/deaths snapshotted from the *meshed* roster at the
    stop -- no EndMission wire intake (that flow is a BT stub; the mesh already
    put every pilot in the host's roster). The owner's own pod is fed locally
    via L4NetworkManager::FeedLocalEgg.

Engine change (ported 1:1 from RP412): gConsoleMarshalsLaunch (APPMGR.h/.cpp) +
the `!gConsoleMarshalsLaunch &&` guard in APP.cpp's WaitingForLaunch self-launch.
The owner has no console connection to itself, so without this it would
self-launch before the mesh staged and never send the coordinated RunMission.
Default False = stock behavior; solo boot un-regressed.

WinMain host path (btl4main.cpp) now honors BT412HOSTPODS (+BT412HOSTPORT) for
the lobby host AND a classic-LAN host: SetNetworkCommonFlatAddress +
InstallNetworkMission, 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. The live multi-machine
Steam mission (FakeIP mesh + pilot-slot matching) is untestable here -- see the
new docs/STEAM-3-MACHINE-TEST.md. Dist README updated: multiplayer is now
"newly implemented, please test" rather than deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-17 07:30:17 -05:00
co-authored by Claude Opus 4.8
parent a92e3a5780
commit 77f019ed4f
11 changed files with 630 additions and 73 deletions
+25 -6
View File
@@ -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]
+18 -15
View File
@@ -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
+107
View File
@@ -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] <addr> 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
(`<fakeip>: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 <A_ip>:1501 <B_ip>: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.
+11 -4
View File
@@ -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);
+5
View File
@@ -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)
+7
View File
@@ -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:
+35 -7
View File
@@ -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;
}
}
+1
View File
@@ -0,0 +1 @@
#include "../../engine/MUNGA_L4/L4NETTRANSPORT.h"
+391 -24
View File
@@ -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 <bt.hpp> // Application, application, DEBUG_STREAM
#pragma hdrstop
#include <string.h> // strncpy (result names from the wire)
#include <string.h> // strncpy / memcpy / memmove
#include <stdio.h> // 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; }
}
+9
View File
@@ -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();
+21 -17
View File
@@ -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