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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-12 18:10:02 -05:00
co-authored by Claude Fable 5
parent 58eb25a792
commit 9f79508257
15 changed files with 351 additions and 0 deletions
+29
View File
@@ -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;
}
+190
View File
@@ -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;
}
+28
View File
@@ -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();
+9
View File
@@ -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)
//########################################################################
+5
View File
@@ -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();
+2
View File
@@ -106,6 +106,7 @@
<ItemGroup>
<ClCompile Include=".\RPL4.CPP" />
<ClCompile Include=".\RPL4APP.cpp" />
<ClCompile Include=".\RPL4CONSOLE.cpp" />
<ClCompile Include=".\RPL4FE.cpp" />
<ClCompile Include=".\RPL4ARND.cpp" />
<ClCompile Include=".\RPL4GAUG.cpp" />
@@ -142,6 +143,7 @@
<ClInclude Include=".\RPL4.h" />
<ClInclude Include=".\RPL4.HPP" />
<ClInclude Include=".\RPL4APP.h" />
<ClInclude Include=".\RPL4CONSOLE.h" />
<ClInclude Include=".\RPL4FE.h" />
<ClInclude Include=".\RPL4ARND.h" />
<ClInclude Include=".\RPL4GAUG.h" />
+6
View File
@@ -36,6 +36,9 @@
<ClCompile Include=".\RPL4FE.cpp">
<Filter>Source Files\RP_L4</Filter>
</ClCompile>
<ClCompile Include=".\RPL4CONSOLE.cpp">
<Filter>Source Files\RP_L4</Filter>
</ClCompile>
<ClCompile Include=".\RPL4ARND.cpp">
<Filter>Source Files\RP_L4</Filter>
</ClCompile>
@@ -140,6 +143,9 @@
<ClInclude Include=".\RPL4FE.h">
<Filter>Header Files\RP_L4</Filter>
</ClInclude>
<ClInclude Include=".\RPL4CONSOLE.h">
<Filter>Header Files\RP_L4</Filter>
</ClInclude>
<ClInclude Include=".\RPL4ARND.h">
<Filter>Header Files\RP_L4</Filter>
</ClInclude>