From 995b6242274264209c6d32a2b245048bc4317850 Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Sun, 4 Oct 2026 23:08:28 -0300 Subject: [PATCH 1/5] feat(ngmp): implement match replay upload to Cloudflare R2 --- .../GameEngine/Source/Common/Recorder.cpp | 21 +++ .../GeneralsOnline/OnlineServices_Manager.h | 17 ++- .../GameEngine/Source/Common/Recorder.cpp | 21 +++ .../GeneralsOnline/OnlineServices_Init.cpp | 5 + .../GeneralsOnline/OnlineServices_Manager.cpp | 136 ++++++++++++++++++ .../OnlineServices_StatsInterface.cpp | 18 ++- docs/WORKLOG/2026-10-DIARY.md | 7 + 7 files changed, 223 insertions(+), 2 deletions(-) diff --git a/Generals/Code/GameEngine/Source/Common/Recorder.cpp b/Generals/Code/GameEngine/Source/Common/Recorder.cpp index f4630adf300..e0bfa0112e9 100644 --- a/Generals/Code/GameEngine/Source/Common/Recorder.cpp +++ b/Generals/Code/GameEngine/Source/Common/Recorder.cpp @@ -51,6 +51,13 @@ #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") +#include "GameNetwork/GeneralsOnline/NGMPGame.h" +#include "GameNetwork/GeneralsOnline/OnlineServices_Manager.h" +extern NGMPGame* TheNGMPGame; +#endif + constexpr const char s_genrep[] = "GENREP"; constexpr const UnsignedInt replayBufferBytes = 8192; @@ -675,6 +682,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/OnlineServices_Manager.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h index f3289b0b3c2..d6f84f23e57 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h @@ -251,6 +251,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 +328,21 @@ 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 state + void dispatchReplayUpload(uint64_t matchId, std::vector bytes, std::string uploadUrl); + struct CachedReplayUpload { + uint64_t dataMatchId = 0; + std::vector bytes; + uint64_t urlMatchId = 0; + std::string uploadUrl; + }; + mutable std::mutex m_replayMutex; + CachedReplayUpload m_cachedReplayUpload; + mutable std::mutex m_eventMutex; std::queue m_eventQueue; std::queue m_uiEventQueue; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp index b9481806091..a2395bf380e 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp @@ -51,6 +51,13 @@ #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) +#include "GameNetwork/GeneralsOnline/NGMPGame.h" +#include "GameNetwork/GeneralsOnline/OnlineServices_Manager.h" +extern NGMPGame* TheNGMPGame; +#endif + constexpr const char s_genrep[] = "GENREP"; constexpr const UnsignedInt replayBufferBytes = 8192; @@ -678,6 +685,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..3552be6b1fc 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -147,6 +147,11 @@ void NGMP_OnlineServicesManager::shutdown() { logout(); + { + std::lock_guard lock(m_replayMutex); + m_cachedReplayUpload = {}; + } + 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..66394ef9191 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" @@ -1451,3 +1452,138 @@ 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) { + 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); + m_cachedReplayUpload.dataMatchId = matchId; + m_cachedReplayUpload.bytes = std::move(replayData); + + if (m_cachedReplayUpload.urlMatchId == matchId && !m_cachedReplayUpload.uploadUrl.empty()) { + bytesToUpload = std::move(m_cachedReplayUpload.bytes); + targetUrl = std::move(m_cachedReplayUpload.uploadUrl); + m_cachedReplayUpload = {}; + } + } + + 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()) { + 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); + m_cachedReplayUpload.urlMatchId = matchId; + m_cachedReplayUpload.uploadUrl = uploadUrl; + + if (m_cachedReplayUpload.dataMatchId == matchId && !m_cachedReplayUpload.bytes.empty()) { + bytesToUpload = std::move(m_cachedReplayUpload.bytes); + targetUrl = std::move(m_cachedReplayUpload.uploadUrl); + m_cachedReplayUpload = {}; + } + } + + 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) { + fprintf(stderr, "[NGMP] dispatchReplayUpload: uploading %zu bytes to storage for match %" PRIu64 "...\n", bytes.size(), matchId); + fflush(stderr); + + std::thread([matchId, bytes = std::move(bytes), uploadUrl = std::move(uploadUrl)]() { + CURL* curl = curl_easy_init(); + if (!curl) { + fprintf(stderr, "[NGMP] dispatchReplayUpload: failed to initialize curl\n"); + fflush(stderr); + return; + } + + 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, uploadUrl.c_str()); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, (const char*)bytes.data()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, (curl_off_t)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] dispatchReplayUpload: SUCCESS for match %" PRIu64 " (HTTP %ld, %zu bytes uploaded)\n", matchId, httpCode, bytes.size()); + } else { + fprintf(stderr, "[NGMP] dispatchReplayUpload: FAILED for match %" PRIu64 " (res=%d, HTTP %ld, body: %s)\n", matchId, (int)res, httpCode, response.text.c_str()); + } + fflush(stderr); + }).detach(); +} + diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp index 5d4be60fd6e..364fe83a082 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp @@ -144,7 +144,7 @@ void NGMP_OnlineServices_StatsInterface::CommitMyOutcome(ScoreKeeper* pScoreKeep std::string authToken = NGMP_OnlineServicesManager::getInstance().getAuthToken(); uint32_t tokenVersion = NGMP_OnlineServicesManager::getInstance().getAuthTokenVersion(); - std::thread([url, payloadStr, authToken, tokenVersion]() { + std::thread([url, payloadStr, authToken, tokenVersion, currentMatchID]() { CURL* curl = curl_easy_init(); if (!curl) { fprintf(stderr, "[NGMP] CommitMyOutcome: failed to initialize curl\n"); @@ -212,6 +212,22 @@ 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"; diff --git a/docs/WORKLOG/2026-10-DIARY.md b/docs/WORKLOG/2026-10-DIARY.md index 8c596c8b77f..6a0de27f24d 100644 --- a/docs/WORKLOG/2026-10-DIARY.md +++ b/docs/WORKLOG/2026-10-DIARY.md @@ -3,6 +3,13 @@ > [!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. +## 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`). + ## 03/10/2026 ### Add midgame match progress telemetry and resolved side persistence From 682d8da7055af31e5b89a82cbab68e611880ee31 Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Sun, 4 Oct 2026 23:43:47 -0300 Subject: [PATCH 2/5] fix(ngmp): resolve linux build macro collision and harden replay upload worker --- .../GameEngine/Source/Common/Recorder.cpp | 8 +- .../GameNetwork/GeneralsOnline/NGMPGame.h | 8 ++ .../GeneralsOnline/OnlineServices_Manager.h | 1 + .../GameEngine/Source/Common/Recorder.cpp | 8 +- .../GeneralsOnline/OnlineServices_Init.cpp | 4 + .../GeneralsOnline/OnlineServices_Manager.cpp | 84 ++++++++++++------- docs/WORKLOG/2026-10-DIARY.md | 3 + 7 files changed, 83 insertions(+), 33 deletions(-) diff --git a/Generals/Code/GameEngine/Source/Common/Recorder.cpp b/Generals/Code/GameEngine/Source/Common/Recorder.cpp index e0bfa0112e9..cd1566df30a 100644 --- a/Generals/Code/GameEngine/Source/Common/Recorder.cpp +++ b/Generals/Code/GameEngine/Source/Common/Recorder.cpp @@ -53,8 +53,14 @@ // 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") -#include "GameNetwork/GeneralsOnline/NGMPGame.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 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 d6f84f23e57..173da40722b 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h @@ -342,6 +342,7 @@ class NGMP_OnlineServicesManager { }; mutable std::mutex m_replayMutex; CachedReplayUpload m_cachedReplayUpload; + std::thread m_replayUploadThread; mutable std::mutex m_eventMutex; std::queue m_eventQueue; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp index a2395bf380e..a9f1a323759 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp @@ -53,8 +53,14 @@ // GeneralsX @feature fbraz3 04/10/2026 Upload replay to NGMP storage on game end #if defined(SAGE_USE_NGMP) -#include "GameNetwork/GeneralsOnline/NGMPGame.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 diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index 3552be6b1fc..2b18f51cc72 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -145,6 +145,10 @@ void NGMP_OnlineServicesManager::shutdown() { m_playlistsThread.join(); } + if (m_replayUploadThread.joinable()) { + m_replayUploadThread.join(); + } + logout(); { diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp index 66394ef9191..72371f4a419 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp @@ -1548,42 +1548,64 @@ void NGMP_OnlineServicesManager::setReplayUploadUrl(uint64_t matchId, const std: // 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; + } + fprintf(stderr, "[NGMP] dispatchReplayUpload: uploading %zu bytes to storage for match %" PRIu64 "...\n", bytes.size(), matchId); fflush(stderr); - std::thread([matchId, bytes = std::move(bytes), uploadUrl = std::move(uploadUrl)]() { - CURL* curl = curl_easy_init(); - if (!curl) { - fprintf(stderr, "[NGMP] dispatchReplayUpload: failed to initialize curl\n"); - fflush(stderr); - return; - } - - 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, uploadUrl.c_str()); - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, (const char*)bytes.data()); - curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, (curl_off_t)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); + if (m_replayUploadThread.joinable()) { + m_replayUploadThread.join(); + } - 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); + m_replayUploadThread = std::thread([matchId, bytes = std::move(bytes), uploadUrl = std::move(uploadUrl)]() { + for (int attempt = 1; attempt <= 3; ++attempt) { + CURL* curl = curl_easy_init(); + if (!curl) { + fprintf(stderr, "[NGMP] dispatchReplayUpload: failed to initialize curl (attempt %d)\n", attempt); + fflush(stderr); + break; + } - if (res == CURLE_OK && httpCode >= 200 && httpCode < 300) { - fprintf(stderr, "[NGMP] dispatchReplayUpload: SUCCESS for match %" PRIu64 " (HTTP %ld, %zu bytes uploaded)\n", matchId, httpCode, bytes.size()); - } else { - fprintf(stderr, "[NGMP] dispatchReplayUpload: FAILED for match %" PRIu64 " (res=%d, HTTP %ld, body: %s)\n", matchId, (int)res, httpCode, response.text.c_str()); + 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, 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*)bytes.data()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, (curl_off_t)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] dispatchReplayUpload: SUCCESS for match %" PRIu64 " (attempt %d, HTTP %ld, %zu bytes uploaded)\n", matchId, attempt, httpCode, bytes.size()); + fflush(stderr); + break; + } else { + fprintf(stderr, "[NGMP] dispatchReplayUpload: attempt %d failed for match %" PRIu64 " (res=%d, HTTP %ld, body: %s)\n", attempt, matchId, (int)res, httpCode, response.text.c_str()); + fflush(stderr); + if (attempt < 3) { + std::this_thread::sleep_for(std::chrono::milliseconds(1000)); + } + } } - fflush(stderr); - }).detach(); + }); } diff --git a/docs/WORKLOG/2026-10-DIARY.md b/docs/WORKLOG/2026-10-DIARY.md index 6a0de27f24d..406e8d24aec 100644 --- a/docs/WORKLOG/2026-10-DIARY.md +++ b/docs/WORKLOG/2026-10-DIARY.md @@ -9,6 +9,9 @@ - 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 From 487f127dec4b9d0db5bacebfd401cade0ae5542c Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Sun, 4 Oct 2026 23:47:19 -0300 Subject: [PATCH 3/5] fix(ngmp): key pending replay uploads by match id to prevent overwrite --- .../GeneralsOnline/OnlineServices_Manager.h | 6 ++--- .../GeneralsOnline/OnlineServices_Init.cpp | 2 +- .../GeneralsOnline/OnlineServices_Manager.cpp | 24 +++++++++---------- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h index 173da40722b..3286af24bc4 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h @@ -334,14 +334,12 @@ class NGMP_OnlineServicesManager { // GeneralsX @feature fbraz3 04/10/2026 Replay upload state void dispatchReplayUpload(uint64_t matchId, std::vector bytes, std::string uploadUrl); - struct CachedReplayUpload { - uint64_t dataMatchId = 0; + struct PendingReplayUpload { std::vector bytes; - uint64_t urlMatchId = 0; std::string uploadUrl; }; mutable std::mutex m_replayMutex; - CachedReplayUpload m_cachedReplayUpload; + std::unordered_map m_pendingReplayUploads; std::thread m_replayUploadThread; mutable std::mutex m_eventMutex; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index 2b18f51cc72..f23fc227609 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -153,7 +153,7 @@ void NGMP_OnlineServicesManager::shutdown() { { std::lock_guard lock(m_replayMutex); - m_cachedReplayUpload = {}; + m_pendingReplayUploads.clear(); } if (TheNGMPGame) { diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp index 72371f4a419..02b2a06ca8a 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp @@ -1502,13 +1502,13 @@ void NGMP_OnlineServicesManager::commitReplay(const std::string& absoluteReplayP { std::lock_guard lock(m_replayMutex); - m_cachedReplayUpload.dataMatchId = matchId; - m_cachedReplayUpload.bytes = std::move(replayData); + auto& entry = m_pendingReplayUploads[matchId]; + entry.bytes = std::move(replayData); - if (m_cachedReplayUpload.urlMatchId == matchId && !m_cachedReplayUpload.uploadUrl.empty()) { - bytesToUpload = std::move(m_cachedReplayUpload.bytes); - targetUrl = std::move(m_cachedReplayUpload.uploadUrl); - m_cachedReplayUpload = {}; + if (!entry.bytes.empty() && !entry.uploadUrl.empty()) { + bytesToUpload = std::move(entry.bytes); + targetUrl = std::move(entry.uploadUrl); + m_pendingReplayUploads.erase(matchId); } } @@ -1531,13 +1531,13 @@ void NGMP_OnlineServicesManager::setReplayUploadUrl(uint64_t matchId, const std: { std::lock_guard lock(m_replayMutex); - m_cachedReplayUpload.urlMatchId = matchId; - m_cachedReplayUpload.uploadUrl = uploadUrl; + auto& entry = m_pendingReplayUploads[matchId]; + entry.uploadUrl = uploadUrl; - if (m_cachedReplayUpload.dataMatchId == matchId && !m_cachedReplayUpload.bytes.empty()) { - bytesToUpload = std::move(m_cachedReplayUpload.bytes); - targetUrl = std::move(m_cachedReplayUpload.uploadUrl); - m_cachedReplayUpload = {}; + if (!entry.bytes.empty() && !entry.uploadUrl.empty()) { + bytesToUpload = std::move(entry.bytes); + targetUrl = std::move(entry.uploadUrl); + m_pendingReplayUploads.erase(matchId); } } From 8c3538956cc559016b6deeca2ae884d04e6b8262 Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Mon, 5 Oct 2026 20:36:22 -0300 Subject: [PATCH 4/5] fix(ngmp): decouple replay uploads into dedicated background task queue and worker --- .../GeneralsOnline/OnlineServices_Manager.h | 17 +++- .../OnlineServices_StatsInterface.h | 8 ++ .../GeneralsOnline/OnlineServices_Init.cpp | 9 +- .../GeneralsOnline/OnlineServices_Manager.cpp | 91 ++++++++++++++++--- .../OnlineServices_StatsInterface.cpp | 25 ++++- docs/WORKLOG/2026-10-DIARY.md | 8 ++ 6 files changed, 138 insertions(+), 20 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Manager.h index 3286af24bc4..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 @@ -332,15 +333,27 @@ class NGMP_OnlineServicesManager { std::chrono::steady_clock::time_point m_lastMatchProgressTime{}; uint64_t m_lastMatchProgressMatchId{0}; - // GeneralsX @feature fbraz3 04/10/2026 Replay upload state + // 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::thread m_replayUploadThread; + 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; 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/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index f23fc227609..9ba539791a3 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -114,6 +114,8 @@ void NGMP_OnlineServicesManager::shutdown() { return; } + m_shuttingDown = true; + fprintf(stderr, "[NGMP] Shutting down NGMP Online Services\n"); fflush(stderr); @@ -145,10 +147,13 @@ void NGMP_OnlineServicesManager::shutdown() { m_playlistsThread.join(); } - if (m_replayUploadThread.joinable()) { - m_replayUploadThread.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(); { diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp index 02b2a06ca8a..9f8d4a39780 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Manager.cpp @@ -34,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) { @@ -1455,6 +1456,10 @@ void NGMP_OnlineServicesManager::changeNetworkRoom(int16_t roomID) { // 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) { @@ -1502,6 +1507,9 @@ void NGMP_OnlineServicesManager::commitReplay(const std::string& absoluteReplayP { std::lock_guard lock(m_replayMutex); + if (m_shuttingDown.load() || m_replayWorkerStopping.load()) { + return; + } auto& entry = m_pendingReplayUploads[matchId]; entry.bytes = std::move(replayData); @@ -1519,7 +1527,7 @@ void NGMP_OnlineServicesManager::commitReplay(const std::string& absoluteReplayP // 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()) { + if (matchId == 0 || uploadUrl.empty() || m_shuttingDown.load()) { return; } @@ -1531,6 +1539,9 @@ void NGMP_OnlineServicesManager::setReplayUploadUrl(uint64_t matchId, const std: { std::lock_guard lock(m_replayMutex); + if (m_shuttingDown.load() || m_replayWorkerStopping.load()) { + return; + } auto& entry = m_pendingReplayUploads[matchId]; entry.uploadUrl = uploadUrl; @@ -1554,18 +1565,59 @@ void NGMP_OnlineServicesManager::dispatchReplayUpload(uint64_t matchId, std::vec return; } - fprintf(stderr, "[NGMP] dispatchReplayUpload: uploading %zu bytes to storage for match %" PRIu64 "...\n", bytes.size(), matchId); + 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); - if (m_replayUploadThread.joinable()) { - m_replayUploadThread.join(); + { + 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); - m_replayUploadThread = std::thread([matchId, bytes = std::move(bytes), uploadUrl = std::move(uploadUrl)]() { for (int attempt = 1; attempt <= 3; ++attempt) { + if (m_replayWorkerStopping.load() && attempt > 1) { + break; + } + CURL* curl = curl_easy_init(); if (!curl) { - fprintf(stderr, "[NGMP] dispatchReplayUpload: failed to initialize curl (attempt %d)\n", attempt); + fprintf(stderr, "[NGMP] replayWorker: failed to initialize curl (attempt %d)\n", attempt); fflush(stderr); break; } @@ -1574,15 +1626,15 @@ void NGMP_OnlineServicesManager::dispatchReplayUpload(uint64_t matchId, std::vec headers = curl_slist_append(headers, "Content-Type: application/octet-stream"); NGMP::Internal::CurlResponse response; - curl_easy_setopt(curl, CURLOPT_URL, uploadUrl.c_str()); + 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*)bytes.data()); - curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, (curl_off_t)bytes.size()); + 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); @@ -1595,17 +1647,30 @@ void NGMP_OnlineServicesManager::dispatchReplayUpload(uint64_t matchId, std::vec curl_easy_cleanup(curl); if (res == CURLE_OK && httpCode >= 200 && httpCode < 300) { - fprintf(stderr, "[NGMP] dispatchReplayUpload: SUCCESS for match %" PRIu64 " (attempt %d, HTTP %ld, %zu bytes uploaded)\n", matchId, attempt, httpCode, bytes.size()); + 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] dispatchReplayUpload: attempt %d failed for match %" PRIu64 " (res=%d, HTTP %ld, body: %s)\n", attempt, matchId, (int)res, httpCode, response.text.c_str()); + 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) { + 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 364fe83a082..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, currentMatchID]() { + { + 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"); @@ -234,7 +252,8 @@ void NGMP_OnlineServices_StatsInterface::CommitMyOutcome(ScoreKeeper* pScoreKeep } 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 406e8d24aec..f7218da0a76 100644 --- a/docs/WORKLOG/2026-10-DIARY.md +++ b/docs/WORKLOG/2026-10-DIARY.md @@ -3,6 +3,14 @@ > [!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. + ## 04/10/2026 ### Implement match replay upload to Cloudflare R2 storage From 9617606dd01072ee726dffe2db518575adf27ade Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Mon, 5 Oct 2026 21:01:27 -0300 Subject: [PATCH 5/5] fix(ngmp): reset replay worker flags during online services init --- .../GameNetwork/GeneralsOnline/OnlineServices_Init.cpp | 6 ++++++ docs/WORKLOG/2026-10-DIARY.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index 9ba539791a3..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; } diff --git a/docs/WORKLOG/2026-10-DIARY.md b/docs/WORKLOG/2026-10-DIARY.md index f7218da0a76..c7ace25b3aa 100644 --- a/docs/WORKLOG/2026-10-DIARY.md +++ b/docs/WORKLOG/2026-10-DIARY.md @@ -9,7 +9,7 @@ - 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. +- 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