NEW gated (BT_STEAM) game/glass/btl4lobby: an ISteamMatchmaking room replacing the arcade Site-Management screen -- host creates (lobby data btl4=1), members join by filter, pilots publish name/mech/color, the host ENTER mints the roster and signals GO. THE FAKEIP LESSON (live-verified, prior-art assumption DISPROVEN): a fresh process gets a DIFFERENT FakeIP, so menu-time fake addresses go stale by mission time -- the first cycle timed out connecting to its own stale address. Redesign: roster addresses are opaque ipv4-shaped TOKENS (169.254.77.N with ports 1501/1502) minted by the host at GO, mapped to Steam IDENTITIES; connections ride ConnectP2P(identity, channel) with console=0/game=1 virtual ports; the map reaches mission processes via env (BT_FE_MYFAKE + BT_FE_STEAMMAP); the GetMyAddress seam feeds the pod its own token for roster self-match; CheckSocket reports peers by token. L4STEAMNET reworked (no FakeIP allocation at all); the marshal gained the Steam-wire branch. Verified single-machine (ON/ON, live Steam): menu -> HOST STEAM LOBBY -> room -> GO -> token map minted -> egg roster carries the token -> mission process up with 1 roster token incl. self -> pod self-matches -> stock console ladder -> mission RUNS (46 ticks). Remaining: the live 2-account cross-machine session (docs/STEAM_TEST.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
502 lines
13 KiB
C++
502 lines
13 KiB
C++
//###########################################################################
|
|
// btl4console -- the in-process LocalConsole marshal (BT_GLASS only;
|
|
// compiled only when the gate is on -- see CMakeLists.txt).
|
|
// Protocol + design: btl4console.hpp and tools/btconsole.py (the wire
|
|
// ground truth; every constant below matches it).
|
|
//###########################################################################
|
|
|
|
#include "btl4console.hpp"
|
|
|
|
#include <winsock2.h>
|
|
#include <ws2tcpip.h>
|
|
#include <windows.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <stdarg.h>
|
|
|
|
//
|
|
// The marshal logs to its own file (marshal.log in the cwd = content\):
|
|
// the engine's DEBUG log stream is single-threaded and this is a worker
|
|
// thread. Ordinary printf-file logging, flushed per line.
|
|
//
|
|
static FILE *marshalLogFile = NULL;
|
|
static void
|
|
MarshalLog(const char *format, ...)
|
|
{
|
|
if (marshalLogFile == NULL)
|
|
{
|
|
marshalLogFile = fopen("marshal.log", "at");
|
|
if (marshalLogFile == NULL)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
va_list arguments;
|
|
va_start(arguments, format);
|
|
fprintf(marshalLogFile, "[marshal] ");
|
|
vfprintf(marshalLogFile, format, arguments);
|
|
fprintf(marshalLogFile, "\n");
|
|
fflush(marshalLogFile);
|
|
va_end(arguments);
|
|
}
|
|
|
|
//
|
|
// Wire constants -- tools/btconsole.py (T2-verified via BT_NET_PROBE=1).
|
|
//
|
|
enum
|
|
{
|
|
ChunkBytes = 1000,
|
|
MessageLength = 1024, // sizeof(ReceiveEggFileMessage)
|
|
EggMessageID = 3, // NetworkManager::ReceiveEggFileMessageID
|
|
ReliableFlag = 1,
|
|
ConsoleHostID = 1, // FirstLegalHostID
|
|
ApplicationClientID = 4, // NetworkClient::ApplicationClientID
|
|
RunMissionMessageID = 5, // Application::RunMissionMessageID
|
|
StopMissionMessageID = 6, // Application::StopMissionMessageID (enum: Run+1, APP.h:383)
|
|
LaunchSettleSeconds = 20, // egg -> first launch (WaitingForLaunch)
|
|
LaunchStepSeconds = 4, // first -> second launch (Launching -> Running)
|
|
MissionEndGraceSeconds = 8 // stop sent -> relaunch the menu
|
|
};
|
|
|
|
struct MarshalState
|
|
{
|
|
char eggPath[128];
|
|
char podList[512];
|
|
int missionSeconds;
|
|
};
|
|
|
|
static MarshalState marshalState;
|
|
|
|
//
|
|
// The Steam-wire branch (BT_STEAM): a pod whose console address is a
|
|
// FakeIP is reached through the engine's Steam transport (munga_engine)
|
|
// instead of raw Winsock -- same wire bytes, different carrier.
|
|
//
|
|
#ifdef BT_STEAM
|
|
extern int BTSteamNet_IsFakeAddress(unsigned long internet_address_be);
|
|
extern SOCKET BTSteamNet_Connect(unsigned long internet_address_be, int remote_port);
|
|
extern int BTSteamNet_Owns(SOCKET wire_socket);
|
|
extern int BTSteamNet_Send(SOCKET wire_socket, const char *data, int length);
|
|
extern int BTSteamNet_Recv(SOCKET wire_socket, char *buffer, int capacity);
|
|
extern void BTSteamNet_Close(SOCKET wire_socket);
|
|
#endif
|
|
|
|
static int
|
|
MarshalIsSteam(SOCKET s)
|
|
{
|
|
#ifdef BT_STEAM
|
|
return BTSteamNet_Owns(s);
|
|
#else
|
|
(void)s;
|
|
return 0;
|
|
#endif
|
|
}
|
|
|
|
static int
|
|
MarshalSend(SOCKET s, const char *data, int length)
|
|
{
|
|
#ifdef BT_STEAM
|
|
if (BTSteamNet_Owns(s))
|
|
{
|
|
return BTSteamNet_Send(s, data, length);
|
|
}
|
|
#endif
|
|
return send(s, data, length, 0);
|
|
}
|
|
|
|
static void
|
|
MarshalClose(SOCKET s)
|
|
{
|
|
#ifdef BT_STEAM
|
|
if (BTSteamNet_Owns(s))
|
|
{
|
|
BTSteamNet_Close(s);
|
|
return;
|
|
}
|
|
#endif
|
|
closesocket(s);
|
|
}
|
|
|
|
//###########################################################################
|
|
|
|
void
|
|
BTFE_RelaunchSelfAndExit(const char *arguments)
|
|
{
|
|
//
|
|
// A MENU relaunch (empty arguments) must not leak the mission
|
|
// process's marshal handoff into the child -- the child inherits our
|
|
// environment, and a stale BT_FE_EGG would skip the menu and arm a
|
|
// marshal with no pod listening (found live: the first full cycle
|
|
// re-armed instead of returning to the menu).
|
|
//
|
|
if (arguments == NULL || arguments[0] == 0)
|
|
{
|
|
SetEnvironmentVariableA("BT_FE_EGG", NULL);
|
|
SetEnvironmentVariableA("BT_FE_PODS", NULL);
|
|
SetEnvironmentVariableA("BT_FE_SECS", NULL);
|
|
SetEnvironmentVariableA("BT_FE_LOOP", NULL);
|
|
}
|
|
|
|
WCHAR exe_path[MAX_PATH];
|
|
GetModuleFileNameW(NULL, exe_path, MAX_PATH);
|
|
|
|
WCHAR command_line[MAX_PATH + 256];
|
|
int n = 0;
|
|
command_line[n++] = L'"';
|
|
for (WCHAR *s = exe_path; *s; ++s) command_line[n++] = *s;
|
|
command_line[n++] = L'"';
|
|
if (arguments != NULL && arguments[0])
|
|
{
|
|
command_line[n++] = L' ';
|
|
for (const char *s = arguments; *s && n < MAX_PATH + 250; ++s)
|
|
{
|
|
command_line[n++] = (WCHAR)*s;
|
|
}
|
|
}
|
|
command_line[n] = 0;
|
|
|
|
STARTUPINFOW startup;
|
|
PROCESS_INFORMATION process;
|
|
memset(&startup, 0, sizeof(startup));
|
|
startup.cb = sizeof(startup);
|
|
memset(&process, 0, sizeof(process));
|
|
if (CreateProcessW(exe_path, command_line, NULL, NULL, FALSE,
|
|
0, NULL, NULL, &startup, &process))
|
|
{
|
|
CloseHandle(process.hThread);
|
|
CloseHandle(process.hProcess);
|
|
}
|
|
ExitProcess(0);
|
|
}
|
|
|
|
//###########################################################################
|
|
// Wire builders
|
|
//###########################################################################
|
|
|
|
//
|
|
// Egg text -> the wire image NotationFile::ReadText expects: NUL-separated
|
|
// lines (NOTATION.cpp walks strchr(buffer,0)+1 per line).
|
|
//
|
|
static unsigned char *
|
|
EggWireImage(const char *path, int *out_length)
|
|
{
|
|
FILE *f = fopen(path, "rb");
|
|
if (f == NULL)
|
|
{
|
|
return NULL;
|
|
}
|
|
fseek(f, 0, SEEK_END);
|
|
long raw_length = ftell(f);
|
|
fseek(f, 0, SEEK_SET);
|
|
char *raw = (char *)malloc(raw_length + 1);
|
|
fread(raw, 1, raw_length, f);
|
|
fclose(f);
|
|
raw[raw_length] = 0;
|
|
|
|
unsigned char *wire = (unsigned char *)malloc(raw_length + 2);
|
|
int w = 0;
|
|
for (long i = 0; i < raw_length; ++i)
|
|
{
|
|
char c = raw[i];
|
|
if (c == '\r')
|
|
{
|
|
continue;
|
|
}
|
|
wire[w++] = (c == '\n') ? 0 : (unsigned char)c;
|
|
}
|
|
if (w == 0 || wire[w - 1] != 0)
|
|
{
|
|
wire[w++] = 0; // terminate the final line
|
|
}
|
|
free(raw);
|
|
*out_length = w;
|
|
return wire;
|
|
}
|
|
|
|
static void
|
|
PutInt32(unsigned char *at, int value)
|
|
{
|
|
memcpy(at, &value, 4); // x86: little-endian, matching the wire
|
|
}
|
|
|
|
//
|
|
// One 1040-byte egg chunk: NetworkPacketHeader (clientID=0, gameID=0,
|
|
// fromHost=1, timeStamp=0) + ReceiveEggFileMessage (len=1024, id=3,
|
|
// flags=Reliable, seq, fileLen, thisLen, data[1000]).
|
|
//
|
|
static int
|
|
SendEggChunks(SOCKET s, const unsigned char *wire, int wire_length)
|
|
{
|
|
unsigned char packet[16 + MessageLength];
|
|
int sequence = 0;
|
|
for (int off = 0; off < wire_length; off += ChunkBytes, ++sequence)
|
|
{
|
|
int this_length = wire_length - off;
|
|
if (this_length > ChunkBytes)
|
|
{
|
|
this_length = ChunkBytes;
|
|
}
|
|
memset(packet, 0, sizeof(packet));
|
|
PutInt32(packet + 0, 0); // clientID (NetworkManager)
|
|
PutInt32(packet + 4, 0); // gameID
|
|
PutInt32(packet + 8, ConsoleHostID); // fromHost
|
|
PutInt32(packet + 12, 0); // timeStamp
|
|
PutInt32(packet + 16, MessageLength);
|
|
PutInt32(packet + 20, EggMessageID);
|
|
PutInt32(packet + 24, ReliableFlag);
|
|
PutInt32(packet + 28, sequence);
|
|
PutInt32(packet + 32, wire_length);
|
|
PutInt32(packet + 36, this_length);
|
|
memcpy(packet + 40, wire + off, this_length);
|
|
if (MarshalSend(s, (const char *)packet, sizeof(packet)) != sizeof(packet))
|
|
{
|
|
return -1;
|
|
}
|
|
}
|
|
return sequence;
|
|
}
|
|
|
|
//
|
|
// The 28-byte application message (RunMission / StopMission): header with
|
|
// clientID=ApplicationClientID + a 12-byte message (len=12, id, flags).
|
|
//
|
|
static int
|
|
SendApplicationMessage(SOCKET s, int message_id)
|
|
{
|
|
unsigned char packet[16 + 12];
|
|
PutInt32(packet + 0, ApplicationClientID);
|
|
PutInt32(packet + 4, 0);
|
|
PutInt32(packet + 8, ConsoleHostID);
|
|
PutInt32(packet + 12, 0);
|
|
PutInt32(packet + 16, 12);
|
|
PutInt32(packet + 20, message_id);
|
|
PutInt32(packet + 24, ReliableFlag);
|
|
return (MarshalSend(s, (const char *)packet, sizeof(packet)) == sizeof(packet))
|
|
? 0 : -1;
|
|
}
|
|
|
|
//###########################################################################
|
|
// The marshal thread
|
|
//###########################################################################
|
|
|
|
static SOCKET
|
|
ConnectWithRetry(const char *host, int port, int attempts)
|
|
{
|
|
#ifdef BT_STEAM
|
|
{
|
|
unsigned long address_be = inet_addr(host);
|
|
if (address_be != INADDR_NONE && BTSteamNet_IsFakeAddress(address_be))
|
|
{
|
|
for (int attempt = 0; attempt < attempts; ++attempt)
|
|
{
|
|
SOCKET s = BTSteamNet_Connect(address_be, port);
|
|
if (s != INVALID_SOCKET)
|
|
{
|
|
return s;
|
|
}
|
|
Sleep(1000);
|
|
}
|
|
return INVALID_SOCKET;
|
|
}
|
|
}
|
|
#endif
|
|
for (int attempt = 0; attempt < attempts; ++attempt)
|
|
{
|
|
SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
|
if (s == INVALID_SOCKET)
|
|
{
|
|
return INVALID_SOCKET;
|
|
}
|
|
sockaddr_in address;
|
|
memset(&address, 0, sizeof(address));
|
|
address.sin_family = AF_INET;
|
|
address.sin_port = htons((unsigned short)port);
|
|
address.sin_addr.s_addr = inet_addr(host);
|
|
if (connect(s, (sockaddr *)&address, sizeof(address)) == 0)
|
|
{
|
|
int nodelay = 1;
|
|
setsockopt(s, IPPROTO_TCP, TCP_NODELAY,
|
|
(const char *)&nodelay, sizeof(nodelay));
|
|
return s;
|
|
}
|
|
closesocket(s);
|
|
Sleep(1000); // the pod may not be listening yet
|
|
}
|
|
return INVALID_SOCKET;
|
|
}
|
|
|
|
static DWORD WINAPI
|
|
MarshalThread(LPVOID)
|
|
{
|
|
WSADATA wsa;
|
|
WSAStartup(MAKEWORD(2, 2), &wsa); // ref-counted; the engine does its own
|
|
|
|
int wire_length = 0;
|
|
unsigned char *wire = EggWireImage(marshalState.eggPath, &wire_length);
|
|
if (wire == NULL)
|
|
{
|
|
MarshalLog("cannot read egg %s -- marshal down", marshalState.eggPath);
|
|
return 1;
|
|
}
|
|
|
|
//
|
|
// Connect every pod (self first -- our own engine is still booting;
|
|
// retry like the real console).
|
|
//
|
|
SOCKET pods[8];
|
|
char pod_names[8][64];
|
|
int pod_count = 0;
|
|
{
|
|
char list_copy[512];
|
|
strcpy(list_copy, marshalState.podList);
|
|
char *cursor = strtok(list_copy, ",");
|
|
while (cursor != NULL && pod_count < 8)
|
|
{
|
|
char host[48];
|
|
int port = 0;
|
|
const char *colon = strrchr(cursor, ':');
|
|
if (colon != NULL && (port = atoi(colon + 1)) > 0 &&
|
|
(int)(colon - cursor) < (int)sizeof(host))
|
|
{
|
|
memcpy(host, cursor, colon - cursor);
|
|
host[colon - cursor] = 0;
|
|
MarshalLog("connecting pod %s:%d", host, port);
|
|
SOCKET s = ConnectWithRetry(host, port, 60);
|
|
if (s == INVALID_SOCKET)
|
|
{
|
|
MarshalLog("pod %s:%d never answered -- aborting mission start",
|
|
host, port);
|
|
BTFE_RelaunchSelfAndExit("");
|
|
}
|
|
strcpy(pod_names[pod_count], cursor);
|
|
pods[pod_count++] = s;
|
|
}
|
|
cursor = strtok(NULL, ",");
|
|
}
|
|
}
|
|
if (pod_count == 0)
|
|
{
|
|
MarshalLog("empty pod list -- marshal down");
|
|
return 1;
|
|
}
|
|
|
|
//
|
|
// Stream the egg to every pod, settle, launch twice, hold the clock.
|
|
//
|
|
for (int p = 0; p < pod_count; ++p)
|
|
{
|
|
int chunks = SendEggChunks(pods[p], wire, wire_length);
|
|
MarshalLog("egg -> %s (%d bytes, %d chunks)", pod_names[p],
|
|
wire_length, chunks);
|
|
}
|
|
free(wire);
|
|
|
|
Sleep(LaunchSettleSeconds * 1000);
|
|
for (int p = 0; p < pod_count; ++p)
|
|
{
|
|
SendApplicationMessage(pods[p], RunMissionMessageID);
|
|
}
|
|
MarshalLog("RunMission #1 sent to %d pod(s)", pod_count);
|
|
Sleep(LaunchStepSeconds * 1000);
|
|
for (int p = 0; p < pod_count; ++p)
|
|
{
|
|
SendApplicationMessage(pods[p], RunMissionMessageID);
|
|
}
|
|
MarshalLog("RunMission #2 sent -- mission running; clock %ds",
|
|
marshalState.missionSeconds);
|
|
|
|
//
|
|
// Own the mission clock. Drain (and discard) pod->console traffic so
|
|
// the sockets stay healthy -- the console must STAY CONNECTED (a
|
|
// disconnect trips the pod's console-loss path).
|
|
//
|
|
DWORD mission_end = GetTickCount()
|
|
+ (DWORD)marshalState.missionSeconds * 1000;
|
|
char drain[4096];
|
|
while (GetTickCount() < mission_end)
|
|
{
|
|
//
|
|
// select() only understands REAL sockets; Steam pseudo-sockets
|
|
// drain nonblocking on their own pass.
|
|
//
|
|
fd_set readable;
|
|
FD_ZERO(&readable);
|
|
int real_count = 0;
|
|
for (int p = 0; p < pod_count; ++p)
|
|
{
|
|
if (!MarshalIsSteam(pods[p]))
|
|
{
|
|
FD_SET(pods[p], &readable);
|
|
++real_count;
|
|
}
|
|
}
|
|
if (real_count > 0)
|
|
{
|
|
timeval tv = { 1, 0 };
|
|
int ready = select(0, &readable, NULL, NULL, &tv);
|
|
if (ready > 0)
|
|
{
|
|
for (int p = 0; p < pod_count; ++p)
|
|
{
|
|
if (!MarshalIsSteam(pods[p]) &&
|
|
FD_ISSET(pods[p], &readable))
|
|
{
|
|
recv(pods[p], drain, sizeof(drain), 0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Sleep(1000);
|
|
}
|
|
#ifdef BT_STEAM
|
|
for (int p = 0; p < pod_count; ++p)
|
|
{
|
|
if (MarshalIsSteam(pods[p]))
|
|
{
|
|
while (BTSteamNet_Recv(pods[p], drain, sizeof(drain)) > 0)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
MarshalLog("mission clock expired -- StopMission to all pods");
|
|
for (int p = 0; p < pod_count; ++p)
|
|
{
|
|
SendApplicationMessage(pods[p], StopMissionMessageID);
|
|
}
|
|
Sleep(MissionEndGraceSeconds * 1000);
|
|
for (int p = 0; p < pod_count; ++p)
|
|
{
|
|
MarshalClose(pods[p]);
|
|
}
|
|
|
|
MarshalLog("relaunching the menu");
|
|
BTFE_RelaunchSelfAndExit("");
|
|
return 0;
|
|
}
|
|
|
|
int
|
|
BTLocalConsole_Start(
|
|
const char *egg_path,
|
|
const char *pod_list,
|
|
int mission_seconds)
|
|
{
|
|
strncpy(marshalState.eggPath, egg_path, sizeof(marshalState.eggPath) - 1);
|
|
strncpy(marshalState.podList, pod_list, sizeof(marshalState.podList) - 1);
|
|
marshalState.missionSeconds =
|
|
(mission_seconds > 0) ? mission_seconds : 600;
|
|
|
|
HANDLE thread = CreateThread(NULL, 0, MarshalThread, NULL, 0, NULL);
|
|
if (thread == NULL)
|
|
{
|
|
return -1;
|
|
}
|
|
CloseHandle(thread);
|
|
return 0;
|
|
}
|