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);