From 0f9cd34916ed1cbce1f509b107bbd5691322d3bf Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:27:16 +0200 Subject: [PATCH 01/11] fix(mesh): Accept connections the server asked us to signal before the roster refreshes --- .../GameNetwork/GeneralsOnline/NetworkMesh.cpp | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index 8d4c474857c..bbff39a6a1e 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -266,23 +266,16 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t } } + // the server started signalling with this peer, so accept it even if our roster is stale if (bPlayerIsInLobby) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][%s] Accepting - Player is in lobby\n", pInfo->m_info.m_szConnectionDescription); - SteamNetworkingSockets()->AcceptConnection(pInfo->m_hConn); } else { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][%s] Rejecting - Player is not in lobby\n", pInfo->m_info.m_szConnectionDescription); - - NetworkLog(ELogVerbosity::LOG_RELEASE, "[DC] Closing connection not in lobby %lld", plrConnection.m_userID); - SteamNetworkingSockets()->CloseConnection(pInfo->m_hConn, 1000, "Player is not in lobby (Rejected)", false); - - if (TheNetwork != nullptr) - { - TheNetwork->GetConnectionManager()->disconnectPlayer(plrConnection.m_userID); - } + NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][%s] Accepting - Server started signalling, roster not refreshed yet\n", pInfo->m_info.m_szConnectionDescription); } + SteamNetworkingSockets()->AcceptConnection(pInfo->m_hConn); } } From cbcf55aa4c6b93622e1d5a76d32eb67864af7f91 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:27:26 +0200 Subject: [PATCH 02/11] fix(lobby): Stop stale exit requests and lobby 404s from ejecting the host or tearing down a match --- .../GeneralsOnline/OnlineServices_LobbyInterface.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index 106d2f9621b..8c717191706 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -882,9 +882,6 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::functionGetAndParseServiceConfig([=]() { @@ -1491,6 +1492,7 @@ void NGMP_OnlineServices_LobbyInterface::CreateLobby(UnicodeString strLobbyName, { m_CurrentLobby = LobbyEntry(); ResetJoinOrder(); + ResetHostMigrationFlags(); m_bJoinOrderKnown = true; std::string strURI = NGMP_OnlineServicesManager::GetAPIEndpoint("Lobbies"); std::map mapHeaders; From 21a33db129769039407074db085c0a8f4aaab883 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:47:04 +0200 Subject: [PATCH 03/11] fix(online): Parse server JSON without undefined behaviour on missing fields --- .../GameNetwork/GeneralsOnline/JsonHelpers.h | 21 +++ .../OnlineServices_LobbyInterface.cpp | 146 +++++++++--------- .../OnlineServices_MatchmakingInterface.cpp | 34 ++-- 3 files changed, 119 insertions(+), 82 deletions(-) create mode 100644 GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/JsonHelpers.h diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/JsonHelpers.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/JsonHelpers.h new file mode 100644 index 00000000000..7de223155b2 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/JsonHelpers.h @@ -0,0 +1,21 @@ +#pragma once + +#include "GameNetwork/GeneralsOnline/json.hpp" + +// Optional field: dest keeps its default when the key is missing or null +template +inline void JsonGetOptional(const nlohmann::json& j, const char* szKey, T& dest) +{ + auto it = j.find(szKey); + if (it != j.end() && !it->is_null()) + { + it->get_to(dest); + } +} + +// Required field: throws when the key is missing or the wrong type +template +inline void JsonGetRequired(const nlohmann::json& j, const char* szKey, T& dest) +{ + j.at(szKey).get_to(dest); +} diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index 8c717191706..f3d185765d8 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -1,5 +1,6 @@ #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" #include "GameNetwork/GeneralsOnline/json.hpp" +#include "GameNetwork/GeneralsOnline/JsonHelpers.h" #include "GameNetwork/GeneralsOnline/HTTP/HTTPManager.h" #include "GameNetwork/GeneralsOnline/OnlineServices_Init.h" #include "GameClient/MapUtil.h" @@ -552,7 +553,7 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function std::vector vecLatencies; std::map mapPlayerLatencies; - jsonObject["latencies"].get_to(vecLatencies); + JsonGetOptional(jsonObject, "latencies", vecLatencies); // player latencies for (const auto& playerLatencyEntryIter : jsonObject["playerlatencies"]) @@ -560,8 +561,8 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function int64_t user_id = -1; int latency = -1; - playerLatencyEntryIter["user_id"].get_to(user_id); - playerLatencyEntryIter["latency"].get_to(latency); + JsonGetOptional(playerLatencyEntryIter, "user_id", user_id); + JsonGetOptional(playerLatencyEntryIter, "latency", latency); if (user_id != -1 && latency != -1) { @@ -572,41 +573,43 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function int latencyIndex = 0; for (const auto& lobbyEntryIter : jsonObject["lobbies"]) { + // latencies are indexed by the server's lobby order, so count skipped entries too + const size_t entryLatencyIndex = latencyIndex++; + try + { LobbyEntry lobbyEntry; - lobbyEntryIter["LobbyID"].get_to(lobbyEntry.lobbyID); - lobbyEntryIter["Owner"].get_to(lobbyEntry.owner); - lobbyEntryIter["Name"].get_to(lobbyEntry.name); - lobbyEntryIter["MapName"].get_to(lobbyEntry.map_name); - lobbyEntryIter["MapPath"].get_to(lobbyEntry.map_path); + JsonGetRequired(lobbyEntryIter, "LobbyID", lobbyEntry.lobbyID); + JsonGetRequired(lobbyEntryIter, "Owner", lobbyEntry.owner); + JsonGetOptional(lobbyEntryIter, "Name", lobbyEntry.name); + JsonGetOptional(lobbyEntryIter, "MapName", lobbyEntry.map_name); + JsonGetOptional(lobbyEntryIter, "MapPath", lobbyEntry.map_path); lobbyEntry.map_path = utf8_to_local(lobbyEntry.map_path); // local file path from here on - lobbyEntryIter["IsMapOfficial"].get_to(lobbyEntry.map_official); - lobbyEntryIter["NumCurrentPlayers"].get_to(lobbyEntry.current_players); - lobbyEntryIter["MaxPlayers"].get_to(lobbyEntry.max_players); - lobbyEntryIter["IsVanillaTeamsOnly"].get_to(lobbyEntry.vanilla_teams); - lobbyEntryIter["StartingCash"].get_to(lobbyEntry.starting_cash); - lobbyEntryIter["IsLimitSuperweapons"].get_to(lobbyEntry.limit_superweapons); - lobbyEntryIter["IsTrackingStats"].get_to(lobbyEntry.track_stats); - lobbyEntryIter["IsPassworded"].get_to(lobbyEntry.passworded); - lobbyEntryIter["AllowObservers"].get_to(lobbyEntry.allow_observers); - lobbyEntryIter["MaximumCameraHeight"].get_to(lobbyEntry.max_cam_height); - lobbyEntryIter["ExeCRC"].get_to(lobbyEntry.exe_crc); - lobbyEntryIter["IniCRC"].get_to(lobbyEntry.ini_crc); - lobbyEntryIter["MatchID"].get_to(lobbyEntry.match_id); - lobbyEntryIter["LobbyType"].get_to(lobbyEntry.lobby_type); - lobbyEntryIter["Region"].get_to(lobbyEntry.region); + JsonGetOptional(lobbyEntryIter, "IsMapOfficial", lobbyEntry.map_official); + JsonGetOptional(lobbyEntryIter, "NumCurrentPlayers", lobbyEntry.current_players); + JsonGetOptional(lobbyEntryIter, "MaxPlayers", lobbyEntry.max_players); + JsonGetOptional(lobbyEntryIter, "IsVanillaTeamsOnly", lobbyEntry.vanilla_teams); + JsonGetOptional(lobbyEntryIter, "StartingCash", lobbyEntry.starting_cash); + JsonGetOptional(lobbyEntryIter, "IsLimitSuperweapons", lobbyEntry.limit_superweapons); + JsonGetOptional(lobbyEntryIter, "IsTrackingStats", lobbyEntry.track_stats); + JsonGetOptional(lobbyEntryIter, "IsPassworded", lobbyEntry.passworded); + JsonGetOptional(lobbyEntryIter, "AllowObservers", lobbyEntry.allow_observers); + JsonGetOptional(lobbyEntryIter, "MaximumCameraHeight", lobbyEntry.max_cam_height); + JsonGetOptional(lobbyEntryIter, "ExeCRC", lobbyEntry.exe_crc); + JsonGetOptional(lobbyEntryIter, "IniCRC", lobbyEntry.ini_crc); + JsonGetOptional(lobbyEntryIter, "MatchID", lobbyEntry.match_id); + JsonGetOptional(lobbyEntryIter, "LobbyType", lobbyEntry.lobby_type); + JsonGetOptional(lobbyEntryIter, "Region", lobbyEntry.region); // attach latency - if (latencyIndex < vecLatencies.size()) + if (entryLatencyIndex < vecLatencies.size()) { - lobbyEntry.latency = vecLatencies[latencyIndex]; + lobbyEntry.latency = vecLatencies[entryLatencyIndex]; } else { // dummy value lobbyEntry.latency = 9001; } - ++latencyIndex; - // correct map path if (lobbyEntry.map_official) { @@ -620,16 +623,16 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function // NOTE: These fields won't be present becauase they're private properties //memberEntryIter["enc_key"].get_to(strEncKey); - for (const auto& memberEntryIter : lobbyEntryIter["Members"]) + for (const auto& memberEntryIter : lobbyEntryIter.at("Members")) { LobbyMemberEntry memberEntry; - memberEntryIter["UserID"].get_to(memberEntry.user_id); - memberEntryIter["DisplayName"].get_to(memberEntry.display_name); - memberEntryIter["IsReady"].get_to(memberEntry.m_bIsReady); - memberEntryIter["SlotIndex"].get_to(memberEntry.m_SlotIndex); - memberEntryIter["SlotState"].get_to(memberEntry.m_SlotState); - memberEntryIter["Region"].get_to(memberEntry.region); + JsonGetRequired(memberEntryIter, "UserID", memberEntry.user_id); + JsonGetOptional(memberEntryIter, "DisplayName", memberEntry.display_name); + JsonGetOptional(memberEntryIter, "IsReady", memberEntry.m_bIsReady); + JsonGetOptional(memberEntryIter, "SlotIndex", memberEntry.m_SlotIndex); + JsonGetOptional(memberEntryIter, "SlotState", memberEntry.m_SlotState); + JsonGetOptional(memberEntryIter, "Region", memberEntry.region); // store latency if (mapPlayerLatencies.contains(memberEntry.user_id)) @@ -645,6 +648,11 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function } parsedLobbies.push_back(std::move(lobbyEntry)); + } + catch (...) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Skipping malformed lobby list entry"); + } } m_vecLobbies = std::move(parsedLobbies); bSearchSucceeded = true; @@ -910,28 +918,28 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::function vecPlaylists; for (const auto& playlistEntryIter : jsonObject["playlists"]) { + try + { PlaylistEntry playlistEntry; - playlistEntryIter["PlaylistID"].get_to(playlistEntry.PlaylistID); - playlistEntryIter["Name"].get_to(playlistEntry.Name); - playlistEntryIter["MinPlayers"].get_to(playlistEntry.MinPlayers); - playlistEntryIter["DesiredPlayers"].get_to(playlistEntry.DesiredPlayers); - playlistEntryIter["MinSelectedMaps"].get_to(playlistEntry.MinSelectedMaps); - playlistEntryIter["AllowTeams"].get_to(playlistEntry.AllowTeams); - playlistEntryIter["TeamSize"].get_to(playlistEntry.TeamSize); - playlistEntryIter["AllowArmySelection"].get_to(playlistEntry.AllowArmySelection); - playlistEntryIter["GracePeriodAtMinPlayersMSec"].get_to(playlistEntry.GracePeriodAtMinPlayersMSec); + JsonGetRequired(playlistEntryIter, "PlaylistID", playlistEntry.PlaylistID); + JsonGetOptional(playlistEntryIter, "Name", playlistEntry.Name); + JsonGetOptional(playlistEntryIter, "MinPlayers", playlistEntry.MinPlayers); + JsonGetOptional(playlistEntryIter, "DesiredPlayers", playlistEntry.DesiredPlayers); + JsonGetOptional(playlistEntryIter, "MinSelectedMaps", playlistEntry.MinSelectedMaps); + JsonGetOptional(playlistEntryIter, "AllowTeams", playlistEntry.AllowTeams); + JsonGetOptional(playlistEntryIter, "TeamSize", playlistEntry.TeamSize); + JsonGetOptional(playlistEntryIter, "AllowArmySelection", playlistEntry.AllowArmySelection); + JsonGetOptional(playlistEntryIter, "GracePeriodAtMinPlayersMSec", playlistEntry.GracePeriodAtMinPlayersMSec); // maps - for (const auto& mapEntryIter : playlistEntryIter["Maps"]) + for (const auto& mapEntryIter : playlistEntryIter.at("Maps")) { PlaylistMapEntry mapEntry; - mapEntryIter["Name"].get_to(mapEntry.Name); - mapEntryIter["Path"].get_to(mapEntry.Path); + JsonGetOptional(mapEntryIter, "Name", mapEntry.Name); + JsonGetRequired(mapEntryIter, "Path", mapEntry.Path); mapEntry.Path = utf8_to_local(mapEntry.Path); // used as a local file path - mapEntryIter["Custom"].get_to(mapEntry.Custom); + JsonGetOptional(mapEntryIter, "Custom", mapEntry.Custom); playlistEntry.Maps.push_back(mapEntry); } vecPlaylists.push_back(playlistEntry); + } + catch (...) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Skipping malformed playlist entry"); + } } // cache From a4960b840368739ee71e6d7c221e1c2ad95e18af Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:47:47 +0200 Subject: [PATCH 04/11] fix(lobby): Time out the host's start connectivity check --- .../GameNetwork/GeneralsOnline/OnlineServices_Init.h | 4 ++++ .../GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp | 5 ++++- .../GameNetwork/GeneralsOnline/OnlineServices_Init.cpp | 1 + .../GeneralsOnline/OnlineServices_RoomsInterface.cpp | 10 +++++++++- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 796f0faffe2..2203977c846 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -185,12 +185,16 @@ class WebSocket // lobby ID the check was started for; a stale reply (different lobby) is ignored int64_t m_connectivityCheckLobbyID = -1; + // when the check was sent; Tick() gives up on it after a client-side timeout + int64_t m_connectivityCheckStartMs = -1; + void SendData_StartFullMeshConnectivityCheck(std::function>, std::string)> cbOnConnectivityCheckComplete); void ClearConnectivityCheckCallback() { m_cbOnConnectivityCheckComplete = nullptr; m_connectivityCheckLobbyID = -1; + m_connectivityCheckStartMs = -1; } void Tick(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp index dbcb3422e87..124079e07b8 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp @@ -1206,7 +1206,10 @@ static void StartPressed() // who is missing who? //std::list> missingConnections - GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Connections: Missing links:"), GameMakeColor(255, 194, 15, 255), -1, -1); + if (!missingConnections.empty()) + { + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Connections: Missing links:"), GameMakeColor(255, 194, 15, 255), -1, -1); + } for (auto& missingPair : missingConnections) { bool bFoundPlayer = false; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index 47b7bd8318e..879610356c3 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -1337,6 +1337,7 @@ void WebSocket::SendData_StartFullMeshConnectivityCheck(std::function(); m_connectivityCheckLobbyID = pLobbyInterface != nullptr ? pLobbyInterface->GetCurrentLobby().lobbyID : -1; + m_connectivityCheckStartMs = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); nlohmann::json j; j["msg_id"] = EWebSocketMessageID::FULL_MESH_CONNECTIVITY_CHECK_HOST_REQUESTS_BEGIN; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index 07e13867223..2a24906c459 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -751,7 +751,15 @@ void WebSocket::Tick() // Main thread only; m_pCurlWS/m_vecWSPartialBuffer/m_bConnected are unlocked here. UpdateReconnect(); - + // longer than the server's 20s backstop; a late reply then finds no callback and is ignored + static constexpr int64_t CONNECTIVITY_CHECK_TIMEOUT_MS = 30000; + if (m_cbOnConnectivityCheckComplete != nullptr && m_connectivityCheckStartMs != -1 && (NowMs() - m_connectivityCheckStartMs) > CONNECTIVITY_CHECK_TIMEOUT_MS) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[FULL_MESH_CONNECTIVITY_CHECK] No reply from the server, timing out"); + auto cbTimedOut = std::move(m_cbOnConnectivityCheckComplete); + ClearConnectivityCheckCallback(); + cbTimedOut(false, {}, "timeout"); + } /* if (strSignal.length() == 6) From 2385b663a3fb71ea7ede94207afeb2265956472b Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:47:59 +0200 Subject: [PATCH 05/11] fix(lobby): Keep a cannot-connect result until a menu handles it --- .../GeneralsOnline/OnlineServices_LobbyInterface.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index f3d185765d8..3caf6202670 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -780,13 +780,13 @@ void NGMP_OnlineServices_LobbyInterface::Tick() m_pLobbyMesh->Tick(); } + // stays pending until a menu has registered to handle it if (m_bCannotConnectToLobbyPending) { - m_bCannotConnectToLobbyPending = false; - auto callbackCopy = m_OnCannotConnectToLobbyCallback; if (callbackCopy != nullptr) { + m_bCannotConnectToLobbyPending = false; callbackCopy(); } } From 1ce4215261cac986b542fba62edc88875ce978d6 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:48:43 +0200 Subject: [PATCH 06/11] fix(quickmatch): Don't cancel the server's requeue or hide a failed match join --- .../GeneralsOnline/OnlineServices_LobbyInterface.h | 3 ++- .../GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp | 8 ++++++++ .../OnlineServices_LobbyInterface.cpp | 14 ++++++++++++-- .../OnlineServices_RoomsInterface.cpp | 11 ++++++++--- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h index 5e89ed8d313..e9db2ecee96 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h @@ -472,7 +472,8 @@ class NGMP_OnlineServices_LobbyInterface NetworkMesh* GetNetworkMeshForLobby() { return m_pLobbyMesh; } - void JoinLobby(LobbyEntry lobby, std::string strPassword); + // false when a join is already in progress and this request was dropped + bool JoinLobby(LobbyEntry lobby, std::string strPassword); void LeaveCurrentLobby(); void ResetForMatchmakingRequeue(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp index 0cdabf7d55e..e4c4afb6004 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp @@ -1341,6 +1341,14 @@ void WOLQuickMatchMenuInit( WindowLayout *layout, void *userData ) if (!pLobbyInterface->IsInLobby()) { + Int index = GadgetListBoxAddEntryText(quickmatchTextWindow, UnicodeString(L"Could not join the match lobby, waiting for the matchmaker..."), GameSpyColor[GSCOLOR_DEFAULT], -1, -1); + GadgetListBoxSetItemData(quickmatchTextWindow, (void*)-1, index); + + matchFoundTimeoutStart = 0; + matchStartCountdownLastSecond = 0; + + buttonBack->winEnable(TRUE); + buttonStop->winEnable(TRUE); return; } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index 3caf6202670..ffda1da15bc 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -903,6 +903,14 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::functionisQMGame() || m_CurrentLobby.lobby_type == ELobbyType::QuickMatch) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Quick match lobby lookup returned 404, tearing down locally only"); + ResetForMatchmakingRequeue(); + return; + } + LeaveCurrentLobby(); // after leaving, which clears it, so the setup menu backs out @@ -1141,12 +1149,12 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::functionJoinLobby(lobbyEntry, std::string()); - - pLobbyInterface->InvokeMatchmakingMessageCallback("Joining QuickMatch Lobby"); + if (pLobbyInterface->JoinLobby(lobbyEntry, std::string())) + { + pLobbyInterface->InvokeMatchmakingMessageCallback("Joining QuickMatch Lobby"); + } + else + { + pLobbyInterface->InvokeMatchmakingMessageCallback("Could not join the QuickMatch lobby, another join is still in progress"); + } } else { From cb01bc4bb12e9ff4b4c64a1f7feb48a2cd5ec0f7 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:50:38 +0200 Subject: [PATCH 07/11] fix(websocket): Read every queued websocket frame each tick --- .../GeneralsOnline/OnlineServices_Init.h | 2 + .../OnlineServices_RoomsInterface.cpp | 58 ++++++++++++------- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 2203977c846..c10c1dd4af6 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -228,6 +228,8 @@ class WebSocket } private: + CURLcode ReceiveOneFrame(); + CURL* m_pCurlWS = nullptr; CURLM* m_pMulti = nullptr; struct curl_slist* m_pHeaders = nullptr; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index c1a812398a1..1602effe499 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -893,6 +893,41 @@ void WebSocket::Tick() } } + // drain what the server queued, a signalling burst shouldn't take one frame per tick + static constexpr int MAX_WS_FRAMES_PER_TICK = 64; + CURLcode ret = CURL_LAST; + for (int numFrames = 0; numFrames < MAX_WS_FRAMES_PER_TICK; ++numFrames) + { + ret = ReceiveOneFrame(); + if (ret != CURLE_OK || !m_bConnected) + { + break; + } + } + + // time since last pong? + if (m_lastPong != -1 && (currTime - m_lastPong) >= m_timeForWSTimeout) + { + // send event to sentry +#if defined(GENERALS_ONLINE_USE_SENTRY) + if (TheNGMPGame != nullptr) + { + AsciiString sentryMsg; + sentryMsg.format("Got websocket disconnect (Timeout: %s), timeout is %lld, last pong was at %lld, current time is %lld, attempting reconnect", curl_easy_strerror(ret), currTime - m_lastPong, m_lastPong, currTime); + sentry_capture_event(sentry_value_new_message_event(SENTRY_LEVEL_ERROR, "WEBSOCKET_DISCONNECT_TIMEOUT", sentryMsg.str())); + } +#endif + + NetworkLog(ELogVerbosity::LOG_RELEASE, "Got websocket disconnect (Timeout: %s), timeout is %lld, last pong was at %lld, current time is %lld, attempting reconnect", curl_easy_strerror(ret), currTime - m_lastPong, m_lastPong, currTime); + m_bConnected = false; + BeginReconnect(); + m_vecWSPartialBuffer.clear(); + }; +} + +// Reads and handles at most one frame, returns the curl_ws_recv result (CURL_LAST if nothing was read) +CURLcode WebSocket::ReceiveOneFrame() +{ // do recv size_t rlen = 0; const struct curl_ws_frame* meta = nullptr; @@ -905,7 +940,7 @@ void WebSocket::Tick() if (rlen > sizeof(bufferThisRecv)) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Received data size %zu exceeds buffer size %zu, discarding", rlen, sizeof(bufferThisRecv)); - return; + return CURL_LAST; } if (ret != CURLE_RECV_ERROR && ret != CURL_LAST && ret != CURLE_AGAIN && ret != CURLE_GOT_NOTHING) @@ -933,7 +968,7 @@ void WebSocket::Tick() { NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Partial buffer overflow, discarding message"); m_vecWSPartialBuffer.clear(); - return; + return CURL_LAST; } // SECURITY FIX: Store old size BEFORE resize to avoid off-by-one error in memcpy @@ -1808,24 +1843,7 @@ void WebSocket::Tick() #endif } - // time since last pong? - if (m_lastPong != -1 && (currTime - m_lastPong) >= m_timeForWSTimeout) - { - // send event to sentry -#if defined(GENERALS_ONLINE_USE_SENTRY) - if (TheNGMPGame != nullptr) - { - AsciiString sentryMsg; - sentryMsg.format("Got websocket disconnect (Timeout: %s), timeout is %lld, last pong was at %lld, current time is %lld, attempting reconnect", curl_easy_strerror(ret), currTime - m_lastPong, m_lastPong, currTime); - sentry_capture_event(sentry_value_new_message_event(SENTRY_LEVEL_ERROR, "WEBSOCKET_DISCONNECT_TIMEOUT", sentryMsg.str())); - } -#endif - - NetworkLog(ELogVerbosity::LOG_RELEASE, "Got websocket disconnect (Timeout: %s), timeout is %lld, last pong was at %lld, current time is %lld, attempting reconnect", curl_easy_strerror(ret), currTime - m_lastPong, m_lastPong, currTime); - m_bConnected = false; - BeginReconnect(); - m_vecWSPartialBuffer.clear(); - }; + return ret; } NGMP_OnlineServices_RoomsInterface::NGMP_OnlineServices_RoomsInterface() From 9eb4d1916dc1b7cadad0e8c117a286b3467b1102 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:50:39 +0200 Subject: [PATCH 08/11] fix(lobby): Ignore lobby responses that arrive out of order --- .../GeneralsOnline/OnlineServices_LobbyInterface.h | 4 ++++ .../OnlineServices_LobbyInterface.cpp | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h index e9db2ecee96..f8e1810d3d2 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h @@ -552,6 +552,10 @@ class NGMP_OnlineServices_LobbyInterface #endif bool m_bAttemptingToJoinLobby = false; + + // GET Lobby/{id} responses can arrive out of order; only apply ones newer than the last applied + std::atomic m_LobbyUpdateRequestSeq = 0; + std::atomic m_LobbyUpdateAppliedSeq = 0; // Invalidates asynchronous callbacks left behind by an abandoned lobby join. std::atomic m_LobbyJoinGeneration = 0; LobbyEntry m_LobbyTryingToJoin; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index ffda1da15bc..1256bd86e8d 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -877,6 +877,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::function mapHeaders; @@ -888,6 +889,18 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::function Date: Tue, 29 Sep 2026 22:50:59 +0200 Subject: [PATCH 09/11] fix(lobby): Stop the countdown when a player joins after the check --- .../OnlineServices_LobbyInterface.cpp | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index 1256bd86e8d..1dfb63d8ac8 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -1111,6 +1111,47 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::functionStopCountdown(); } + // a player who joined after the connectivity check passed hasn't been checked, so the host must re-run it + if (IsHost() + && TheNGMPGame != nullptr + && TheNGMPGame->IsCountdownStarted() + && lobbyEntry.lobby_type != ELobbyType::QuickMatch + && !TheNGMPGame->isQMGame()) + { + bool bPlayerJoined = false; + for (const LobbyMemberEntry& newMember : lobbyEntry.members) + { + if (!newMember.IsHuman()) + { + continue; + } + + bool bWasInLobby = false; + for (const LobbyMemberEntry& oldMember : m_CurrentLobby.members) + { + if (oldMember.IsHuman() && oldMember.user_id == newMember.user_id) + { + bWasInLobby = true; + break; + } + } + + if (!bWasInLobby) + { + bPlayerJoined = true; + break; + } + } + + if (bPlayerJoined) + { + TheNGMPGame->StopCountdown(); + + UnicodeString strInform = UnicodeString(L"Countdown stopped: a player joined, start again to re-check connections."); + SendAnnouncementMessageToCurrentLobby(strInform, true); + } + } + // store m_CurrentLobby = lobbyEntry; RecordJoinOrder(lobbyEntry.members); From 918ba69c55eb69f5fe5cfa23fae0cb4ed63a8b50 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:51:34 +0200 Subject: [PATCH 10/11] fix(mesh): Ignore signalling for a lobby we are not in --- .../GeneralsOnline/OnlineServices_Init.h | 7 +++++ .../OnlineServices_LobbyInterface.cpp | 7 +++++ .../OnlineServices_RoomsInterface.cpp | 30 ++++++++++++------- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index c10c1dd4af6..ce44c18055a 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -219,6 +219,13 @@ class WebSocket m_pendingSignals.push(std::move(payload)); } + // signals queued for a lobby we've since left must not reach the next one + void ClearPendingSignals() + { + std::scoped_lock lock(m_pendingSignalsMutex); + m_pendingSignals = std::queue>(); + } + std::queue> DrainPendingSignals() { std::scoped_lock lock(m_pendingSignalsMutex); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index 1dfb63d8ac8..0f5f61d6a9a 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -1448,6 +1448,7 @@ void NGMP_OnlineServices_LobbyInterface::LeaveCurrentLobby() if (pWS != nullptr) { pWS->ClearConnectivityCheckCallback(); + pWS->ClearPendingSignals(); } // reset host migration flags @@ -1498,6 +1499,12 @@ void NGMP_OnlineServices_LobbyInterface::ResetForMatchmakingRequeue() ResetHostMigrationFlags(); AnticheatPlugInterface::EndSession(); + std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); + if (pWS != nullptr) + { + pWS->ClearPendingSignals(); + } + if (m_pLobbyMesh != nullptr) { m_pLobbyMesh->Disconnect(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index 1602effe499..f56e38c060d 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -1337,22 +1337,30 @@ CURLcode WebSocket::ReceiveOneFrame() WebSocketMessage_NetworkStartSignalling startSignallingData; bool bParsed = JSONGetAsObject(jsonObject, &startSignallingData); - // TODO_NGMP: Better location for this - // When we find a new player, get their latest stats. Tooltip and loading screen need it, so we'll grab it now and then use cached data later since it cannot possibly change while in a lobby - NGMP_OnlineServices_StatsInterface* pStatsInterface = NGMP_OnlineServicesManager::GetInterface(); - if (pStatsInterface != nullptr) - { - pStatsInterface->findPlayerStatsByID(startSignallingData.user_id, [=](bool bSuccess, PSPlayerStats stats) - { - - }, EStatsRequestPolicy::BYPASS_CACHE_FORCE_REQUEST); - } - if (bParsed) { NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); if (pLobbyInterface != nullptr) { + // -1 while a join is still in flight, when this can legitimately arrive first + const int64_t currentLobbyID = pLobbyInterface->GetCurrentLobby().lobbyID; + if (currentLobbyID != -1 && currentLobbyID != startSignallingData.lobby_id) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NETWORK_CONNECTION_START_SIGNALLING] Ignoring signalling for lobby %lld, we are in lobby %lld", startSignallingData.lobby_id, currentLobbyID); + break; + } + + // TODO_NGMP: Better location for this + // When we find a new player, get their latest stats. Tooltip and loading screen need it, so we'll grab it now and then use cached data later since it cannot possibly change while in a lobby + NGMP_OnlineServices_StatsInterface* pStatsInterface = NGMP_OnlineServicesManager::GetInterface(); + if (pStatsInterface != nullptr) + { + pStatsInterface->findPlayerStatsByID(startSignallingData.user_id, [=](bool bSuccess, PSPlayerStats stats) + { + + }, EStatsRequestPolicy::BYPASS_CACHE_FORCE_REQUEST); + } + NetworkMesh* pMesh = pLobbyInterface->GetNetworkMeshForLobby(); if (pMesh != nullptr) From cfde97b4becf6d7bfe83c9824c516574dce68e6b Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:51:59 +0200 Subject: [PATCH 11/11] fix(online): Pass anticheat middleware ids to the log as C strings --- .../GeneralsOnline/OnlineServices_RoomsInterface.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index f56e38c060d..fcd7c1c80ee 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -1390,7 +1390,7 @@ CURLcode WebSocket::ReceiveOneFrame() if (bParsed) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[AC] Websocket AC_REGISTER_PLAYER for %lld and %s", acData.user_id, acData.mwid); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[AC] Websocket AC_REGISTER_PLAYER for %lld and %s", acData.user_id, acData.mwid.c_str()); if (!AnticheatPlugInterface::RegisterPlayer(acData.mwid, acData.user_id)) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[AC] AnticheatPlugInterface::RegisterPlayer failed"); @@ -1406,7 +1406,7 @@ CURLcode WebSocket::ReceiveOneFrame() if (bParsed) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[AC] Websocket AC_DEREGISTER_PLAYER for %lld and %s", acData.user_id, acData.mwid); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[AC] Websocket AC_DEREGISTER_PLAYER for %lld and %s", acData.user_id, acData.mwid.c_str()); if (!AnticheatPlugInterface::DeregisterPlayer(acData.mwid, acData.user_id)) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[AC] AnticheatPlugInterface::DeregisterPlayer failed");