NEW gated dir game/glass/: btl4fe (green-on-black GDI catalog menu -- map/ time/weather/length/mech/color/pilot/mode/port/peers; egg writer emitting the FULL console egg shape, golden-tested vs MP.EGG: identical section sequence at identical line offsets, the 4 static ordinal pages verbatim, GDI-rendered 128x32/64x16 pilot-name plasma bitmaps in MP.EGG framing) and btl4console (the marshal: a worker-thread console CLIENT speaking the btconsole.py wire protocol verbatim -- 1040-byte chunked egg, 20s settle, RunMission x2, STAY CONNECTED, own the clock, StopMission at expiry; logs marshal.log). The engine sees a real connected console and runs the 100% stock ladder -- NO engine hooks (less invasive than planned: the L4APP.H setters proved unnecessary; launches go through real argv on relaunch). WinMain (one gated block): zero-mission-arg glass launch -> menu -> relaunch self with the mission argv (per-mission process relaunch; BT_FE_* env arms the marshal); post-RunMissions BT_FE_LOOP tail returns to the menu. Menu relaunches CLEAR the BT_FE_* handoff (found live: the inherited env re-armed a marshal instead of showing the menu). Verified end-to-end (synthetic menu drive): menu -> frontend.egg -> relaunch -> marshal feeds self over loopback -> stock console ladder -> mission RUNS (58+ ticks) -> 60s clock -> StopMission -> clean menu return, no env leak. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
406 lines
11 KiB
C++
406 lines
11 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;
|
|
|
|
//###########################################################################
|
|
|
|
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 (send(s, (const char *)packet, sizeof(packet), 0) != 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 (send(s, (const char *)packet, sizeof(packet), 0) == sizeof(packet))
|
|
? 0 : -1;
|
|
}
|
|
|
|
//###########################################################################
|
|
// The marshal thread
|
|
//###########################################################################
|
|
|
|
static SOCKET
|
|
ConnectWithRetry(const char *host, int port, int attempts)
|
|
{
|
|
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)
|
|
{
|
|
fd_set readable;
|
|
FD_ZERO(&readable);
|
|
for (int p = 0; p < pod_count; ++p)
|
|
{
|
|
FD_SET(pods[p], &readable);
|
|
}
|
|
timeval tv = { 1, 0 };
|
|
int ready = select(0, &readable, NULL, NULL, &tv);
|
|
if (ready > 0)
|
|
{
|
|
for (int p = 0; p < pod_count; ++p)
|
|
{
|
|
if (FD_ISSET(pods[p], &readable))
|
|
{
|
|
recv(pods[p], drain, sizeof(drain), 0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
closesocket(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;
|
|
}
|