From f5ad3036cc42fdab81ad138202ad0ac3b8a579b8 Mon Sep 17 00:00:00 2001 From: Cyd Date: Tue, 11 Aug 2026 11:54:53 -0500 Subject: [PATCH] Keeping the race, chosen under YOUR ROLE The recording tee, which is what the Live Cam was for. L4SpoolingApplication turned out not to be the obstacle it looked like. The review build already records by teeing - it spools each packet and then hands it on - and what tied that to a review build was never the recording but where the buffer came from. MissionReviewApplicationManager is only a pool allocator, and SpoolFile takes whatever buffer it is handed, so SpoolRecorder owns one buffer and needs none of it. One hook covers what the review build taps in two places. Both InterestManager and NetworkManager derive from NetworkClient, so NetworkClient::ReceiveNetworkPacket sees entity updates and mission control alike, and it sits before Dispatch so a packet is kept whether or not anything downstream wants it. The recorder arms on the first packet rather than at the green light, because playback rebuilds the world from the LoadMission and RunMission packets and a spool that starts at the flag cannot be replayed. Two things a live recorder must do that the review one did not. It must not touch the packet. The spooler restamps in place with local arrival time, which is right in itself - playback paces off those stamps and packets from different senders carry different clock origins - but the sender's timestamp is what Simulation::ReadUpdateRecord hands to RP412NETCLOCK and from there to the projection. Overwriting it live would feed arrival jitter into where remote pods are drawn, which is the tick just fixed. So the write position is taken first and the COPY is stamped, in the spool, afterwards. And it must not take the race down. SpoolFile::SpoolPacket answers a full buffer with PostQuitMessage - a fair end to a replay, and killing the race being recorded on a live host. The recorder checks the room first and stops, and says so. RP412RECORDSIZE defaults to 100MB rather than the review build's 6, on Cyd's call: a full grid sends around 17KB a second, so six megabytes is six minutes and a hundred is an hour and a half, which costs nothing on any machine that can run this. Saved at the buzzer - the first of the two StopMissions, the end of the race rather than the fade timer - into SPOOLS\.spl and copied to last.spl, matching where the review build looks. Co-Authored-By: Claude Opus 5 (1M context) --- MUNGA/APP.cpp | 1 + MUNGA/APP.h | 12 +++ MUNGA/NETWORK.cpp | 34 ++++++- MUNGA/SPOOLER.cpp | 220 ++++++++++++++++++++++++++++++++++++++++++ MUNGA/SPOOLER.h | 69 +++++++++++++ RP_L4/RPL4APP.cpp | 13 +++ RP_L4/RPL4ENVIRON.cpp | 15 +++ RP_L4/RPL4FE.cpp | 52 +++++++++- 8 files changed, 414 insertions(+), 2 deletions(-) diff --git a/MUNGA/APP.cpp b/MUNGA/APP.cpp index 765b92e..1365dd6 100644 --- a/MUNGA/APP.cpp +++ b/MUNGA/APP.cpp @@ -34,6 +34,7 @@ Application *application = NULL; int Exit_Code = 0; Logical Application::suppressGauges = False; Logical Application::cameraStation = False; +Logical Application::recordMission = False; // // RP412CAMLOG - see app.h. Cached: the waiting trace asks once a second diff --git a/MUNGA/APP.h b/MUNGA/APP.h index f2f7b74..9792376 100644 --- a/MUNGA/APP.h +++ b/MUNGA/APP.h @@ -469,6 +469,17 @@ public: static Logical IsCameraStation() { return cameraStation; } static void SetCameraStation(Logical state) { cameraStation = state; } + // + // Whether to keep a spool of this race. Picked on the setup screen + // under the role, and set the same way and for the same reason: it + // has to be known before the network manager is built, which happens + // after the menu, and it must be set either way because the same + // process races again and a stale True would quietly record a session + // nobody asked to keep. + // + static Logical IsRecording() { return recordMission; } + static void SetRecording(Logical state) { recordMission = state; } + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Modules // @@ -505,6 +516,7 @@ protected: static Logical suppressGauges; static Logical cameraStation; + static Logical recordMission; //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Module Creation diff --git a/MUNGA/NETWORK.cpp b/MUNGA/NETWORK.cpp index fcaea72..85ca2e3 100644 --- a/MUNGA/NETWORK.cpp +++ b/MUNGA/NETWORK.cpp @@ -5,6 +5,7 @@ #include "interest.h" #include "icom.h" #include "app.h" +#include "spooler.h" #include "notation.h" #include "nttmgr.h" @@ -72,10 +73,41 @@ Logical // void NetworkClient::ReceiveNetworkPacket( - NetworkPacket*, + NetworkPacket *packet, Receiver::Message *packet_message ) { + // + // The recording tee. + // + // Every network client comes through here - the interest manager + // carrying entity updates and the network manager carrying mission + // control - which are the two places the review build spools + // separately. One hook covers both, and it sits before Dispatch so a + // packet is kept whether or not anything downstream makes use of it. + // + // The recorder arms itself on the first packet rather than at the + // green light: playback rebuilds the world from the LoadMission and + // RunMission packets, so a spool that starts at the flag cannot be + // replayed. + // + if (Application::IsRecording()) + { + SpoolRecorder *recorder = SpoolRecorder_Get(); + + if (!recorder->IsArmed()) + { + if (!recorder->Arm()) + { + // + // Could not reserve the buffer - say so once, by turning + // the request off, rather than asking again every packet. + // + Application::SetRecording(False); + } + } + recorder->Record(packet); + } Dispatch(packet_message); } diff --git a/MUNGA/SPOOLER.cpp b/MUNGA/SPOOLER.cpp index 8b65ac0..12143e8 100644 --- a/MUNGA/SPOOLER.cpp +++ b/MUNGA/SPOOLER.cpp @@ -158,6 +158,226 @@ NetworkPacket* return (NetworkPacket*)GetPointer(); } +//############################################################################# +//############################## SpoolRecorder ############################## +//############################################################################# + +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +// How much memory to give a recording, in megabytes. +// +// The review build's SPOOL_SIZE is 6MB, which was a sensible arcade +// number and is a silly desktop one: a full grid sends on the order of +// 17KB a second, so six megabytes is about six minutes and a long race +// would hit the end of it. A hundred megabytes is roughly an hour and a +// half and costs nothing on any machine that can run this. +// +static size_t + RecordSizeBytes() +{ + static size_t cached = 0; + + if (cached == 0) + { + const char *setting = getenv("RP412RECORDSIZE"); + int megabytes = (setting != NULL) ? atoi(setting) : 100; + + if (megabytes < 1) { megabytes = 1; } + if (megabytes > 512) { megabytes = 512; } + cached = (size_t) megabytes * 1024 * 1024; + } + return cached; +} + +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +SpoolRecorder::SpoolRecorder(): + buffer(NULL), + spool(NULL), + bufferSize(0), + armed(False), + full(False), + packetsRecorded(0) +{ +} + +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +SpoolRecorder::~SpoolRecorder() +{ + Disarm(); +} + +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +Logical + SpoolRecorder::Arm() +{ + Check(this); + + if (armed) + { + return True; + } + + bufferSize = RecordSizeBytes(); + buffer = new char[bufferSize]; + if (buffer == NULL) + { + DEBUG_STREAM << "Record: could not reserve " + << (bufferSize / (1024 * 1024)) + << "MB - recording disabled for this race\n" << std::flush; + bufferSize = 0; + return False; + } + + spool = new SpoolFile(buffer, bufferSize); + spool->spoolState = SpoolFile::Spooling; + armed = True; + full = False; + packetsRecorded = 0; + + DEBUG_STREAM << "Record: armed, " << (bufferSize / (1024 * 1024)) + << "MB (RP412RECORDSIZE)\n" << std::flush; + return True; +} + +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +void + SpoolRecorder::Record(const NetworkPacket *packet) +{ + Check(this); + + if (!armed || full || packet == NULL) + { + return; + } + Check_Pointer(spool); + + int length = + packet->messageData.messageLength + sizeof(NetworkPacketHeader); + + // + // Ask before writing. SpoolFile::SpoolPacket answers a full buffer by + // quitting the process, which would end the race this is recording - + // so the recorder never lets it get that far, and stops instead. + // + if ((int) spool->GetBytesRemaining() < length) + { + full = True; + DEBUG_STREAM << "Record: buffer full after " << packetsRecorded + << " packets - the race continues, the recording stops here." + << " Raise RP412RECORDSIZE to keep more.\n" << std::flush; + return; + } + + // + // Where the copy is about to land, taken BEFORE the write so the + // timestamp can be applied to the copy afterwards. The live packet is + // never modified: its timeStamp is the sender's sampling moment, which + // Simulation::ReadUpdateRecord hands to RP412NETCLOCK and from there to + // the dead reckoner's projection. Restamping it in place - which is + // what the review spooler does, harmlessly, having nothing else to + // serve - would put arrival jitter straight into where remote pods are + // drawn. + // + NetworkPacket *copy = (NetworkPacket*) spool->GetPointer(); + + spool->SpoolPacket((NetworkPacket*) packet); + + // + // Playback paces from these, and packets from different senders carry + // different clock origins, so the spool needs them all on one clock: + // ours, at the moment of arrival. + // + copy->timeStamp = Now(); + ++packetsRecorded; +} + +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +void + SpoolRecorder::Save() +{ + Check(this); + + if (!armed || spool == NULL) + { + return; + } + if (packetsRecorded == 0) + { + DEBUG_STREAM << "Record: nothing captured, no file written\n" + << std::flush; + return; + } + + // + // The review build writes here and so does this, so one folder holds + // every spool however it was made and the playback build finds them + // all in the same place. + // + CreateDirectoryA("SPOOLS", NULL); + + struct tm newtime; + __int64 ltime; + + _time64(<ime); + _gmtime64_s(&newtime, <ime); + + char filename[MAX_PATH]; + + sprintf( + filename, + "SPOOLS\\%.4i_%.2i_%.2i_%.2i%.2i%.2i.spl", + newtime.tm_year + 1900, newtime.tm_mon + 1, newtime.tm_mday, + newtime.tm_hour, newtime.tm_min, newtime.tm_sec + ); + + spool->SaveAs(filename); + CopyFileA(filename, "last.spl", FALSE); + + DEBUG_STREAM << "Record: wrote " << filename << " - " + << packetsRecorded << " packets, " + << (spool->GetBytesUsed() / 1024) << "KB" + << (full ? " (truncated - buffer filled)" : "") + << "\n" << std::flush; +} + +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +void + SpoolRecorder::Disarm() +{ + Check(this); + + if (spool != NULL) + { + delete spool; + spool = NULL; + } + if (buffer != NULL) + { + delete [] buffer; + buffer = NULL; + } + bufferSize = 0; + armed = False; + full = False; + packetsRecorded = 0; +} + +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +SpoolRecorder * + SpoolRecorder_Get() +{ + static SpoolRecorder recorder; + + return &recorder; +} + //############################################################################# //################# MissionReviewApplicationManager ##################### //############################################################################# diff --git a/MUNGA/SPOOLER.h b/MUNGA/SPOOLER.h index 28a3db6..ca19126 100644 --- a/MUNGA/SPOOLER.h +++ b/MUNGA/SPOOLER.h @@ -38,6 +38,75 @@ public: NextPacket(); }; +//########################################################################## +//########################## SpoolRecorder ############################# +//########################################################################## +// +// Keeping a race from a station that is also PLAYING one. +// +// The review build already records by teeing: L4SpoolingNetworkManager +// spools each packet and then hands it to the ordinary receive path. What +// tied that to a review build was never the recording - it was where the +// buffer came from. MissionReviewApplicationManager is only a pool +// allocator, and SpoolFile takes whatever buffer it is handed, so a +// recorder that owns one buffer needs none of it. +// +// Two things a live recorder must do that the review one did not: +// +// It must not touch the packet. The spooler restamps each packet with +// local arrival time, which is right - playback paces off those stamps and +// packets from different senders carry different clock origins, so they +// have to be put on one clock. But doing it in place would overwrite the +// sender's timestamp that Simulation::ReadUpdateRecord feeds to +// RP412NETCLOCK and the dead reckoner, which is exactly the input behind +// the projection tick. So it stamps the COPY, in the spool, after writing. +// +// And it must not take the race down with it. SpoolFile::SpoolPacket +// answers a full buffer with PostQuitMessage, which for a review is a fair +// end to a replay and for a live host is killing the race being recorded. +// The recorder checks the room first and simply stops. +// +class SpoolRecorder +{ +public: + SpoolRecorder(); + ~SpoolRecorder(); + + // Allocate and begin. Call before the mission loads: playback rebuilds + // the world from the LoadMission and RunMission packets, so a spool + // armed at the green light cannot be replayed. + Logical + Arm(); + + // Tee one received packet. Safe to call unarmed or after the buffer + // has filled - both do nothing. + void + Record(const NetworkPacket *packet); + + // Write SPOOLS\.spl and copy it to last.spl. + void + Save(); + + void + Disarm(); + + Logical + IsArmed() const + { return armed; } + +protected: + char *buffer; + SpoolFile *spool; + size_t bufferSize; + Logical armed; + Logical full; + int packetsRecorded; +}; + +// The process-wide recorder, made on first use. +SpoolRecorder * + SpoolRecorder_Get(); + //########################################################################## //############## MissionReviewApplicationManager ##################### //########################################################################## diff --git a/RP_L4/RPL4APP.cpp b/RP_L4/RPL4APP.cpp index fd9e535..0c76afa 100644 --- a/RP_L4/RPL4APP.cpp +++ b/RP_L4/RPL4APP.cpp @@ -11,6 +11,7 @@ #include "..\munga_l4\l4ctrl.h" #include "..\munga_l4\l4mppr.h" #include "..\munga\appmgr.h" +#include "..\munga\spooler.h" #include "rpl4mode.h" #include "..\rp\vtv.h" #include "rpl4mppr.h" @@ -467,6 +468,18 @@ void Post(LowEventPriority, this, &podium_message, event_time); DEBUG_STREAM << "WinnersCircle: race over, fading out\n" << std::flush; + + // + // Write the recording here rather than at teardown. This is the + // first of the two StopMissions - the buzzer, not the fade timer - + // so it is the end of the RACE, and everything worth keeping has + // arrived. Waiting for teardown would risk the process going away + // first and taking the spool with it. + // + if (Application::IsRecording()) + { + SpoolRecorder_Get()->Save(); + } } L4Application::StopMissionMessageHandler(message); diff --git a/RP_L4/RPL4ENVIRON.cpp b/RP_L4/RPL4ENVIRON.cpp index b7e8833..61c0ce2 100644 --- a/RP_L4/RPL4ENVIRON.cpp +++ b/RP_L4/RPL4ENVIRON.cpp @@ -337,6 +337,21 @@ namespace "# machine loads very slowly.\n" "RP412CONNECTWAIT=20\n" "\n" +"# How much memory to set aside for a recording, in megabytes. 1 to 512,\n" +"# default 100.\n" +"#\n" +"# RECORDING on the setup screen keeps a spool of the race in SPOOLS\\,\n" +"# named for the time it finished, and copies it to last.spl. It records\n" +"# what THIS machine received, so a Live Cam host - which watches rather\n" +"# than races, and therefore hears every pod over the wire - keeps the\n" +"# most complete account of a race there is.\n" +"#\n" +"# A full grid sends on the order of 17KB a second, so a hundred\n" +"# megabytes is around an hour and a half. If a race outlasts the buffer\n" +"# the recording simply stops and the race carries on - it is never worth\n" +"# interrupting a race to protect a recording of it - and the log says so.\n" +"RP412RECORDSIZE=100\n" +"\n" "# ---- Test harness -----------------------------------------------------------\n" "\n" "# The knobs that make a run repeatable and measurable. All are off\n" diff --git a/RP_L4/RPL4FE.cpp b/RP_L4/RPL4FE.cpp index f96ba3c..fcefbf6 100644 --- a/RP_L4/RPL4FE.cpp +++ b/RP_L4/RPL4FE.cpp @@ -179,6 +179,24 @@ namespace }; enum { kRoleRacer = 0, kRoleCamera = 1 }; + // + // Whether to keep the race. Recording writes a spool of every packet + // this station received, into SPOOLS\, which replays later as a full + // mission - so the camera work can be redone afterwards at any angle + // rather than being decided live and lost. + // + // Offered next to the role because it is the same question asked twice + // - what am I here to do - and because a Live Cam that did not record + // would be a camera pointed at nothing anybody keeps. Left to the + // player rather than implied by the role: a racer may well want the + // race kept too, and a camera operator may be doing a dry run. + // + const CatalogEntry kRecording[] = + { + { "off", "Off" }, { "on", "Record to SPOOLS\\" }, + }; + enum { kRecordOff = 0, kRecordOn = 1 }; + struct LengthEntry { int seconds; // 0 = endless (length line omitted) @@ -213,6 +231,7 @@ namespace GroupWeather, GroupLength, GroupRole, // racer or Live Cam (lobby races only) + GroupRecord, // keep a .spl of this race GroupScenario, GroupTeam, // football only GroupPosition, // football only @@ -241,6 +260,11 @@ namespace return RPL4Lobby_Configured() && selection[GroupRole] == kRoleCamera; } + Logical WantsRecording(const int *selection) + { + return selection[GroupRecord] == kRecordOn; + } + const CatalogEntry *ActiveMaps(const int *selection, int *count) { if (IsFootball(selection)) @@ -327,7 +351,7 @@ namespace { GroupBadge, "badge" }, { GroupTeam, "team" }, { GroupPosition, "position" }, { GroupTime, "time" }, { GroupWeather, "weather" }, { GroupLength, "length" }, - { GroupRole, "role" }, + { GroupRole, "role" }, { GroupRecord, "record" }, }; // @@ -356,6 +380,7 @@ namespace case GroupWeather: return FE_COUNT(kWeather); case GroupLength: return FE_COUNT(kLengths); case GroupRole: return FE_COUNT(kRoles); + case GroupRecord: return FE_COUNT(kRecording); } return 0; } @@ -675,6 +700,14 @@ namespace { groups[n++] = GroupRole; } + // + // Directly under the role, and offered for every kind of race. + // A recording is just this station's received packets, so a + // single-player run records as readily as a lobby one - and + // unlike the role, a camera with nothing to watch is not a + // hazard here, so there is nothing to gate it on. + // + groups[n++] = GroupRecord; groups[n++] = GroupVehicle; if (IsFootball(selection)) { @@ -977,6 +1010,7 @@ namespace case GroupWeather: return "WEATHER"; case GroupLength: return "GAME LENGTH"; case GroupRole: return "YOUR ROLE"; + case GroupRecord: return "RECORDING"; } return ""; } @@ -1003,6 +1037,7 @@ namespace case GroupWeather: return kWeather[index].name; case GroupLength: return kLengths[index].name; case GroupRole: return kRoles[index].name; + case GroupRecord: return kRecording[index].name; case GroupLaunch: return "L A U N C H G A M E"; case GroupSteamHost: return "HOST STEAM GAME"; case GroupSteamJoin: return "JOIN STEAM GAME"; @@ -1634,6 +1669,21 @@ namespace << std::flush; } + // + // Handed over the same way and for the same reason: the network + // manager is built after this menu, and it is the thing that has + // to tee the packets. Set either way - the process races again + // after this one and a stale True would record a session nobody + // asked to keep. + // + Logical owner_records = WantsRecording(fe->selection); + + Application::SetRecording(owner_records); + if (owner_records) + { + DEBUG_STREAM << "FE: recording this race to SPOOLS\\\n" << std::flush; + } + for (int p = 0; p < pilot_count; ++p) { Logical camera_entry = (p == 0 && owner_is_camera);