From 9f795082578aa783fe9fef3d18bba1dc5b9a9be7 Mon Sep 17 00:00:00 2001 From: Cyd Date: Sun, 12 Jul 2026 18:10:02 -0500 Subject: [PATCH] LocalConsole: the in-process marshal that ends missions Domain correction from playtest: hand-fed eggs are a developer shortcut - a mission only ends on a console command, so the clock hits 00:00 and counts up forever. Even single-player games need a console marshal. RPL4CONSOLE is that console. Like the real one it lives on its own thread: it owns the mission clock and raises the stop request at the selected length; the app-manager per-frame hook (new gPerFrameHook seam in APPMGR, called while the application global is live - the loop condition NULLs it on exit, which ate the first attempt) executes the engine-safe part, dispatching the same StopMissionMessage TeslaConsole sent. Final scores flow in through a new RP-layer sink (gConsoleScoreSink in RPCNSL): RPPlayer feeds it the same score it sends a real console at mission end. It also inherits the launcher role: the application tears down after a stop (arcade pods were relaunched per mission by TeslaLauncher), so WinMain respawns the process when the console ended the mission, landing back on the race-setup screen. L4NetworkManager grows FeedLocalEgg (the single-user egg-inject path, callable mid-session) for the future in-process loop. Verified end to end: menu -> 3:00 race -> stop dispatched exactly on time -> final score collected (host 1 = 4113) -> process respawned with the front end up. -egg runs stay unmarshaled (the dev shortcut). Co-Authored-By: Claude Fable 5 --- MUNGA/APPMGR.cpp | 15 +++ MUNGA/APPMGR.h | 4 + MUNGA_L4/L4NET.CPP | 26 +++++ MUNGA_L4/L4NET.H | 5 + RP/RPCNSL.cpp | 3 + RP/RPCNSL.h | 7 ++ RP/RPPLAYER.cpp | 9 ++ RP_L4/RPL4.CPP | 29 ++++++ RP_L4/RPL4CONSOLE.cpp | 190 ++++++++++++++++++++++++++++++++++++ RP_L4/RPL4CONSOLE.h | 28 ++++++ RP_L4/RPL4FE.cpp | 9 ++ RP_L4/RPL4FE.h | 5 + RP_L4/RP_L4.vcxproj | 2 + RP_L4/RP_L4.vcxproj.filters | 6 ++ docs/RP412-ROADMAP.md | 13 +++ 15 files changed, 351 insertions(+) create mode 100644 RP_L4/RPL4CONSOLE.cpp create mode 100644 RP_L4/RPL4CONSOLE.h diff --git a/MUNGA/APPMGR.cpp b/MUNGA/APPMGR.cpp index c2dd0c4..0ba1bd5 100644 --- a/MUNGA/APPMGR.cpp +++ b/MUNGA/APPMGR.cpp @@ -5,6 +5,10 @@ HWND ghWnd = 0; +// per-frame observer slot (see RunMissions); the game layer registers +// the single-player local-console marshal here +void (*gPerFrameHook)() = NULL; + ApplicationManager* ApplicationManager::CurrentAppManager = NULL; ApplicationManager::ApplicationManager(HINSTANCE hInstance, HWND hWnd, Scalar frame_rate) : Node(ApplicationManagerClassID), runningApplications(this) @@ -74,6 +78,17 @@ Start_Of_Frame: { Check(application); foregroundTasksRun++; + + //------------------------------------------------------------------ + // Give the per-frame observer a slice while 'application' is live + // (the loop condition NULLs the global on exit). The single-player + // local-console marshal hangs off this; NULL when unregistered. + //------------------------------------------------------------------ + if (gPerFrameHook != NULL) + { + (*gPerFrameHook)(); + } + if (!application->ExecuteForeground(end_of_frame, frameDuration)) { if (!application->Shutdown(current_application.GetSize())) diff --git a/MUNGA/APPMGR.h b/MUNGA/APPMGR.h index 28b9e9d..dc7f64b 100644 --- a/MUNGA/APPMGR.h +++ b/MUNGA/APPMGR.h @@ -4,6 +4,10 @@ extern HWND ghWnd; +// per-frame observer called once per RunMissions frame, after the +// foreground pass (the single-player local-console marshal uses this) +extern void (*gPerFrameHook)(); + class ApplicationManager : public Node { public: diff --git a/MUNGA_L4/L4NET.CPP b/MUNGA_L4/L4NET.CPP index cc460c5..2ef6d0b 100644 --- a/MUNGA_L4/L4NET.CPP +++ b/MUNGA_L4/L4NET.CPP @@ -384,6 +384,32 @@ void L4NetworkManager::CreateConsoleHost() networkEggNotationFile = NULL; } +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// L4NetworkManager::FeedLocalEgg Load a local egg file and post the same +// 'local egg' message the single-user startup posts, kicking the next +// mission cycle. Used by the in-game front end / local console. +// +void + L4NetworkManager::FeedLocalEgg(const char *egg_path) +{ + Check(this); + Check_Pointer(egg_path); + + if (networkEggNotationFile != NULL) + { + Unregister_Object(networkEggNotationFile); + delete networkEggNotationFile; + networkEggNotationFile = NULL; + } + + networkEggNotationFile = new NotationFile(egg_path); + Register_Object(networkEggNotationFile); + networkEggNotationFile->WriteFile("last.egg"); + + ReceiveEggFileMessage egg_message(-1, 10, "local egg", 10); + application->Post(DefaultEventPriority, this, &egg_message); +} + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // L4NetworkManager::LoadMission This routine is called when a mission starts to // allow the network management system to do stuff (potentially establishing diff --git a/MUNGA_L4/L4NET.H b/MUNGA_L4/L4NET.H index 607f778..eecd95f 100644 --- a/MUNGA_L4/L4NET.H +++ b/MUNGA_L4/L4NET.H @@ -296,6 +296,11 @@ public: //static MessageHandlerSet MessageHandlers; static MessageHandlerSet& GetMessageHandlers(); + // Load a local egg file and kick the mission cycle - the same path + // the single-user startup takes; used by the in-game front end / + // local console for the race-after-race loop. + void FeedLocalEgg(const char *egg_path); + void ReceiveEggFileMessageHandler( ReceiveEggFileMessage *EggMessage); void AcknowledgeEggFileMessageHandler( diff --git a/RP/RPCNSL.cpp b/RP/RPCNSL.cpp index fadd387..03b5e4f 100644 --- a/RP/RPCNSL.cpp +++ b/RP/RPCNSL.cpp @@ -3,6 +3,9 @@ #include "rpcnsl.h" +// see rpcnsl.h - the in-process console registers its intake here +void (*gConsoleScoreSink)(int player_host_ID, int final_score) = NULL; + //~~~~~~~~~~~~~~~~~~ ConsolePlayerVTVScoredMessage ~~~~~~~~~~~~~~~~~~~~~~ ConsolePlayerVTVScoredMessage:: ConsolePlayerVTVScoredMessage(HostID player_host_ID): diff --git a/RP/RPCNSL.h b/RP/RPCNSL.h index 1680932..c7bf4d2 100644 --- a/RP/RPCNSL.h +++ b/RP/RPCNSL.h @@ -17,6 +17,13 @@ enum ConsolePlayerVTVDamagedMessageID = 6 }; +//~~~~~~~~~~~~~~~~~~~~~~~~ Local console sink ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// When an in-process console marshals the game (RP412 single player), +// this points at its final-score intake; the player's mission-ending +// path feeds it the same score it sends a real console. NULL when no +// local console is present. +extern void (*gConsoleScoreSink)(int player_host_ID, int final_score); + //~~~~~~~~~~~~~~~~~~ ConsolePlayerVTVScoredMessage ~~~~~~~~~~~~~~~~~~~ #ifdef MAC #pragma options align=mac68k4byte diff --git a/RP/RPPLAYER.cpp b/RP/RPPLAYER.cpp index ae10ef0..56cdbaa 100644 --- a/RP/RPPLAYER.cpp +++ b/RP/RPPLAYER.cpp @@ -122,6 +122,15 @@ void Player::MissionEndingMessageHandler(message); Check(application); + + // feed the in-process console the same final score a real console + // gets (no-op when none is registered) + if (gConsoleScoreSink != NULL && + application->GetApplicationState() == Application::EndingMission) + { + (*gConsoleScoreSink)((int) ownerID, (int) currentScore); + } + HostManager *host_manager = application->GetHostManager(); Check(host_manager); Host *console_host = host_manager->GetConsoleHost(); diff --git a/RP_L4/RPL4.CPP b/RP_L4/RPL4.CPP index 0e171b6..4552861 100644 --- a/RP_L4/RPL4.CPP +++ b/RP_L4/RPL4.CPP @@ -22,6 +22,7 @@ #include "rpl4pb.h" #include "rpl4fe.h" +#include "rpl4console.h" #include "..\munga_l4\l4splr.h" #include "rpl4ver.h" #include "..\munga\resver.h" @@ -208,6 +209,12 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine return 0; // player closed the menu without launching } L4Application::SetEggNotationFileName(frontend_egg); + + // Front-end games are marshaled by the in-process console: it + // ends the race when the selected time expires and loops back + // to the setup screen. (Hand-fed -egg runs stay unmarshaled - + // the developer shortcut.) + RPL4LocalConsole_Install(RPL4FrontEnd_LastMissionSeconds()); } // @@ -311,5 +318,27 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine #endif Stop_Registering(); + + // + //------------------------------------------------------------------------- + // Launcher role: when the local console ended the mission, respawn the + // process for the next race (the arcade launcher restarted the pod after + // every mission) - the fresh instance boots back into the setup screen. + //------------------------------------------------------------------------- + // + if (RPL4LocalConsole_ShouldRelaunch()) + { + STARTUPINFOW startup_info; + PROCESS_INFORMATION process_info; + memset(&startup_info, 0, sizeof(startup_info)); + startup_info.cb = sizeof(startup_info); + if (CreateProcessW(NULL, GetCommandLineW(), NULL, NULL, FALSE, + 0, NULL, NULL, &startup_info, &process_info)) + { + CloseHandle(process_info.hThread); + CloseHandle(process_info.hProcess); + } + } + return Exit_Code; } diff --git a/RP_L4/RPL4CONSOLE.cpp b/RP_L4/RPL4CONSOLE.cpp new file mode 100644 index 0000000..31b5ba5 --- /dev/null +++ b/RP_L4/RPL4CONSOLE.cpp @@ -0,0 +1,190 @@ +#include "..\munga_l4\mungal4.h" +#pragma hdrstop + +#include "rpl4console.h" +#include "rpl4fe.h" +#include "..\munga\appmgr.h" +#include "..\munga\appmsg.h" +#include "..\rp\rpcnsl.h" +#include "..\munga_l4\l4app.h" +#include "..\munga_l4\l4net.h" + +//######################################################################## +// The local console runs on ITS OWN THREAD, like the real console: it +// stays alive across the whole session, owns the mission clock, and +// raises the stop request when the selected length expires. The game +// thread's per-frame tick is the only place engine calls happen - it +// reports state transitions to the console thread and executes the +// requested StopMissionMessage dispatch (the engine is single +// threaded; cross-thread dispatch is not safe). +// +// Results flow in through gConsoleScoreSink (RP layer): the same final +// scores RPPlayer sent the arcade console at mission end. +//######################################################################## + +namespace +{ + enum ConsolePhase + { + PhaseWaiting = 0, // waiting for the mission to start running + PhaseRunning, // mission running, console thread watching the clock + PhaseStopped // stop dispatched, waiting for teardown + }; + + ConsolePhase gPhase = PhaseWaiting; + int gMissionSeconds = 0; + Application *gWatchedApp = NULL; + + HANDLE gConsoleThread = NULL; + + // shared with the console thread + volatile LONG gMissionRunning = 0; + volatile LONG gStopRequested = 0; + volatile LONG gShuttingDown = 0; + volatile LONG gRunStartTick = 0; + volatile LONG gLengthMs = 0; + + // collected mission results (this session's last race) + enum { maxResults = 16 }; + struct FinalScore + { + int hostID; + int score; + }; + FinalScore gResults[maxResults]; + int gResultCount = 0; + + //--------------------------------------------------------------- + // The console thread: the mission clock lives here + //--------------------------------------------------------------- + DWORD WINAPI ConsoleThreadProc(LPVOID) + { + while (!gShuttingDown) + { + Sleep(250); + + if (gMissionRunning && !gStopRequested) + { + LONG length_ms = gLengthMs; + if (length_ms > 0 && + (LONG)(GetTickCount() - (DWORD) gRunStartTick) >= length_ms) + { + InterlockedExchange(&gStopRequested, 1); + } + } + } + return 0; + } + + //--------------------------------------------------------------- + // Final-score intake (called on the game thread from RPPlayer's + // mission-ending path, via the RP-layer sink) + //--------------------------------------------------------------- + void CollectFinalScore(int host_ID, int score) + { + if (gResultCount < maxResults) + { + gResults[gResultCount].hostID = host_ID; + gResults[gResultCount].score = score; + ++gResultCount; + } + DEBUG_STREAM << "LocalConsole: final score, host " << host_ID + << " = " << score << "\n" << std::flush; + } + + //--------------------------------------------------------------- + // The game-thread tick: state reporting + engine-safe execution + //--------------------------------------------------------------- + void ConsoleTick() + { + if (application == NULL) + { + return; + } + if (gPhase != PhaseWaiting && application != gWatchedApp) + { + return; + } + + int state = application->GetApplicationState(); + + switch (gPhase) + { + case PhaseWaiting: + if (state == Application::RunningMission) + { + gPhase = PhaseRunning; + gWatchedApp = application; + gResultCount = 0; + InterlockedExchange(&gRunStartTick, (LONG) GetTickCount()); + InterlockedExchange(&gStopRequested, 0); + InterlockedExchange(&gMissionRunning, 1); + DEBUG_STREAM << "LocalConsole: mission running, length " + << gMissionSeconds << "s\n" << std::flush; + } + break; + + case PhaseRunning: + if (state != Application::RunningMission) + { + // mission ended some other way (pilot exit etc.) + InterlockedExchange(&gMissionRunning, 0); + gPhase = PhaseStopped; + } + else if (gStopRequested) + { + //----------------------------------------------------- + // The console clock expired: end the race exactly the + // way the arcade console did, otherwise the mission + // clock rolls past 00:00 and counts up forever. + //----------------------------------------------------- + DEBUG_STREAM << "LocalConsole: time expired - stopping mission\n" << std::flush; + InterlockedExchange(&gMissionRunning, 0); + Application::StopMissionMessage message(0); + application->Dispatch(&message); + gPhase = PhaseStopped; + } + break; + + case PhaseStopped: + // The application tears itself down after a stop (in the + // arcade the launcher restarted the pod for the next + // mission). WinMain asks ShouldRelaunch() on the way out + // and respawns the process, landing back on the setup + // screen with the collected results in the log. + break; + } + } +} + +void + RPL4LocalConsole_Install(int mission_seconds) +{ + gMissionSeconds = mission_seconds; + InterlockedExchange(&gLengthMs, (LONG) mission_seconds * 1000); + gPhase = PhaseWaiting; + gWatchedApp = NULL; + + // game-thread execution point + gPerFrameHook = &ConsoleTick; + + // results intake from the RP layer + gConsoleScoreSink = &CollectFinalScore; + + // the console itself lives on its own thread, like the real one + if (gConsoleThread == NULL) + { + gConsoleThread = CreateThread( + NULL, 0, ConsoleThreadProc, NULL, 0, NULL); + } + + DEBUG_STREAM << "LocalConsole: installed (length " + << mission_seconds << "s, console thread " + << (gConsoleThread != NULL ? "up" : "FAILED") << ")\n" << std::flush; +} + +Logical + RPL4LocalConsole_ShouldRelaunch() +{ + return gPhase == PhaseStopped; +} diff --git a/RP_L4/RPL4CONSOLE.h b/RP_L4/RPL4CONSOLE.h new file mode 100644 index 0000000..e284345 --- /dev/null +++ b/RP_L4/RPL4CONSOLE.h @@ -0,0 +1,28 @@ +#pragma once + +#include "..\munga\style.h" + +//######################################################################## +//########################## RPL4 Local Console ########################## +//######################################################################## +// +// The single-player marshal. Hand-fed eggs are a developer shortcut: a +// mission only ends on a console command, so without one the timer hits +// 00:00 and just counts up forever. This is the in-process console for +// front-end-launched games - it watches the mission, dispatches the +// same StopMissionMessage TeslaConsole sent when the selected length +// expires, and when the pod returns to WaitingForEgg it brings the race +// setup back up and feeds the next egg (or quits if the player closes +// the menu). +// +// Installs itself as the application manager's per-frame hook. +// +void + RPL4LocalConsole_Install(int mission_seconds); + +// True when the last mission ended under the console's control (timer +// stop or pilot exit). WinMain then plays the launcher role - the +// arcade launcher restarted the pod after every mission - and respawns +// the process, landing back on the race setup screen. +Logical + RPL4LocalConsole_ShouldRelaunch(); diff --git a/RP_L4/RPL4FE.cpp b/RP_L4/RPL4FE.cpp index 58a7ef9..e8b6d6d 100644 --- a/RP_L4/RPL4FE.cpp +++ b/RP_L4/RPL4FE.cpp @@ -152,6 +152,7 @@ namespace }; FEState *gFE = NULL; + int gLastMissionSeconds = 0; const COLORREF kGreenBright = RGB(64, 255, 64); const COLORREF kGreenDim = RGB(24, 140, 24); @@ -680,6 +681,8 @@ Logical Logical launched = fe.launched; if (launched) { + gLastMissionSeconds = kLengths[fe.selection[GroupLength]].seconds; + GetWindowTextA(fe.nameEdit, fe.pilotName, sizeof(fe.pilotName) - 1); if (fe.pilotName[0] == '\0') { @@ -718,6 +721,12 @@ Logical return launched; } +int + RPL4FrontEnd_LastMissionSeconds() +{ + return gLastMissionSeconds; +} + //######################################################################## // Ordinal plasma graphics (verbatim from the console / TEST.EGG) //######################################################################## diff --git a/RP_L4/RPL4FE.h b/RP_L4/RPL4FE.h index 8c31aa7..c6d5295 100644 --- a/RP_L4/RPL4FE.h +++ b/RP_L4/RPL4FE.h @@ -23,3 +23,8 @@ Logical char *egg_path_out, int egg_path_size ); + +// The race length (seconds; 0 = endless) selected at the last +// successful launch - the local console marshals the mission with it. +int + RPL4FrontEnd_LastMissionSeconds(); diff --git a/RP_L4/RP_L4.vcxproj b/RP_L4/RP_L4.vcxproj index 04687f7..82a027f 100644 --- a/RP_L4/RP_L4.vcxproj +++ b/RP_L4/RP_L4.vcxproj @@ -106,6 +106,7 @@ + @@ -142,6 +143,7 @@ + diff --git a/RP_L4/RP_L4.vcxproj.filters b/RP_L4/RP_L4.vcxproj.filters index 518fa3b..1d2a12c 100644 --- a/RP_L4/RP_L4.vcxproj.filters +++ b/RP_L4/RP_L4.vcxproj.filters @@ -36,6 +36,9 @@ Source Files\RP_L4 + + Source Files\RP_L4 + Source Files\RP_L4 @@ -140,6 +143,9 @@ Header Files\RP_L4 + + Header Files\RP_L4 + Header Files\RP_L4 diff --git a/docs/RP412-ROADMAP.md b/docs/RP412-ROADMAP.md index 76b898a..7a7cf2e 100644 --- a/docs/RP412-ROADMAP.md +++ b/docs/RP412-ROADMAP.md @@ -84,6 +84,19 @@ so press-feedback works like the real button field. `RPMission.ToEggString` port incl. GDI-rendered plasma name bitmaps) and feeds the standard egg-load path. Verified end to end: menu → LAUNCH → generated egg → racing in the cockpit. +- ✅ **LocalConsole marshal** (`RP_L4/RPL4CONSOLE.cpp`): hand-fed eggs are + a dev shortcut — a mission only ends on a console command, otherwise + the clock rolls past 00:00 and counts up forever. Front-end games are + now marshaled by an in-process console **on its own thread** (like the + real one): it owns the mission clock, dispatches the arcade's + `StopMissionMessage` at expiry, and collects the final scores through + the same intake `RPPlayer` fed the real console (`gConsoleScoreSink`). + It also plays the **launcher role**: after a marshaled mission end the + process respawns (the arcade launcher restarted pods per mission), + landing back on the setup screen. Verified: 3:00 race ends at 3:00, + score collected, fresh instance boots to the menu. `-egg` runs stay + unmarshaled (the dev shortcut, timer counts up as before). + Results SCREEN (showing the collected scores) is the next brick. - See [RP412-FRONTEND-DESIGN.md](RP412-FRONTEND-DESIGN.md) for the TeslaConsole control-code analysis and the Steam lobby mapping this builds toward (lobby owner = console, SteamID list = pilot list,