//===========================================================================// // 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 #include // CommandLineToArgvW #include // generated: 4.11. () #include // _CrtSetDbgFlag (heap validation, gated by BT_HEAPCHECK) #include #include #include #include #include // BT_EXPIRE tripwire (time) #include #include #include // LoadBgfFile (BGF probe) -- via game/fwd shim #include // 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: // _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_.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(<); // 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(<, &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 _YYYYMMDD.log; once it passes the cap the NEXT launch opens // _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=. 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(<); 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 " 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(<); 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=[,...] 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 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 // 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 )" << 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; }