Files
BT411/game/btl4main.cpp
T
Joe DiPrimaandClaude Fable 5 5fc969ae95 the log day now starts at 6am, because the playtesters are night owls
A midnight boundary splits one evening across two files at exactly the moment
the session is most worth reading whole: the 01:30 crash lands in a different
file from the 23:00 run that set it up. And a 02:00 session is "last night" to
everyone who was in it, so filing it under the new calendar date reads wrong
even when nothing goes bad.

So shift the clock back 6h before taking the date for the FILENAME. The log day
runs 06:00 -> 06:00 and one night stays in one file.

Only the filename moves. The session header and the lastrun breadcrumb each
call GetLocalTime separately (three distinct SYSTEMTIMEs in this function), so
every timestamp a human reads is still true local time -- checked the scoping
rather than assuming it, since sharing one variable would have silently
backdated the header by six hours.

FileTimeToSystemTime carries the month and year boundaries, verified against a
scratch harness rather than reasoned about:

  2026-07-28 20:00 -> solo_20260728.log     one playtest night,
  2026-07-28 23:59 -> solo_20260728.log     start to finish,
  2026-07-29 00:01 -> solo_20260728.log     in a single file
  2026-07-29 05:59 -> solo_20260728.log
  2026-07-29 06:00 -> solo_20260729.log     the boundary
  2026-08-01 01:00 -> solo_20260731.log     month
  2027-01-01 03:00 -> solo_20261231.log     year
  2026-03-01 02:00 -> solo_20260228.log     non-leap February

Nothing in the tooling parses these filenames -- the operator app's own log is
operator_relay.log and _collect_egg is mission settings, not logs -- so the
console auto-transfer is unaffected.

The player-facing text did need fixing though: the bats and README told players
to send the log "dated today", which is now wrong for anyone who plays past
midnight. They now say to take the NEWEST one and ignore the date, with a note
on why. That was already the safer instruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:22:35 -05:00

1580 lines
68 KiB
C++

//===========================================================================//
// File: btl4main.cpp //
// Project: BattleTech Brick: BattleTech LBE Application launcher //
//---------------------------------------------------------------------------//
// Win32 entry point for the reconstructed BattleTech port. //
// //
// Reconstructed/adapted from the Red Planet launcher RP_L4\RPL4.CPP -- the //
// only game-specific differences are the application class (BTL4Application //
// vs RPL4Application), the resource file (BTL4.RES), and the program name. //
// The RP-only mission-review / spool playback branch is intentionally //
// dropped for the first single-player bring-up. //
//===========================================================================//
#define WIN32_LEAN_AND_MEAN
#define _WIN32_WINNT 0x0500
#define WINVER 0x0500
// #define BT_HEAPCHECK // enable to validate the heap on every alloc (heap-corruption hunt)
#include <windows.h>
#include <shellapi.h> // CommandLineToArgvW
#include <btversion.h> // generated: 4.11.<commit count> (<hash>)
#include <crtdbg.h> // _CrtSetDbgFlag (heap validation, gated by BT_HEAPCHECK)
#include <fstream>
#include <iostream>
#include <cstdlib>
#include <cstring>
#include <ctime> // BT_EXPIRE tripwire (time)
#include <string>
#include <vector>
#include <bgfload.h> // LoadBgfFile (BGF probe) -- via game/fwd shim
#include <bt.hpp> // BT umbrella -- engine Entity/Application chain (matches btl4app.cpp)
#include "btl4app.hpp" // BTL4Application (pulls L4Application / Application chain)
#include "appmgr.hpp" // ApplicationManager
#include "resource.hpp" // StreamableResourceFile
#include "memreg.hpp" // Start_Registering / Register_Object / Stop_Registering
// Data version. The check (RESOURCE.cpp StreamableResourceFile ctor) compares our
// version[i] against the file's versionArray[i+1] (it skips the format byte), and the
// file is v1.1.0.6 → versionArray[1..3] = {1,0,6}. So our 3 bytes must be {1,0,6}.
Byte version[3] = { 1, 0, 6 };
extern const char* const ProgName;
const char* const ProgName = "btl4";
Application *btl4App = NULL;
HWND hWnd = NULL;
// Set by Application::StopMissionMessageHandler (APP.cpp): the round ended by
// operator/clock (NOT a window close) -- drives the between-rounds auto-rejoin.
int gBTMissionStoppedByConsole = 0;
// GLASS: resolvers for the per-display windows (declared in l4vb16.h), defined
// HERE off the launcher's own app + window pointers. They deliberately do NOT
// touch the `application`/`ghWnd` globals: those are duplicate-defined and bind
// non-deterministically per link under /FORCE (CMakeLists.txt:186), so a fresh
// engine TU (L4GLASSWIN) can read the NULL copy. btl4App/hWnd live in THIS obj
// and are the real, assigned pointers -- always correct.
class GaugeRenderer;
GaugeRenderer *BTResolveGaugeRenderer()
{
return btl4App ? btl4App->GetGaugeRenderer() : 0;
}
void *BTResolveMainWindow()
{
return (void *)hWnd;
}
//===========================================================================//
// Bring-up player DRIVE input (Tier 2 locomotion).
// Populated by the keyboard handler in WndProc below (WASD / arrow keys)
// and consumed each frame by Mech::PerformAndWatch
// (decomp/reconstructed/mech4.cpp), which walks the player mech and advances
// its localToWorld -- the matrix the render tree AND the chase camera are
// bound to, so the mech moves and the camera follows.
//
// throttle: +1 full ahead .. -1 full reverse turn: +1 right .. -1 left
// Both are the OUTPUT of the virtual-control integrator in mech4.cpp
// (dt-scaled, fps-independent) -- the pod's real inputs were an ABSOLUTE
// analog throttle LEVER + turn stick, which momentary 0/1 keys can't
// express directly (and at 60fps every key tap = full demand = the "too
// sensitive controls" report). The WndProc only records KEY STATE
// (keyFwd/keyBack/keyLeft/keyRight); mech4 integrates:
// W/S = move the persistent throttle lever (tap ~= +/-0.07 fine step,
// hold sweeps full range in ~1.4s, DETENT at zero: braking stops
// AT 0 -- release and press S again to engage reverse)
// A/D = ramped momentary stick (deflects over ~0.4s, auto-centers)
// X = all stop (lever -> 0)
// forced : DEBUG toggle (env BT_FORCE_THROTTLE), default OFF. When set the
// mech walks full-ahead with NO keypress -- used only for headless
// autonomous verification (you can't press keys in a headless run).
//===========================================================================//
// fire: weapon trigger held (1 while the fire key is down).
// fireForced: DEBUG auto-fire (env BT_FORCE_FIRE) -- fires on cooldown with no
// keypress, for headless verification.
struct BTDriveInput { float throttle; float turn; int forced; int fire; int fireForced; float forcedThrottle;
int keyFwd; int keyBack; int keyLeft; int keyRight; int allStop; };
BTDriveInput gBTDrive = { 0.0f, 0.0f, 0, 0, 0, 1.0f, 0, 0, 0, 0, 0 };
// The human clicked X (WM_CLOSE). Read by BTFE_RelaunchSelfAndExit: a
// user-closed window exits for REAL instead of relaunching into the join
// wait (the pod's authentic immortality, wrong for a desktop window).
volatile int gBTUserRequestedExit = 0;
LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_KEYDOWN:
case WM_KEYUP:
// Drive keys are NOT read here: the engine's keyboard reader
// (L4CTRL.cpp:1506) GetMessage()s every WM_KEYUP / WM_CHAR out of the
// queue for its key-command channel, so this WndProc only ever saw the
// KEYDOWNs -- key state latched on and never released (the "controls
// incoherent" bug). The virtual-control integrator in mech4.cpp polls
// GetAsyncKeyState per frame instead (with a foreground guard), which
// is immune to message stealing.
return 0;
case WM_LBUTTONDOWN:
case WM_RBUTTONDOWN:
// COCKPIT SURROUND: route clicks on the composited panel buttons to the
// RIO (glass build) via the layout hit-test. No-op in other modes.
{
extern int gBTGaugeCockpit;
extern int BTCockpitMouseDown(int, int, int, int, int);
if (gBTGaugeCockpit)
{
RECT rc; GetClientRect(hWnd, &rc);
int consumed = BTCockpitMouseDown(
(int)(short)LOWORD(lParam), (int)(short)HIWORD(lParam),
(int)(rc.right - rc.left), (int)(rc.bottom - rc.top),
uMsg == WM_RBUTTONDOWN);
if (consumed && uMsg == WM_LBUTTONDOWN)
SetCapture(hWnd); // a release outside the button still clears it
}
}
return 0;
case WM_LBUTTONUP:
{
extern int gBTGaugeCockpit;
extern void BTCockpitMouseUp(void);
if (gBTGaugeCockpit) { BTCockpitMouseUp(); ReleaseCapture(); }
}
return 0;
case WM_SIZE:
// Window-resize aspect fix (task #20): rebuild the projection for the
// new client aspect so the scene doesn't stretch fat/skinny (the D3D9
// backbuffer stays at the configured size and is stretched into the
// client area; rendering with the client aspect cancels the stretch).
// Under the cockpit LETTERBOX the canvas is scaled uniformly instead,
// so the aspect comes from the view rect -- BTWorldAspectOf handles it.
if (wParam != SIZE_MINIMIZED && LOWORD(lParam) > 0 && HIWORD(lParam) > 0)
{
extern void L4NotifyWindowResized(int client_w, int client_h);
L4NotifyWindowResized((int)LOWORD(lParam), (int)HIWORD(lParam));
// Repaint the letterbox bars: a COPY present only writes its dest
// rect, so whatever the old bars held would otherwise persist. The
// class brush is black, so an erase is all it takes.
extern int gBTCockpitLetterbox;
if (gBTCockpitLetterbox)
InvalidateRect(hWnd, NULL, TRUE);
}
return 0;
case WM_CLOSE:
// The human clicked X / pressed Alt-F4. Without this stamp the
// relaunch chain treats the resulting RunMissions return like a
// round ending and RESURRECTS the client (the pod was immortal by
// design; a desktop window must not be) -- see the gate in
// BTFE_RelaunchSelfAndExit.
gBTUserRequestedExit = 1;
DestroyWindow(hWnd);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hWnd, uMsg, wParam, lParam);
}
#ifdef BT_GLASS
#include "btl4fe.hpp"
#include "btl4console.hpp"
#endif
//
// CRASH SELF-REPORT (2026-07-24, the #35/#41 heisenbug hunt): every unhandled
// exception logs its code, address, and an EBP-chain stack walk as
// MODULE-RELATIVE offsets ("btl4+0x1234") into BT_LOG before the process
// dies -- so any field crash on any machine hands us a resolvable stack (we
// hold the PDB). The walker is guarded per-dereference; a corrupt frame
// chain just truncates the walk.
//
static LONG WINAPI
BTCrashFilter(EXCEPTION_POINTERS *info)
{
unsigned long base = (unsigned long)GetModuleHandleA(NULL);
unsigned long code = info->ExceptionRecord->ExceptionCode;
unsigned long addr = (unsigned long)info->ExceptionRecord->ExceptionAddress;
std::cout << "\n[crash] UNHANDLED EXCEPTION code=0x" << std::hex << code
<< " addr=0x" << addr;
if (addr >= base && addr < base + 0x2000000UL)
std::cout << " (btl4+0x" << (addr - base) << ")";
if (code == 0xC0000005 && info->ExceptionRecord->NumberParameters >= 2)
std::cout << " access=" << std::dec
<< (int)info->ExceptionRecord->ExceptionInformation[0]
<< std::hex << " target=0x"
<< (unsigned long)info->ExceptionRecord->ExceptionInformation[1];
std::cout << "\n[crash] stack:";
unsigned long eip = info->ContextRecord->Eip;
unsigned long ebp = info->ContextRecord->Ebp;
for (int depth = 0; depth < 24; ++depth)
{
std::cout << " ";
if (eip >= base && eip < base + 0x2000000UL)
std::cout << "btl4+0x" << std::hex << (eip - base);
else
std::cout << "0x" << std::hex << eip;
MEMORY_BASIC_INFORMATION mbi;
if (ebp == 0 || (ebp & 3) != 0
|| VirtualQuery((void *)ebp, &mbi, sizeof(mbi)) == 0
|| mbi.State != MEM_COMMIT
|| (mbi.Protect & (PAGE_READWRITE | PAGE_READONLY)) == 0)
break;
unsigned long next_eip = ((unsigned long *)ebp)[1];
unsigned long next_ebp = ((unsigned long *)ebp)[0];
if (next_eip == 0 || next_ebp <= ebp)
break;
eip = next_eip;
ebp = next_ebp;
}
std::cout << std::dec << "\n" << std::flush;
return EXCEPTION_EXECUTE_HANDLER; // die (after the evidence is out)
}
//===========================================================================//
// environ.ini -- the player's settings file (cwd = content\). One KEY=VALUE
// per line; the real environment always WINS, so a launcher .bat or a shell
// export overrides the file rather than fighting it.
//
// ⚠ Loaded EARLY (2026-07-26). It used to be read ~300 lines into WinMain,
// after the platform-profile block had already run its getenv()s -- so every
// setting the profile reads (BT_PLATFORM, BT_COCKPIT, BT_GLASS_PANELS,
// BT_DEV_GAUGES, L4CONTROLS...) was silently ignored from the file and only
// worked as a real env var. It also putenv()'d comment lines verbatim, which
// turned a commented-out option into an environment variable literally named
// "#BT_MFD_SCALE" -- harmless, but it meant shipping a self-documenting file
// would have littered the environment with junk.
//===========================================================================//
//
// The shipped default, written on first run when the file is absent -- the
// bindings.txt convention ([[glass-cockpit]] §bindings.txt is a COMPATIBILITY
// SURFACE): the player customizes it, it stays UNTRACKED, and an
// extract-over-top upgrade therefore never clobbers their settings. Every
// option ships COMMENTED OUT, so a fresh install applies nothing and behaves
// exactly as it did before the file existed.
//
static const char *kEnvironIniDefault =
"# ============================================================================\n"
"# environ.ini -- BattleTech 4.11 settings\n"
"# ============================================================================\n"
"# One KEY=VALUE per line, read at game start. Lines starting with # or ;\n"
"# are comments. A real environment variable always WINS over this file, so\n"
"# a launcher .bat can override anything here. Delete a line to fall back to\n"
"# the built-in default.\n"
"#\n"
"# This file is for PERSISTENT settings only. One-shot switches (BT_JOYCONFIG\n"
"# -- use joyconfig.bat instead) are ignored here on purpose: they are designed\n"
"# to clear themselves after running once, and a file line would re-arm them at\n"
"# every mission start. Test/debug hooks (BT_MP_FORCE_DMG and friends) do not\n"
"# belong here either -- they would silently persist across every session.\n"
"#\n"
"# Input bindings live in bindings.txt beside this file (written with the\n"
"# full documented layout on first run; delete it to restore defaults).\n"
"\n"
"# ---- The cockpit ------------------------------------------------------------\n"
"\n"
"# Where the five MFDs and the map go:\n"
"# (unset) the COCKPIT SURROUND -- instruments composited around\n"
"# the viewscreen in one window (the default)\n"
"# BT_GLASS_PANELS=1 one desktop window PER display, arranged around the\n"
"# game window (the 'exploded' view -- handy for reading\n"
"# or screenshotting a display at full size)\n"
"# BT_COCKPIT=0 the plain docked gauge strip along the bottom\n"
"#BT_GLASS_PANELS=1\n"
"#BT_COCKPIT=0\n"
"\n"
"# Size of the secondary displays in the cockpit surround, as a percentage of\n"
"# their pod size. The pod bolted them down at one size; on a big panel there\n"
"# is room to trade viewscreen for instrument, so turn these up if you want to\n"
"# actually read the other displays while you fly. 100 = as the pod had them.\n"
"# Range 25-200 (out-of-range or unreadable values fall back to the group\n"
"# setting, then to 100). The surround grows to fit whatever you ask for.\n"
"#\n"
"# BT_MFD_SCALE sets all five MFDs at once...\n"
"#BT_MFD_SCALE=100\n"
"# ...and any single display can override it:\n"
"# UL upper left (heat) UC upper center (engineering)\n"
"# UR upper right (comm) LL lower left (left weapons)\n"
"# LR lower right (right weapons)\n"
"#BT_MFD_SCALE_UL=100\n"
"#BT_MFD_SCALE_UC=100\n"
"#BT_MFD_SCALE_UR=100\n"
"#BT_MFD_SCALE_LL=100\n"
"#BT_MFD_SCALE_LR=100\n"
"\n"
"# The portrait map/radar, sized on its own.\n"
"#BT_RADAR_SCALE=100\n"
"\n"
"# Where the map sits:\n"
"# CENTER bottom centre, under the viewscreen, as the pod had it\n"
"# (default; BOTTOM and CENTRE mean the same)\n"
"# LEFT bottom left corner (or BOTTOMLEFT)\n"
"# RIGHT bottom right corner (or BOTTOMRIGHT)\n"
"# MIDLEFT left edge, halfway up (or LEFTCENTER)\n"
"# MIDRIGHT right edge, halfway up (or RIGHTCENTER)\n"
"# Anywhere but CENTER stops it sitting where the road is. In a bottom corner\n"
"# the lower MFD whose corner it takes slides inboard beside it; halfway up a\n"
"# side it leaves the bottom row entirely.\n"
"#BT_RADAR_POS=CENTER\n"
"\n"
"# Phosphor colour of the mono MFDs, RRGGBB. Default is a green tube.\n"
"#BT_COCKPIT_TINT=21FF42\n"
"\n"
"# ---- Display ----------------------------------------------------------------\n"
"\n"
"# The cockpit is fitted into the window at ONE uniform scale, centred, with\n"
"# the leftover black -- drag the window to any shape and nothing distorts.\n"
"# Launch with -fit (or -windowed-fullscreen) for a borderless window over the\n"
"# whole monitor.\n"
"\n"
"# Simulation/render frame rate, integer frames per second. The desktop\n"
"# default is 60; the arcade pods shipped at 25.\n"
"#TARGETFPS=60\n"
"\n"
"# Anti-aliasing sample count, passed straight to Direct3D 9: 0 = off, else\n"
"# 2..16 as the GPU supports. NOTE: turning this on disables the letterbox\n"
"# fit above (the two use incompatible swap modes) and the cockpit stretches\n"
"# to the window instead.\n"
"#MULTISAMPLE=0\n"
"\n"
"# Particle budget, integer. Default 8192.\n"
"#MAXPARTICLES=8192\n"
"\n"
"# ---- Optional ---------------------------------------------------------------\n"
"\n"
"# RGB keyboard lamp mirror (Windows Dynamic Lighting, Windows 11 22H2+):\n"
"# keys bound to a lamp button glow with the panel and flash in step with the\n"
"# on-screen buttons. OFF unless you set 1 (unshipped: the player base is\n"
"# not on Windows 11, and some builds carry only a dormant stub).\n"
"#BT_KEYLIGHT=1\n"
"\n"
"# ---- Multiplayer ------------------------------------------------------------\n"
"\n"
"# 1 = Steam networking (lobbies, FakeIP mesh). Needs the Steam client\n"
"# running; without it the game logs the reason and falls back to Winsock.\n"
"#BT_STEAM_NET=1\n";
//===========================================================================//
// CWD GUARD (2026-07-26, field report).
//
// The engine resolves BTL4.RES, VIDEO\, BTDPL.INI, the eggs AND both player
// config files (bindings.txt, environ.ini) RELATIVE TO CWD -- every launcher
// .bat does `cd content` first, so this was invisible. A BARE launch of the
// exe (double-click, or a shell sitting in build\Release) then:
// - found no resources ("Resource file btl4.res v1.0.0.0 is obsolete!"),
// - wrote a stray bindings.txt / environ.ini / btl4.log NEXT TO THE EXE
// (so the player's real ones in content\ looked like they never appeared),
// - and killed the mission generation the menu launched.
// Reported live: `.\btl4.exe -fit` from build\Release -> menu opens -> crash
// on Launch.
//
// So find content\ ourselves rather than trusting whoever started us. Runs
// BEFORE the log file opens, so the log lands in content\ too -- same place
// the launchers put it. Returns what happened for the boot line.
//===========================================================================//
static const char *gBTCwdFixNote = NULL;
//
// -fit / -windowed-fullscreen, parsed EARLY: the miniconsole front end exits
// this process long before the window-creation code runs, and it rebuilds the
// mission child's command line from scratch -- so without this the flag was
// silently dropped the moment you launched from the menu (field report:
// `btl4.exe -fit` gave a borderless menu and then a windowed mission).
// btl4console.cpp reads it when composing the relaunch.
//
int gBTFitDisplay = 0;
static void BTEnsureContentDirectory(void)
{
if (GetFileAttributesA("BTL4.RES") != INVALID_FILE_ATTRIBUTES)
{
return; // already in the right place
}
char exeDir[MAX_PATH];
if (GetModuleFileNameA(NULL, exeDir, MAX_PATH) == 0)
{
return;
}
char *slash = strrchr(exeDir, '\\');
if (slash == NULL)
{
return;
}
*slash = 0;
// build\Release\btl4.exe -> ..\..\content is the shipped layout; the
// others cover a flattened install and an exe dropped into content\.
static const char *kProbe[] = {
"\\..\\..\\content", "\\..\\content", "\\content",
"", "\\..", "\\..\\.."
};
for (int i = 0; i < (int)(sizeof(kProbe) / sizeof(kProbe[0])); ++i)
{
char candidate[MAX_PATH * 2];
char resource[MAX_PATH * 2];
sprintf_s(candidate, sizeof(candidate), "%s%s", exeDir, kProbe[i]);
sprintf_s(resource, sizeof(resource), "%s\\BTL4.RES", candidate);
if (GetFileAttributesA(resource) != INVALID_FILE_ATTRIBUTES)
{
if (SetCurrentDirectoryA(candidate))
{
gBTCwdFixNote = "found the content directory from the exe path";
}
return;
}
}
gBTCwdFixNote = "NO content directory found (BTL4.RES is nowhere near the "
"exe) -- resources and settings will not load";
}
static void BTWriteDefaultEnvironIni(void)
{
FILE *probe;
if (fopen_s(&probe, "environ.ini", "r") == 0) // already there -- leave it alone
{
fclose(probe);
return;
}
// Only ever write it BESIDE THE RESOURCES. If the cwd guard could not
// find content\, writing here would scatter a stray settings file next to
// whatever the player happened to launch from -- which is exactly the
// confusion the guard exists to end.
if (GetFileAttributesA("BTL4.RES") == INVALID_FILE_ATTRIBUTES)
{
return;
}
FILE *out;
if (fopen_s(&out, "environ.ini", "w") != 0)
return;
fputs(kEnvironIniDefault, out);
fclose(out);
std::cout << "[boot] wrote a default content\\environ.ini (all options "
"commented out -- edit to taste)" << std::endl << std::flush;
}
static void BTLoadEnvironIni(void)
{
BTWriteDefaultEnvironIni();
FILE *file;
if (fopen_s(&file, "environ.ini", "r") != 0)
return;
char line[1024];
int applied = 0, skipped = 0;
while (fgets(line, sizeof(line), file))
{
// strip EOL + trailing blanks
int n = (int)strlen(line);
while (n > 0 && (line[n-1] == '\n' || line[n-1] == '\r' ||
line[n-1] == ' ' || line[n-1] == '\t'))
line[--n] = 0;
const char *p = line;
while (*p == ' ' || *p == '\t') ++p;
if (*p == 0 || *p == '#' || *p == ';') // blank or comment
continue;
const char *eq = strchr(p, '=');
if (eq == NULL || eq == p) // no key, or no '=' at all
{
++skipped;
continue;
}
// The real environment wins: only set what is not already set, so a
// launcher or a shell export beats the file.
char key[256];
size_t klen = (size_t)(eq - p);
if (klen >= sizeof(key)) { ++skipped; continue; }
memcpy(key, p, klen);
key[klen] = 0;
while (klen > 0 && (key[klen-1] == ' ' || key[klen-1] == '\t'))
key[--klen] = 0;
if (getenv(key) != NULL)
continue;
// ONE-SHOT variables never load from the file (merge review
// 2026-07-26). BT_JOYCONFIG is deliberately DELETED from the process
// environment after the wizard runs (the #66 one-shot) so the
// front-end relaunch chain cannot re-run it -- but a file line would
// re-arm it in every relaunched child, since by then there is no real
// env var left to win over the file. Result: the capture wizard at
// every mission start. environ.ini is for persistent settings;
// anything designed to clear itself cannot live in it.
if (_stricmp(key, "BT_JOYCONFIG") == 0)
{
++skipped;
continue;
}
putenv(p);
++applied;
}
fclose(file);
std::cout << "[boot] environ.ini: " << applied << " setting(s) applied"
<< (skipped ? ", " : "") << (skipped ? std::to_string(skipped) : std::string())
<< (skipped ? " line(s) ignored" : "") << std::endl << std::flush;
}
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
// Boot tick for the relaunch storm damper (btl4console.cpp, issue #33):
// generations that die young relaunch with a 5 s pause.
{
extern unsigned long gBTBootTick;
gBTBootTick = GetTickCount();
}
// Crash self-report (see BTCrashFilter above) -- armed before anything
// else so even boot crashes leave a stack in the log.
SetUnhandledExceptionFilter(BTCrashFilter);
// Sit in content\ before ANYTHING resolves a relative path -- the log file
// below included, so a bare launch logs where the launchers log.
BTEnsureContentDirectory();
// BT_CRASHTEST=1: deliberately AV after the log opens -- verifies the
// crash filter's forensic path end-to-end on any machine.
// Heap-corruption hunt (env BT_HEAPCHECK=1; default OFF -- ~100x slower): validate
// the whole debug heap on EVERY alloc/free, so an out-of-bounds write is caught at
// the very next heap op (a stack near the culprit) instead of much later at some
// unrelated free. Route the CRT report to the debugger so cdb breaks at detection.
if (getenv("BT_HEAPCHECK"))
{
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_CHECK_ALWAYS_DF);
_CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_DEBUG);
_CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_DEBUG);
}
// Bring-up: route CRT asserts (e.g. fread stream!=nullptr) to the debugger
// instead of a modal MessageBox, so cdb breaks at the assert and we can dump
// the faulting stack in a headless run. Gated; default OFF (no behavior change).
if (getenv("BT_ASSERT_TO_DEBUGGER"))
{
_CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_DEBUG);
_CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_DEBUG);
}
// Redirect the engine's std::cout/DEBUG_STREAM to a log file. TWO rules,
// in this order (2026-07-28):
//
// BT_LOG SET -> used VERBATIM, append per BT_LOG_APPEND. This is the
// documented multi-instance contract and MUST NOT change:
// mp_a.log/mp_b.log for 2-node runs from one cwd, the
// operator GUI's rotated operator_N.log, and every
// scratchpad/mp_*.sh harness that greps the file it named.
// BT_LOG UNSET -> the exe names a PER-DAY file itself and ALWAYS appends:
// <stem>_YYYYMMDD.log.
//
// WHY the per-day file: the launcher used to rotate (del *.old.log, then
// ren *.log *.old.log), keeping only TWO generations. On 2026-07-27 a
// player crashed twice in an Owens, kept playing, and his own relaunches
// deleted both crash sessions before anyone could ask for them -- the
// stacks are gone permanently. A per-day file has no rotation window to
// fall out of. The stem preserves the launcher identity the .bat used to
// supply; every gate below is set before the log opens and is inherited
// across the menu->mission relaunch.
// The STEM identifies which launcher started us. Hoisted out of the naming
// block because the launch record (lastrun_<stem>.txt) uses it too: one
// shared lastrun.txt let a second bat's `del` destroy the first launch's
// block, and file the first's exit code under the wrong pid.
char logStem[32];
lstrcpynA(logStem, "btl4", sizeof(logStem));
if (getenv("BT_JOYCONFIG")) lstrcpynA(logStem, "joyconfig", sizeof(logStem));
else if (getenv("BT_STEAM_NET")) lstrcpynA(logStem, "steam", sizeof(logStem));
else if (getenv("BT_FE_SOLO")) lstrcpynA(logStem, "solo", sizeof(logStem));
else if (getenv("BT_FE_JOIN") || getenv("BT_RELAY")) lstrcpynA(logStem, "join", sizeof(logStem));
char resolvedLog[MAX_PATH];
int logSelfNamed = 0;
{
const char *env_log = getenv("BT_LOG");
if (env_log != NULL && *env_log != '\0')
{
lstrcpynA(resolvedLog, env_log, MAX_PATH);
}
else
{
SYSTEMTIME lt;
GetLocalTime(&lt); // NOT cmd's %DATE%: locale-ordered, and
// on en-US it contains '/' (path chars).
// THE LOG DAY STARTS AT 06:00 LOCAL, NOT MIDNIGHT. Playtests run
// late, and a midnight boundary splits one evening across two files
// exactly when the session is most worth reading whole -- the 01:30
// crash lands in a different file from the 23:00 run that set it up.
// (A 02:00 session is also "last night" to everyone who was in it,
// so a file named for the new calendar date reads wrong anyway.)
// Shift the clock back 6h before taking the date: the log day then
// runs 06:00 -> 06:00 and one night is one file. Only the FILENAME
// is shifted -- every line inside still carries real local time, and
// the session header still records the true date.
{
const ULONGLONG LogDayStartHour = 6;
FILETIME ft;
if (SystemTimeToFileTime(&lt, &ft)) // pure arithmetic; no TZ
{ // conversion either way
ULARGE_INTEGER u;
u.LowPart = ft.dwLowDateTime;
u.HighPart = ft.dwHighDateTime;
u.QuadPart -= LogDayStartHour * 3600ULL * 10000000ULL; // 100ns
ft.dwLowDateTime = u.LowPart;
ft.dwHighDateTime = u.HighPart;
SYSTEMTIME shifted;
// FileTimeToSystemTime carries month/year boundaries for us,
// so 01:00 on the 1st correctly names the previous month.
if (FileTimeToSystemTime(&ft, &shifted))
lt = shifted;
}
}
// SIZE ROLL-OVER -- never deletes anything, but keeps each FILE small
// enough to actually send. A playtest evening concatenates into one
// file (4.3 MB from a single session in scratchpad/night5/), and the
// channel this evidence travels through is a chat attachment. Part 0
// is <stem>_YYYYMMDD.log; once it passes the cap the NEXT launch opens
// <stem>_YYYYMMDD.1.log, and so on. Checked only at open, so one very
// long session can still overrun -- that is deliberate: never split a
// live session across files.
const DWORD part_max = 8UL * 1024UL * 1024UL;
for (int part = 0; part < 1000; ++part)
{
if (part == 0)
wsprintfA(resolvedLog, "%s_%04d%02d%02d.log",
logStem, (int)lt.wYear, (int)lt.wMonth, (int)lt.wDay);
else
wsprintfA(resolvedLog, "%s_%04d%02d%02d.%d.log",
logStem, (int)lt.wYear, (int)lt.wMonth, (int)lt.wDay, part);
WIN32_FILE_ATTRIBUTE_DATA fad;
if (!GetFileAttributesExA(resolvedLog, GetFileExInfoStandard, &fad))
break; // does not exist yet -- use it
if (fad.nFileSizeHigh == 0 && fad.nFileSizeLow < part_max)
break; // still room to append
}
logSelfNamed = 1;
}
}
std::ofstream logfile;
// A self-named day file appends UNCONDITIONALLY -- the default open mode is
// std::ios::out (TRUNCATE), so relying on the caller to pass BT_LOG_APPEND
// would let the second launch of the day erase the morning. (The operator
// GUI's exported bats do not set it and truncate today.)
logfile.open(resolvedLog,
(logSelfNamed
|| (getenv("BT_LOG_APPEND") != NULL && *getenv("BT_LOG_APPEND") == '1'))
? (std::ios::out | std::ios::app) : std::ios::out);
// If the log cannot be opened, DO NOT silently rebind cout to a dead buffer:
// the header, [boot] and any crash stack would vanish while lastrun still
// claimed a log=<name>. Fall back to a per-pid name, and if even that fails
// leave cout alone so the lines still go somewhere.
if (!logfile.is_open())
{
char fallback[MAX_PATH];
wsprintfA(fallback, "%s_pid%u.log", logStem, (unsigned)GetCurrentProcessId());
logfile.open(fallback, std::ios::out | std::ios::app);
if (logfile.is_open())
lstrcpynA(resolvedLog, fallback, MAX_PATH);
}
if (logfile.is_open())
std::cout.rdbuf(logfile.rdbuf());
// Pin the RESOLVED name (and append mode) into the environment so the
// menu->mission relaunch inherits it verbatim instead of re-deriving it.
// Without this a session started at 23:50 would re-resolve after midnight
// and split across two files -- the fragmentation this change exists to fix.
//
// _putenv_s, NOT SetEnvironmentVariableA. MSVC's CRT keeps its OWN copy of
// the environment; SetEnvironmentVariableA updates only the Win32 block, so
// a later getenv() IN THIS PROCESS still returns the old value (measured:
// getenv reads NULL immediately after a successful SetEnvironmentVariableA).
// That matters because btl4console.cpp/btl4lobby.cpp resolve the day log via
// getenv("BT_LOG") -- with SetEnvironmentVariableA they silently fell back to
// marshal.log and the fold never happened. _putenv_s updates BOTH the CRT
// copy and the Win32 block, so in-process getenv AND the inherited
// environment of the menu->mission relaunch both see it.
//
if (logSelfNamed)
{
_putenv_s("BT_LOG", resolvedLog);
_putenv_s("BT_LOG_APPEND", "1");
}
// SESSION HEADER -- the log must identify ITSELF, because attachments lose
// their filename in transit (Discord renames pasted logs to message.txt;
// most of the 2026-07-27 evidence arrived that way and had to be
// re-identified by hand). It doubles as the session separator inside a
// per-day file, and it puts build+pid ABOVE any [crash] block so the
// btl4+0xNNNN offsets stay matchable to the right PDB.
{
SYSTEMTIME lt;
GetLocalTime(&lt);
char machine[MAX_COMPUTERNAME_LENGTH + 1] = "?";
DWORD machine_len = sizeof(machine);
GetComputerNameA(machine, &machine_len);
std::cout << "\n===== BT411 SESSION"
<< " build=" << BT_VERSION_FULL // FULL = version + commit hash:
// a never-rotated day file spans
// builds, and btl4+0xNNNN offsets
// are meaningless without the exact
// build to match a PDB against.
// WHO and WHICH BOX -- the operator receives many of these from many
// players at once, often renamed in transit, so a file must be
// attributable from its CONTENTS alone. callsign is the name the
// operator actually knows a player by; user/machine survive even when
// no callsign has been chosen yet (menu, solo, joyconfig).
<< " machine=" << machine
<< " user=" << (getenv("USERNAME") ? getenv("USERNAME") : "?")
<< " callsign=" << (getenv("BT_CALLSIGN") && *getenv("BT_CALLSIGN")
? getenv("BT_CALLSIGN") : "-")
<< " mode=" << logStem
<< " pid=" << (unsigned)GetCurrentProcessId()
<< " local=" << lt.wYear << "-"
<< (lt.wMonth < 10 ? "0" : "") << lt.wMonth << "-"
<< (lt.wDay < 10 ? "0" : "") << lt.wDay << " "
<< (lt.wHour < 10 ? "0" : "") << lt.wHour << ":"
<< (lt.wMinute< 10 ? "0" : "") << lt.wMinute << ":"
<< (lt.wSecond< 10 ? "0" : "") << lt.wSecond
<< " log=" << resolvedLog
<< " =====" << std::endl << std::flush;
}
if (getenv("BT_CRASHTEST") != NULL && *getenv("BT_CRASHTEST") == '1')
{
*(volatile int *)0 = 0; // BT_CRASHTEST: exercise BTCrashFilter
}
// FIRST-BREATH line, flushed IMMEDIATELY (field 2026-07-23: a player's
// exe died repeatedly with a 0-byte join.log -- indistinguishable from
// "never ran"). After this line, an empty log can only mean the exe was
// killed before WinMain (antivirus / loader) -- an external cause, not
// ours. Cheap forensics: version, PID, args, generation markers.
std::cout << "[boot] btl4 " << BT_VERSION_STRING << " pid="
<< (unsigned)GetCurrentProcessId()
<< " args='" << (lpCmdLine ? lpCmdLine : "") << "'"
<< (getenv("BT_FE_LOOP") ? " (relaunched generation)" : " (first process)")
<< std::endl << std::flush;
// LAUNCH BREADCRUMB -- replaces launch_report.txt (Gitea #41 forensics).
// The .bat deletes this file before launching and tests for it afterwards:
// PRESENT means the exe reached WinMain, ABSENT means it was blocked before
// any of our code ran (loader failure / antivirus). This REPLACES the old
// "if not exist <log>" probe, which a per-day log silently breaks -- the
// file survives from earlier launches, so the probe could never fire again
// and every healthy run would be reported as blocked. It also records
// which day-log this launch actually wrote to, which the .bat cannot derive
// itself (cmd has no locale-safe date).
// APPEND, never truncate: the .bat deletes this file before launching and
// writes its own "launching" line into it FIRST, so truncating here would
// erase the outside half of the bracket. One file carries the whole launch
// record (bat -> exe -> bat), which is why launch_report.txt is gone.
// PER-STEM name (lastrun_solo.txt, lastrun_join.txt, ...): one shared
// lastrun.txt meant a second launcher's `del` wiped the first launch's block
// and filed its exit code under the wrong pid. Each bat now deletes and
// probes only its OWN record. Self-identifying for the same reason the log
// header is: these arrive from many players, often renamed.
{
SYSTEMTIME lt;
GetLocalTime(&lt);
char machine[MAX_COMPUTERNAME_LENGTH + 1] = "?";
DWORD machine_len = sizeof(machine);
GetComputerNameA(machine, &machine_len);
char lastrun_name[MAX_PATH];
wsprintfA(lastrun_name, "lastrun_%s.txt", logStem);
std::ofstream breadcrumb(lastrun_name, std::ios::out | std::ios::app);
if (breadcrumb.is_open())
{
breadcrumb << "exe reached WinMain"
<< " build=" << BT_VERSION_FULL
<< " machine=" << machine
<< " user=" << (getenv("USERNAME") ? getenv("USERNAME") : "?")
<< " callsign=" << (getenv("BT_CALLSIGN") && *getenv("BT_CALLSIGN")
? getenv("BT_CALLSIGN") : "-")
<< " mode=" << logStem
<< " pid=" << (unsigned)GetCurrentProcessId()
<< " local=" << lt.wYear << "-"
<< (lt.wMonth < 10 ? "0" : "") << lt.wMonth << "-"
<< (lt.wDay < 10 ? "0" : "") << lt.wDay << " "
<< (lt.wHour < 10 ? "0" : "") << lt.wHour << ":"
<< (lt.wMinute < 10 ? "0" : "") << lt.wMinute << ":"
<< (lt.wSecond < 10 ? "0" : "") << lt.wSecond
<< "\nlog=" << resolvedLog
<< "\nargs=" << (lpCmdLine ? lpCmdLine : "") << "\n";
}
}
// Say so when we had to go looking for content\ -- a player who launched
// the exe directly should see WHY their settings appeared where they did.
if (gBTCwdFixNote != NULL)
{
char here[MAX_PATH] = { 0 };
GetCurrentDirectoryA(MAX_PATH, here);
std::cout << "[boot] cwd: " << gBTCwdFixNote << " -> " << here
<< std::endl << std::flush;
}
// The player's settings, before ANYTHING reads the environment (the
// platform profile, the joystick wizard, the layout resolver...).
BTLoadEnvironIni();
if (lpCmdLine && (strstr(lpCmdLine, "-fit") != 0 ||
strstr(lpCmdLine, "-windowed-fullscreen") != 0))
{
gBTFitDisplay = 1;
}
// BT_JOYCONFIG=1: the generic-joystick capture wizard (flight sticks /
// HOTAS / pedals -- L4JOY.h). Console prompts detect which device/axis
// the player moves for each pod control and write the joystick section
// of content\bindings.txt, then the boot continues into the game with
// the new bindings live (PadRIO loads the file after this point).
if (getenv("BT_JOYCONFIG") != NULL && *getenv("BT_JOYCONFIG") != '0')
{
extern int BTJoyConfigWizard(void);
BTJoyConfigWizard();
//
// ONE-SHOT (input-path audit row 10): joyconfig.bat sets BT_JOYCONFIG=1
// *and* BT_FE_SOLO=1, so the wizard's process hands off to the front end,
// which CreateProcess()es the next generation with the CURRENT environment
// (btl4console.cpp clears only BT_FE_EGG/PODS/SECS/LOOP). The child then
// re-entered the wizard and blocked on console input BEHIND the 3D window
// -- an unrecoverable hang for anyone who configures a joystick. Clear it
// in the Win32 environment (what CreateProcess inherits, matching the
// console's own SetEnvironmentVariableA pattern) the moment the wizard is
// done, so exactly one generation ever runs it.
//
SetEnvironmentVariableA("BT_JOYCONFIG", NULL);
}
// MP wire-format probe (env BT_NET_PROBE=1): print the exact packet constants
// the console emulator (tools/btconsole.py) must speak, computed from OUR build
// (message IDs chain across class enums at compile time -- never hand-compute).
if (getenv("BT_NET_PROBE"))
{
std::cout << "[netprobe] ReceiveEggFileMessageID=" << (int)NetworkManager::ReceiveEggFileMessageID
<< " sizeof(NetworkPacketHeader)=" << sizeof(NetworkPacketHeader)
<< " sizeof(Receiver__Message)=" << sizeof(Receiver__Message)
<< " sizeof(ReceiveEggFileMessage)=" << sizeof(NetworkManager::ReceiveEggFileMessage)
<< " sizeof(Time)=" << sizeof(Time)
<< " NetworkManagerClientID=" << (int)NetworkClient::NetworkManagerClientID
<< " RunMissionMessageID=" << (int)Application::RunMissionMessageID
<< " sizeof(RunMissionMessage)=" << sizeof(Application::RunMissionMessage)
<< " ApplicationClientID=" << (int)NetworkClient::ApplicationClientID
<< "\n" << std::flush;
return 0;
}
// BGF loader probe (env BT_PROBE_BGF=<name>[,<name>...] or =ALL): load the named
// model(s) directly through the engine's LoadBgfFile -- NO app, NO renderer, NO
// mechs -- then exit. Isolates the loader: if this crashes/corrupts, the bug is
// in bgfload.cpp itself (H1); if hundreds of iterations are clean, the in-game
// corruption comes from elsewhere and the loader's frees only DETECT it (H2).
// Repeats each load BT_PROBE_N times (default 8). cwd must be the pod BT dir
// (the loader indexes VIDEO\ relative to cwd). Combine with BT_HEAPCHECK=1.
if (const char *probe = getenv("BT_PROBE_BGF"))
{
const int reps = getenv("BT_PROBE_N") ? atoi(getenv("BT_PROBE_N")) : 8;
std::vector<std::string> names;
if (_stricmp(probe, "ALL") == 0)
{
WIN32_FIND_DATAA fd;
HANDLE h = FindFirstFileA("VIDEO\\GEO\\*.BGF", &fd);
if (h != INVALID_HANDLE_VALUE)
{
do { names.push_back(fd.cFileName); } while (FindNextFileA(h, &fd));
FindClose(h);
}
}
else
{
std::string s(probe);
size_t p = 0;
while (p < s.size())
{
size_t c = s.find(',', p);
if (c == std::string::npos) c = s.size();
if (c > p) names.push_back(s.substr(p, c - p));
p = c + 1;
}
}
std::cout << "[probe] BGF probe: " << names.size() << " model(s) x " << reps
<< " reps, heapcheck=" << (getenv("BT_HEAPCHECK") ? 1 : 0) << "\n" << std::flush;
int fails = 0;
for (size_t i = 0; i < names.size(); ++i)
{
for (int r = 0; r < reps; ++r)
{
BgfData data;
bool ok = LoadBgfFile(names[i], data);
if (r == 0 || !ok)
std::cout << "[probe] " << names[i] << " rep " << r
<< (ok ? " OK" : " FAIL") << " verts=" << data.verts.size()
<< " idx=" << data.indices.size() << " tris=" << data.tris
<< (ok ? "" : (" err=" + data.error)) << "\n" << std::flush;
if (!ok) ++fails;
if (!_CrtCheckMemory())
{
std::cout << "[probe] *** HEAP CORRUPT after " << names[i]
<< " rep " << r << " ***\n" << std::flush;
return 3;
}
}
}
std::cout << "[probe] DONE: heap clean, fails=" << fails << "\n" << std::flush;
return 0;
}
// ---------------------------------------------------------------------------
// PLATFORM PROFILE -- dev (default) vs pod. This is NOT a code fork: the
// profile just selects WHICH environment preset the existing video/gauge/input
// code reads (the pod multi-surface path already exists -- FindBestAdapterIndices
// / SVGA16::BuildWindows / L4GaugeRenderer -- gated on these env vars).
// dev = single 800x600 window + keyboard (no L4GAUGE -> MakeGaugeRenderer
// returns NULL, btl4app.cpp:353). The working dev build.
// pod = multi-surface gauges/MFDs (L4GAUGE set) + RIO cockpit I/O, mirroring
// content/SETENV.BAT (the authentic pod preset). The `if getenv==NULL`
// guard lets a real pod environment (SETENV.BAT) override every value.
// Select with env BT_PLATFORM=pod|dev (robust; primary) or a `-platform pod`
// launcher arg. ⚠ A FULL pod run needs the pod's 2 adapters / 7 monitors + RIO
// & plasma serial + GAUGE\L4GAUGE.INI (Phase-8, on real hardware). On a dev box
// `-platform pod` ATTEMPTS the path (FindBestAdapterIndices degrades to the
// primary adapter) -- selectable for bring-up, not fully validated off-pod.
// ---------------------------------------------------------------------------
//
// MENU-MODE detection FIRST (glass): the front-end process must NOT
// apply a platform profile -- its putenv()s would leak into the
// relaunched mission child and defeat the child's own preset (found
// live: a direct btl4.exe start ran the menu under the DEV profile,
// the child inherited L4CONTROLS=KEYBOARD, and the glass preset could
// never select PAD -- no PadRIO, no panel, plasma only).
//
int gBTPlatformPod = 0;
int gBTPlatformGlass = 0;
{
const char *pe = getenv("BT_PLATFORM");
int platform_dev = 0;
if (pe && _stricmp(pe, "pod") == 0) gBTPlatformPod = 1;
if (pe && _stricmp(pe, "glass") == 0) gBTPlatformGlass = 1;
if (pe && _stricmp(pe, "dev") == 0) platform_dev = 1;
// Convenience: also accept a raw `-platform pod` on the command line,
// parsed off lpCmdLine independently of L4Application::ParseCommandLine.
const char *pf = lpCmdLine ? strstr(lpCmdLine, "-platform") : 0;
if (pf)
{
char pv[16] = { 0 };
if (sscanf(pf + 9, " %15s", pv) == 1 && _stricmp(pv, "pod") == 0)
gBTPlatformPod = 1;
if (pv[0] && _stricmp(pv, "glass") == 0)
gBTPlatformGlass = 1;
if (pv[0] && _stricmp(pv, "dev") == 0)
platform_dev = 1;
}
//
// DESKTOP DEFAULT = GLASS (2026-07-22, "competing keymaps" fix): every
// launcher that forgot BT_PLATFORM=glass booted the DEV profile -- no
// PadRIO, the bring-up keyboard bridge owning the keys ("the old
// keymap again": play bats, steam bat, then the operator console,
// each needed the same one-line fix). Glass is now the DEFAULT when
// no platform is chosen, so omission can never resurrect the old
// input stack. BT_PLATFORM=dev opts back into the legacy DEV profile
// for debugging; the FUTURE pod cabinet must set BT_PLATFORM=pod in
// its SETENV.BAT (contract recorded in context/glass-cockpit.md --
// note this changes the old "bare boot == cabinet shape" assumption).
//
if (!gBTPlatformPod && !gBTPlatformGlass && !platform_dev)
gBTPlatformGlass = 1;
}
// MENU GATE (2026-07-21, amended 2026-07-22): the miniconsole menu runs
// on a zero-arg GLASS boot. With glass now the desktop DEFAULT, a bare
// double-click of btl4.exe opens the menu -- the desired desktop UX.
// The pod cabinet opts out via BT_PLATFORM=pod (SETENV.BAT contract);
// BT_PLATFORM=dev keeps the old bare-boot wait-for-egg shape.
int fe_menu_mode = 0;
{
int fe_has_egg = lpCmdLine && strstr(lpCmdLine, "-egg") != NULL;
int fe_has_net = lpCmdLine && strstr(lpCmdLine, "-net") != NULL;
fe_menu_mode = !fe_has_egg && !fe_has_net && getenv("BT_FE_EGG") == NULL
&& (gBTPlatformGlass || getenv("BT_FE_MENU") != NULL);
}
if (fe_menu_mode)
{
// the front end owns this process; no profile putenvs (see above)
}
else if (gBTPlatformPod)
{
// POD profile. RIO cockpit input, which falls back to KEYBOARD when the
// serial port isn't present (e.g. a dev box: "RIO initialization failed!
// Shutting Down Serial Port!" -> keyboard).
if (getenv("L4CONTROLS") == NULL) putenv("L4CONTROLS=RIO,KEYBOARD");
// The multi-surface gauges/MFDs are a POD-HARDWARE capability: each surface
// needs its OWN fullscreen D3D device on one of the pod's 2 video cards
// (separate adapters), which can't be created on a typical dev box -- the
// L4GaugeRenderer/SVGA16 path bails there. So `-platform pod` does NOT
// auto-enable them; it stays bootable everywhere as single-window + the pod
// INPUT profile. On the REAL pod the gauges come from content/SETENV.BAT
// (which sets L4GAUGE + the gauge/adapter env); the existing, untouched
// FindBestAdapterIndices / SVGA16 / L4GaugeRenderer path then runs.
// To force-test the surfaces off-pod, set L4GAUGE explicitly (needs >=2 real
// display ADAPTERS, not just monitors). FOLLOW-UP for a dev-visible pod HUD:
// composite the MFDs/gauges into the single window ("MFD compositing on dev").
if (getenv("L4GAUGE") != NULL && getenv("L4PLASMA") == NULL)
putenv("L4PLASMA=com2"); // only when the pod env already asked for gauges
}
#ifdef BT_GLASS
else if (gBTPlatformGlass)
{
// GLASS profile (dev tooling, step 2e): the desktop cockpit --
// PadRIO input (XInput+keyboard, content\bindings.txt), the
// dev-composited MFD surfaces, and the desktop plasma window.
// Each only when not already set, so a developer env overrides.
if (getenv("L4CONTROLS") == NULL) putenv("L4CONTROLS=PAD,KEYBOARD");
if (getenv("BT_DEV_GAUGES") == NULL) putenv("BT_DEV_GAUGES=1");
if (getenv("L4PLASMA") == NULL) putenv("L4PLASMA=SCREEN");
// The SECONDARY-DISPLAY LAYOUT (surround / per-display windows / docked
// strip / separate window) is resolved once, below, after the shared
// defaults land -- it used to be half-decided here, which is how
// BT_COCKPIT=0 came to mean "per-display windows" despite being
// documented as the dock-bottom opt-out (the dock was unreachable under
// glass). This block now only sets the profile's env presets.
}
#endif
else
{
// DEV profile (default): keyboard controls, single 800x600 window.
if (getenv("L4CONTROLS") == NULL) putenv("L4CONTROLS=KEYBOARD");
}
// Shared renderer/config defaults (both profiles). L4DPLCFG=BTDPL.INI gives
// DPLReadEnvironment the real BT environment (paths/fog/lights/ambient) instead
// of the dpldflt.ini fallback.
if (getenv("L4DPLCFG") == NULL) putenv("L4DPLCFG=BTDPL.INI");
if (getenv("DPLARG") == NULL) putenv("DPLARG=1");
if (getenv("MULTISAMPLE") == NULL) putenv("MULTISAMPLE=0");
if (getenv("TARGETFPS") == NULL) putenv("TARGETFPS=60");
if (getenv("MAXPARTICLES")== NULL) putenv("MAXPARTICLES=8192");
// DEV-COMPOSITE GAUGES (opt-in, default OFF): BT_DEV_GAUGES wakes the (otherwise
// dormant) gauge renderer so the pod's gauges/MFDs render + can be composited into
// the dev window. Setting L4GAUGE makes MakeGaugeRenderer build the renderer; on a
// dev box the pod multi-surface (SVGA16 per-adapter fullscreen devices) stays at 0
// surfaces (guarded in SVGA16::Update), and a composite pass in the main renderer
// blits the CPU-rastered pixelBuffer as a window inset. See docs/GAUGE_COMPOSITE.md.
if (getenv("BT_DEV_GAUGES") != NULL && getenv("L4GAUGE") == NULL)
putenv("L4GAUGE=640x480x16");
// -------------------------------------------------------------------------
// SECONDARY-DISPLAY LAYOUT -- resolved ONCE, here, now that every profile
// putenv has landed. It used to be decided TWICE (the glass profile block
// picked BT_PAD_PANEL/BT_GLASS_PANELS; the window-sizing block further down
// re-derived cockpit-vs-dock from the same env with its own copy of the
// precedence) and the boot banner read NEITHER -- it announced "per-display
// cockpit windows" for every glass boot, including the surround default.
// The split also broke BT_COCKPIT=0: documented as the dock-bottom opt-out,
// it actually landed on the per-display windows, leaving the docked strip
// unreachable under the glass profile.
//
// One resolver, one answer, consumed by the banner, the pad-panel decision
// and the sizing block. Precedence:
// BT_GLASS_PANELS!=0 per-display cockpit windows (explicit only)
// BT_DEV_GAUGES_WINDOW the legacy separate MFD window
// BT_DEV_GAUGES_DOCK the docked bottom strip
// BT_COCKPIT=0 ...also the docked strip (the documented opt-out)
// (nothing) COCKPIT SURROUND -- the glass default
// -------------------------------------------------------------------------
enum BTGlassLayout { GlassLayoutNone, GlassLayoutCockpit, GlassLayoutPanels,
GlassLayoutDock, GlassLayoutWindow };
int glassLayout = GlassLayoutNone;
if (!fe_menu_mode && getenv("BT_DEV_GAUGES") != NULL)
{
int panels = 0;
#ifdef BT_GLASS
{ extern int BTGlassPanelsActive(); panels = BTGlassPanelsActive(); }
#endif
const char *ck = getenv("BT_COCKPIT");
if (panels) glassLayout = GlassLayoutPanels;
else if (getenv("BT_DEV_GAUGES_WINDOW")) glassLayout = GlassLayoutWindow;
else if (getenv("BT_DEV_GAUGES_DOCK")) glassLayout = GlassLayoutDock;
else if (ck != NULL && ck[0] == '0') glassLayout = GlassLayoutDock;
else glassLayout = GlassLayoutCockpit;
// The surround and the per-display windows carry their own buttons; the
// dock strip and the separate MFD window don't, so the single combined
// pad panel supplies them (else the whole 72-button field is unclickable).
if ((glassLayout == GlassLayoutDock || glassLayout == GlassLayoutWindow)
&& getenv("BT_PAD_PANEL") == NULL)
putenv("BT_PAD_PANEL=1");
}
static const char *kGlassLayoutName[] = {
"no dev gauges",
"cockpit surround",
"per-display cockpit windows [BT_GLASS_PANELS]",
"docked gauge strip",
"separate MFD window"
};
std::cout << "[boot] platform profile: "
<< (fe_menu_mode ? "MENU (front end -- no profile applied)"
: gBTPlatformPod ? "POD (RIO cockpit input; multi-surface gauges/MFDs via pod hardware or explicit L4GAUGE)"
: gBTPlatformGlass ? "GLASS (PadRIO + plasma window)"
: "DEV (single window + keyboard)");
if (!fe_menu_mode)
std::cout << " [secondary displays: " << kGlassLayoutName[glassLayout] << "]";
std::cout << std::endl << std::flush;
#ifdef BT_GLASS
//
// MINICONSOLE FRONT END (glass step 3): a zero-mission-arg launch (no
// -egg, no -net) runs the menu INSTEAD of the game, then RELAUNCHES
// this exe with the chosen mission's real arguments -- the per-mission
// process-relaunch pattern (reconstructed gBT* globals do not survive
// in-process re-init). BT_FE_* env vars arm the mission process's
// LocalConsole marshal; BT_FE_LOOP returns post-mission exits to the
// menu. The console-wire ladder itself is 100% stock -- the engine
// sees a real connected console (btl4console.cpp).
//
{
if (fe_menu_mode)
{
BTFeLaunchSpec fe_spec;
if (BTFrontEnd_Run(&fe_spec) != 0 || fe_spec.mode == BTFeLaunchNone)
{
return 0; // quit from the menu
}
char fe_arguments[192];
char fe_value[64];
SetEnvironmentVariableA("BT_FE_LOOP", "1");
SetEnvironmentVariableA("BT_PLATFORM", "glass");
switch (fe_spec.mode)
{
case BTFeLaunchRawSolo:
SetEnvironmentVariableA("BT_FE_EGG", NULL);
sprintf(fe_arguments, "-egg %s -platform glass", fe_spec.eggPath);
break;
case BTFeLaunchJoinLan:
SetEnvironmentVariableA("BT_FE_EGG", NULL);
sprintf(fe_arguments, "-net %d -platform glass", fe_spec.consolePort);
break;
case BTFeLaunchJoinRelay:
// BT_FE_JOIN (join.bat): the callsign/mech request rides env
// into the relaunched pod; L4NET's SEAT_REQUEST carries it to
// the relay, which writes it into the session egg. Args = the
// universal join contract (OPERATOR.EGG + port 1501); the
// operator console overrides the port per LOCAL instance via
// BT_FE_JOINPORT (two instances on one box can't share -net).
SetEnvironmentVariableA("BT_FE_EGG", NULL);
SetEnvironmentVariableA("BT_CALLSIGN", fe_spec.joinCallsign);
SetEnvironmentVariableA("BT_MECH", fe_spec.joinVehicle);
{
const char *join_port = getenv("BT_FE_JOINPORT");
sprintf(fe_arguments, "-egg OPERATOR.EGG -net %d -platform glass",
join_port != NULL ? atoi(join_port) : 1501);
}
break;
#ifdef BT_STEAM
case BTFeLaunchJoinSteam:
SetEnvironmentVariableA("BT_FE_EGG", NULL);
SetEnvironmentVariableA("BT_STEAM_NET", "1");
SetEnvironmentVariableA("BT_FE_MYFAKE", fe_spec.steamMyToken);
SetEnvironmentVariableA("BT_FE_STEAMMAP", fe_spec.steamMap);
sprintf(fe_arguments, "-net %d -platform glass", fe_spec.consolePort);
break;
case BTFeLaunchHostSteam:
SetEnvironmentVariableA("BT_STEAM_NET", "1");
SetEnvironmentVariableA("BT_FE_MYFAKE", fe_spec.steamMyToken);
SetEnvironmentVariableA("BT_FE_STEAMMAP", fe_spec.steamMap);
// falls into the marshal-armed default
#endif
default: // Solo / HostLan: the marshal armed via env handoff
SetEnvironmentVariableA("BT_FE_EGG", fe_spec.eggPath);
SetEnvironmentVariableA("BT_FE_PODS", fe_spec.podList);
sprintf(fe_value, "%d", fe_spec.missionSeconds);
SetEnvironmentVariableA("BT_FE_SECS", fe_value);
sprintf(fe_arguments, "-net %d -platform glass", fe_spec.consolePort);
break;
}
{
// The user just clicked JOIN/LAUNCH in the menu -- never
// storm-damp a human (btl4console.cpp, issue #33).
extern int gBTRelaunchUserInitiated;
gBTRelaunchUserInitiated = 1;
}
BTFE_RelaunchSelfAndExit(fe_arguments); // never returns
}
#ifdef BT_STEAM
//
// The Steam transport (step 4b): BT_STEAM_NET=1 (set by the lobby
// launch path, or by hand for testing) brings up FakeIP + SDR
// before the engine opens any connection. Degrades to Winsock.
//
if (getenv("BT_STEAM_NET") != NULL && *getenv("BT_STEAM_NET") != '0')
{
extern int BTSteamNet_Install();
BTSteamNet_Install();
}
#endif
//
// A marshal-armed mission process: start the LocalConsole worker
// (it retries until our own console listener is up).
//
const char *fe_egg = getenv("BT_FE_EGG");
if (fe_egg != NULL && fe_egg[0])
{
const char *fe_pods = getenv("BT_FE_PODS");
const char *fe_secs = getenv("BT_FE_SECS");
BTLocalConsole_Start(fe_egg,
fe_pods ? fe_pods : "",
fe_secs ? atoi(fe_secs) : 600);
}
}
#endif
// DEBUG (bring-up, default OFF): force the player mech to walk full-ahead with
// no keypress so locomotion + camera-follow can be verified in a headless run.
// BT_FORCE_THROTTLE may carry a VALUE: +1 full run, ~0.3 walk, -1 reverse (default 1.0).
if (getenv("BT_FORCE_THROTTLE") != NULL)
{
gBTDrive.forced = 1;
float v = (float)atof(getenv("BT_FORCE_THROTTLE"));
gBTDrive.forcedThrottle = (v != 0.0f) ? v : 1.0f; // bare "1"/empty -> full ahead
}
if (getenv("BT_FORCE_FIRE") != NULL) gBTDrive.fireForced = 1;
// (environ.ini is loaded much earlier now -- see BTLoadEnvironIni.)
// Version scheme: 4.10 = the 1995 arcade release; 4.11 = this win32
// reconstruction; build = git commit count, hash pins exact source
// ('+' = built from an uncommitted tree). Stamped by tools/btversion.cmake.
std::cout << "BattleTech " << BT_VERSION_FULL
<< " (win32 reconstruction of Tesla 4.10)" << std::endl << std::flush;
#ifdef BT_EXPIRE
// TESTER-BUILD EXPIRY TRIPWIRE (compile gate BT_EXPIRE, default ON --
// see CMakeLists). Distributed zips go stale and stray; refuse to run
// BT_EXPIRE_DAYS after the build day (btversion.h BT_BUILD_UNIX, UTC
// midnight). Dev builds re-stamp every build, so this never fires
// locally. Quality gate, not DRM.
{
const time_t expires = (time_t)BT_BUILD_UNIX
+ (time_t)BT_EXPIRE_DAYS * 86400;
const time_t now = time(0);
if (now >= expires)
{
std::cout << "[boot] TEST BUILD EXPIRED (built " << BT_BUILD_DATE
<< ", " << BT_EXPIRE_DAYS << "-day test window)"
<< std::endl << std::flush;
char expired_text[256];
sprintf(expired_text,
"This BattleTech test build (%s, built %s) has expired.\n\n"
"Test builds stop working %d days after they are made so\n"
"stale copies don't wander around.\n\n"
"Ask the operator for the current build.",
BT_VERSION_STRING, BT_BUILD_DATE, (int)BT_EXPIRE_DAYS);
MessageBoxA(NULL, expired_text,
"BattleTech -- test build expired", MB_OK | MB_ICONWARNING);
return 0;
}
int days_left = (int)((expires - now + 86399) / 86400);
std::cout << "[boot] test build window: " << days_left
<< " day(s) left (built " << BT_BUILD_DATE << ")"
<< std::endl << std::flush;
}
#endif
// CPU pin (timing stability). BT_AFFINITY overrides the mask; =0 disables --
// required for multi-instance runs (two instances pinned to core 0 starve
// each other, and the L4NET connect-retry loop busy-waits).
{
DWORD_PTR mask = getenv("BT_AFFINITY")
? (DWORD_PTR)strtoul(getenv("BT_AFFINITY"), 0, 0) : (DWORD_PTR)1;
if (mask != 0)
SetThreadAffinityMask(GetCurrentThread(), mask);
}
// Parse the command line so the egg (mission descriptor) source is set:
// btl4.exe -egg <file.egg>
// With no network common address (single-user mode), L4NetworkManager reads
// GetEggNotationFileName() and posts the egg locally -- no real net needed.
int argc;
LPWSTR *argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (!L4Application::ParseCommandLine(argc, argv, L4Application::ParseToken))
{
std::cout << "ParseCommandLine failed (need: -egg <file>)" << std::endl << std::flush;
return 1;
}
Start_Registering();
// Create the main window (single 800x600 view for dev; pod multi-monitor is Phase 8).
WNDCLASS wc;
if (!hPrevInstance)
{
wc.style = 0;
wc.lpfnWndProc = (WNDPROC)WndProc;
wc.cbClsExtra = 0;
wc.cbWndExtra = 0;
wc.hInstance = hInstance;
wc.hIcon = LoadIcon((HINSTANCE)NULL, IDI_APPLICATION);
wc.hCursor = LoadCursor((HINSTANCE)NULL, IDC_ARROW);
wc.hbrBackground = (HBRUSH)GetStockObject(BLACK_BRUSH);
wc.lpszMenuName = NULL;
wc.lpszClassName = L"MainWndClass";
if (!RegisterClass(&wc)) return FALSE;
}
//
// Window/backbuffer resolution (task #20): the engine already parses
// `-res W H` (L4APP.cpp:165) for the D3D backbuffer; the window must match
// or the render is stretched. Parse the same args here for the WINDOW
// (client area = render size); default stays the authentic pod 800x600.
// Launch with e.g. `-egg DEV.EGG -res 1600 1200` for a 4x-pixel view.
//
int winW = 800, winH = 600;
{
const char *rp = lpCmdLine ? strstr(lpCmdLine, "-res") : 0;
if (rp != 0)
{
int w = 0, h = 0;
if (sscanf(rp + 4, " %d %d", &w, &h) == 2 && w >= 320 && h >= 240)
{
winW = w;
winH = h;
}
}
}
// DOCK-BOTTOM (single-window gauges, 2026-07-12): under BT_DEV_GAUGES the
// gauge strip is APPENDED below the world view in THIS window (strip height
// = width x 480/1320, the panel's design aspect); BT_DEV_GAUGES_WINDOW=1
// restores the old separate MFD window.
{
extern int gBTGaugeDockBottom;
extern int gBTGaugeCockpit;
extern int gBTCockpitCanvasW, gBTCockpitCanvasH;
extern int BTGaugeStripHeightFor(int width);
extern void BTCockpitCanvasFor(int viewW, int viewH, int *cw, int *ch);
// The mode came from the ONE resolver above (glassLayout) -- BT_GLASS_PANELS
// keeps the world window at its normal size (the per-display windows own the
// gauges: no cockpit surround, no bottom strip).
{
if (glassLayout == GlassLayoutCockpit)
{
gBTGaugeCockpit = 1;
// -res W H = the WORLD VIEW size in cockpit mode; default 900x500.
int viewW = 900, viewH = 500;
if (strstr(lpCmdLine ? lpCmdLine : "", "-res") != 0) { viewW = winW; viewH = winH; }
// Clamp the CANVAS to the work area (minus the window frame): shrink
// viewH (floor 400) then viewW (floor 640) until it fits (1080p tight).
RECT wa = { 0, 0, 1920, 1080 };
SystemParametersInfo(SPI_GETWORKAREA, 0, &wa, 0);
RECT fr = { 0, 0, 1000, 1000 };
AdjustWindowRect(&fr, WS_OVERLAPPEDWINDOW, FALSE);
int availW = (wa.right - wa.left) - ((fr.right - fr.left) - 1000);
int availH = (wa.bottom - wa.top) - ((fr.bottom - fr.top) - 1000);
int cw, ch;
BTCockpitCanvasFor(viewW, viewH, &cw, &ch);
while (ch > availH && viewH > 400) { viewH -= 20; BTCockpitCanvasFor(viewW, viewH, &cw, &ch); }
while (cw > availW && viewW > 640) { viewW -= 20; BTCockpitCanvasFor(viewW, viewH, &cw, &ch); }
winW = cw; winH = ch;
gBTCockpitCanvasW = winW; // windowed backbuffer = client canvas
gBTCockpitCanvasH = winH;
//
// LETTERBOX INTENT, decided HERE rather than at device creation:
// the first WM_SIZE arrives before the device exists, and the
// world-aspect calc needs to know whether the canvas will be
// scaled uniformly (aspect = the view's own) or stretched to
// the client (aspect = the stretched one). Deciding it late
// gave a -fit boot aspect=3.14 on an ultrawide -- applied on
// the first frame, since nothing resizes the window again.
// L4VIDEO clears the flag if it cannot actually get COPY.
{
extern int gBTCockpitLetterbox;
const char *ms = getenv("MULTISAMPLE");
gBTCockpitLetterbox = (ms == NULL || atoi(ms) == 0) ? 1 : 0;
}
std::cout << "[cockpit] view " << viewW << "x" << viewH
<< " canvas " << winW << "x" << winH << std::endl << std::flush;
}
else if (glassLayout == GlassLayoutDock)
{
gBTGaugeDockBottom = 1;
// Readability default (user-reported: the strip at 800 wide is a
// 0.6x downscale, hard to read): a WIDER world region (the
// projection is aspect-correct for any shape) buys strip pixels --
// 1100x600 world + 1100x400 strip = a 1000-tall window that fits a
// 1080p desktop with the panel at 83% of design size. -res still
// overrides both.
if (strstr(lpCmdLine ? lpCmdLine : "", "-res") == 0)
{
winW = 1100;
winH = 600;
}
winH += BTGaugeStripHeightFor(winW);
}
}
}
//
// -fit (alias -windowed-fullscreen): borderless over the whole monitor.
// The cockpit canvas letterboxes inside it at one uniform scale, so a
// wider-than-canvas monitor gets black bars rather than a stretch -- the
// same deal a dragged window gets, just without the chrome. Only useful
// with a layout that HAS a canvas to fit; other modes keep their window.
//
extern int gBTFitDisplay; // parsed early (the menu exits before here)
int fitDisplay = gBTFitDisplay;
RECT wr = { 0, 0, winW, winH };
AdjustWindowRect(&wr, WS_OVERLAPPEDWINDOW, FALSE);
DWORD winStyle = WS_OVERLAPPEDWINDOW;
int winX = 0, winY = 0;
if (fitDisplay)
{
RECT mon = { 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN) };
MONITORINFO mi;
memset(&mi, 0, sizeof(mi));
mi.cbSize = sizeof(mi);
POINT origin = { 0, 0 };
HMONITOR hmon = MonitorFromPoint(origin, MONITOR_DEFAULTTOPRIMARY);
if (GetMonitorInfo(hmon, &mi))
mon = mi.rcMonitor;
winStyle = WS_POPUP | WS_CLIPCHILDREN;
winX = mon.left;
winY = mon.top;
wr.left = 0; wr.top = 0;
wr.right = mon.right - mon.left;
wr.bottom = mon.bottom - mon.top;
std::cout << "[cockpit] -fit: borderless " << (wr.right - wr.left) << "x"
<< (wr.bottom - wr.top) << " (canvas letterboxes inside)"
<< std::endl << std::flush;
}
//
// Window identity (MP dev): tag the title with the -net port so a player
// running two nodes side-by-side can tell the windows apart when
// reporting ("node 1501" = A / player 1, "node 1601" = B / player 2).
//
wchar_t winTitle[64];
{
int netPort = 0;
const char *np = lpCmdLine ? strstr(lpCmdLine, "-net") : 0;
if (np != 0)
sscanf(np + 4, " %d", &netPort);
if (netPort > 0)
swprintf(winTitle, 64, L"BattleTech %S \x2014 node %d",
BT_VERSION_STRING, netPort);
else
swprintf(winTitle, 64, L"BattleTech %S", BT_VERSION_STRING);
}
hWnd = CreateWindowEx(0, L"MainWndClass", winTitle, winStyle,
winX, winY, wr.right - wr.left, wr.bottom - wr.top,
(HWND)NULL, (HMENU)NULL, hInstance, (LPVOID)NULL);
if (!hWnd) return FALSE;
ShowWindow(hWnd, nShowCmd);
{
std::cout << "[boot] opening btl4.res..." << std::endl << std::flush;
StreamableResourceFile resources("btl4.res", version);
Check(&resources);
std::cout << "[boot] resources OK; creating ApplicationManager..." << std::endl << std::flush;
// Frame rate (task #20 perf): TARGETFPS was read with no null check --
// atoi(NULL) when unset -> garbage frameRate -> the APPMGR frame pacer
// (end_of_frame = now + 1/frameRate) degenerated and the game ran at a
// timer-quantized ~15 FPS (66-70ms frames measured) on ANY hardware.
// Default 60; env TARGETFPS still overrides (clamped to sanity).
int target_fps = 30; // the pod's authentic rate; frame work (~20-40ms in
// this build) misses 60Hz beats -> jitter, but holds
// 30 rock-steady. TARGETFPS env still overrides.
{
const char *tf = getenv("TARGETFPS");
if (tf != 0)
{
int v = atoi(tf);
if (v >= 15 && v <= 240)
target_fps = v;
}
}
std::cout << "[boot] target frame rate: " << target_fps << " fps" << std::endl << std::flush;
ApplicationManager *app_manager =
new ApplicationManager(hInstance, hWnd, (Scalar)target_fps);
Register_Object(app_manager);
std::cout << "[boot] ApplicationManager OK; creating BTL4Application..." << std::endl << std::flush;
Application *new_app = new BTL4Application(hInstance, hWnd, &resources);
btl4App = new_app;
Register_Object(new_app);
std::cout << "[boot] BTL4Application OK; StartApplication..." << std::endl << std::flush;
app_manager->StartApplication(new_app);
std::cout << "[boot] StartApplication returned; entering RunMissions..." << std::endl << std::flush;
app_manager->RunMissions();
std::cout << "[boot] RunMissions returned (mission loop exited)." << std::endl << std::flush;
// MATCHLOG AUTO-UPLOAD (2026-07-22): in relay mode, send this peer's
// match forensic log back to the operator's relay (saved under the
// relay's matchlogs\ folder). No-op for mesh/solo or when the
// matchlog never armed; the local file stays either way.
{
extern void BTRelayUploadMatchLog(void);
BTRelayUploadMatchLog();
}
// LOBBY LOOP (2026-07-23): a round ended by the operator/clock
// relaunches this pod straight back into the join wait -- the arcade
// between-rounds flow (pods rebooted to attract mode; players stayed
// put). BT_SEAT_CLAIM carries our seat tag so the relay gives us the
// SAME seat back (static ordering, real-pod style); BT_CALLSIGN /
// BT_MECH ride the inherited environment so the choice persists.
// Closing the window still exits for good (flag set only by
// StopMission).
if (gBTMissionStoppedByConsole && getenv("BT_RELAY") != NULL)
{
extern const char *BTRelaySelfTag(void);
const char *seat_tag = BTRelaySelfTag();
if (seat_tag != NULL && seat_tag[0] != 0)
SetEnvironmentVariableA("BT_SEAT_CLAIM", seat_tag);
char rejoin_arguments[512];
int m = 0;
for (const char *s2 = lpCmdLine;
s2 != NULL && *s2 && m < (int)sizeof(rejoin_arguments) - 1; ++s2)
rejoin_arguments[m++] = *s2;
rejoin_arguments[m] = 0;
std::cout << "[boot] round ended -- relaunching into the join wait"
<< std::endl << std::flush;
BTFE_RelaunchSelfAndExit(rejoin_arguments); // never returns
}
#ifdef BT_GLASS
//
// Front-end loop (glass step 3): a menu-launched mission returns
// to the menu on exit. (Marshal missions normally relaunch from
// the marshal thread at clock expiry; this tail covers raw-solo
// and early exits.)
//
if (getenv("BT_FE_LOOP") != NULL)
{
std::cout << "[fe] mission over -- relaunching the menu"
<< std::endl << std::flush;
BTFE_RelaunchSelfAndExit("");
}
#endif
btl4App = NULL;
Unregister_Object(app_manager);
delete app_manager;
}
Stop_Registering();
return Exit_Code;
}