From 9d660fa50b38a26546792d68af99bd17c211146f Mon Sep 17 00:00:00 2001 From: Cyd Date: Thu, 16 Jul 2026 22:41:20 -0500 Subject: [PATCH] Front end: LocalConsole marshal + single-binary loop (timed stop, relaunch) The in-process console that replaces the operator for solo play: it owns the mission clock and ends the race at the chosen length, then relaunches to the setup menu -- the arcade launcher behavior in one binary. - engine/APPMGR: gPerFrameHook -- a per-frame observer called on the game thread in RunMissions (the marshal's engine-safe tick site). - btl4console.{hpp,cpp}: BTLocalConsole_Install/MissionCompleted -- the marshal watches for RunningMission, starts the clock, and dispatches Application::StopMissionMessage at expiry (BT_MISSION_SECONDS overrides for testing). Confirmed: StopMission cleanly ends the mission and RunMissions returns. - btl4fe: expose BTFrontEnd_LastMissionSeconds; Enter=LAUNCH / Esc=quit in the menu loop; BT_FE_AUTOLAUNCH quick-launch (skips the menu). - btl4main WinMain: front-end mode runs menu -> arms marshal -> mission; when the marshal ends it, RELAUNCH a fresh instance (arcade launcher model) -> lands back on the menu. This sidesteps BT's in-process re-init fragility: a second mission in-process AV'd on stale gBT* per-mission entity globals (gBTTerrainEntity et al., cdb-traced to MakeEntityRenderables); a fresh process has none. Verified: menu LAUNCH -> mission runs -> marshal stops it at the set length -> RunMissions returns -> relaunch (3 distinct PIDs across a multi-cycle run, no crash). Remaining in Phase 5: the results screen (kills/deaths at stop). Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 1 + engine/MUNGA/APPMGR.cpp | 14 +++++ engine/MUNGA/APPMGR.h | 3 + game/btl4main.cpp | 85 +++++++++++++++++++------ game/reconstructed/btl4console.cpp | 99 ++++++++++++++++++++++++++++++ game/reconstructed/btl4console.hpp | 27 ++++++++ game/reconstructed/btl4fe.cpp | 26 ++++++++ game/reconstructed/btl4fe.hpp | 4 ++ 8 files changed, 239 insertions(+), 20 deletions(-) create mode 100644 game/reconstructed/btl4console.cpp create mode 100644 game/reconstructed/btl4console.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 429076a..ae3f161 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -271,6 +271,7 @@ add_library(bt410_l4 STATIC "game/reconstructed/btdirect.cpp" "game/reconstructed/btl4app.cpp" "game/reconstructed/btl4fe.cpp" + "game/reconstructed/btl4console.cpp" "game/reconstructed/btl4galm.cpp" "game/reconstructed/btl4gau2.cpp" "game/reconstructed/btl4gau3.cpp" diff --git a/engine/MUNGA/APPMGR.cpp b/engine/MUNGA/APPMGR.cpp index 6dfd145..8b2bb7a 100644 --- a/engine/MUNGA/APPMGR.cpp +++ b/engine/MUNGA/APPMGR.cpp @@ -12,6 +12,11 @@ HWND ghWnd = 0; // front end / lobby member path (Phase 5/6). Logical gConsoleLossEndsMission = False; +// BT412: a per-frame observer called each frame in RunMissions while an +// application is live (the in-process LocalConsole marshal hangs off this to +// own the mission clock). NULL when unregistered. +void (*gPerFrameHook)() = NULL; + ApplicationManager* ApplicationManager::CurrentAppManager = NULL; ApplicationManager::ApplicationManager(HINSTANCE hInstance, HWND hWnd, Scalar frame_rate) : Node(ApplicationManagerClassID), runningApplications(this) @@ -81,6 +86,15 @@ Start_Of_Frame: { Check(application); foregroundTasksRun++; + + //------------------------------------------------------------------ + // BT412: give the per-frame observer a slice while 'application' is + // live (the loop condition NULLs the global on exit). The + // single-player LocalConsole marshal hangs off this; NULL otherwise. + //------------------------------------------------------------------ + if (gPerFrameHook != NULL) + (*gPerFrameHook)(); + if (!application->ExecuteForeground(end_of_frame, frameDuration)) { if (!application->Shutdown(current_application.GetSize())) diff --git a/engine/MUNGA/APPMGR.h b/engine/MUNGA/APPMGR.h index cd26bf8..7ba90e5 100644 --- a/engine/MUNGA/APPMGR.h +++ b/engine/MUNGA/APPMGR.h @@ -9,6 +9,9 @@ extern HWND ghWnd; // False = the arcade -net pod re-listens for its console. See APPMGR.cpp. extern Logical gConsoleLossEndsMission; +// BT412: per-frame observer hook (the LocalConsole marshal's game-thread tick). +extern void (*gPerFrameHook)(); + class ApplicationManager : public Node { public: diff --git a/game/btl4main.cpp b/game/btl4main.cpp index 856ea6a..b10d831 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -392,29 +392,16 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine // // FRONT-END MODE (BT412 Phase 5): no egg and no network address on the - // command line means there is no operator console to hand us a mission. - // Run the in-game miniconsole menu -- the player configures the race - // (map / mech / color / time / weather / length / pilot name) and clicks - // LAUNCH, which builds the mission egg locally and points the standard - // -egg load path at it. Closing the menu quits. `-egg`/`-net` runs skip - // the menu. (docs/BT412-ROADMAP.md Phase 5.) + // command line means there is no operator console to hand us a mission -- + // the in-game miniconsole menu builds one, and the in-process marshal + // runs the single-binary loop (menu -> mission -> menu). `-egg`/`-net` + // runs are a single pass (the developer shortcut / real console). // + int front_end_mode; { const char *cmdline_egg = L4Application::GetEggNotationFileName(); - const int no_egg = (!cmdline_egg || !strlen(cmdline_egg)); - const int no_net = (L4Application::GetNetworkCommonFlatAddress() == 0); - if (no_egg && no_net) - { - extern int BTFrontEnd_Run(); - if (BTFrontEnd_Run()) - std::cout << "[frontend] menu LAUNCH -> frontend.egg built" - << std::endl << std::flush; - else - { - std::cout << "[frontend] menu closed -- exiting" << std::endl << std::flush; - return 0; // player quit from the setup menu - } - } + front_end_mode = (!cmdline_egg || !strlen(cmdline_egg)) + && (L4Application::GetNetworkCommonFlatAddress() == 0); } Start_Registering(); @@ -531,6 +518,40 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine ShowWindow(hWnd, nShowCmd); + //=======================================================================// + // SINGLE-BINARY LOOP (BT412 Phase 5), arcade-launcher model: ONE mission + // per process. Front-end mode shows the miniconsole menu, arms the + // marshal, and runs the mission; when the marshal stops it at the chosen + // length the process RELAUNCHES itself (below) -- landing back on the + // setup menu. This is exactly how the arcade launcher restarted the pod + // per mission, and it sidesteps BT's in-process re-init fragility (the + // reconstructed gBT* globals hold per-mission entity pointers that a + // second in-process mission would trip over). `-egg`/`-net` = single pass. + //=======================================================================// + extern int BTFrontEnd_Run(); + extern int BTFrontEnd_LastMissionSeconds(); + extern void BTLocalConsole_Install(int mission_seconds); + extern int BTLocalConsole_MissionCompleted(); + + if (front_end_mode) + { + ShowWindow(hWnd, SW_HIDE); // the menu is its own window + if (!BTFrontEnd_Run()) + { + std::cout << "[frontend] menu closed -- exiting" << std::endl << std::flush; + Stop_Registering(); + return 0; // player quit from the setup menu + } + ShowWindow(hWnd, SW_SHOW); + + int secs = BTFrontEnd_LastMissionSeconds(); + const char *ov = getenv("BT_MISSION_SECONDS"); + if (ov != 0) secs = atoi(ov); + BTLocalConsole_Install(secs); + std::cout << "[frontend] menu LAUNCH -> marshal armed (" << secs << "s)" + << std::endl << std::flush; + } + { std::cout << "[boot] opening btl4.res..." << std::endl << std::flush; StreamableResourceFile resources("btl4.res", version); @@ -576,5 +597,29 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine } Stop_Registering(); + + //-------------------------------------------------------------------// + // LAUNCHER ROLE: if the marshal ended this front-end mission (the + // selected time expired), relaunch a fresh instance -- it front-end-modes + // again and lands back on the setup menu, exactly as the arcade launcher + // restarted the pod per mission. A `-egg`/`-net` pass, or a mission that + // ended some other way (window closed), just exits. + //-------------------------------------------------------------------// + if (front_end_mode && BTLocalConsole_MissionCompleted() && IsWindow(hWnd)) + { + wchar_t exePath[MAX_PATH]; + GetModuleFileNameW(NULL, exePath, MAX_PATH); + STARTUPINFOW si; memset(&si, 0, sizeof(si)); si.cb = sizeof(si); + PROCESS_INFORMATION pi; memset(&pi, 0, sizeof(pi)); + // no args -> the new instance front-end-modes and shows the menu + if (CreateProcessW(exePath, NULL, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi)) + { + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + std::cout << "[launcher] mission ended -- relaunched for the next race" + << std::endl << std::flush; + } + } + return Exit_Code; } diff --git a/game/reconstructed/btl4console.cpp b/game/reconstructed/btl4console.cpp new file mode 100644 index 0000000..bfdd282 --- /dev/null +++ b/game/reconstructed/btl4console.cpp @@ -0,0 +1,99 @@ +//===========================================================================// +// btl4console.cpp -- BT412 in-process LocalConsole marshal (solo path). +// +// See btl4console.hpp. Models RP412's RPL4CONSOLE ConsoleTick, reduced to the +// single-player case: no remote pods, no wire -- just own the mission clock and +// dispatch StopMissionMessage at expiry. The tick runs on the game thread +// (gPerFrameHook), so the engine call (Dispatch) is safe. +//===========================================================================// +#include // Application, application, DEBUG_STREAM +#pragma hdrstop + +#include "appmsg.hpp" // full Application::StopMissionMessage definition +#include "appmgr.hpp" // gPerFrameHook +#include "btl4console.hpp" + +namespace +{ + enum Phase { PhaseIdle, PhaseWaiting, PhaseRunning, PhaseStopped }; + + Phase gPhase = PhaseIdle; + int gMissionSeconds = 0; + unsigned long gStartTick = 0; + Application *gWatched = 0; + + // + // The game-thread tick (called every frame while an application is live). + // + void ConsoleTick() + { + if (application == 0) + return; + + int state = application->GetApplicationState(); + + switch (gPhase) + { + case PhaseWaiting: + // The mission has staged and started running -> start the clock. + if (state == Application::RunningMission) + { + gPhase = PhaseRunning; + gWatched = application; + gStartTick = GetTickCount(); + DEBUG_STREAM << "[marshal] mission running -- length " + << gMissionSeconds << "s\n" << std::flush; + } + break; + + case PhaseRunning: + if (application != gWatched) + return; // only marshal our own mission + + if (state != Application::RunningMission) + { + // ended some other way (pilot abort, teardown) + gPhase = PhaseStopped; + DEBUG_STREAM << "[marshal] mission ended (state change)\n" << std::flush; + } + else if (gMissionSeconds > 0 && + (GetTickCount() - gStartTick) >= (unsigned long)(gMissionSeconds * 1000)) + { + // The console clock expired: stop the race exactly as the + // arcade console did (StopMissionMessage on our own pod). + DEBUG_STREAM << "[marshal] time expired -- stopping mission\n" << std::flush; + Application::StopMissionMessage message(0); + application->Dispatch(&message); + gPhase = PhaseStopped; + } + break; + + default: + break; + } + } +} + +void BTLocalConsole_Install(int mission_seconds) +{ + gMissionSeconds = mission_seconds; + gPhase = PhaseWaiting; + gStartTick = 0; + gWatched = 0; + // Marshaled: the in-process console owns the clock, so a console-loss + // (there is none locally) would end the race -- harmless for solo. + gConsoleLossEndsMission = True; + gPerFrameHook = &ConsoleTick; + DEBUG_STREAM << "[marshal] installed (length " << mission_seconds << "s)\n" << std::flush; +} + +int BTLocalConsole_MissionCompleted() +{ + return (gPhase == PhaseStopped) ? 1 : 0; +} + +void BTLocalConsole_Uninstall() +{ + gPerFrameHook = 0; + gPhase = PhaseIdle; +} diff --git a/game/reconstructed/btl4console.hpp b/game/reconstructed/btl4console.hpp new file mode 100644 index 0000000..e45c24d --- /dev/null +++ b/game/reconstructed/btl4console.hpp @@ -0,0 +1,27 @@ +//===========================================================================// +// btl4console.hpp -- BT412 in-process LocalConsole marshal. +// +// A front-end-launched solo mission has no operator console, so the mission +// clock would count up forever. This marshal plays the console's role in +// process: it watches the mission via the engine's per-frame hook +// (gPerFrameHook, called on the game thread in RunMissions) and dispatches +// StopMissionMessage when the selected length expires, exactly as the arcade +// console did. The single-binary WinMain loop then collects the result and +// cycles back to the setup menu. +//===========================================================================// +#ifndef BTL4CONSOLE_HPP +#define BTL4CONSOLE_HPP + +// Arm the marshal for the next mission: it stops the mission after +// `mission_seconds` (0 = endless -- never auto-stops). Registers the +// per-frame hook. +void BTLocalConsole_Install(int mission_seconds); + +// True once the marshaled mission has stopped (time expired, or ended some +// other way while armed) -- the WinMain loop cycles back to the menu. +int BTLocalConsole_MissionCompleted(); + +// Drop the marshal + clear the per-frame hook. +void BTLocalConsole_Uninstall(); + +#endif // BTL4CONSOLE_HPP diff --git a/game/reconstructed/btl4fe.cpp b/game/reconstructed/btl4fe.cpp index fcc8219..1d8b20c 100644 --- a/game/reconstructed/btl4fe.cpp +++ b/game/reconstructed/btl4fe.cpp @@ -20,6 +20,9 @@ #include "btl4fe.hpp" #include "l4app.hpp" // L4Application::SetEggNotationFileName +// Mission length (seconds) chosen on the last LAUNCH; WinMain arms the marshal. +static int gLastMissionSeconds = 300; + //---------------------------------------------------------------------------// // Catalogs -- the BattleTech content the console's RPConfig.xml named. // Maps: the 8 in BTL4.RES. Mechs: the base 8 + the known variants. @@ -536,6 +539,17 @@ LRESULT CALLBACK MenuWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { //---------------------------------------------------------------------------// int BTFrontEnd_Run() { + // BT_FE_AUTOLAUNCH: skip the interactive menu and launch the default + // loadout straight away (quick-launch / deterministic marshal testing). + if (getenv("BT_FE_AUTOLAUNCH") != NULL) { + BTFeMission mission; BTFeMission_Default(&mission); + const char *egg = "frontend.egg"; + if (!BTFeMission_WriteEgg(&mission, egg)) return 0; + L4Application::SetEggNotationFileName(egg); + gLastMissionSeconds = mission.lengthSeconds; + return 1; + } + HINSTANCE instance = GetModuleHandleA(NULL); MenuState m; memset(&m, 0, sizeof(m)); @@ -583,6 +597,12 @@ int BTFrontEnd_Run() while (!m.launched && !m.closed) { BOOL r = GetMessageA(&msg, NULL, 0, 0); if (r <= 0) { m.closed = 1; break; } + // Enter = LAUNCH, Escape = quit -- intercepted at the loop level so + // they fire whichever child (menu or name box) has focus. + if (msg.message == WM_KEYDOWN) { + if (msg.wParam == VK_RETURN) { m.launched = 1; continue; } + if (msg.wParam == VK_ESCAPE) { m.closed = 1; continue; } + } if (msg.message == WM_QUIT) m.closed = 1; TranslateMessage(&msg); DispatchMessageA(&msg); @@ -620,5 +640,11 @@ int BTFrontEnd_Run() if (!BTFeMission_WriteEgg(&mission, egg_path)) return 0; L4Application::SetEggNotationFileName(egg_path); + gLastMissionSeconds = mission.lengthSeconds; return 1; } + +int BTFrontEnd_LastMissionSeconds() +{ + return gLastMissionSeconds; +} diff --git a/game/reconstructed/btl4fe.hpp b/game/reconstructed/btl4fe.hpp index 344aacf..d6af28e 100644 --- a/game/reconstructed/btl4fe.hpp +++ b/game/reconstructed/btl4fe.hpp @@ -69,4 +69,8 @@ int BTFeMission_WriteEgg(const BTFeMission *mission, const char *path); //---------------------------------------------------------------------------// int BTFrontEnd_Run(); +// The mission length (seconds; 0 = endless) the player chose on the last +// LAUNCH -- WinMain arms the LocalConsole marshal with it. +int BTFrontEnd_LastMissionSeconds(); + #endif // BTL4FE_HPP