Cyd's screenshot: the commit board's LAUNCH button lit - which requires the local pod to be staged - while the host's own row read 'connecting', which is the word for a state of -1. Two reads of the same fact from two places, disagreeing: the tick read the application state and armed on it, and the paint handler read it AGAIN, separately, and got something else. Why the paint's read returned -1 is not proven, and it does not need to be: re-deriving state at paint time was the mistake, the same one that broke five instruments in one night of the render-tick hunt. The tick already stores every state it acted on in gShownStates, for change detection - the paint now draws those values and reads nothing else. The board and the arming can no longer disagree, because they are the same read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1212 lines
35 KiB
C++
1212 lines
35 KiB
C++
#include "..\munga_l4\mungal4.h"
|
|
#pragma hdrstop
|
|
|
|
#include "rpl4console.h"
|
|
#include "rpl4fe.h"
|
|
#include "..\munga\appmgr.h"
|
|
#include "..\munga\appmsg.h"
|
|
#include "..\munga\console.h"
|
|
#include "..\munga\spooler.h"
|
|
#include "..\rp\rpcnsl.h"
|
|
#include "..\munga_l4\l4app.h"
|
|
#include "..\munga_l4\l4net.h"
|
|
#include "..\munga_l4\l4nettransport.h"
|
|
|
|
#define CONSOLE_NET_PORT 1501 // arcade default (matches L4NET.CPP)
|
|
|
|
//########################################################################
|
|
// 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).
|
|
//
|
|
// NETWORK RACES (lobby owner as console): the same tick additionally
|
|
// marshals REMOTE pods over the NetTransport wire, speaking the exact
|
|
// arcade console protocol - egg chunks + ACK, state queries,
|
|
// RunMission when everyone reaches WaitingForLaunch, StopMission at
|
|
// expiry, EndMission score intake. The owner's own pod runs in
|
|
// network mode (it meshes like any pod) but is fed its egg locally
|
|
// and driven by direct engine calls, so the console never needs a
|
|
// connection to itself.
|
|
//
|
|
// Results flow in through gConsoleScoreSink (RP layer) for the local
|
|
// pod and EndMission wire messages for remote pods: the same final
|
|
// scores every pod 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;
|
|
|
|
//---------------------------------------------------------------
|
|
// Network race state: remote pods marshaled over the wire
|
|
//---------------------------------------------------------------
|
|
enum { maxRemotePods = 8 };
|
|
enum { remoteRxSize = 8192 };
|
|
|
|
struct RemotePod
|
|
{
|
|
char address[64]; // console channel, "ip[:port]"
|
|
NetTransport::Connection
|
|
connection;
|
|
int state; // last reported application state (-1 unknown)
|
|
Logical eggAcknowledged;
|
|
DWORD lastQueryTick;
|
|
DWORD eggSentTick; // 0 = never sent
|
|
Logical scored;
|
|
char rx[remoteRxSize]; // wire frame reassembly
|
|
int rxCount;
|
|
};
|
|
|
|
RemotePod gRemotePods[maxRemotePods];
|
|
int gRemotePodCount = 0;
|
|
|
|
Logical gNetworkRace = False;
|
|
char gEggPath[MAX_PATH] = "";
|
|
char *gEggWire = NULL; // newline->NUL image for chunking
|
|
int gEggWireSize = 0;
|
|
Logical gLocalEggFed = False;
|
|
Logical gRunSent = False;
|
|
Logical gRemoteStopsSent = False;
|
|
DWORD gRemoteStopTick = 0;
|
|
|
|
// pilot names in [pilots] order; host IDs start at FirstLegalHostID+1
|
|
// (the console reserves the first), so host 2 = pilot index 0
|
|
enum { firstPilotHostID = 2 };
|
|
char gPilotNames[maxRemotePods + 1][32];
|
|
int gPilotNameCount = 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 (game thread: the RP-layer sink for the
|
|
// local pod, the wire pump for remote pods)
|
|
//---------------------------------------------------------------
|
|
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 wire: the arcade console protocol over NetTransport
|
|
//---------------------------------------------------------------
|
|
void SendWire(RemotePod *pod, int client_ID, const void *message, int size)
|
|
{
|
|
char packet[sizeof(NetworkPacketHeader) + 1400];
|
|
if (size > (int) sizeof(packet) - (int) sizeof(NetworkPacketHeader))
|
|
{
|
|
return;
|
|
}
|
|
memset(packet, 0, sizeof(NetworkPacketHeader));
|
|
NetworkPacketHeader *header = (NetworkPacketHeader *) packet;
|
|
header->clientID = (NetworkClient::ClientID) client_ID;
|
|
header->gameID = 0;
|
|
header->fromHost = 1; // the console's reserved host ID
|
|
memcpy(packet + sizeof(NetworkPacketHeader), message, size);
|
|
NetTransport_Get()->Send(
|
|
pod->connection, packet, (int) sizeof(NetworkPacketHeader) + size);
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// The same packet, delivered to the station on this machine.
|
|
//
|
|
// A real pod bay console sits on its own machine and every station
|
|
// hears it over the wire. Here it is colocated, and the network stack
|
|
// is quite right not to push bytes through a socket to reach a client
|
|
// in the same process - but the console had gone further than that and
|
|
// called application->Dispatch, stepping past the client's receive
|
|
// entry altogether. Anything watching packets therefore never saw the
|
|
// console speak: the first Live Cam recording held all 14,342 of the
|
|
// racer's packets and not one LoadMission, RunMission or StopMission,
|
|
// because those were the messages that came from inside the house.
|
|
//
|
|
// So build the packet SendWire would have built and hand it to the
|
|
// client's own front door. No socket, no wire, no copy of the protocol
|
|
// - just the delivery arriving where a delivery arrives.
|
|
//---------------------------------------------------------------
|
|
void DeliverLocal(int client_ID, const void *message, int size)
|
|
{
|
|
char packet[sizeof(NetworkPacketHeader) + 1400];
|
|
|
|
if (size > (int) sizeof(packet) - (int) sizeof(NetworkPacketHeader))
|
|
{
|
|
return;
|
|
}
|
|
memset(packet, 0, sizeof(NetworkPacketHeader));
|
|
|
|
NetworkPacketHeader *header = (NetworkPacketHeader *) packet;
|
|
|
|
header->clientID = (NetworkClient::ClientID) client_ID;
|
|
header->gameID = 0;
|
|
header->fromHost = 1; // the console's reserved host ID
|
|
memcpy(packet + sizeof(NetworkPacketHeader), message, size);
|
|
|
|
NetworkPacket *received = (NetworkPacket *) packet;
|
|
|
|
Check(application);
|
|
application->ReceiveNetworkPacket(received, &received->messageData);
|
|
}
|
|
|
|
void SendEggTo(RemotePod *pod)
|
|
{
|
|
int chunk_count = (gEggWireSize + 999) / 1000;
|
|
for (int i = 0; i < chunk_count; ++i)
|
|
{
|
|
int offset = i * 1000;
|
|
int length = gEggWireSize - offset;
|
|
if (length > 1000)
|
|
{
|
|
length = 1000;
|
|
}
|
|
NetworkManager__ReceiveEggFileMessage chunk(
|
|
i, gEggWireSize, gEggWire + offset, length);
|
|
SendWire(pod, NetworkClient::NetworkManagerClientID,
|
|
&chunk, (int) chunk.messageLength);
|
|
}
|
|
pod->eggSentTick = GetTickCount();
|
|
DEBUG_STREAM << "LocalConsole: egg sent to " << pod->address
|
|
<< " (" << chunk_count << " chunks)\n" << std::flush;
|
|
}
|
|
|
|
void PumpRemote(RemotePod *pod)
|
|
{
|
|
//
|
|
// Read whatever the wire has pending
|
|
//
|
|
for (;;)
|
|
{
|
|
int space = remoteRxSize - pod->rxCount;
|
|
if (space <= 0)
|
|
{
|
|
break;
|
|
}
|
|
int received = NetTransport_Get()->Receive(
|
|
pod->connection, pod->rx + pod->rxCount, space);
|
|
if (received <= 0)
|
|
{
|
|
break; // no data / disconnected
|
|
}
|
|
pod->rxCount += received;
|
|
if (received < space)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
//
|
|
// Parse complete frames: NetworkPacketHeader + engine message
|
|
//
|
|
const int header_size = (int) sizeof(NetworkPacketHeader);
|
|
const int base_size = (int) sizeof(Receiver__Message);
|
|
for (;;)
|
|
{
|
|
if (pod->rxCount < header_size + base_size)
|
|
{
|
|
break;
|
|
}
|
|
NetworkPacketHeader *header = (NetworkPacketHeader *) pod->rx;
|
|
Receiver__Message *base = (Receiver__Message *)(pod->rx + header_size);
|
|
int total = header_size + (int) base->messageLength;
|
|
if (total < header_size + base_size || total > remoteRxSize)
|
|
{
|
|
DEBUG_STREAM << "LocalConsole: garbage frame from "
|
|
<< pod->address << " - dropping buffer\n" << std::flush;
|
|
pod->rxCount = 0;
|
|
break;
|
|
}
|
|
if (pod->rxCount < total)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if ((int) header->clientID == (int) NetworkClient::ConsoleClientID)
|
|
{
|
|
if ((int) base->messageID == ConsoleApplicationStateResponseMessageID)
|
|
{
|
|
ConsoleApplicationStateResponseMessage *message =
|
|
(ConsoleApplicationStateResponseMessage *) base;
|
|
if (pod->state != (int) message->GetApplicationState())
|
|
{
|
|
DEBUG_STREAM << "LocalConsole: " << pod->address
|
|
<< " state -> " << (int) message->GetApplicationState()
|
|
<< "\n" << std::flush;
|
|
}
|
|
pod->state = (int) message->GetApplicationState();
|
|
}
|
|
else if ((int) base->messageID == ConsoleApplicationEndMissionMessageID)
|
|
{
|
|
ConsoleApplicationEndMissionMessage *message =
|
|
(ConsoleApplicationEndMissionMessage *) base;
|
|
CollectFinalScore(
|
|
(int) message->GetPlayerHostID(),
|
|
(int) message->GetFinalScore());
|
|
pod->scored = True;
|
|
}
|
|
// VTV telemetry (IDs 2-6) skips through for now
|
|
}
|
|
else if ((int) header->clientID == (int) NetworkClient::NetworkManagerClientID)
|
|
{
|
|
if ((int) base->messageID == (int) L4NetworkManager::AcknowledgeEggFileMessageID)
|
|
{
|
|
if (!pod->eggAcknowledged)
|
|
{
|
|
DEBUG_STREAM << "LocalConsole: " << pod->address
|
|
<< " EGG ACK (mesh complete)\n" << std::flush;
|
|
}
|
|
pod->eggAcknowledged = True;
|
|
}
|
|
}
|
|
|
|
memmove(pod->rx, pod->rx + total, pod->rxCount - total);
|
|
pod->rxCount -= total;
|
|
}
|
|
}
|
|
|
|
void MarshalRemotes()
|
|
{
|
|
DWORD now = GetTickCount();
|
|
for (int i = 0; i < gRemotePodCount; ++i)
|
|
{
|
|
RemotePod *pod = &gRemotePods[i];
|
|
|
|
// state poll, once a second (the arcade console's cadence)
|
|
if ((LONG)(now - pod->lastQueryTick) >= 1000)
|
|
{
|
|
Application::StateQueryMessage query(1);
|
|
SendWire(pod, NetworkClient::ApplicationClientID,
|
|
&query, (int) query.messageLength);
|
|
pod->lastQueryTick = now;
|
|
}
|
|
|
|
PumpRemote(pod);
|
|
|
|
// egg feed: 5s retry until the pod ACKs (post-mesh)
|
|
if (pod->state == (int) Application::WaitingForEgg &&
|
|
!pod->eggAcknowledged &&
|
|
(pod->eggSentTick == 0 || (LONG)(now - pod->eggSentTick) >= 5000))
|
|
{
|
|
SendEggTo(pod);
|
|
}
|
|
}
|
|
}
|
|
|
|
Logical AllRemotesInState(int state)
|
|
{
|
|
for (int i = 0; i < gRemotePodCount; ++i)
|
|
{
|
|
if (gRemotePods[i].state != state)
|
|
{
|
|
return False;
|
|
}
|
|
}
|
|
return True;
|
|
}
|
|
|
|
Logical AllRemotesScored()
|
|
{
|
|
for (int i = 0; i < gRemotePodCount; ++i)
|
|
{
|
|
if (!gRemotePods[i].scored)
|
|
{
|
|
return False;
|
|
}
|
|
}
|
|
return True;
|
|
}
|
|
|
|
void DisconnectRemotes()
|
|
{
|
|
for (int i = 0; i < gRemotePodCount; ++i)
|
|
{
|
|
if (gRemotePods[i].connection != NetTransport::InvalidConnection)
|
|
{
|
|
NetTransport_Get()->Close(gRemotePods[i].connection);
|
|
gRemotePods[i].connection = NetTransport::InvalidConnection;
|
|
}
|
|
}
|
|
}
|
|
|
|
void DispatchLocalStop()
|
|
{
|
|
DEBUG_STREAM << "LocalConsole: stopping local pod\n" << std::flush;
|
|
InterlockedExchange(&gMissionRunning, 0);
|
|
Application::StopMissionMessage message(0);
|
|
DeliverLocal(
|
|
NetworkClient::ApplicationClientID,
|
|
&message,
|
|
(int) message.messageLength
|
|
);
|
|
gPhase = PhaseStopped;
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// The countdown the engine shows, taken from the clock that will
|
|
// actually end the race (gMissionClockHook - see APPMGR.h).
|
|
//
|
|
// Called on the game thread, reading two volatile LONGs the console
|
|
// thread writes with InterlockedExchange. Aligned 32-bit reads, and
|
|
// a torn value could only mistime the cockpit clock by one tick of
|
|
// a countdown nobody reads to the millisecond - not worth a lock on
|
|
// the frame path.
|
|
//---------------------------------------------------------------
|
|
Logical MissionClock(Scalar *seconds_remaining)
|
|
{
|
|
//
|
|
// Only answer for the race this console is actually marshalling.
|
|
// Nothing ever uninstalls the hook, so a player who hosts a race
|
|
// and then joins somebody else's lobby still has it wired up -
|
|
// and in that race the console is a bystander whose gLengthMs and
|
|
// gRunStartTick belong to the previous mission entirely.
|
|
//
|
|
if (gWatchedApp == NULL || gWatchedApp != application)
|
|
{
|
|
return False;
|
|
}
|
|
if (!gMissionRunning)
|
|
{
|
|
return False; // not started, or already stopped
|
|
}
|
|
LONG length_ms = gLengthMs;
|
|
if (length_ms <= 0)
|
|
{
|
|
return False; // endless: nothing to count down
|
|
}
|
|
|
|
// DWORD subtraction, so a GetTickCount wrap costs nothing
|
|
LONG elapsed_ms = (LONG)(GetTickCount() - (DWORD) gRunStartTick);
|
|
LONG left_ms = length_ms - elapsed_ms;
|
|
if (left_ms < 0)
|
|
{
|
|
//
|
|
// The console polls at 250 ms, so the clock reaches zero
|
|
// slightly before the stop is dispatched. Hold at zero
|
|
// rather than showing negative time in the cockpit.
|
|
//
|
|
left_ms = 0;
|
|
}
|
|
*seconds_remaining = (Scalar) left_ms / 1000.0f;
|
|
return True;
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// The commit board: the pod bay ritual, on screen.
|
|
//
|
|
// In the pod bay the mission is COMMITTED first - every pod preps
|
|
// up to, but not past, the launch - and then the operator presses
|
|
// the launch button. The prep window is not dead time: the MFDs
|
|
// and map buttons are alive before the mission starts, so pilots
|
|
// use it to set maps and presets, and the wait is part of the
|
|
// game's social fabric.
|
|
//
|
|
// This is that ritual for a lobby race. The host's menu button
|
|
// says COMMIT; committing marshals everyone exactly as before, but
|
|
// where the console used to fire RunMission the instant all pods
|
|
// staged, it now arms a LAUNCH button on a small status board and
|
|
// waits for the operator. The board lists every pod and what it is
|
|
// doing - connecting, loading, READY - so the host can see who the
|
|
// room is waiting on, and it gets out of the way the moment the
|
|
// mission drops.
|
|
//
|
|
// Game thread throughout: created, painted, clicked and destroyed
|
|
// inside ConsoleTick, so the game's own message pump (already
|
|
// alive - that is why the MFDs work) delivers its input, and no
|
|
// state crosses a thread. WS_EX_NOACTIVATE keeps the game window
|
|
// focused: PadRIO controls answer only while it is, and a launch
|
|
// click must not cost the host their controls.
|
|
//---------------------------------------------------------------
|
|
|
|
HWND gStatusWindow = NULL;
|
|
Logical gLaunchArmed = False; // everyone staged: button lit
|
|
Logical gLaunchRequested = False; // the operator pressed it
|
|
RECT gLaunchRect; // client coords, hit-tested
|
|
int gShownStates[maxRemotePods + 2];
|
|
|
|
const COLORREF kBoardGreen = RGB(64, 255, 64);
|
|
const COLORREF kBoardGreenDim = RGB(24, 140, 24);
|
|
|
|
const char *StateWord(int state)
|
|
{
|
|
switch (state)
|
|
{
|
|
case -1: return "connecting";
|
|
case Application::WaitingForEgg: return "waiting for egg";
|
|
case Application::CreatingMission:
|
|
case Application::LoadingMission: return "loading";
|
|
case Application::WaitingForLaunch: return "READY";
|
|
case Application::LaunchingMission:
|
|
case Application::RunningMission: return "running";
|
|
default: return "starting";
|
|
}
|
|
}
|
|
|
|
LRESULT CALLBACK StatusBoardWndProc(
|
|
HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
|
|
{
|
|
switch (message)
|
|
{
|
|
case WM_MOUSEACTIVATE:
|
|
// take the click, leave the focus with the game
|
|
return MA_NOACTIVATE;
|
|
|
|
case WM_ERASEBKGND:
|
|
return 1;
|
|
|
|
case WM_LBUTTONDOWN:
|
|
{
|
|
int x = (int)(short) LOWORD(lParam);
|
|
int y = (int)(short) HIWORD(lParam);
|
|
|
|
if (gLaunchArmed &&
|
|
x >= gLaunchRect.left && x < gLaunchRect.right &&
|
|
y >= gLaunchRect.top && y < gLaunchRect.bottom)
|
|
{
|
|
gLaunchRequested = True;
|
|
}
|
|
}
|
|
return 0;
|
|
|
|
case WM_PAINT:
|
|
{
|
|
PAINTSTRUCT ps;
|
|
HDC hdc = BeginPaint(hwnd, &ps);
|
|
RECT client;
|
|
GetClientRect(hwnd, &client);
|
|
|
|
HDC mem = CreateCompatibleDC(hdc);
|
|
HBITMAP surface = CreateCompatibleBitmap(
|
|
hdc, client.right, client.bottom);
|
|
HGDIOBJ old_surface = SelectObject(mem, surface);
|
|
|
|
FillRect(mem, &client,
|
|
(HBRUSH) GetStockObject(BLACK_BRUSH));
|
|
HBRUSH frame = CreateSolidBrush(kBoardGreenDim);
|
|
FrameRect(mem, &client, frame);
|
|
DeleteObject(frame);
|
|
|
|
HFONT font = CreateFontA(-14, 0, 0, 0, FW_NORMAL,
|
|
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_DEFAULT_PRECIS,
|
|
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
|
|
DEFAULT_PITCH | FF_MODERN, "Consolas");
|
|
HGDIOBJ old_font = SelectObject(mem, font);
|
|
SetBkMode(mem, TRANSPARENT);
|
|
|
|
int row_h = 20;
|
|
RECT line;
|
|
line.left = 10;
|
|
line.right = client.right - 10;
|
|
line.top = 8;
|
|
line.bottom = line.top + row_h;
|
|
|
|
SetTextColor(mem, kBoardGreen);
|
|
DrawTextA(mem, "MISSION COMMITTED", -1, &line,
|
|
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
|
line.top += row_h + 4;
|
|
line.bottom += row_h + 4;
|
|
|
|
//
|
|
// One row per pod: the host first, then the remotes in
|
|
// [pilots] order. READY rows bright, the rest dim, so
|
|
// who the room is waiting on reads at a glance.
|
|
//
|
|
// Every state on this board comes from gShownStates -
|
|
// the values the TICK stored when it decided whether to
|
|
// arm - and nothing is re-read at paint time. The first
|
|
// version re-read application->GetApplicationState()
|
|
// here, and the host's own row said 'connecting' under
|
|
// a lit LAUNCH button: two reads of one fact from two
|
|
// places, disagreeing - the exact mistake behind five
|
|
// broken instruments in one night of this project. One
|
|
// frame of reference: the tick decides, the paint
|
|
// repeats what it decided.
|
|
//
|
|
char text[96];
|
|
|
|
sprintf(text, "%-14s %s",
|
|
(gPilotNameCount > 0) ? gPilotNames[0] : "HOST",
|
|
StateWord(gShownStates[0]));
|
|
SetTextColor(mem,
|
|
(gShownStates[0] == Application::WaitingForLaunch)
|
|
? kBoardGreen : kBoardGreenDim);
|
|
DrawTextA(mem, text, -1, &line,
|
|
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
|
|
|
for (int i = 0; i < gRemotePodCount; ++i)
|
|
{
|
|
line.top += row_h;
|
|
line.bottom += row_h;
|
|
|
|
const char *name = (i + 1 < gPilotNameCount)
|
|
? gPilotNames[i + 1] : gRemotePods[i].address;
|
|
|
|
sprintf(text, "%-14s %s", name,
|
|
StateWord(gShownStates[i + 1]));
|
|
SetTextColor(mem,
|
|
(gShownStates[i + 1] == Application::WaitingForLaunch)
|
|
? kBoardGreen : kBoardGreenDim);
|
|
DrawTextA(mem, text, -1, &line,
|
|
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
|
}
|
|
|
|
//
|
|
// The operator's button. Lit only when every system is
|
|
// ready - the same condition that used to fire the run
|
|
// automatically.
|
|
//
|
|
gLaunchRect.left = 10;
|
|
gLaunchRect.right = client.right - 10;
|
|
gLaunchRect.bottom = client.bottom - 8;
|
|
gLaunchRect.top = gLaunchRect.bottom - 26;
|
|
|
|
HBRUSH button = CreateSolidBrush(
|
|
gLaunchArmed ? kBoardGreen : kBoardGreenDim);
|
|
FrameRect(mem, &gLaunchRect, button);
|
|
DeleteObject(button);
|
|
SetTextColor(mem,
|
|
gLaunchArmed ? kBoardGreen : kBoardGreenDim);
|
|
DrawTextA(mem,
|
|
gLaunchArmed ? "L A U N C H" : "waiting for pods...",
|
|
-1, &gLaunchRect,
|
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
|
|
|
BitBlt(hdc, 0, 0, client.right, client.bottom,
|
|
mem, 0, 0, SRCCOPY);
|
|
SelectObject(mem, old_font);
|
|
DeleteObject(font);
|
|
SelectObject(mem, old_surface);
|
|
DeleteObject(surface);
|
|
DeleteDC(mem);
|
|
EndPaint(hwnd, &ps);
|
|
}
|
|
return 0;
|
|
}
|
|
return DefWindowProcA(hwnd, message, wParam, lParam);
|
|
}
|
|
|
|
void CreateStatusBoard()
|
|
{
|
|
static Logical class_registered = False;
|
|
|
|
if (!class_registered)
|
|
{
|
|
class_registered = True;
|
|
WNDCLASSA window_class;
|
|
memset(&window_class, 0, sizeof(window_class));
|
|
window_class.lpfnWndProc = StatusBoardWndProc;
|
|
window_class.hInstance = GetModuleHandleA(NULL);
|
|
window_class.hCursor = LoadCursor(NULL, IDC_ARROW);
|
|
window_class.hbrBackground = (HBRUSH) GetStockObject(BLACK_BRUSH);
|
|
window_class.lpszClassName = "RPCommitBoard";
|
|
RegisterClassA(&window_class);
|
|
}
|
|
|
|
int height = 8 + 24 + (gRemotePodCount + 1) * 20 + 12 + 26 + 8;
|
|
int width = 280;
|
|
|
|
//
|
|
// Top-right of the game window, inset a little - over the 3D
|
|
// view, clear of the instrument panes along the edges.
|
|
//
|
|
RECT game;
|
|
GetWindowRect(ghWnd, &game);
|
|
|
|
gStatusWindow = CreateWindowExA(
|
|
WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW,
|
|
"RPCommitBoard", "", WS_POPUP,
|
|
game.right - width - 48, game.top + 64,
|
|
width, height,
|
|
ghWnd, NULL, GetModuleHandleA(NULL), NULL);
|
|
if (gStatusWindow != NULL)
|
|
{
|
|
ShowWindow(gStatusWindow, SW_SHOWNOACTIVATE);
|
|
}
|
|
for (int i = 0; i < maxRemotePods + 2; ++i)
|
|
{
|
|
gShownStates[i] = -999;
|
|
}
|
|
}
|
|
|
|
void DestroyStatusBoard()
|
|
{
|
|
if (gStatusWindow != NULL)
|
|
{
|
|
DestroyWindow(gStatusWindow);
|
|
gStatusWindow = NULL;
|
|
}
|
|
gLaunchArmed = False;
|
|
gLaunchRequested = False;
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// 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:
|
|
//
|
|
// A mission can die before it ever runs - Alt+Q during the
|
|
// commit hold, or while loading. The console only learned a
|
|
// mission was over from PhaseRunning, so an abort during prep
|
|
// left it in PhaseWaiting forever, MissionCompleted() answered
|
|
// False, and WinMain's single-binary loop fell out to the
|
|
// DESKTOP instead of returning to the setup screen. The commit
|
|
// hold makes the prep window somewhere players actually stand,
|
|
// so the hole finally had traffic. A prep death counts as a
|
|
// completed mission now: back to the menu, lobby intact.
|
|
//
|
|
if (state == Application::EndingMission ||
|
|
state == Application::StoppingMission ||
|
|
state == Application::AbortingMission)
|
|
{
|
|
DEBUG_STREAM << "LocalConsole: mission ended during prep - "
|
|
<< "back to the menu\n" << std::flush;
|
|
InterlockedExchange(&gMissionRunning, 0);
|
|
gPhase = PhaseStopped;
|
|
DestroyStatusBoard();
|
|
if (gNetworkRace)
|
|
{
|
|
DisconnectRemotes();
|
|
}
|
|
break;
|
|
}
|
|
if (gNetworkRace)
|
|
{
|
|
MarshalRemotes();
|
|
|
|
//
|
|
// Feed our own pod its egg locally: it meshes like any
|
|
// pod but the console drives it without a connection
|
|
//
|
|
if (!gLocalEggFed && state == Application::WaitingForEgg)
|
|
{
|
|
L4NetworkManager *network_manager =
|
|
(L4NetworkManager *) application->GetNetworkManager();
|
|
if (network_manager != NULL)
|
|
{
|
|
network_manager->FeedLocalEgg(gEggPath);
|
|
gLocalEggFed = True;
|
|
DEBUG_STREAM << "LocalConsole: local egg fed\n" << std::flush;
|
|
}
|
|
}
|
|
|
|
//
|
|
// The commit board, from the moment there is anything to
|
|
// show. It repaints only when a state actually changes -
|
|
// this ticks every frame.
|
|
//
|
|
if (gRemotePodCount > 0 && !gRunSent)
|
|
{
|
|
if (gStatusWindow == NULL)
|
|
{
|
|
CreateStatusBoard();
|
|
}
|
|
if (gStatusWindow != NULL)
|
|
{
|
|
Logical changed = (gShownStates[0] != state);
|
|
|
|
gShownStates[0] = state;
|
|
for (int i = 0; i < gRemotePodCount; ++i)
|
|
{
|
|
if (gShownStates[i + 1] != gRemotePods[i].state)
|
|
{
|
|
gShownStates[i + 1] = gRemotePods[i].state;
|
|
changed = True;
|
|
}
|
|
}
|
|
if (changed)
|
|
{
|
|
InvalidateRect(gStatusWindow, NULL, FALSE);
|
|
}
|
|
}
|
|
}
|
|
|
|
//
|
|
// Everyone staged: ARM. The console used to fire the run
|
|
// itself right here; the run belongs to the operator now,
|
|
// pod bay fashion, and the prep hold is the point - the
|
|
// MFDs and map buttons are alive, so pilots set maps and
|
|
// presets before the drop.
|
|
//
|
|
if (!gRunSent &&
|
|
state == Application::WaitingForLaunch &&
|
|
AllRemotesInState(Application::WaitingForLaunch))
|
|
{
|
|
if (!gLaunchArmed)
|
|
{
|
|
gLaunchArmed = True;
|
|
DEBUG_STREAM << "LocalConsole: all pods staged - "
|
|
<< "committed, LAUNCH is the operator's\n"
|
|
<< std::flush;
|
|
if (gStatusWindow != NULL)
|
|
{
|
|
InvalidateRect(gStatusWindow, NULL, FALSE);
|
|
}
|
|
}
|
|
|
|
//
|
|
// A solo commit (no remote pods) launches itself: with
|
|
// nobody to wait for there is no board, and the old
|
|
// instant start is what a lone pilot expects.
|
|
//
|
|
if (gLaunchRequested || gRemotePodCount == 0)
|
|
{
|
|
DEBUG_STREAM << "LocalConsole: RUN\n" << std::flush;
|
|
for (int i = 0; i < gRemotePodCount; ++i)
|
|
{
|
|
Application::RunMissionMessage run;
|
|
SendWire(&gRemotePods[i], NetworkClient::ApplicationClientID,
|
|
&run, (int) run.messageLength);
|
|
}
|
|
Application::RunMissionMessage local_run;
|
|
DeliverLocal(
|
|
NetworkClient::ApplicationClientID,
|
|
&local_run,
|
|
(int) local_run.messageLength
|
|
);
|
|
gRunSent = True;
|
|
|
|
// out of the way: the host has a race to fly
|
|
DestroyStatusBoard();
|
|
}
|
|
}
|
|
else if (gLaunchArmed && !gRunSent)
|
|
{
|
|
//
|
|
// Somebody fell back out of readiness (a reload, a
|
|
// drop). Disarm rather than launching a room that is
|
|
// no longer whole.
|
|
//
|
|
gLaunchArmed = False;
|
|
if (gStatusWindow != NULL)
|
|
{
|
|
InvalidateRect(gStatusWindow, NULL, FALSE);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (state == Application::RunningMission)
|
|
{
|
|
gPhase = PhaseRunning;
|
|
DestroyStatusBoard(); // however the run began
|
|
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 (gNetworkRace)
|
|
{
|
|
// telemetry + final scores keep flowing during the race
|
|
for (int i = 0; i < gRemotePodCount; ++i)
|
|
{
|
|
PumpRemote(&gRemotePods[i]);
|
|
}
|
|
}
|
|
|
|
if (state != Application::RunningMission)
|
|
{
|
|
// mission ended some other way (pilot exit etc.)
|
|
InterlockedExchange(&gMissionRunning, 0);
|
|
gPhase = PhaseStopped;
|
|
DestroyStatusBoard();
|
|
DisconnectRemotes();
|
|
}
|
|
else if (gStopRequested)
|
|
{
|
|
//-----------------------------------------------------
|
|
// The console clock expired: end the race exactly the
|
|
// way the arcade console did. Remote pods stop first;
|
|
// the local pod holds on briefly so their EndMission
|
|
// scores can land before our own teardown.
|
|
//-----------------------------------------------------
|
|
if (!gNetworkRace)
|
|
{
|
|
DEBUG_STREAM << "LocalConsole: time expired - stopping mission\n" << std::flush;
|
|
DispatchLocalStop();
|
|
}
|
|
else if (!gRemoteStopsSent)
|
|
{
|
|
DEBUG_STREAM << "LocalConsole: time expired - stopping remote pods\n" << std::flush;
|
|
for (int i = 0; i < gRemotePodCount; ++i)
|
|
{
|
|
Application::StopMissionMessage stop(0);
|
|
SendWire(&gRemotePods[i], NetworkClient::ApplicationClientID,
|
|
&stop, (int) stop.messageLength);
|
|
}
|
|
gRemoteStopsSent = True;
|
|
gRemoteStopTick = GetTickCount();
|
|
}
|
|
else if (AllRemotesScored() ||
|
|
(LONG)(GetTickCount() - gRemoteStopTick) >= 5000)
|
|
{
|
|
DispatchLocalStop();
|
|
DisconnectRemotes();
|
|
}
|
|
}
|
|
break;
|
|
|
|
case PhaseStopped:
|
|
// The application tears itself down after a stop (arcade
|
|
// pods were relaunched per mission). WinMain's race loop
|
|
// asks MissionCompleted() and cycles back to the setup
|
|
// screen in the same process.
|
|
break;
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Shared install plumbing
|
|
//---------------------------------------------------------------
|
|
void InstallCommon(int mission_seconds)
|
|
{
|
|
// debug: L4CONSOLELEN overrides the mission length (test races)
|
|
const char *override_string = getenv("L4CONSOLELEN");
|
|
if (override_string != NULL && atoi(override_string) > 0)
|
|
{
|
|
mission_seconds = atoi(override_string);
|
|
DEBUG_STREAM << "LocalConsole: L4CONSOLELEN override, "
|
|
<< mission_seconds << "s\n" << std::flush;
|
|
}
|
|
|
|
//
|
|
// RP412MISSIONSECONDS overrides the menu's game length. The shortest
|
|
// the menu offers is 3:00, which is a long wait when what you are
|
|
// testing is what happens at the buzzer.
|
|
//
|
|
const char *seconds_override = getenv("RP412MISSIONSECONDS");
|
|
if (seconds_override != NULL && atoi(seconds_override) > 0)
|
|
{
|
|
mission_seconds = atoi(seconds_override);
|
|
DEBUG_STREAM << "LocalConsole: length overridden to "
|
|
<< mission_seconds << "s by RP412MISSIONSECONDS\n" << std::flush;
|
|
}
|
|
|
|
gMissionSeconds = mission_seconds;
|
|
InterlockedExchange(&gLengthMs, (LONG) mission_seconds * 1000);
|
|
gPhase = PhaseWaiting;
|
|
gWatchedApp = NULL;
|
|
gRunSent = False;
|
|
gRemoteStopsSent = False;
|
|
gLocalEggFed = False;
|
|
DestroyStatusBoard(); // no board survives into a new race
|
|
|
|
// game-thread execution point
|
|
gPerFrameHook = &ConsoleTick;
|
|
|
|
// the cockpit clock now counts down the same clock that will stop
|
|
// the race, rather than the engine's own reckoning of it
|
|
gMissionClockHook = &MissionClock;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
void
|
|
RPL4LocalConsole_Install(int mission_seconds)
|
|
{
|
|
gNetworkRace = False;
|
|
gRemotePodCount = 0;
|
|
gPilotNameCount = 0;
|
|
// single player launches itself (the engine's no-console self-run)
|
|
gConsoleMarshalsLaunch = False;
|
|
InstallCommon(mission_seconds);
|
|
}
|
|
|
|
Logical
|
|
RPL4LocalConsole_InstallNetworkRace(
|
|
int mission_seconds,
|
|
const char *egg_path,
|
|
const char *remote_pod_list,
|
|
const char *pilot_names
|
|
)
|
|
{
|
|
gNetworkRace = True;
|
|
gRemotePodCount = 0;
|
|
gPilotNameCount = 0;
|
|
|
|
// the owner pod must stage at WaitingForLaunch with everyone else -
|
|
// this console launches the whole mesh at once
|
|
gConsoleMarshalsLaunch = True;
|
|
|
|
strncpy(gEggPath, egg_path, sizeof(gEggPath) - 1);
|
|
gEggPath[sizeof(gEggPath) - 1] = '\0';
|
|
|
|
//
|
|
// The recording needs this too. A spool says what moved; the egg says
|
|
// what it moved through, and the console is where the egg's name is
|
|
// actually known.
|
|
//
|
|
SpoolRecorder_Get()->SetEggPath(gEggPath);
|
|
|
|
//
|
|
// The wire image of the egg: file newlines become NULs, exactly
|
|
// what the arcade console sent (RPMission.ToEggFileMessages)
|
|
//
|
|
if (gEggWire != NULL)
|
|
{
|
|
delete[] gEggWire;
|
|
gEggWire = NULL;
|
|
gEggWireSize = 0;
|
|
}
|
|
FILE *egg_file = fopen(egg_path, "rb");
|
|
if (egg_file == NULL)
|
|
{
|
|
DEBUG_STREAM << "LocalConsole: cannot read egg " << egg_path << "\n" << std::flush;
|
|
return False;
|
|
}
|
|
fseek(egg_file, 0, SEEK_END);
|
|
long raw_size = ftell(egg_file);
|
|
fseek(egg_file, 0, SEEK_SET);
|
|
char *raw = new char[raw_size];
|
|
fread(raw, 1, raw_size, egg_file);
|
|
fclose(egg_file);
|
|
|
|
gEggWire = new char[raw_size];
|
|
gEggWireSize = 0;
|
|
for (long b = 0; b < raw_size; ++b)
|
|
{
|
|
if (raw[b] == '\r')
|
|
{
|
|
continue; // \r\n collapses to one NUL
|
|
}
|
|
gEggWire[gEggWireSize++] = (raw[b] == '\n') ? '\0' : raw[b];
|
|
}
|
|
delete[] raw;
|
|
|
|
//
|
|
// Pilot names in [pilots] order (results screen labels)
|
|
//
|
|
if (pilot_names != NULL)
|
|
{
|
|
const char *cursor = pilot_names;
|
|
while (*cursor != '\0' && gPilotNameCount < maxRemotePods + 1)
|
|
{
|
|
int length = 0;
|
|
while (cursor[length] != '\0' && cursor[length] != ',' &&
|
|
length < (int) sizeof(gPilotNames[0]) - 1)
|
|
{
|
|
gPilotNames[gPilotNameCount][length] = cursor[length];
|
|
++length;
|
|
}
|
|
gPilotNames[gPilotNameCount][length] = '\0';
|
|
++gPilotNameCount;
|
|
cursor += length;
|
|
if (*cursor == ',')
|
|
{
|
|
++cursor;
|
|
}
|
|
}
|
|
}
|
|
|
|
//
|
|
// Connect to every remote pod's console channel. Blocking with
|
|
// retry, like the arcade console redialing a pod that is still
|
|
// booting; runs before the engine block so nothing is waiting.
|
|
//
|
|
NetTransport_Get()->Startup();
|
|
|
|
//
|
|
// An escape pressed during the last race must not cancel this one.
|
|
//
|
|
NetTransport_ClearWaitCancel();
|
|
|
|
const char *cursor = remote_pod_list;
|
|
while (*cursor != '\0' && gRemotePodCount < maxRemotePods)
|
|
{
|
|
RemotePod *pod = &gRemotePods[gRemotePodCount];
|
|
memset(pod, 0, sizeof(*pod));
|
|
pod->state = -1;
|
|
pod->connection = NetTransport::InvalidConnection;
|
|
|
|
int length = 0;
|
|
while (cursor[length] != '\0' && cursor[length] != ',' &&
|
|
length < (int) sizeof(pod->address) - 1)
|
|
{
|
|
pod->address[length] = cursor[length];
|
|
++length;
|
|
}
|
|
pod->address[length] = '\0';
|
|
cursor += length;
|
|
if (*cursor == ',')
|
|
{
|
|
++cursor;
|
|
}
|
|
|
|
SOCKADDR_IN console_address;
|
|
NetTransport_Get()->Resolve(pod->address, &console_address);
|
|
if (console_address.sin_port == 0)
|
|
{
|
|
console_address.sin_port = htons(CONSOLE_NET_PORT);
|
|
}
|
|
|
|
DEBUG_STREAM << "LocalConsole: connecting to pod " << pod->address
|
|
<< "...\n" << std::flush;
|
|
pod->connection = NetTransport_Get()->Connect(&console_address, 0);
|
|
if (pod->connection == NetTransport::InvalidConnection)
|
|
{
|
|
DEBUG_STREAM << "LocalConsole: could not reach pod "
|
|
<< pod->address << "\n" << std::flush;
|
|
return False;
|
|
}
|
|
++gRemotePodCount;
|
|
}
|
|
|
|
DEBUG_STREAM << "LocalConsole: network race, " << gRemotePodCount
|
|
<< " remote pod(s) connected\n" << std::flush;
|
|
|
|
InstallCommon(mission_seconds);
|
|
return True;
|
|
}
|
|
|
|
Logical
|
|
RPL4LocalConsole_MissionCompleted()
|
|
{
|
|
return gPhase == PhaseStopped;
|
|
}
|
|
|
|
int
|
|
RPL4LocalConsole_ResultCount()
|
|
{
|
|
return gResultCount;
|
|
}
|
|
|
|
Logical
|
|
RPL4LocalConsole_GetResult(int index, int *host_ID, int *score)
|
|
{
|
|
if (index < 0 || index >= gResultCount)
|
|
{
|
|
return False;
|
|
}
|
|
*host_ID = gResults[index].hostID;
|
|
*score = gResults[index].score;
|
|
return True;
|
|
}
|
|
|
|
const char *
|
|
RPL4LocalConsole_GetResultName(int host_ID)
|
|
{
|
|
int index = host_ID - firstPilotHostID;
|
|
if (index < 0 || index >= gPilotNameCount)
|
|
{
|
|
return NULL;
|
|
}
|
|
return gPilotNames[index];
|
|
}
|
|
|
|
void
|
|
RPL4LocalConsole_ClearResults()
|
|
{
|
|
gResultCount = 0;
|
|
gPilotNameCount = 0;
|
|
}
|
|
|
|
void
|
|
RPL4LocalConsole_InjectResult(int host_ID, int score, const char *name)
|
|
{
|
|
if (gResultCount >= maxResults)
|
|
{
|
|
return;
|
|
}
|
|
gResults[gResultCount].hostID = host_ID;
|
|
gResults[gResultCount].score = score;
|
|
++gResultCount;
|
|
|
|
int index = host_ID - firstPilotHostID;
|
|
if (name != NULL && index >= 0 && index < maxRemotePods + 1)
|
|
{
|
|
strncpy(gPilotNames[index], name, sizeof(gPilotNames[index]) - 1);
|
|
gPilotNames[index][sizeof(gPilotNames[index]) - 1] = '\0';
|
|
if (index >= gPilotNameCount)
|
|
{
|
|
gPilotNameCount = index + 1;
|
|
}
|
|
}
|
|
}
|