diff --git a/Generals/Code/GameEngine/Source/Common/Recorder.cpp b/Generals/Code/GameEngine/Source/Common/Recorder.cpp index f4630adf300..cd1566df30a 100644 --- a/Generals/Code/GameEngine/Source/Common/Recorder.cpp +++ b/Generals/Code/GameEngine/Source/Common/Recorder.cpp @@ -51,6 +51,19 @@ #include "Common/version.h" #include "Lib/PathUtil.h" +// GeneralsX @feature fbraz3 04/10/2026 Upload replay to NGMP storage on game end +#if defined(SAGE_USE_NGMP) && __has_include("GameNetwork/GeneralsOnline/OnlineServices_Manager.h") +#if defined(min) +#undef min +#endif +#if defined(max) +#undef max +#endif +#include "GameNetwork/GeneralsOnline/OnlineServices_Manager.h" +class NGMPGame; +extern NGMPGame* TheNGMPGame; +#endif + constexpr const char s_genrep[] = "GENREP"; constexpr const UnsignedInt replayBufferBytes = 8192; @@ -675,6 +688,20 @@ void RecorderClass::stopRecording() { if (m_archiveReplays) archiveReplay(m_fileName); + + // GeneralsX @feature fbraz3 04/10/2026 Upload replay to NGMP storage on game end +#if defined(SAGE_USE_NGMP) && __has_include("GameNetwork/GeneralsOnline/OnlineServices_Manager.h") + if (TheNGMPGame != nullptr) + { + AsciiString absoluteReplayPath = getReplayDir(); + absoluteReplayPath.concat(m_fileName); + if (!absoluteReplayPath.endsWith(getReplayExtention())) { + absoluteReplayPath.concat(getReplayExtention()); + } + + NGMP_OnlineServicesManager::getInstance().commitReplay(absoluteReplayPath.str()); + } +#endif } m_fileName.clear(); } diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h index 03799515cd8..1ba31640410 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h @@ -3,6 +3,14 @@ #include "GameNetwork/GameInfo.h" #include "GameNetwork/GeneralsOnline/NGMP_types.h" + +#if defined(min) +#undef min +#endif +#if defined(max) +#undef max +#endif + #include struct LobbyEntry; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h index f3289b0b3c2..783aff1139e 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -251,6 +252,10 @@ class NGMP_OnlineServicesManager { static NetworkMesh* GetNetworkMesh(); NetworkMesh* getNetworkMesh() { return m_pNetworkMesh.get(); } + // GeneralsX @feature fbraz3 04/10/2026 Replay upload routines for Cloudflare R2 + void commitReplay(const std::string& absoluteReplayPath); + void setReplayUploadUrl(uint64_t matchId, const std::string& uploadUrl); + // Internal thread-safe event poster (called from worker threads) void postEvent(const NGMPEvent& event); @@ -324,10 +329,32 @@ class NGMP_OnlineServicesManager { // Chat WebSocket session std::unique_ptr m_chatSession; - // GeneralsX @feature fbraz3 03/10/2026 Periodic match progress timer + // GeneralsX @feature fbraz3 04/10/2026 Periodic match progress timer std::chrono::steady_clock::time_point m_lastMatchProgressTime{}; uint64_t m_lastMatchProgressMatchId{0}; + // GeneralsX @feature fbraz3 04/10/2026 Replay upload queue & worker state + struct ReplayUploadTask { + uint64_t matchId{0}; + std::vector bytes; + std::string uploadUrl; + }; + void dispatchReplayUpload(uint64_t matchId, std::vector bytes, std::string uploadUrl); + void replayWorkerLoop(); + void stopReplayWorker(); + + struct PendingReplayUpload { + std::vector bytes; + std::string uploadUrl; + }; + mutable std::mutex m_replayMutex; + std::unordered_map m_pendingReplayUploads; + std::queue m_replayQueue; + std::condition_variable m_replayQueueCv; + std::thread m_replayWorkerThread; + std::atomic m_replayWorkerStopping{false}; + std::atomic m_shuttingDown{false}; + mutable std::mutex m_eventMutex; std::queue m_eventQueue; std::queue m_uiEventQueue; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.h index 541591c6df3..f73d30a6f96 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.h @@ -3,6 +3,8 @@ #include #include +#include +#include #include "GameNetwork/RankPointValue.h" #include "GameNetwork/GameSpy/PersistentStorageThread.h" @@ -26,10 +28,16 @@ class NGMP_OnlineServices_StatsInterface { public: NGMP_OnlineServices_StatsInterface(); + ~NGMP_OnlineServices_StatsInterface(); void findPlayerStatsByID(int64_t userID, std::function callback, EStatsRequestPolicy policy = EStatsRequestPolicy::RESPECT_CACHE_ALLOW_REQUEST); void CommitMyOutcome(ScoreKeeper* pScoreKeeper, bool bWon); + void quiesceOutcome(); // GeneralsX @feature fbraz3 03/10/2026 Send initial or periodic match progress telemetry to server void SendMatchProgress(bool isInitial = false); void UpdateMyStats(PSPlayerStats stats) {} + +private: + std::thread m_outcomeThread; + std::mutex m_outcomeMutex; }; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp index b9481806091..a9f1a323759 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp @@ -51,6 +51,19 @@ #include "Common/version.h" #include "Lib/PathUtil.h" +// GeneralsX @feature fbraz3 04/10/2026 Upload replay to NGMP storage on game end +#if defined(SAGE_USE_NGMP) +#if defined(min) +#undef min +#endif +#if defined(max) +#undef max +#endif +#include "GameNetwork/GeneralsOnline/OnlineServices_Manager.h" +class NGMPGame; +extern NGMPGame* TheNGMPGame; +#endif + constexpr const char s_genrep[] = "GENREP"; constexpr const UnsignedInt replayBufferBytes = 8192; @@ -678,6 +691,20 @@ void RecorderClass::stopRecording() { if (m_archiveReplays) archiveReplay(m_fileName); + + // GeneralsX @feature fbraz3 04/10/2026 Upload replay to NGMP storage on game end +#if defined(SAGE_USE_NGMP) + if (TheNGMPGame != nullptr) + { + AsciiString absoluteReplayPath = getReplayDir(); + absoluteReplayPath.concat(m_fileName); + if (!absoluteReplayPath.endsWith(getReplayExtention())) { + absoluteReplayPath.concat(getReplayExtention()); + } + + NGMP_OnlineServicesManager::getInstance().commitReplay(absoluteReplayPath.str()); + } +#endif } m_fileName.clear(); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index f8657468f13..db1e978bf72 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -105,6 +105,12 @@ bool NGMP_OnlineServicesManager::init() { TheGameSpyPSMessageQueue = new DummyGameSpyPSMessageQueue(); } + { + std::lock_guard lock(m_replayMutex); + m_shuttingDown = false; + m_replayWorkerStopping = false; + } + m_initialized = true; return true; } @@ -114,6 +120,8 @@ void NGMP_OnlineServicesManager::shutdown() { return; } + m_shuttingDown = true; + fprintf(stderr, "[NGMP] Shutting down NGMP Online Services\n"); fflush(stderr); @@ -145,8 +153,20 @@ void NGMP_OnlineServicesManager::shutdown() { m_playlistsThread.join(); } + // GeneralsX @bugfix fbraz3 05/10/2026 Quiesce outcome worker before stopping replay upload worker so no subsequent upload can be started + if (m_pStatsInterface) { + m_pStatsInterface->quiesceOutcome(); + } + + stopReplayWorker(); + logout(); + { + std::lock_guard lock(m_replayMutex); + m_pendingReplayUploads.clear(); + } + if (TheNGMPGame) { delete TheNGMPGame; TheNGMPGame = nullptr; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp index 9552098fc86..9f8d4a39780 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp @@ -15,6 +15,7 @@ #include "Common/GlobalData.h" #include "Common/PlayerList.h" #include +#include #include #include #include "GameNetwork/GeneralsOnline/NGMP_json.h" @@ -33,6 +34,7 @@ NetworkMesh* NGMP_OnlineServicesManager::GetNetworkMesh() { NGMP_OnlineServicesManager::NGMP_OnlineServicesManager() = default; NGMP_OnlineServicesManager::~NGMP_OnlineServicesManager() { shutdown(); + stopReplayWorker(); } void NGMP_OnlineServicesManager::postEvent(const NGMPEvent& event) { @@ -1451,3 +1453,224 @@ void NGMP_OnlineServicesManager::changeNetworkRoom(int16_t roomID) { fflush(stderr); } } + +// GeneralsX @feature fbraz3 04/10/2026 Commit and cache recorded replay file for S3/R2 upload +void NGMP_OnlineServicesManager::commitReplay(const std::string& absoluteReplayPath) { + if (m_shuttingDown.load()) { + return; + } + + NGMP_OnlineServices_LobbyInterface* pLobby = GetInterface(); + uint64_t matchId = pLobby ? pLobby->GetCurrentMatchID() : 0; + if (matchId == 0) { + matchId = m_lastMatchProgressMatchId; + } + if (matchId == 0) { + fprintf(stderr, "[NGMP] commitReplay: match ID is unavailable, skipping replay upload\n"); + fflush(stderr); + return; + } + + FILE* pFile = fopen(absoluteReplayPath.c_str(), "rb"); + if (!pFile) { + fprintf(stderr, "[NGMP] commitReplay: failed to open replay file: %s\n", absoluteReplayPath.c_str()); + fflush(stderr); + return; + } + + fseek(pFile, 0, SEEK_END); + long fileSize = ftell(pFile); + fseek(pFile, 0, SEEK_SET); + + if (fileSize <= 0) { + fclose(pFile); + fprintf(stderr, "[NGMP] commitReplay: replay file is empty: %s\n", absoluteReplayPath.c_str()); + fflush(stderr); + return; + } + + std::vector replayData(fileSize); + size_t readBytes = fread(replayData.data(), 1, fileSize, pFile); + fclose(pFile); + + if (readBytes != (size_t)fileSize) { + fprintf(stderr, "[NGMP] commitReplay: failed to read full file (%zu / %ld bytes)\n", readBytes, fileSize); + fflush(stderr); + return; + } + + fprintf(stderr, "[NGMP] commitReplay: cached %zu bytes for match %" PRIu64 "\n", replayData.size(), matchId); + fflush(stderr); + + std::vector bytesToUpload; + std::string targetUrl; + + { + std::lock_guard lock(m_replayMutex); + if (m_shuttingDown.load() || m_replayWorkerStopping.load()) { + return; + } + auto& entry = m_pendingReplayUploads[matchId]; + entry.bytes = std::move(replayData); + + if (!entry.bytes.empty() && !entry.uploadUrl.empty()) { + bytesToUpload = std::move(entry.bytes); + targetUrl = std::move(entry.uploadUrl); + m_pendingReplayUploads.erase(matchId); + } + } + + if (!bytesToUpload.empty() && !targetUrl.empty()) { + dispatchReplayUpload(matchId, std::move(bytesToUpload), std::move(targetUrl)); + } +} + +// GeneralsX @feature fbraz3 04/10/2026 Set presigned S3/R2 upload URL received from server outcome response +void NGMP_OnlineServicesManager::setReplayUploadUrl(uint64_t matchId, const std::string& uploadUrl) { + if (matchId == 0 || uploadUrl.empty() || m_shuttingDown.load()) { + return; + } + + fprintf(stderr, "[NGMP] setReplayUploadUrl: received upload URL for match %" PRIu64 "\n", matchId); + fflush(stderr); + + std::vector bytesToUpload; + std::string targetUrl; + + { + std::lock_guard lock(m_replayMutex); + if (m_shuttingDown.load() || m_replayWorkerStopping.load()) { + return; + } + auto& entry = m_pendingReplayUploads[matchId]; + entry.uploadUrl = uploadUrl; + + if (!entry.bytes.empty() && !entry.uploadUrl.empty()) { + bytesToUpload = std::move(entry.bytes); + targetUrl = std::move(entry.uploadUrl); + m_pendingReplayUploads.erase(matchId); + } + } + + if (!bytesToUpload.empty() && !targetUrl.empty()) { + dispatchReplayUpload(matchId, std::move(bytesToUpload), std::move(targetUrl)); + } +} + +// GeneralsX @feature fbraz3 04/10/2026 Dispatch asynchronous HTTP PUT of replay buffer to S3/R2 +void NGMP_OnlineServicesManager::dispatchReplayUpload(uint64_t matchId, std::vector bytes, std::string uploadUrl) { + if (uploadUrl.rfind("https://", 0) != 0) { + fprintf(stderr, "[NGMP] dispatchReplayUpload: rejected non-HTTPS upload URL: %s\n", uploadUrl.c_str()); + fflush(stderr); + return; + } + + if (m_shuttingDown.load()) { + fprintf(stderr, "[NGMP] dispatchReplayUpload: manager is shutting down, dropping upload for match %" PRIu64 "\n", matchId); + fflush(stderr); + return; + } + + fprintf(stderr, "[NGMP] dispatchReplayUpload: queueing %zu bytes to storage for match %" PRIu64 "...\n", bytes.size(), matchId); + fflush(stderr); + + { + std::lock_guard lock(m_replayMutex); + if (m_replayWorkerStopping.load() || m_shuttingDown.load()) { + return; + } + m_replayQueue.push({matchId, std::move(bytes), std::move(uploadUrl)}); + if (!m_replayWorkerThread.joinable()) { + m_replayWorkerThread = std::thread(&NGMP_OnlineServicesManager::replayWorkerLoop, this); + } + } + m_replayQueueCv.notify_one(); +} + +void NGMP_OnlineServicesManager::replayWorkerLoop() { + while (true) { + ReplayUploadTask task; + { + std::unique_lock lock(m_replayMutex); + m_replayQueueCv.wait(lock, [this]() { + return m_replayWorkerStopping.load() || !m_replayQueue.empty(); + }); + + if (m_replayQueue.empty()) { + if (m_replayWorkerStopping.load()) { + break; + } + continue; + } + + task = std::move(m_replayQueue.front()); + m_replayQueue.pop(); + } + + fprintf(stderr, "[NGMP] replayWorker: uploading %zu bytes for match %" PRIu64 "...\n", task.bytes.size(), task.matchId); + fflush(stderr); + + for (int attempt = 1; attempt <= 3; ++attempt) { + if (m_replayWorkerStopping.load() && attempt > 1) { + break; + } + + CURL* curl = curl_easy_init(); + if (!curl) { + fprintf(stderr, "[NGMP] replayWorker: failed to initialize curl (attempt %d)\n", attempt); + fflush(stderr); + break; + } + + struct curl_slist* headers = nullptr; + headers = curl_slist_append(headers, "Content-Type: application/octet-stream"); + + NGMP::Internal::CurlResponse response; + curl_easy_setopt(curl, CURLOPT_URL, task.uploadUrl.c_str()); +#if defined(CURLOPT_PROTOCOLS_STR) + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); +#elif defined(CURLOPT_PROTOCOLS) && defined(CURLPROTO_HTTPS) + curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); +#endif + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, (const char*)task.bytes.data()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, (curl_off_t)task.bytes.size()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, NGMP::Internal::WriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + + CURLcode res = curl_easy_perform(curl); + long httpCode = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res == CURLE_OK && httpCode >= 200 && httpCode < 300) { + fprintf(stderr, "[NGMP] replayWorker: SUCCESS for match %" PRIu64 " (attempt %d, HTTP %ld, %zu bytes uploaded)\n", task.matchId, attempt, httpCode, task.bytes.size()); + fflush(stderr); + break; + } else { + fprintf(stderr, "[NGMP] replayWorker: attempt %d failed for match %" PRIu64 " (res=%d, HTTP %ld, body: %s)\n", attempt, task.matchId, (int)res, httpCode, response.text.c_str()); + fflush(stderr); + if (attempt < 3 && !m_replayWorkerStopping.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(1000)); + } + } + } + } +} + +void NGMP_OnlineServicesManager::stopReplayWorker() { + { + std::lock_guard lock(m_replayMutex); + m_replayWorkerStopping = true; + std::queue emptyQueue; + std::swap(m_replayQueue, emptyQueue); + } + m_replayQueueCv.notify_all(); + if (m_replayWorkerThread.joinable()) { + m_replayWorkerThread.join(); + } +} + diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp index 5d4be60fd6e..3eeefe33ea5 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp @@ -64,6 +64,20 @@ NGMP_OnlineServices_StatsInterface::NGMP_OnlineServices_StatsInterface() { } +NGMP_OnlineServices_StatsInterface::~NGMP_OnlineServices_StatsInterface() +{ + quiesceOutcome(); +} + +void NGMP_OnlineServices_StatsInterface::quiesceOutcome() +{ + std::lock_guard lock(m_outcomeMutex); + if (m_outcomeThread.joinable()) + { + m_outcomeThread.join(); + } +} + void NGMP_OnlineServices_StatsInterface::findPlayerStatsByID(int64_t userID, std::function callback, EStatsRequestPolicy policy) { PSPlayerStats stats; @@ -143,8 +157,12 @@ void NGMP_OnlineServices_StatsInterface::CommitMyOutcome(ScoreKeeper* pScoreKeep std::string url = NGMP::GetAPIEndpoint("Lobby/Outcome"); std::string authToken = NGMP_OnlineServicesManager::getInstance().getAuthToken(); uint32_t tokenVersion = NGMP_OnlineServicesManager::getInstance().getAuthTokenVersion(); - - std::thread([url, payloadStr, authToken, tokenVersion]() { + { + std::lock_guard lock(m_outcomeMutex); + if (m_outcomeThread.joinable()) { + m_outcomeThread.join(); + } + m_outcomeThread = std::thread([url, payloadStr, authToken, tokenVersion, currentMatchID]() { CURL* curl = curl_easy_init(); if (!curl) { fprintf(stderr, "[NGMP] CommitMyOutcome: failed to initialize curl\n"); @@ -212,13 +230,30 @@ void NGMP_OnlineServices_StatsInterface::CommitMyOutcome(ScoreKeeper* pScoreKeep if (res == CURLE_OK && httpCode == 200) { fprintf(stderr, "[NGMP] CommitMyOutcome: server accepted outcome (HTTP 200)\n"); ev.payload = "1"; + + // GeneralsX @feature fbraz3 04/10/2026 Parse replay_url from match outcome response and notify OnlineServicesManager + try { + json outcomeJson = json::parse(response.text); + if (outcomeJson.contains("replay_url") && outcomeJson["replay_url"].is_string()) { + std::string replayUrl = outcomeJson["replay_url"].get(); + if (!replayUrl.empty()) { + fprintf(stderr, "[NGMP] CommitMyOutcome: received replay upload URL\n"); + fflush(stderr); + NGMP_OnlineServicesManager::getInstance().setReplayUploadUrl(currentMatchID, replayUrl); + } + } + } catch (const std::exception& e) { + fprintf(stderr, "[NGMP] CommitMyOutcome: JSON parse exception: %s\n", e.what()); + fflush(stderr); + } } else { fprintf(stderr, "[NGMP] CommitMyOutcome: POST failed (res=%d, HTTP %ld, body: %s)\n", (int)res, httpCode, response.text.c_str()); ev.payload = "0"; } fflush(stderr); NGMP_OnlineServicesManager::getInstance().postEvent(ev); - }).detach(); + }); + } } // GeneralsX @feature fbraz3 03/10/2026 Send initial or periodic match progress telemetry to server diff --git a/docs/WORKLOG/2026-10-DIARY.md b/docs/WORKLOG/2026-10-DIARY.md index 8c596c8b77f..c7ace25b3aa 100644 --- a/docs/WORKLOG/2026-10-DIARY.md +++ b/docs/WORKLOG/2026-10-DIARY.md @@ -3,6 +3,24 @@ > [!NOTE] > **AI-Generated Content Disclosure**: This worklog is automatically generated and maintained by AI coding agents to document daily progress, debugging sessions, and technical decisions. +## 05/10/2026 + +### Decouple NGMP replay upload into dedicated background task queue and worker +- Replaced synchronous `join()` on previous upload thread with an asynchronous upload queue (`m_replayQueue`) and dedicated background worker thread (`m_replayWorkerThread`), eliminating caller blocking during match end on the main thread and outcome HTTP worker. +- Protected replay task enqueueing and worker lifecycle under `m_replayMutex` to serialize concurrent dispatches and prevent race conditions on thread handles. +- Tracked outcome HTTP request worker in `NGMP_OnlineServices_StatsInterface` via `m_outcomeThread` and added `quiesceOutcome()`, ensuring the outcome request completes before replay worker shutdown. +- Gated replay commit and upload methods with `m_shuttingDown` so no new upload tasks or worker threads can be spawned after manager shutdown commences, and reset both `m_shuttingDown` and `m_replayWorkerStopping` under `m_replayMutex` during `init()` to allow submissions after reinitialization. + +## 04/10/2026 + +### Implement match replay upload to Cloudflare R2 storage +- Hooked `RecorderClass::stopRecording` under `SAGE_USE_NGMP` in `Recorder.cpp` (both Zero Hour and Base Game) to commit the recorded `.rep` binary file to `NGMP_OnlineServicesManager` upon match conclusion. +- Added `commitReplay`, `setReplayUploadUrl`, and `dispatchReplayUpload` in `OnlineServices_Manager` to safely cache replay binary data and match it with the presigned S3/R2 PUT URL from the GameServer. +- Updated `NGMP_OnlineServices_StatsInterface::CommitMyOutcome` to extract `replay_url` from the backend outcome response and notify `OnlineServicesManager` to trigger the asynchronous upload via libcurl (`PUT application/octet-stream`). +- Resolved Linux GCC 15 macro collision with `std::chrono::year::min` in `Recorder.cpp` and `NGMPGame.h` by safeguarding `min`/`max` preprocessor definitions. +- Enforced HTTPS upload endpoint restriction and protocol handling (`CURLPROTO_HTTPS`) with bounded retry backoff for transient network issues. +- Tracked upload worker thread `m_replayUploadThread` in `OnlineServices_Manager` and joined it gracefully during `NGMP_OnlineServicesManager::shutdown`. + ## 03/10/2026 ### Add midgame match progress telemetry and resolved side persistence