From f5f1edbfcf7713e5a2ab4fcd5f958dc19cc1b283 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 16 Aug 2026 20:59:15 +0200 Subject: [PATCH 1/2] feat(live): Add live spectating of matches in progress via a relay server Adds the client side of live spectating. A player's client streams its replay bytes to a relay server; a spectator's client receives that stream and plays it back locally as a live game. GO owns the orchestration (stream registration, watch tickets, broadcast delay); this is the transport and playback half on both ends. - LiveObserver: plays back the growing replay file over WebSocket - live-edge tracking, buffering pause behind the broadcast delay, fast-forward gate, spectator chat, rate-matched playback. - LiveStreamer: a sink on the recorder that forwards the recorded bytes plus a frame heartbeat and match telemetry. - Recorder: RECORDERMODETYPE_LIVE_OBSERVER, startLiveObserverPlayback/endLivePlayback, an IReplayStreamSink, EOF-waiting read for the growing file. - Pre-game observer lobby (read-only slots with roll prediction, chat both ways, host /observerchat kill switch), a live-games browser, watch tickets and stream registration (websocket opcodes 44-51), a host-controlled broadcast delay, and password-protected streams. --- Core/GameEngine/Include/Common/GameCommon.h | 8 + .../Include/Common/OptionPreferences.h | 12 +- .../Include/GameClient/GameWindow.h | 5 + .../Source/Common/OptionPreferences.cpp | 65 + .../Source/GameClient/GUI/GameWindow.cpp | 34 + .../Source/GameClient/GUI/LoadScreen.cpp | 15 +- .../GameClient/MessageStream/CommandXlat.cpp | 90 +- .../GameClient/MessageStream/MetaEvent.cpp | 16 + .../GameLogic/System/GameLogicDispatch.cpp | 16 +- .../Source/GameNetwork/ConnectionManager.cpp | 43 + .../Source/GameNetwork/GameSpy/LobbyUtils.cpp | 10 +- GeneralsMD/Code/GameEngine/CMakeLists.txt | 5 + .../GameEngine/Include/Common/GlobalData.h | 5 + .../GameEngine/Include/Common/LiveObserver.h | 639 +++++ .../GameEngine/Include/Common/LiveStreamer.h | 292 +++ .../Code/GameEngine/Include/Common/Recorder.h | 42 +- .../Include/Common/ReplayStreamSink.h | 44 + .../Include/GameClient/GUICallbacks.h | 10 + .../GameEngine/Include/GameClient/InGameUI.h | 33 + .../Include/GameClient/LiveGamesMenu.h | 51 + .../Include/GameClient/LiveObserverSession.h | 64 + .../Include/GameClient/LobbyObserverMenu.h | 48 + .../GameEngine/Include/GameLogic/GameLogic.h | 9 + .../GeneralsOnline/GeneralsOnline_Settings.h | 19 + .../GeneralsOnline/OnlineServices_Init.h | 14 +- .../OnlineServices_LobbyInterface.h | 56 +- .../GameEngine/Source/Common/GameEngine.cpp | 9 + .../GameEngine/Source/Common/GlobalData.cpp | 8 + .../GameEngine/Source/Common/LiveObserver.cpp | 2201 +++++++++++++++++ .../GameEngine/Source/Common/LiveStreamer.cpp | 1266 ++++++++++ .../GameEngine/Source/Common/Recorder.cpp | 416 +++- .../GUI/GUICallbacks/InGameChat.cpp | 29 +- .../GUI/GUICallbacks/Menus/LiveGamesMenu.cpp | 552 +++++ .../GUICallbacks/Menus/LobbyObserverMenu.cpp | 1418 +++++++++++ .../GUI/GUICallbacks/Menus/MainMenu.cpp | 35 + .../GUI/GUICallbacks/Menus/PopupHostGame.cpp | 51 +- .../GUI/GUICallbacks/Menus/PopupJoinGame.cpp | 115 +- .../GUI/GUICallbacks/Menus/ReplayMenu.cpp | 63 +- .../GUICallbacks/Menus/WOLGameSetupMenu.cpp | 292 ++- .../GUICallbacks/Menus/WOLQuickMatchMenu.cpp | 4 + .../GUI/GUICallbacks/Menus/WOLWelcomeMenu.cpp | 119 + .../GameClient/GUI/LiveObserverSession.cpp | 244 ++ .../GameEngine/Source/GameClient/InGameUI.cpp | 274 +- .../Source/GameLogic/System/GameLogic.cpp | 56 +- .../GeneralsOnline_Settings.cpp | 21 + .../GeneralsOnline/OnlineServices_Init.cpp | 48 + .../OnlineServices_LobbyInterface.cpp | 311 ++- .../OnlineServices_RoomsInterface.cpp | 60 +- cmake/config-debug.cmake | 6 + 49 files changed, 9133 insertions(+), 110 deletions(-) create mode 100644 GeneralsMD/Code/GameEngine/Include/Common/LiveObserver.h create mode 100644 GeneralsMD/Code/GameEngine/Include/Common/LiveStreamer.h create mode 100644 GeneralsMD/Code/GameEngine/Include/Common/ReplayStreamSink.h create mode 100644 GeneralsMD/Code/GameEngine/Include/GameClient/LiveGamesMenu.h create mode 100644 GeneralsMD/Code/GameEngine/Include/GameClient/LiveObserverSession.h create mode 100644 GeneralsMD/Code/GameEngine/Include/GameClient/LobbyObserverMenu.h create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/LiveObserver.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/LiveStreamer.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LiveGamesMenu.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LobbyObserverMenu.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/GameClient/GUI/LiveObserverSession.cpp diff --git a/Core/GameEngine/Include/Common/GameCommon.h b/Core/GameEngine/Include/Common/GameCommon.h index cc1eccb7c78..ba64fc294bb 100644 --- a/Core/GameEngine/Include/Common/GameCommon.h +++ b/Core/GameEngine/Include/Common/GameCommon.h @@ -69,6 +69,14 @@ enum LOGICFRAMES_PER_SECOND = WWSyncPerSecond, MSEC_PER_SECOND = 1000 }; +// Live-observer broadcast delay. In seconds, not frames, so it survives a change of logic tick +// rate; lives here rather than Recorder.h so OptionPreferences (Core) can share it. +enum +{ + LIVE_DELAY_SECONDS_DEFAULT = 15, // used when the streamer or relay supplies nothing + LIVE_DELAY_SECONDS_MAX = 600 +}; + const Real LOGICFRAMES_PER_MSEC_REAL = (((Real)LOGICFRAMES_PER_SECOND) / ((Real)MSEC_PER_SECOND)); const Real MSEC_PER_LOGICFRAME_REAL = (((Real)MSEC_PER_SECOND) / ((Real)LOGICFRAMES_PER_SECOND)); const Real LOGICFRAMES_PER_SECONDS_REAL = (Real)LOGICFRAMES_PER_SECOND; diff --git a/Core/GameEngine/Include/Common/OptionPreferences.h b/Core/GameEngine/Include/Common/OptionPreferences.h index d41945256a2..0ee1d109fd6 100644 --- a/Core/GameEngine/Include/Common/OptionPreferences.h +++ b/Core/GameEngine/Include/Common/OptionPreferences.h @@ -137,4 +137,14 @@ class OptionPreferences : public UserPreferences Int getObserverNotificationFontSize(void); Bool getObserverNotificationSpecialPowerUsage(void); Bool getObserverNotificationSpecialPowerPurchase(void); - Bool getObserverNotificationMilestone(void);}; + Bool getObserverNotificationMilestone(void); + + Bool getLiveStreamEnabled() const; + void setLiveStreamEnabled(Bool enabled); + Bool getLiveStreamCanStream() const; + + /// Broadcast delay in seconds that this client asks the relay to apply to its observers. + /// Clamped to [0, LIVE_DELAY_SECONDS_MAX]; absent or malformed reads as the default. + Int getLiveStreamDelaySeconds() const; + void setLiveStreamDelaySeconds(Int seconds); +}; diff --git a/Core/GameEngine/Include/GameClient/GameWindow.h b/Core/GameEngine/Include/GameClient/GameWindow.h index 0bc54da0b88..ead7e14bdab 100644 --- a/Core/GameEngine/Include/GameClient/GameWindow.h +++ b/Core/GameEngine/Include/GameClient/GameWindow.h @@ -270,6 +270,11 @@ class GameWindow : public MemoryPoolObject // -------------------------------------------------------------------------- // new methods for setting images + /// Adopt another window's whole visual state, so a gadget created in code can look like one + /// defined in a .wnd instead of the placeholder gogoGadget*(..., defaultVisual=TRUE) leaves. + /// Source and destination must be the same gadget type for the draw-data slots to line up. + void winCopyVisualsFrom(GameWindow* src); + Int winSetEnabledImage(Int index, const Image* image); Int winSetEnabledColor(Int index, Color color); Int winSetEnabledBorderColor(Int index, Color color); diff --git a/Core/GameEngine/Source/Common/OptionPreferences.cpp b/Core/GameEngine/Source/Common/OptionPreferences.cpp index 40b417abb4b..2dd137a77fa 100644 --- a/Core/GameEngine/Source/Common/OptionPreferences.cpp +++ b/Core/GameEngine/Source/Common/OptionPreferences.cpp @@ -33,6 +33,7 @@ #include "Common/AudioSettings.h" #include "Common/GameAudio.h" +#include "Common/GameCommon.h" // LIVE_DELAY_SECONDS_DEFAULT / _MAX #include "Common/GameLOD.h" #include "Common/GlobalData.h" #include "Common/OptionPreferences.h" @@ -170,6 +171,70 @@ Bool OptionPreferences::getObserverNotificationMilestone(void) return FALSE; } +Bool OptionPreferences::getLiveStreamEnabled() const +{ + OptionPreferences::const_iterator it = find("LiveStreamEnabled"); + if (it == end()) + return TRUE; + if (stricmp(it->second.str(), "yes") == 0) + { + return TRUE; + } + return FALSE; +} + +void OptionPreferences::setLiveStreamEnabled(Bool enabled) +{ + (*this)["LiveStreamEnabled"] = enabled ? "yes" : "no"; +} + +Int OptionPreferences::getLiveStreamDelaySeconds() const +{ + OptionPreferences::const_iterator it = find("LiveObserverDelaySeconds"); + if (it == end()) + return LIVE_DELAY_SECONDS_DEFAULT; + + // atoi returns 0 for junk, which is indistinguishable from a deliberate 0 - and silently + // streaming with no delay would defeat the spoiler window entirely. + const char* str = it->second.str(); + if (str == nullptr || *str == '\0') + return LIVE_DELAY_SECONDS_DEFAULT; + for (const char* c = str; *c != '\0'; ++c) + { + if (*c < '0' || *c > '9') + return LIVE_DELAY_SECONDS_DEFAULT; + } + + Int seconds = atoi(str); + if (seconds > LIVE_DELAY_SECONDS_MAX) + return LIVE_DELAY_SECONDS_MAX; + return seconds; +} + +void OptionPreferences::setLiveStreamDelaySeconds(Int seconds) +{ + if (seconds < 0) + seconds = 0; + if (seconds > LIVE_DELAY_SECONDS_MAX) + seconds = LIVE_DELAY_SECONDS_MAX; + + AsciiString value; + value.format("%d", seconds); + (*this)["LiveObserverDelaySeconds"] = value; +} + +Bool OptionPreferences::getLiveStreamCanStream() const +{ + OptionPreferences::const_iterator it = find("LiveStreamCanStream"); + if (it == end()) + return TRUE; + if (stricmp(it->second.str(), "yes") == 0) + { + return TRUE; + } + return FALSE; +} + Int OptionPreferences::getObserverStatsFontSize(void) { OptionPreferences::const_iterator it = find("ObserverStatsFontSize"); diff --git a/Core/GameEngine/Source/GameClient/GUI/GameWindow.cpp b/Core/GameEngine/Source/GameClient/GUI/GameWindow.cpp index 84dc88726cc..57a1a0ec164 100644 --- a/Core/GameEngine/Source/GameClient/GUI/GameWindow.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/GameWindow.cpp @@ -1590,6 +1590,40 @@ void GameWinDefaultDraw( GameWindow *window, WinInstanceData *instData ) } +// GameWindow::winCopyVisualsFrom ============================================= +/** Adopt another window's complete visual state. See the header for why. */ +//============================================================================= +void GameWindow::winCopyVisualsFrom( GameWindow *src ) +{ + if( src == NULL ) + return; + + for( Int i = 0; i < MAX_DRAW_DATA; ++i ) + { + winSetEnabledImage( i, src->winGetEnabledImage( i ) ); + winSetEnabledColor( i, src->winGetEnabledColor( i ) ); + winSetEnabledBorderColor( i, src->winGetEnabledBorderColor( i ) ); + + winSetDisabledImage( i, src->winGetDisabledImage( i ) ); + winSetDisabledColor( i, src->winGetDisabledColor( i ) ); + winSetDisabledBorderColor( i, src->winGetDisabledBorderColor( i ) ); + + winSetHiliteImage( i, src->winGetHiliteImage( i ) ); + winSetHiliteColor( i, src->winGetHiliteColor( i ) ); + winSetHiliteBorderColor( i, src->winGetHiliteBorderColor( i ) ); + } + + WinInstanceData *srcData = src->winGetInstanceData(); + if( srcData != NULL ) + { + winSetEnabledTextColors( srcData->m_enabledText.color, srcData->m_enabledText.borderColor ); + winSetDisabledTextColors( srcData->m_disabledText.color, srcData->m_disabledText.borderColor ); + winSetHiliteTextColors( srcData->m_hiliteText.color, srcData->m_hiliteText.borderColor ); + if( srcData->getFont() != NULL ) + winSetFont( srcData->getFont() ); + } +} + // GameWindow::winSetEnabledImage ============================================= /** Set an enabled image into the draw data for the enabled state */ //============================================================================= diff --git a/Core/GameEngine/Source/GameClient/GUI/LoadScreen.cpp b/Core/GameEngine/Source/GameClient/GUI/LoadScreen.cpp index d227346e3b5..d74fd687c7a 100644 --- a/Core/GameEngine/Source/GameClient/GUI/LoadScreen.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/LoadScreen.cpp @@ -65,6 +65,7 @@ #include "Common/Player.h" #include "Common/PlayerList.h" #include "Common/PlayerTemplate.h" +#include "Common/Recorder.h" #include "GameClient/CampaignManager.h" #include "GameClient/Display.h" #include "GameClient/GadgetProgressBar.h" @@ -1390,7 +1391,19 @@ void MultiPlayerLoadScreen::init( GameInfo *game ) const PlayerTemplate* pt = ThePlayerTemplateStore->getNthPlayerTemplate(slot->getPlayerTemplate()); GadgetStaticTextSetText(m_playerSide[netSlot], pt ? pt->getDisplayName() : slot->getApparentPlayerTemplateDisplayName()); #else - GadgetStaticTextSetText(m_playerSide[netSlot], slot->getApparentPlayerTemplateDisplayName()); +#if defined(GENERALS_ONLINE) + // A live observer is nobody's ally, so the apparent-name masking would print "Random" + // for every random-picked side on the board. They watch the whole game anyway. + if (TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER) + { + const PlayerTemplate* pt = ThePlayerTemplateStore->getNthPlayerTemplate(slot->getPlayerTemplate()); + GadgetStaticTextSetText(m_playerSide[netSlot], pt ? pt->getDisplayName() : slot->getApparentPlayerTemplateDisplayName()); + } + else +#endif + { + GadgetStaticTextSetText(m_playerSide[netSlot], slot->getApparentPlayerTemplateDisplayName()); + } #endif m_playerSide[netSlot]->winSetEnabledTextColors(houseColor, m_playerSide[netSlot]->winGetEnabledTextBorderColor()); diff --git a/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp b/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp index ab8a8c5380b..f5fa85f45e5 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp @@ -47,6 +47,9 @@ #include "Common/PlayerTemplate.h" #include "Common/Radar.h" #include "Common/Recorder.h" +#if defined(GENERALS_ONLINE) +#include "Common/LiveObserver.h" +#endif #include "Common/SpecialPower.h" #include "Common/StatsCollector.h" #include "Common/ThingTemplate.h" @@ -93,6 +96,22 @@ #include "WW3D2/ww3d.h" #include "../OnlineServices_Init.h" +// A live-observer session runs as a replay game, but its chat window is meaningful: Enter +// sends to the spectator channel instead of the mesh. Constant FALSE outside GeneralsOnline +// so the call sites below need no guards of their own - this file is shared with Generals, +// which has neither LiveObserver nor the recorder mode. +// +// Constant FALSE outside GeneralsOnline so the call sites below need no guards of their own: +// this file is shared with Generals, which has neither LiveObserver nor the recorder mode. +static Bool IsLiveObserverSession() +{ +#if defined(GENERALS_ONLINE) + return TheRecorder != nullptr && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER; +#else + return FALSE; +#endif +} + #if defined(RTS_DEBUG) /*non-static*/ Real TheSkateDistOverride = 0.0f; @@ -3255,6 +3274,8 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage //----------------------------------------------------------------------------------------- case GameMessage::MSG_META_CHAT_ALLIES: + // Deliberately not opened for a live observer: they have no allies, and every send + // from that window goes to the spectator channel regardless of the type shown. if (TheGameLogic->isInMultiplayerGame() && !TheGameLogic->isInReplayGame()) { Player *localPlayer = ThePlayerList->getLocalPlayer(); @@ -3269,7 +3290,7 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage //----------------------------------------------------------------------------------------- case GameMessage::MSG_META_CHAT_EVERYONE: - if (TheGameLogic->isInMultiplayerGame() && !TheGameLogic->isInReplayGame()) + if ((TheGameLogic->isInMultiplayerGame() && !TheGameLogic->isInReplayGame()) || IsLiveObserverSession()) { Player *localPlayer = ThePlayerList->getLocalPlayer(); // TheSuperHackers @tweak skyaero 19/07/2025 Observers can now chat @@ -3563,6 +3584,17 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage { if( TheGlobalData ) { +#if defined(GENERALS_ONLINE) + // Fast forward is disabled once within the broadcast delay of live, so it can only + // ever close a backlog, never catch up to the real game and spoil it. + if (TheLiveObserver && TheLiveObserver->isWithinBroadcastDelay(TheGameLogic->getFrame())) + { + TheInGameUI->messageNoFormat( + TheGameText->FETCH_OR_SUBSTITUTE("GUI:FF_DISABLED_LIVE", L"Fast Forward is disabled in live mode")); + disp = DESTROY_MESSAGE; + break; + } +#endif #if !defined(_ALLOW_DEBUG_CHEATS_IN_RELEASE)//may be defined in GameCommon.h if (TheGameLogic->isInReplayGame()) #endif @@ -3582,6 +3614,17 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage case GameMessage::MSG_META_TOGGLE_PAUSE: case GameMessage::MSG_META_TOGGLE_PAUSE_ALT: { +#if defined(GENERALS_ONLINE) + // P toggles the user's intent only; the observer recomputes the actual pause as + // (userPaused || waitingForData) on its next poll. Calling setGamePaused() here would + // let the buffering gate re-pause on the next tick and discard the user's intent. + if (IsLiveObserverSession() && TheLiveObserver) + { + TheLiveObserver->toggleUserPause(); + disp = DESTROY_MESSAGE; + break; + } +#endif if (!TheGameLogic->isInMultiplayerGame()) { if (TheGameLogic->isGamePaused()) @@ -3933,6 +3976,51 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage disp = DESTROY_MESSAGE; } + else if (key == KEY_F6) + { + if (TheInGameUI) + { + TheInGameUI->toggleLiveObserverStatusVisible(); + if (TheControlBar && TheControlBar->isObserverControlBarOn()) + TheInGameUI->messageNoFormat(TheInGameUI->isLiveObserverStatusVisible() + ? TheGameText->FETCH_OR_SUBSTITUTE("GUI:LiveStatusOn", L"Live status bar ON (F6)") + : TheGameText->FETCH_OR_SUBSTITUTE("GUI:LiveStatusOff", L"Live status bar OFF (F6)")); + } + disp = DESTROY_MESSAGE; + } + // Cycles auto (spoiler-gated) -> forced ON -> off. + else if (key == KEY_F7) + { + if (IsLiveObserverSession() && TheLiveObserver) + { + const LiveObserver::SpectatorChatMode mode = TheLiveObserver->toggleSpectatorChatMode(); + UnicodeString label; + if (mode == LiveObserver::SPECTATOR_CHAT_AUTO) + label = TheGameText->FETCH_OR_SUBSTITUTE("GUI:SpecChatAuto", L"Spectator chat: auto (F7)"); + else if (mode == LiveObserver::SPECTATOR_CHAT_FORCED_ON) + label = TheGameText->FETCH_OR_SUBSTITUTE("GUI:SpecChatOn", L"Spectator chat: forced ON (F7)"); + else + label = TheGameText->FETCH_OR_SUBSTITUTE("GUI:SpecChatOff", L"Spectator chat: OFF (F7)"); + if (TheInGameUI) + TheInGameUI->messageNoFormat(label); + } + disp = DESTROY_MESSAGE; + } + // Rate-matched playback on/off. Following an 11 fps match means watching it at 11 fps + // and staying as far behind as you already are; full speed spends the backlog instead + // and closes the gap, at the price of stalling once there is no backlog left. + else if (key == KEY_F8) + { + if (IsLiveObserverSession() && TheLiveObserver) + { + const Bool matching = TheLiveObserver->togglePaceMatching(); + if (TheInGameUI) + TheInGameUI->messageNoFormat(matching + ? TheGameText->FETCH_OR_SUBSTITUTE("GUI:PaceMatchOn", L"Playback: match the game's speed (F8)") + : TheGameText->FETCH_OR_SUBSTITUTE("GUI:PaceMatchOff", L"Playback: full speed (F8)")); + } + disp = DESTROY_MESSAGE; + } break; } diff --git a/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp b/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp index ed504001712..ef181317dd3 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp @@ -32,6 +32,9 @@ #include "Common/GameUtility.h" #include "Common/INI.h" +#if defined(GENERALS_ONLINE) +#include "Common/LiveObserver.h" +#endif #include "Common/MessageStream.h" #include "Common/Player.h" #include "Common/PlayerList.h" @@ -636,6 +639,19 @@ void MetaEventTranslator::onKeyPressed(GameMessageDisposition &disp, Int systemK if( TheGlobalData && TheGameLogic->isInReplayGame()) #endif { +#if defined(GENERALS_ONLINE) + // Same broadcast-delay rule as CommandXlat's MSG_META_TOGGLE_FAST_FORWARD_REPLAY; + // this path exists because the translator is disabled during cinematics. + if (TheLiveObserver && TheLiveObserver->isWithinBroadcastDelay(TheGameLogic->getFrame())) + { + if (TheInGameUI) + TheInGameUI->messageNoFormat( + TheGameText->FETCH_OR_SUBSTITUTE("GUI:FF_DISABLED_LIVE", L"Fast Forward is disabled in live mode")); + disp = KEEP_MESSAGE; + break; + } +#endif + if ( TheWritableGlobalData ) TheWritableGlobalData->m_TiVOFastMode = 1 - TheGlobalData->m_TiVOFastMode; diff --git a/Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp b/Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp index c9c7cbb7602..d357d712355 100644 --- a/Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp +++ b/Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp @@ -49,6 +49,9 @@ #include "Common/Upgrade.h" #include "Common/StatsCollector.h" #include "Common/Radar.h" +#if defined(GENERALS_ONLINE) +#include "Common/LiveObserver.h" +#endif #include "GameLogic/AIPathfind.h" #include "GameLogic/GameLogic.h" @@ -259,6 +262,13 @@ void GameLogic::clearGameData( Bool showScoreScreen ) setClearingGameData( TRUE ); +#if defined(GENERALS_ONLINE) + // Every game-end path converges here, so this is where a live session is told. Whether it + // actually ends is LiveObserver's call: clearing game data is also how a session starts, + // since playbackFile() clears the shell map first. + liveObserverOnGameCleared(); +#endif + // m_background = TheWindowManager->winCreateLayout("Menus/BlankWindow.wnd"); // DEBUG_ASSERTCRASH(m_background,("We Couldn't Load Menus/BlankWindow.wnd")); // m_background->hide(FALSE); @@ -859,10 +869,12 @@ bool GameLogic::onNewGame(MAYBE_UNUSED GameMessage *msg) // TheSuperHackers @fix stephanmeesters 11/03/2026 // Make sure we're ready to start a new game. This prevents an issue where an infinite disconnect screen // can be force-triggered in an online match by using cheats. - if ( isInGame() || isClearingGameData() || isLoadingMap() ) + // isInGame() is true for the shell too, which dropped the legitimate MSG_NEW_GAME a + // live-observer join sends from the main menu. An active match is still blocked. + if ( isInInteractiveGame() || isClearingGameData() || isLoadingMap() ) { DEBUG_CRASH( ("Called MSG_NEW_GAME while game is not ready (inGame=%d, clearingData=%d, loadingMap=%d)", - isInGame(), isClearingGameData(), isLoadingMap()) ); + isInInteractiveGame(), isClearingGameData(), isLoadingMap()) ); return false; } diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp index 20454323318..1cd29869812 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp @@ -757,6 +757,40 @@ void ConnectionManager::processDisconnectChat(NetDisconnectChatCommandMsg *msg) TheDisconnectMenu->showChat(unitext); // <-- need to implement this } +#if defined(GENERALS_ONLINE) +// ------------------------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------------------------ +// A chat is public iff it reaches someone who is neither the sender nor one of the sender's +// allies - the allies-only and self-only channels reach nobody else by construction. Counting +// recipients cannot tell those apart: a team holding all but one slot has the same population as +// an "everyone" mask whose sender muted somebody. The ally test mirrors the sender's own, in +// InGameChat.cpp, so an allies mask can never satisfy this. +static Bool isGlobalChatMask(Int playerMask, const Player* sender) +{ + if (sender == nullptr) + return FALSE; + + for (Int i = 0; i < MAX_SLOTS; ++i) + { + if ((playerMask & (1 << i)) == 0) + continue; + + AsciiString playerName; + playerName.format("player%d", i); + const Player* p = ThePlayerList->findPlayerWithNameKey( + TheNameKeyGenerator->nameToKey(playerName)); + if (p == nullptr || p == sender) + continue; + + const Bool allied = (p->getRelationship(sender->getDefaultTeam()) == ALLIES && + sender->getRelationship(p->getDefaultTeam()) == ALLIES); + if (!allied) + return TRUE; + } + return FALSE; +} +#endif // GENERALS_ONLINE + void ConnectionManager::processChat(NetChatCommandMsg *msg) { UnicodeString unitext; @@ -802,6 +836,15 @@ void ConnectionManager::processChat(NetChatCommandMsg *msg) // feedback for received chat messages in-game AudioEventRTS audioEvent("GUICommunicatorIncoming"); TheAudio->addAudioEvent(&audioEvent); + +#if defined(GENERALS_ONLINE) + // Forward the chat exactly as displayed, but only when it is addressed to everyone. + // The frame is the message's synchronized execution frame, not TheGameLogic->getFrame(): + // the sender processes chat a frame or two ahead of the receivers, and every source's + // payload has to be byte-identical for the relay to dedupe the all-push copies. + if (TheRecorder && isGlobalChatMask(msg->getPlayerMask(), player)) + TheRecorder->onChatMessage(msg->getExecutionFrame(), unitext, player->getPlayerColor()); +#endif } } diff --git a/Core/GameEngine/Source/GameNetwork/GameSpy/LobbyUtils.cpp b/Core/GameEngine/Source/GameNetwork/GameSpy/LobbyUtils.cpp index 857417db862..fb221583155 100644 --- a/Core/GameEngine/Source/GameNetwork/GameSpy/LobbyUtils.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameSpy/LobbyUtils.cpp @@ -786,9 +786,15 @@ static Int insertGame(GameWindow* win, LobbyEntry& lobbyInfo, Bool showMap) { gameColor = GameSpyColor[GSCOLOR_GAME_CRCMISMATCH]; } + // Priority-player matches render gold, exactly like the Watch Live browser (LiveGamesMenu). + // Ranked below the CRC check on purpose: a lobby you cannot join is not worth highlighting. + else if (lobbyInfo.priority) + { + gameColor = GameMakeColor(255, 215, 0, 255); + } #if defined(GENERALS_ONLINE) - // Buddy lobby highlight: - if (theBuddyGames && theBuddyGames->count(lobbyInfo.lobbyID)) + // Buddy lobby highlight (kept off priority rows - gold already marks those): + if (theBuddyGames && !lobbyInfo.priority && theBuddyGames->count(lobbyInfo.lobbyID)) { const bool nonJoinable = (gameColor == GameSpyColor[GSCOLOR_GAME_CRCMISMATCH]); diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index 7cb8b0cba9f..460872e4995 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -601,6 +601,8 @@ set(GAMEENGINE_SRC # Source/Common/INI/INIWeapon.cpp # Source/Common/INI/INIWebpageURL.cpp Source/Common/Language.cpp + Source/Common/LiveObserver.cpp + Source/Common/LiveStreamer.cpp Source/Common/MessageStream.cpp Source/Common/MiniLog.cpp Source/Common/MultiplayerSettings.cpp @@ -752,6 +754,8 @@ set(GAMEENGINE_SRC Source/GameClient/GUI/GUICallbacks/Menus/LanGameOptionsMenu.cpp Source/GameClient/GUI/GUICallbacks/Menus/LanLobbyMenu.cpp Source/GameClient/GUI/GUICallbacks/Menus/LanMapSelectMenu.cpp + Source/GameClient/GUI/GUICallbacks/Menus/LiveGamesMenu.cpp + Source/GameClient/GUI/GUICallbacks/Menus/LobbyObserverMenu.cpp Source/GameClient/GUI/GUICallbacks/Menus/MainMenu.cpp Source/GameClient/GUI/GUICallbacks/Menus/MapSelectMenu.cpp Source/GameClient/GUI/GUICallbacks/Menus/NetworkDirectConnect.cpp @@ -786,6 +790,7 @@ set(GAMEENGINE_SRC Source/GameClient/GUI/GUICallbacks/ReplayControls.cpp # Source/GameClient/GUI/HeaderTemplate.cpp # Source/GameClient/GUI/IMEManager.cpp + Source/GameClient/GUI/LiveObserverSession.cpp # Source/GameClient/GUI/LoadScreen.cpp # Source/GameClient/GUI/ProcessAnimateWindow.cpp Source/GameClient/GUI/Shell/Shell.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index 5b87552ada6..33215f27c50 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -134,6 +134,11 @@ class GlobalData : public SubsystemInterface // URL to POST compressed stats JSON after export. AsciiString m_statsUrl; + // Live streaming to relay server + Bool m_liveStreamEnabled; ///< Enable live streaming of game commands to relay server. + Bool m_liveStreamCanStream; ///< Can this client act as the streamer? + Int m_liveStreamDelaySeconds; ///< Broadcast delay applied to this game's observers. + Bool m_windowed; Int m_xResolution; Int m_yResolution; diff --git a/GeneralsMD/Code/GameEngine/Include/Common/LiveObserver.h b/GeneralsMD/Code/GameEngine/Include/Common/LiveObserver.h new file mode 100644 index 00000000000..97cb8f3c21c --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/Common/LiveObserver.h @@ -0,0 +1,639 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +// FILE: LiveObserver.h /////////////////////////////////////////////////////////////////////////// +// Receives a live match's replay bytes from the relay server and feeds them to the Recorder, +// so a third party can watch a game in progress without being a network peer. +/////////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#if defined(GENERALS_ONLINE) + +#include "Common/AsciiString.h" +#include "Common/GameCommon.h" +#include "Common/UnicodeString.h" +#include +#include +#include +#include +#include +#include + +class File; + +/** + * Receives raw replay bytes (HEADER/PATCH/BODY/END) from the relay over a WebSocket and writes + * them to a local "_live.rep" file, which the Recorder plays back in + * RECORDERMODETYPE_LIVE_OBSERVER while this class's background thread appends to it. + * + * The Recorder must never read past getSafeReadOffset(); everything below it is whole records. + */ +class LiveObserver +{ +public: + LiveObserver(); + ~LiveObserver(); + + /// Start watching a livestream by GO lobby id. Non-blocking; spawns a background thread. The + /// relay URL comes from GO with the single-use watch ticket, so callers never build it. + /// expectedDelaySeconds (-1 = unknown) pre-seeds the countdown until the ticket or ROLE + /// pins the real value. + void connect(const AsciiString& lobbyId, const std::string& password = std::string(), + Int expectedDelaySeconds = -1); + + /// GO answered the ticket request with 401: the stream is password protected and the password + /// was missing or wrong. The join pump re-prompts instead of waiting out the retry window. + Bool isPasswordRejected() const { return m_passwordRejected.load(); } + + /// Returns true once the HEADER has been received and playback can start. + Bool isReady() const { return m_headerReceived.load(); } + + /// Returns true if connected to the relay server. + Bool isConnected() const { return m_connected.load(); } + + /// Returns true if the streamer has ended the session. + Bool isStreamEnded() const { return m_streamEnded.load(); } + + /// Latched by RecorderClass::startLiveObserverPlayback() once playback is actually running. + /// Until then the session is still being set up, and clearing game data must not end it - + /// see liveObserverOnGameCleared(). + void notePlaybackStarted() { m_playbackStarted = TRUE; } + Bool hasPlaybackStarted() const { return m_playbackStarted; } + + /// The live file is safe to start playing: header in place, first body record on disk, and the + /// buffer already covers the broadcast delay (or the stream ended). Waiting for this is what + /// stops a not-yet-arrived first record from looking like the end of the replay. + Bool isPlaybackReady() const; + + /// Whole seconds until isPlaybackReady(); 0 once it is. Shown by the shell while joining. + Int getSecondsUntilPlaybackReady() const; + + /// How long the join may wait for isPlaybackReady(): the broadcast delay plus headroom for + /// the connection, ticket minting and the first record. + UnsignedInt getJoinTimeoutMs() const; + + /// Absolute deadline (timeGetTime ms) for the join to have started playback. While GO holds + /// the ticket this is anchored to the hold end, never to when the join started, so time spent + /// held cannot eat the budget. Otherwise join start plus getJoinTimeoutMs(). + UnsignedInt getJoinDeadlineMs() const; + + /// Returns the filename of the live replay file (e.g. "996C586F_live.rep"). + const AsciiString& getLiveReplayFilename() const { return m_liveFilename; } + + /// Highest frame held in a fully-received record. Published by the network thread as data + /// arrives, so reading it is O(1) and always fresh. + UnsignedInt getMaxCompleteFrame() const { return m_maxCompleteFrame.load(); } + + /// Where the live game actually is - the value the buffering gate reasons about, and the + /// highest frame that is safe to simulate up to. + /// + /// The record edge alone only advances on frames that carried input, so it sawtooths in ~1.7 s + /// jumps and would stall the observer on a stream that is not late. The streamer's heartbeat + /// (MSG_TICK) states the frame outright and is only emitted after that frame's records are + /// flushed, so take whichever of the two is further ahead. + UnsignedInt getLiveEdge() const + { + const UnsignedInt records = m_maxCompleteFrame.load(); + const UnsignedInt heartbeat = m_liveFrameHint.load(); + return heartbeat > records ? heartbeat : records; + } + + /// Absolute file offset one past the last complete record. The Recorder must never read + /// beyond this: a torn record at the growing tail misaligns the playback stream permanently. + Int getSafeReadOffset() const { return m_safeReadOffset.load(); } + + /// File offset of the first body byte (the header length). The Recorder rewinds its read + /// cursor here when starting playback, because playbackFile()'s seeding read leaves it + /// past the first record's frame field - and the live loop reads the frame itself. + Int getBodyStartOffset() const { return m_bodyStartOffset; } + + /// Close the connection and shut down the background thread. + void close(); + + // ---- Session policy: the broadcast delay and the buffering gate -------------------- + // + // This state belongs to the session rather than the Recorder, so that ending a session is + // destroying the object and nothing can leak into the next one. + + /// Re-evaluate the gate for this tick and apply the resulting pause. + void updatePlaybackGate(UnsignedInt curFrame); + + /// Logic frames per second the source produced, measured over the last second as the slope of + /// the live edge. 0 until the sample window has filled. This is the rate the streamer's own + /// simulation actually ran at: a network match runs at its slowest peer's rate, so it sits + /// below nominal whenever the match is under load. + UnsignedInt getSourceFps() const { return m_sourceFps; } + + /// Logic frames per second this client is currently playing at. Equals LOGICFRAMES_PER_SECOND + /// unless the pace controller has slowed playback to match the source. + UnsignedInt getPaceFps() const { return m_paceFps; } + + /// The streamer's own logic frame rate and ping, as it reported them (MSG_STATS). 0 until the + /// first stats frame arrives - an older streamer never sends one, so a readout must treat 0 as + /// "unknown" rather than as a measurement. These are what the HUD shows: getSourceFps() above + /// is the rate measured here after transport, which is the pace controller's input and not the + /// number a player in the match would recognise. + UnsignedInt getStreamerLogicFps() const { return m_srcLogicFps.load(); } + UnsignedInt getStreamerPingMs() const { return m_srcPingMs.load(); } + + /// Whether playback follows the match's own logic rate (default) or runs at nominal speed. + /// + /// Off is for a viewer sitting on a backlog: matching an 11 fps match means watching it at + /// 11 fps *and staying two minutes behind*, where running at nominal spends the backlog and + /// closes the gap. On is the default because once the backlog is gone there is nothing left + /// to spend, and nominal playback then just outruns the source and stalls. + Bool isPaceMatchingEnabled() const { return m_paceMatchingEnabled; } + + /// Flip it. Returns the new state, for the message the hotkey shows. + Bool togglePaceMatching() { m_paceMatchingEnabled = !m_paceMatchingEnabled; return m_paceMatchingEnabled; } + + /// Put the frame pacer back the way this session found it. Must run before the session ends - + /// the pacer is global engine state, and leaking a slowed logic scale into the next game would + /// be indistinguishable from an engine bug. + void restorePlaybackPace(); + + /// The player's own pause intent, kept apart from the buffering gate's. updatePlaybackGate() + /// ORs the two, so buffering can never undo a manual pause nor the reverse. + void toggleUserPause() { m_userPaused = !m_userPaused; } + + /// Whether playback must wait rather than consume more records. Valid once + /// updatePlaybackGate() has run this tick; the Recorder applies it, it does not decide it. + Bool shouldHoldPlayback() const { return m_holdPlayback; } + + /// True only when playback is held *and* the source has genuinely stopped producing - + /// not during the normal sawtooth of maintaining the delay at the boundary. + Bool isStalled() const { return m_stalled; } + + /// Playback sits inside the broadcast delay, as close to the live game as it is allowed to + /// get. Fast-forward is refused here, so it can only close a backlog and never catch up to + /// the real game. + Bool isWithinBroadcastDelay(UnsignedInt curFrame) const; + + /// The broadcast delay this session started with. Held in seconds because that is what a + /// streamer configures, and it survives a change of logic tick rate. + UnsignedInt getDelaySeconds() const { return m_delaySeconds.load(); } + UnsignedInt getDelayFrames() const { return m_delaySeconds.load() * LOGICFRAMES_PER_SECOND; } + + /// How far behind the live edge this session aims to sit, in frames: the larger of the + /// broadcast delay and the viewer's jitter buffer. Paid once at join as a fixed offset, + /// never as a rate change - playback runs at exactly 100% so the observer's clock agrees + /// with the real match clock. + UnsignedInt getTargetLeadFrames() const; + + /// 0 when GO holds the stream server-side, since the ticket was only minted once the delay had + /// elapsed and the stream is therefore already delayed; otherwise the session delay. The gate + /// functions use this so a server-held stream plays at the live edge instead of double-holding. + UnsignedInt getEffectiveDelaySeconds() const + { + return m_serverHeld.load() ? 0 : m_delaySeconds.load(); + } + + /// GO is holding this viewer's watch ticket behind the broadcast delay (423). The join pump + /// and countdown show the hold rather than a failure. + Bool isWaitingForBroadcastDelay() const { return m_delayWaitActive.load(); } + + /// Whole seconds left in the admission hold (rounded up), 0 when not waiting. + Int getBroadcastDelayRemainingSeconds() const; + + /// TRUE once the watch ticket was minted with server_held=true: GO owns the broadcast + /// delay, so this client must not hold playback itself. + Bool isServerHeld() const { return m_serverHeld.load(); } + + /// The delay known before the ticket or ROLE arrives: connect()'s expected value when given, + /// otherwise the current session delay. Used by the countdown until the hold publishes its + /// exact remaining time. + UnsignedInt getExpectedDelaySeconds() const + { + return (m_expectedDelaySeconds >= 0) ? (UnsignedInt)m_expectedDelaySeconds : m_delaySeconds.load(); + } + + /// Record the frame at which this client's simulation was first seen to diverge from the + /// streamed one. Playback deliberately continues afterwards - the observer just needs to be + /// told that what it is watching is no longer the real game. Only the first divergence is + /// recorded; a desynced simulation diverges further every frame after it. + void noteDesync(UnsignedInt frame); + Bool isDesynced() const { return m_desyncFrame != 0; } + UnsignedInt getDesyncFrame() const { return m_desyncFrame; } + + // ---- Chat -------------------------------------------------------------------------- + + /// Manual spectator-chat mode (F7 cycles through these). + enum SpectatorChatMode + { + SPECTATOR_CHAT_AUTO = 0, ///< spoiler-gated: shown within 5s of the delay boundary + SPECTATOR_CHAT_FORCED_ON, ///< always shown (spoilers accepted) + SPECTATOR_CHAT_OFF ///< never shown + }; + + /// Pop and display queued chat: player chat released once playback reaches the streamer's + /// frame, spectator chat live per SpectatorChatMode. Called once per logic frame, including + /// during the pre-game phase, where player chat is held and spectator chat is dropped. + void pollChatMessages(UnsignedInt curFrame); + + /// Cycle the spectator-chat mode (auto -> forced ON -> off -> auto). Returns the new mode. + SpectatorChatMode toggleSpectatorChatMode() + { + m_spectatorChatMode = (SpectatorChatMode)((m_spectatorChatMode + 1) % 3); + return m_spectatorChatMode; + } + + /// Queue a spectator chat line for the network thread to send to the relay + /// (MSG_SPECTATOR_CHAT). The sender's display name is stamped from the signed-in user. + void sendSpectatorChat(const UnicodeString& text); + +private: + // Player chat is frame-stamped and released when playback reaches that frame. Spectator chat + // is live and spoiler-gated: shown on arrival within 5s of the delay boundary, else dropped. + struct ChatEntry + { + UnsignedInt frame; ///< player chat: streamer-side frame; spectator chat: unused + UnsignedInt colorArgb; ///< display color (sender color for player chat) + Bool spectator; ///< live spectator chat (no frame gate, spoiler gate instead) + Bool disaster = FALSE; ///< stream-failure notice: shown unconditionally, never gated + UnicodeString text; ///< already-formatted line ("[name] msg" / "[Spec] name: msg") + }; + std::deque m_chatQueue; // written by the network thread (handleFrame) + mutable std::mutex m_chatMutex; // game thread (pollChatMessages) drains + std::deque> m_outboundChatQueue; // spectator chat to send + mutable std::mutex m_outboundChatMutex; // network thread drains + SpectatorChatMode m_spectatorChatMode; // F7 cycle: auto / forced on / off + + /// Display one chat entry in the HUD message log (shared player/spectator path). + void displayChat(const ChatEntry& entry); + + /// The 5-second spoiler gate for spectator chat: TRUE while the observer is within + /// ~5s of the broadcast-delay boundary (effectively watching live). + Bool isSpectatorGateOpen(UnsignedInt curFrame) const; + + /// Background thread for network I/O. + void networkThreadFunc(); + + /// Consume complete records from newly-arrived body bytes and republish the + /// watermarks. Called on the network thread only. + void advanceParseCursor(Int chunkOffset, const unsigned char* data, size_t dataLen); + + /// Reset the parse cursor and watermarks (new session / disconnect). + void resetParseCursor(Int bodyStartOffset); + + /// Re-evaluate the playback pace for this tick and drive TheFramePacer. Called by + /// updatePlaybackGate, which owns every other pacing decision as well. + void updatePlaybackPace(UnsignedInt nowMs, UnsignedInt gap, UnsignedInt targetLead, Bool streamEnded); + + /// Push a pace onto TheFramePacer, or leave it alone if nothing changed. + void applyPaceFps(Int paceFps); + + /// Connect via WebSocket (called from network thread). + bool connectToRelay(); + + /// Ask GO for a single-use watch ticket for m_gameId, using the logged-in session token. + /// On success outConnectUrl is the complete relay URL to connect to, ticket included. + /// There is no fallback: without a ticket the relay refuses the connection. + bool fetchWatchTicket(AsciiString& outConnectUrl); + + /// Send data over WebSocket binary (called from network thread). + bool wsSendBinary(const unsigned char* data, size_t len); + + /// Outcome of one wsRecv() attempt. Three states rather than a bool because the drain loop + /// must tell "curl has nothing more buffered" (stop draining, go back to polling) apart from + /// "that message was not ours" - a keepalive ping read as "nothing more" would end the drain + /// early and put the receive rate back on the poll timeout. + enum WsRecvResult + { + WS_RECV_DATA, ///< outBuffer holds a binary payload + WS_RECV_SKIPPED, ///< a frame arrived but is not stream data (ping/pong/text/close) + WS_RECV_NONE ///< nothing buffered right now, or the connection is gone + }; + + /// Receive one WebSocket message (non-blocking). One call yields at most one message. + WsRecvResult wsRecv(std::vector& outBuffer); + + /// Open the local replay file for writing. + bool openLiveFile(); + + /// Process an incoming binary frame from the relay. + void handleFrame(unsigned char type, const char* payload, size_t len); + + std::atomic m_connected; + std::atomic m_shouldRun; + std::atomic m_headerReceived; + std::atomic m_streamEnded; + + // How long the live edge must sit still before a hold counts as a stall rather than normal + // delay maintenance. At the boundary the hold toggles every few ticks, so without this the + // status bar reads WAITING FOR FRAMES during healthy playback. + enum { LIVE_STALL_THRESHOLD_MS = 1000 }; + + // Let the game get on its feet before holding it. GameClient::step() only runs on ticks where + // logic runs, so holding at frame 1 leaves a loaded but never-composed scene - a black screen. + enum { LIVE_PREROLL_WARMUP_FRAMES = 120 }; + + // Lead the gate rebuilds past the target before resuming from a hold. Must stay at one + // heartbeat interval (Recorder::LIVE_TICK_INTERVAL_FRAMES) or the gate re-engages every tick + // and micro-stutters. + enum { LIVE_GATE_RELEASE_MARGIN_FRAMES = 10 }; + + // Silence this long - no stream bytes and none of the relay's ~20 s keepalive pings - means + // the relay or the connection to it is gone, and the watch winds down like a stream END + // instead of freezing on the last frame forever. + enum { LIVE_RELAY_WATCHDOG_MS = 120000 }; + + // ---- Rate-matched playback ----------------------------------------------------------- + // + // A network match runs at the rate its slowest peer sustains, so the streamer's simulation + // dips below nominal under load. Playing those frames back at a fixed nominal rate consumes + // the lead faster than it is produced - a rate mismatch, which no buffer size can absorb, + // because a bigger buffer only postpones the moment it runs out. So playback follows the + // source's rate instead, and a pause is left as the floor case for when there is genuinely + // nothing to play. + // + // Slowing down can never bring the observer closer to the live game, so it cannot weaken the + // broadcast delay. The pace is therefore clamped at nominal and never above: catching up + // stays the fast-forward gate's business, which already refuses inside the delay boundary. + + // Window the source rate is measured over. One second is long enough to average out the + // arrival quantisation of individual records, short enough to follow a real dip. + enum { LIVE_PACE_WINDOW_MS = 1000 }; + + // Samples kept for that window. The gate runs once per rendered frame, so this is sized for a + // high refresh rate; the window is enforced by timestamp, not by count. + enum { LIVE_PACE_MAX_SAMPLES = 256 }; + + // Floor for the pace. Corrected 2026-08-15: this was LOGICFRAMES_PER_SECOND / 2, on the + // reasoning that below half nominal something is wrong that pacing should not paper over. + // That reasoning was wrong. A match on a loaded host really does run at 11 logic frames per + // second, and at a floor of 30 the observer outruns it threefold, drains its lead and pauses - + // the exact symptom rate-matching exists to remove. The floor's only job is to keep a *stalled* + // source (srcFps collapsing towards 0) from crawling the picture instead of stopping it + // honestly, which the buffering gate and the stall indicator handle. + enum { LIVE_PACE_MIN_FPS = 5 }; + + // How quickly surplus or missing lead is repaid, in seconds. The pace carries a correction of + // (gap - targetLead) / this, so a 10-frame surplus at 4 s adds 2.5 fps rather than lurching. + enum { LIVE_PACE_CORRECTION_SECONDS = 4 }; + + // Floor for that correction's magnitude, so a very slow source still gets *some* authority to + // drift its lead back to target rather than being pinned exactly at the source rate forever. + // The cap itself is relative (half the source rate) - see updatePlaybackPace. + enum { LIVE_PACE_MIN_CORRECTION_FPS = 2 }; + + // Do not touch the pacer for less than this, nor more often than this. A controller that + // chases every sample hunts, and hunting is more visible than the lag it corrects. + enum { LIVE_PACE_MIN_STEP_FPS = 2 }; + enum { LIVE_PACE_MIN_INTERVAL_MS = 500 }; + + // Buffering-gate state. Game thread only - updatePlaybackGate() is the sole writer. + Bool m_holdPlayback; // playback must wait; the Recorder acts on this + Bool m_nearLiveHeld; // latched: the near-live gate is holding (hysteresis) + Bool m_preRollComplete; // latches TRUE once the initial buffer is first built + Bool m_autoPaused; // the buffering logic owns the current pause + Bool m_userPaused; // the player pressed P and wants it paused + Bool m_stalled; // held, and no new data has arrived for a while + Bool m_playbackStarted; // the Recorder is actually playing this session's file + UnsignedInt m_lastSeenLiveEdge; + UnsignedInt m_lastLiveEdgeChangeMs; + UnsignedInt m_desyncFrame; // frame of the first observed CRC divergence, 0 = none + + // Once-per-second gate trace. The previous sample is kept so the log can state playback and + // source *rates* rather than raw counters: those two numbers side by side are what tells a + // transport hiccup (source rate steady, playback starving) apart from the source itself + // running below its nominal logic rate (both drop together), which no other log shows. + UnsignedInt m_lastGateLogMs; + UnsignedInt m_lastGateLogFrame; + UnsignedInt m_lastGateLogEdge; + UnsignedInt m_underrunCount; // near-live gate engagements: the buffer ran dry this often + + // Pace-controller state. Game thread only - updatePlaybackGate is the sole writer, same as + // the buffering gate above. + struct PaceSample + { + UnsignedInt ms; + UnsignedInt edge; + }; + PaceSample m_paceSamples[LIVE_PACE_MAX_SAMPLES]; + Int m_paceSampleCount; // entries in use, oldest first + UnsignedInt m_sourceFps; // measured source rate, 0 until the window has filled + UnsignedInt m_paceFps; // what we are currently playing at + UnsignedInt m_lastPaceApplyMs; + Bool m_paceMatchingEnabled; // F8; see isPaceMatchingEnabled + + // The pacer is global engine state, so this session records what it found on first touch and + // puts it back when it ends. Latched rather than assumed, because another feature (the replay + // game-speed hotkey) drives the same knob. + Bool m_pacerTouched; + Int m_savedLogicScaleFps; + Bool m_savedLogicScaleEnabled; + + // Written by the network thread when the relay's ROLE frame arrives, read by the game thread + // every tick. Atomic because those are genuinely two threads. + std::atomic m_delaySeconds; + + // GO admission-hold state. Written by the network thread in fetchWatchTicket, read by the game + // thread. The deadline is a steady-clock ms timestamp; m_serverHeld latches on the 200 + // response and switches the effective delay to 0 for the rest of the session. + std::atomic m_serverHeld; + std::atomic m_delayWaitActive; + std::atomic m_delayWaitDeadlineMs; + + // The lobby's delay as known before any ticket or ROLE response, -1 = unknown. Written on the + // main thread before the network thread spawns, so thread creation orders it. + Int m_expectedDelaySeconds; + + // Watermarks published by the network thread, read by the game thread. + std::atomic m_maxCompleteFrame; + std::atomic m_safeReadOffset; + + // When connect() was called (timeGetTime ms) - the baseline for the non-held join deadline. + // Re-based forward when the watch ticket is granted, so the post-admission budget is measured + // from admission and not from the start of a possibly minutes-long GO hold. Written by the + // main thread (connect) and the network thread (ticket grant), read by the game thread. + std::atomic m_joinStartedAtMs; + + // Timestamp of the last websocket frame of any kind, including the relay's ~20 s keepalive + // pings. Feeds the relay watchdog (LIVE_RELAY_WATCHDOG_MS). + std::atomic m_lastFrameReceivedMs{ 0 }; + + // Parse-cursor state. Owned exclusively by the network thread - no locking. + std::vector m_parseTail; // bytes after the last complete record + Int m_parseAbsOffset; // absolute file offset of m_parseTail[0] + Int m_bodyStartOffset; // file offset of the first body byte (the header length) + Bool m_parseCorrupt; // latched: watermark frozen, see advanceParseCursor + Bool m_parseGapPending; // a chunk arrived out of order; bytes are missing behind the cursor + + // Newest frame stated by the streamer's heartbeat (MSG_TICK). Zero until the first tick, + // which is why getLiveEdge() maxes rather than prefers: an older streamer never sends one. + std::atomic m_liveFrameHint; + + // The streamer's last reported logic rate and ping (MSG_STATS). Written by the network thread, + // read by the game thread for the HUD. Display only - nothing simulates off them. + std::atomic m_srcLogicFps{ 0 }; + std::atomic m_srcPingMs{ 0 }; + + AsciiString m_gameId; + + // Password for a password-protected livestream, sent with the watch-ticket request. Written + // on the main thread before the network thread spawns, read only by the network thread - + // happens-before via thread creation, no lock needed. + std::string m_password; + std::atomic m_passwordRejected{ FALSE }; + + File* m_liveFile; + AsciiString m_liveFilePath; + AsciiString m_liveFilename; // e.g. "996C586F_live.rep" + + void* m_curlEasy; + void* m_curlMulti; + + std::thread m_networkThread; +}; + +extern LiveObserver* TheLiveObserver; +LiveObserver* createLiveObserver(); + +/// End the live-observer session: destroy the observer, then let the Recorder wind down its live +/// playback. Deliberately not stopPlayback(), which also exits the game. +/// +/// Must run before starting another session: the live file is named after the streamer's game, so +/// rejoining a game already watched targets the same path, and Windows will not let the observer +/// recreate a file the Recorder still holds open. +void liveObserverEndSession(void); + +/// One-shot "a live-observer game just ended" latch. Set in liveObserverEndSession() while a +/// game was actually running; consumed by the shell screens' game-end hooks +/// (WOLGameSetupMenuInit / MainMenuInit), which then return the player to the Watch Live +/// browser instead of the main menu. An aborted join (no game ran) must not set it. +Bool LiveObserverConsumeReturnedFromGame(void); + +/// Called from GameLogic::clearGameData(), the one point every game-end path converges on. +/// +/// Ends the live session, but only once it has actually started playing: clearing game data is +/// also the *first* thing a starting session does, because RecorderClass::playbackFile() tears +/// down the shell map and the shell map counts as a game. +void liveObserverOnGameCleared(void); + +// --------------------------------------------------------------------------------------- +// Standalone relay HTTP fetch, for the live game browser. +// +// Deliberately not routed through HTTPManager, which lives behind NGMP_OnlineServicesManager and +// is not initialised on the main menu unless the player has signed in, so every request silently +// no-ops there. The request runs on its own thread and the result is collected by polling from +// the main loop rather than by callback, so nothing here touches gadget state. + +/// Start an async GET. Returns FALSE if a fetch is already in flight. +Bool liveRelayBeginFetch(const AsciiString& url); + +/// Collect a finished fetch. Returns TRUE exactly once per completed request. +Bool liveRelayPollFetch(AsciiString& outBody, Bool& outSuccess, Int& outStatusCode); + +/// TRUE while a request is outstanding. +Bool liveRelayFetchInFlight(); + +// --------------------------------------------------------------------------- +// GO services calls +// +// Livestreams are orchestrated by GO, not by the relay: GO owns the list of what is being +// streamed and mints the single-use credentials for both watching and streaming. The relay only +// honours a credential GO issued, so every one of these calls needs the player's session token - +// which is why the live game browser requires a sign-in. + +/// One live game, as GO describes it, already parsed and display-ready. GO's JSON shape stays +/// out of the menu, so a contract change is a change here rather than in a GUI callback. +struct LiveGameEntry +{ + AsciiString lobbyId; ///< GO's LobbyID as decimal text; also the relay's session key. + AsciiString name; ///< The lobby's display name (popup titles, diagnostics). + AsciiString mapName; ///< Display name, never a path. + AsciiString players; ///< Human players, comma separated. + Int observerCount; + Int delaySeconds; + Int ageSeconds; + Int state; ///< 1 = live stream, 0 = pre-game lobby (read-only observe). + Bool passworded; + Int pendingObserverCount; ///< Pre-game observers waiting on this lobby (0 when live). + + /// What GO says to do with this row, computed per viewer: + /// 0 = observe (pre-game - enter the read-only lobby view), + /// 1 = wait (stream not live yet, or this viewer is held behind the broadcast delay - + /// enter the read-only lobby view and wait there), + /// 2 = join (stream live and this viewer may mint now - connect directly, skip the lobby). + Int watchAction; + + /// Remaining broadcast-delay hold in seconds for this viewer (0 when not held). + Int delayRemainingSeconds; + + /// TRUE when the lobby is a priority-player match (latched by GO at create/join). The + /// browser marks these rows gold. + Bool priority; + + LiveGameEntry() : observerCount(0), delaySeconds(0), ageSeconds(0), + state(1), passworded(FALSE), pendingObserverCount(0), + watchAction(2), delayRemainingSeconds(0), priority(FALSE) {} +}; + +/// Parse a GO /Livestreams reply into entries. FALSE when the body is not usable at all; +/// an empty list with TRUE simply means nobody is streaming. +Bool liveServicesParseLivestreams(const AsciiString& body, std::vector& outGames); + +/// Full URL for a GO services endpoint, e.g. liveServicesEndpoint("Livestreams"). +AsciiString liveServicesEndpoint(const char* szEndpoint); + +/// Blocking authenticated request against GO services. For callers already on a worker thread +/// (the observer's and streamer's own network threads) - never call this from the main loop. +/// Returns FALSE when not signed in or when the request could not be made at all; outStatusCode +/// carries GO's reply otherwise, which the caller must still check. +Bool liveServicesRequest(const AsciiString& url, Bool bPost, const char* szPostBody, + AsciiString& outBody, Int& outStatusCode); + +// Queueing and pumping a live-observer session lives in GameClient/LiveObserverSession.h - +// it sequences shell screens (TheShell, transitions, the password popup), which is not this +// layer's business. + +// --------------------------------------------------------------------------------------- +// Live observer/streamer file logging. +// +// Controlled by the RTS_DEBUG_LIVE_OBSERVER cmake option (DEFAULT/ON/OFF), resolving the same way +// as DEBUG_LOGGING in Debug.h. Kept separate from RTS_DEBUG_LOGGING because this log flushes every +// line, so it survives a crash. +// +// Enable in a release build with: cmake --preset win32 -DRTS_DEBUG_LIVE_OBSERVER=ON +#if defined(ALLOW_DEBUG_UTILS) && !defined(LIVE_OBSERVER_LOGGING) && !defined(DISABLE_LIVE_OBSERVER_LOGGING) + #define LIVE_OBSERVER_LOGGING 1 +#endif + +// Identifies the build that produced a log, so a stale binary is not debugged by mistake. Bump on +// every change to the instrumentation. Shared by LiveObserver.cpp and LiveStreamer.cpp, which must +// therefore include this header - that is also what resolves LIVE_OBSERVER_LOGGING above, without +// which logging silently stays off in a DEFAULT build. +#define LIVE_OBSERVER_BUILD_TAG "2026-08-15-observer-pace-toggle" + +void liveObserverLog(const char* fmt, ...); +void liveObserverInitLog(const char* lobbyId); + +// Gates instrumentation outside these two files so it only fires for an actual live-observer +// session, and not on every game start (including the streamer's own local game). Expands to +// nothing when logging is off, so the arguments are not evaluated either - unlike a plain call to +// the (then empty) liveObserverLog. Callers must have included Common/Recorder.h for +// TheRecorder/RECORDERMODETYPE_LIVE_OBSERVER. +#if defined(LIVE_OBSERVER_LOGGING) + #define LIVE_OBSERVER_LOG(...) \ + do { if (TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER) (liveObserverLog)(__VA_ARGS__); } while (0) +#else + #define LIVE_OBSERVER_LOG(...) do { } while (0) +#endif + +#endif // GENERALS_ONLINE diff --git a/GeneralsMD/Code/GameEngine/Include/Common/LiveStreamer.h b/GeneralsMD/Code/GameEngine/Include/Common/LiveStreamer.h new file mode 100644 index 00000000000..ae3d2dbad76 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/Common/LiveStreamer.h @@ -0,0 +1,292 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +// FILE: LiveStreamer.h /////////////////////////////////////////////////////////////////////////// +// Uploads a live match's replay bytes to the relay server, for live observers to play back. +/////////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "Common/AsciiString.h" +#include "Common/GameCommon.h" +#include "Common/ReplayStreamSink.h" +#include "Common/UnicodeString.h" +#include +#include +#include +#include +#include +#include +#include + +/** + * Binary message types sent over WebSocket between streamer/observer and relay. + */ +enum LiveMsgType : unsigned char { + LIVE_MSG_REGISTER = 0, + LIVE_MSG_HEADER = 1, + LIVE_MSG_PATCH = 2, + LIVE_MSG_BODY = 3, + LIVE_MSG_END = 4, + LIVE_MSG_ROLE = 5, + LIVE_MSG_ERROR = 6, + LIVE_MSG_CHAT = 7, // player chat: [frame u32][textLen u32][UTF-8 text][color u32] + LIVE_MSG_SPECTATOR_CHAT = 8, // spectator chat: [nameLen u32][UTF-8 name][textLen u32][UTF-8 text] + LIVE_MSG_TICK = 9, // frame heartbeat: [frame u32] + LIVE_MSG_STATS = 10, // match telemetry: [logicFps u32][pingMs u32] - see publishStats() +}; + +class LiveStreamer; + +/** + * Everything the relay needs to open a session. The lobby fills it in and leaves it pending; the + * Recorder sends it at match start, which keeps Recorder.cpp free of any GeneralsOnline includes. + * Every player registers, since any of them can supply replay bytes, but only the host describes + * the game - otherwise the description would be whichever REGISTER happened to arrive first. + */ +struct LiveStreamRegistration +{ + /// GO's LobbyID as plain decimal, spelled exactly as GO's /Lobbies JSON prints it. Doubles as + /// the relay's session key and the id an observer watches by (/watch/). + AsciiString lobbyId; + /// Local player, for the relay's logs only. Never used to identify the session. + AsciiString playerName; + /// TRUE when the local player owns this lobby. Gates the two host-only fields below. + Bool isHost; + /// Whether this client is willing to upload replay bytes. This machine's bandwidth, so unlike + /// the fields below it is not the host's to decide. + Bool canStream; + + /// HOST ONLY. Complete JSON object literal describing the lobby, in GO's own key spelling, + /// already escaped by its builder. Empty on non-hosts, which send the lobby id alone. + std::string lobbyJson; + /// HOST ONLY. Broadcast delay every observer is held behind. Negative means "not mine to say". + Int delaySeconds; + + LiveStreamRegistration() : isHost(FALSE), canStream(FALSE), delaySeconds(-1) {} + + Bool isValid() const { return !lobbyId.isEmpty(); } +}; + +/// Hand a completed registration to the Recorder. Safe to call repeatedly - the last one wins, +/// so a lobby that changes between the player arriving and the match starting is not a problem. +void liveStreamSetPendingRegistration(const LiveStreamRegistration& registration); + +/// Drop anything pending. Called when a lobby is left without starting a match, so a later, +/// unrelated recording cannot pick up a stale lobby's registration. +void liveStreamClearPendingRegistration(); + +/// Open the pending session: create TheLiveStreamer, connect it to the relay and send REGISTER. +/// Returns the streamer for the caller to hook in as a replay sink, or nullptr when nothing is +/// pending or live streaming is switched off - in which case the game simply records as usual. +LiveStreamer* liveStreamStartPendingSession(); + +/** + * Forwards raw replay bytes to the relay over a WebSocket, in a simple binary envelope. Has no + * knowledge of the replay file format - it receives header/body/patch bytes from the Recorder. + */ +class LiveStreamer : public IReplayStreamSink +{ +public: + LiveStreamer(); + virtual ~LiveStreamer(); + + /// IReplayStreamSink - called by Recorder during recording + virtual void onHeaderBytes(const void* data, Int size) override; + virtual void onHeaderComplete() override; + virtual void onHeaderPatch(Int offset, const void* data, Int size) override; + virtual void onBodyBytes(const void* data, Int size) override; + virtual void onBodyFlush() override; + virtual void onRecordingEnded() override; + + /// Start the network thread, which registers the stream with GO and connects to whatever + /// relay URL GO returns. Non-blocking. + void init(); + + /// Shut down the background thread and close the connection. + void close(); + + /// Register a session with the relay server. See LiveStreamRegistration. + void registerForGame(const LiveStreamRegistration& registration); + + /// The relay has confirmed the session with a role of "streamer", "backup" or "none". A backup + /// stops uploading but keeps recording locally, so it can take over later. + void onRoleAssigned(const AsciiString& role, const AsciiString& lobbyId, uint64_t bodyOffset); + + /// Promoted from backup to active streamer. Backfills the relay's missing bytes from the local + /// recording starting at bodyOffset, then resumes live - seamless because a demoted backup + /// never stopped recording. + void onTakeover(uint64_t bodyOffset); + + /// m_isBackup gates data flow, not just the UI: while backup the sink drops HEADER/PATCH/BODY + /// (END is still sent) so a demoted streamer stops using its uplink. + Bool isStreaming() const { return m_isStreaming.load(); } + Bool isBackup() const { return m_isBackup.load(); } + AsciiString getLobbyId() const { return m_lobbyId; } + + /// Forward a displayed global chat line to the relay (MSG_CHAT). + virtual void onChat(UnsignedInt frame, const UnicodeString& text, UnsignedInt colorArgb) override; + + /// Publish our current logic frame (MSG_TICK), so an observer can follow the live edge through + /// quiet play, when the body carries only one CRC record per REPLAY_CRC_INTERVAL frames. + /// Sent immediately after onBodyFlush() for the same frame and frames leave in queue order, so + /// a tick for N proves every record up to N has been sent - the observer may simulate to N. + virtual void onTick(UnsignedInt frame) override; + + /// Drain spectator chat received from the relay into the HUD message log. Called once per + /// logic frame while recording a live-streamed game. + void pumpSpectatorChat(); + + /// Publish this client's logic frame rate and ping (MSG_STATS), so an observer can show the + /// same numbers a player in the match sees. Sampled from onTick, which already runs on a fixed + /// frame cadence, and sent only when a value actually moves - a ping walking 64 -> 92 is two + /// messages, not sixty. See LIVE_STATS_* for the quantisation that makes that true. + void publishStats(UnsignedInt frame); + + struct QueuedFrame + { + unsigned char type; + std::vector data; + }; + +private: + void networkThreadFunc(); + + /// Ask GO to register this livestream and mint our single-use stream token, returning the + /// relay URL to connect to. Blocking, so network thread only. + bool requestStreamUrl(AsciiString& outUrl); + + bool connectToRelay(); + + /// Tri-state send outcome: Sent = frame handed to the socket, WouldBlock = socket buffer + /// full (CURLE_AGAIN - nothing was sent, retry the same frame later), Error = connection + /// is gone. WouldBlock is a pause, never a failure: the relay is merely reading slowly. + enum class WsSendResult { Sent, WouldBlock, Error }; + WsSendResult wsSendBinary(const unsigned char* data, size_t len); + bool wsRecv(std::vector& outBuffer); + WsSendResult sendBinaryFrame(LiveMsgType type, const void* payload, size_t payloadLen); + WsSendResult sendBinaryFrame(const QueuedFrame& frame); + void queueFrame(LiveMsgType type, const void* data, size_t len); + + // ---- MSG_STATS: send-on-change telemetry --------------------------------------------- + // + // The readout is a counter on the observer's HUD, so it needs each value when it changes and + // nothing in between. Two rules make "on change" mean something: + // + // - Quantise before comparing. A raw ping wobbling by a millisecond is a change on every + // sample, and the deduplication would buy nothing at all. + // - Bound the rate from both sides. The floor stops a genuinely noisy value flooding the + // relay; the heartbeat ceiling means a joiner is not left with a blank readout on a value + // that happens to be stable, and a stuck reading is visibly stuck rather than silently + // stale. + enum { LIVE_STATS_PING_QUANTUM_MS = 5 }; + enum { LIVE_STATS_MIN_INTERVAL_MS = 500 }; + enum { LIVE_STATS_HEARTBEAT_MS = 5000 }; + enum { LIVE_STATS_PING_MAX_MS = 2000 }; + + Int m_lastSentLogicFps; // -1 = nothing sent yet + Int m_lastSentPingMs; + UnsignedInt m_lastStatsSentMs; + + // Previous sample for the achieved logic rate. The reported rate must be frames actually + // advanced per wall-clock second, not TheNetwork->getFrameRate() - that is the rate the mesh + // negotiated, and a loaded host sits far below it (60 negotiated while stepping 11, observed + // 2026-08-15 with eight instances on one machine). Reporting the negotiated rate would tell a + // viewer the match is healthy while they watch it crawl, and would disagree with the rate the + // observer derives for itself from the live edge. + UnsignedInt m_statsLastFrame; + UnsignedInt m_statsLastFrameMs; + + // UI-informational flags; m_isBackup additionally gates data flow (see onRoleAssigned) + std::atomic m_isStreaming; + std::atomic m_isBackup; + std::atomic m_connected; + std::atomic m_shouldRun; + + // Timestamp of the last websocket frame of any kind, including the relay's ~20 s keepalive + // pings. Zero means nothing received yet, so the watchdog cannot fire before the first ROLE. + std::atomic m_lastFrameReceivedMs{ 0 }; + + // Silence this long while connected means the relay, or the path to it, is gone; without a + // timeout the streamer uploads into a dead socket forever. 120 s = ~6 missed pings. + enum { LIVE_STREAM_WATCHDOG_MS = 120000 }; + + // Why the network thread ended (shutdown / relay-silent / send-failed / relay-error), printed + // in the thread-end summary so a dead stream is always attributable to a side. + AsciiString m_endReason; + + // Bytes and frames actually put on the wire, for the thread-end summary. + size_t m_sentBytes; + size_t m_sentFrames; + + AsciiString m_lobbyId; + /// Host-only fields kept from the registration, because the stream is registered with GO + /// from the network thread and the registration struct is gone by then. + Bool m_isHost; + Int m_delaySeconds; + AsciiString m_playerName; + + void* m_curlEasy; + void* m_curlMulti; + + std::thread m_networkThread; + mutable std::mutex m_sendMutex; + + // Written by the network thread, drained by pumpSpectatorChat on the game thread. This client + // is a source, so spectator chat is received and never sent. + struct SpectatorChatEntry + { + UnicodeString displayName; + UnicodeString text; + }; + std::deque m_spectatorChatQueue; + mutable std::mutex m_spectatorChatMutex; + + // deque, not queue: on CURLE_AGAIN unsent frames go back at the FRONT, since order is data + // and a misordered stream is corrupt. + std::deque m_outgoingQueue; + /// Bytes queued, and whether the budget has been blown. Frames are queued before the relay + /// connection exists, so a registration GO refuses would otherwise grow the queue all match + /// with nothing draining it. + size_t m_queuedBytes; + bool m_queueOverflowed; + + // Header accumulation - buffered until onHeaderComplete() + std::vector m_headerBuffer; + + // One buffer, both roles. While streaming it flushes every BODY_FLUSH_THRESHOLD bytes. While + // backup, onBodyFlush is a no-op, so the same buffer accumulates the body from the demotion + // point onward - the backfill source a later takeover needs. m_bodySentOffset freezes at + // buffer[0]'s absolute offset while backup so takeover offsets stay correct. Guarded by + // m_sendMutex: game thread writes, network thread takes over. + std::vector m_bodyBuffer; + static const size_t BODY_FLUSH_THRESHOLD = 4096; + uint64_t m_bodySentOffset; // absolute file offset for next BODY chunk + /// Ceiling on the backup accumulation. On overflow the oldest bytes are dropped and + /// m_bodySentOffset advances, so a takeover older than the retained window skips forward. + static const size_t BODY_BUFFER_MAX = 8 * 1024 * 1024; +}; + +extern LiveStreamer* TheLiveStreamer; +LiveStreamer* createLiveStreamer(); + +void liveStreamLog(const char* fmt, ...); + +/// Escape a string for embedding in a JSON string literal. UTF-8 bytes pass through unchanged, +/// being already valid there. +std::string liveStreamJsonEscape(const char* str); diff --git a/GeneralsMD/Code/GameEngine/Include/Common/Recorder.h b/GeneralsMD/Code/GameEngine/Include/Common/Recorder.h index fba73ad5ec3..4676a846ef2 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/Recorder.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/Recorder.h @@ -25,6 +25,7 @@ #pragma once #include "Common/MessageStream.h" +#include "Common/ReplayStreamSink.h" #include "GameNetwork/GameInfo.h" class File; @@ -50,6 +51,7 @@ enum RecorderModeType CPP_11(: Int) { RECORDERMODETYPE_RECORD, RECORDERMODETYPE_PLAYBACK, RECORDERMODETYPE_SIMULATION_PLAYBACK, // Play back replay without any graphics + RECORDERMODETYPE_LIVE_OBSERVER, // Live observer mode - receiving frames from relay server RECORDERMODETYPE_NONE // this is a valid state to be in on the shell map, or in saved games }; @@ -104,7 +106,10 @@ class RecorderClass : public SubsystemInterface AsciiString getCurrentReplayFilename(); ///< valid during playback only UnsignedInt getPlaybackFrameCount() const { return m_playbackFrameCount; } ///< valid during playback only void stopPlayback(); ///< Stops playback. Its fine to call this even if not playing back a file. + /// Teardown for a live-observer session; see the definition for what it deliberately keeps. + void endLivePlayback(); Bool simulateReplay(AsciiString filename); + Bool startLiveObserverPlayback(AsciiString filename); #if defined(RTS_DEBUG) Bool analyzeReplay( AsciiString filename ); #endif @@ -136,8 +141,8 @@ class RecorderClass : public SubsystemInterface }; Bool readReplayHeader( ReplayHeader& header ); - RecorderModeType getMode(); ///< Returns the current operating mode. - Bool isPlaybackMode() const { return m_mode == RECORDERMODETYPE_PLAYBACK || m_mode == RECORDERMODETYPE_SIMULATION_PLAYBACK; } + RecorderModeType getMode(); ///< Returns the current operating mode. + Bool isPlaybackMode() const { return m_mode == RECORDERMODETYPE_PLAYBACK || m_mode == RECORDERMODETYPE_SIMULATION_PLAYBACK || m_mode == RECORDERMODETYPE_LIVE_OBSERVER; } void initControls(); ///< Show or Hide the Replay controls static AsciiString getReplayDir(); ///< Returns the directory that holds the replay files. @@ -158,6 +163,18 @@ class RecorderClass : public SubsystemInterface void setArchiveEnabled(Bool enable) { m_archiveReplays = enable; } ///< Enable or disable replay archiving. void stopRecording(); ///< Stop recording and close m_file. + + IReplayStreamSink* getStreamSink() { return m_streamSink; } + + /// Forward a displayed in-game chat line to the live stream sink. The sink is attached only + /// for live-streamed games, so its absence is the whole gate: plain games no-op here. + /// ConnectionManager calls this for global chat lines, exactly as displayed. + void onChatMessage(UnsignedInt frame, const UnicodeString& text, UnsignedInt colorArgb) + { + if (m_streamSink) + m_streamSink->onChat(frame, text, colorArgb); + } + protected: void startRecording(GameDifficulty diff, Int originalGameMode, Int rankPoints, Int maxFPS); ///< Start recording to m_file. void writeToFile(GameMessage *msg); ///< Write this GameMessage to m_file. @@ -168,8 +185,19 @@ class RecorderClass : public SubsystemInterface AsciiString readAsciiString(); ///< Read the next string from m_file using ascii characters. UnicodeString readUnicodeString(); ///< Read the next string from m_file using unicode characters. - void readNextFrame(); ///< Read the next frame number to execute a command on. + /// Outcome of trying to read the next record's frame number in a live stream. + enum ReadFrameResult CPP_11(: Int) + { + READFRAME_OK, ///< m_nextFrame updated (or a future frame was peeked and rewound) + READFRAME_EOF_WAITING, ///< no complete record available yet; m_nextFrame untouched + READFRAME_STREAM_STOPPED ///< the stream really ended; playback has been stopped + }; + + ReadFrameResult readNextFrame(); ///< Read the next frame number to execute a command on. void appendNextCommand(); ///< Read the next GameMessage and append it to TheCommandList. + + /// TRUE when nothing more can arrive on the live file. Fails closed: no observer, no session. + Bool liveStreamEnded() const; void writeArgument(GameMessageArgumentDataType type, const GameMessageArgumentType arg); void readArgument(GameMessageArgumentDataType type, GameMessage *msg); @@ -198,6 +226,14 @@ class RecorderClass : public SubsystemInterface Int m_originalGameMode; // valid in replays UnsignedInt m_nextFrame; ///< The Frame that the next message is to be executed on. This can be -1. + + IReplayStreamSink* m_streamSink; + + /// How often updateRecord() publishes a frame heartbeat. Bounds how stale an observer's view + /// of the live edge can be; at 60 logic fps, ~6 ticks/s. Trades uplink against that staleness. + enum { LIVE_TICK_INTERVAL_FRAMES = 10 }; + + UnsignedInt m_lastStreamTickFrame; ///< Frame of the last onTick(), for the interval above. }; extern RecorderClass *TheRecorder; diff --git a/GeneralsMD/Code/GameEngine/Include/Common/ReplayStreamSink.h b/GeneralsMD/Code/GameEngine/Include/Common/ReplayStreamSink.h new file mode 100644 index 00000000000..dfdecb1b188 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/Common/ReplayStreamSink.h @@ -0,0 +1,44 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +#pragma once + +#include "Common/GameCommon.h" +#include "Common/UnicodeString.h" + +class IReplayStreamSink +{ +public: + virtual void onHeaderBytes(const void* data, Int size) = 0; + virtual void onHeaderComplete() = 0; + virtual void onHeaderPatch(Int offset, const void* data, Int size) = 0; + virtual void onBodyBytes(const void* data, Int size) = 0; + virtual void onBodyFlush() = 0; + virtual void onRecordingEnded() = 0; + + /// Player chat line that this client displayed, for live-stream capture. frame is the + /// recording client's game frame at capture (the observer frame-gates on it), text is the + /// already-formatted "[name] message", colorArgb the sender's player color as displayed. + virtual void onChat(UnsignedInt frame, const UnicodeString& text, UnsignedInt colorArgb) {} + + /// Frame heartbeat: the recording client's current logic frame, so a live observer can know + /// where the game is without waiting for the next record to appear in the body. Must be + /// called immediately after onBodyFlush() for the same frame - that ordering is what lets a + /// receiver read it as "every record up to this frame has been sent" rather than as a guess. + virtual void onTick(UnsignedInt frame) {} +}; diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/GUICallbacks.h b/GeneralsMD/Code/GameEngine/Include/GameClient/GUICallbacks.h index 4db166d7c7d..b70eb6d064c 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/GUICallbacks.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/GUICallbacks.h @@ -289,6 +289,16 @@ extern WindowMsgHandledType InGamePopupMessageInput( GameWindow *window, Unsigne extern void PopupJoinGameInit( WindowLayout *layout, void *userData ); extern WindowMsgHandledType PopupJoinGameSystem( GameWindow *window, UnsignedInt msg, WindowMsgData mData1, WindowMsgData mData2 ); extern WindowMsgHandledType PopupJoinGameInput( GameWindow *window, UnsignedInt msg, WindowMsgData mData1, WindowMsgData mData2 ); +#if defined(GENERALS_ONLINE) +// PopupJoinGame doubles as the password gate for a password-protected livestream. On submit the +// observer session is queued with the entered password; bPopShellOnSubmit also pops the shell so +// the screen below takes over the pending-session pump. +extern void liveWatchOpenPasswordPopup( const AsciiString& lobbyId, const AsciiString& displayName, + Bool bPopShellOnSubmit ); + +// Same popup for a pre-game lobby: on submit the read-only lobby view opens carrying the password. +extern void liveWatchOpenObservePasswordPopup( const AsciiString& lobbyId, const AsciiString& displayName ); +#endif // Network Direct ConnectWindow --------------------------------------------------------------------------------- extern void NetworkDirectConnectInit( WindowLayout *layout, void *userData ); diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h index af61c9b0355..8d3be7f0e22 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h @@ -612,6 +612,10 @@ class InGameUI : public SubsystemInterface, public Snapshot void drawSystemTime(Int& x, Int& y); void drawGameTime(); void drawPlayerInfoList(); + void drawLiveStatus(); ///< live streaming / live-observer status banner + /// Draw one live-status banner line with the shared cached string. setText runs only when the + /// label changed, so an unchanged banner costs a single draw(). + void drawLiveStatusBanner(const AsciiString& label, UnsignedInt colour, Int y); void drawObserverStats(Int &x, Int &y); Bool m_observerStatsHidden = false; // hide/show observer overlay @@ -648,6 +652,12 @@ class InGameUI : public SubsystemInterface, public Snapshot virtual void xfer(Xfer* xfer) override; virtual void loadPostProcess() override; +public: + // Defaults hidden every session (see reset()): the LIVE -> LIVE - ENDED transition reveals + // that the real game finished ~15s before the observer's own delayed view gets there. + void toggleLiveObserverStatusVisible() { m_liveObserverStatusVisible = !m_liveObserverStatusVisible; } + Bool isLiveObserverStatusVisible() const { return m_liveObserverStatusVisible; } + protected: // ---------------------------------------------------------------------------------------------- @@ -830,6 +840,19 @@ class InGameUI : public SubsystemInterface, public Snapshot Color m_networkLatencyDropColor; UnsignedInt m_lastNetworkLatencyFrames; + // Live-observer latency counter: the streamer's last drawn ping and logic rate. Kept apart + // from m_lastNetworkLatencyFrames because both values appear in the string, so the counter + // has to refresh when either moves, not only when the derived frame count does. + UnsignedInt m_lastObserverPingMs; + UnsignedInt m_lastObserverLogicFps; + UnsignedInt m_lastObserverPaceFps; + + // The match's logic rate is drawn as its own string so it can carry its own colour: red when + // rate-matching is off, because then what you are watching is faster than the match actually + // ran and the number no longer describes the picture in front of you. Split for the same + // reason m_renderFpsString and m_renderFpsLimitString are. + DisplayString* m_observerLogicFpsString; + // Render FPS Counter DisplayString* m_renderFpsString; DisplayString* m_renderFpsLimitString; @@ -1088,6 +1111,16 @@ class InGameUI : public SubsystemInterface, public Snapshot int64_t lastFPSUpdate = -1; #endif + Bool m_liveObserverStatusVisible; + + // Reused by drawLiveStatus() every frame. Allocated on first draw, freed by + // freeCustomUiResources(); m_liveStatusFontSize / m_liveStatusLabel track the last applied + // values so an unchanged banner costs a single draw(). The streamer and observer banners are + // mutually exclusive, so one cached string serves both. + DisplayString* m_liveStatusString; + Int m_liveStatusFontSize; + AsciiString m_liveStatusLabel; + // ---------------------------------------------------------------------------------------------- // STATIC Protected Data ------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------- diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/LiveGamesMenu.h b/GeneralsMD/Code/GameEngine/Include/GameClient/LiveGamesMenu.h new file mode 100644 index 00000000000..c74c70fb84a --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/LiveGamesMenu.h @@ -0,0 +1,51 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +/////////////////////////////////////////////////////////////////////////////////////// +// FILE: LiveGamesMenu.h +// Description: The Watch Live browser. It reuses the ReplayMenu.wnd layout in a "LIVE GAMES" +// mode, and this module owns that mode. Free functions + file statics, like the other menu +// modules in Source/GameClient/GUI/GUICallbacks/Menus. +/////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "GameClient/GameWindow.h" // WindowMsgData + +#if defined(GENERALS_ONLINE) + +/// Arm live-games mode before pushing ReplayMenu.wnd (WOLWelcomeMenu calls this). +void LiveGamesMenuEnterLiveGamesMode(void); + +/// TRUE while the replay menu is running in live-games mode. +Bool LiveGamesMenuIsLiveGamesMode(void); + +/// Live-mode half of ReplayMenuInit: retitle, repurpose the buttons, first fetch. +void LiveGamesMenuInit(void); + +/// Live-mode half of ReplayMenuShutdown: restore the layout, clear all state. +void LiveGamesMenuShutdown(void); + +/// Live-mode half of ReplayMenuUpdate: fetch poll, auto-refresh, CONNECT/OBSERVE label. +void LiveGamesMenuUpdate(void); + +/// Returns TRUE when the system message was consumed by live-games mode (the caller then +/// returns MSG_HANDLED); FALSE lets the legacy replay handling run. +Bool LiveGamesMenuHandleSystemMessage(UnsignedInt msg, WindowMsgData mData1, WindowMsgData mData2); + +#endif // defined(GENERALS_ONLINE) diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/LiveObserverSession.h b/GeneralsMD/Code/GameEngine/Include/GameClient/LiveObserverSession.h new file mode 100644 index 00000000000..e881c681ea0 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/LiveObserverSession.h @@ -0,0 +1,64 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +/////////////////////////////////////////////////////////////////////////////////////// +// FILE: LiveObserverSession.h +// Description: Queues and pumps a live-observer session. Joining a livestream is a two-step +// affair that no single screen can own: the intent is recorded from whichever screen picked the +// game, and the connect-then-start-playback sequence is pumped by whichever screen the player is +// standing on when it completes. This module holds that state machine so no menu has to. +/////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "Lib/BaseType.h" + +#if defined(GENERALS_ONLINE) + +/// Queue a live-observer session for the given lobby. Records the intent only: the join waits on +/// the relay's HEADER and then starts a game, neither of which may happen while a screen is still +/// animating, so the screen the player lands on performs it via LiveObserverStartPendingSession(). +/// +/// password is the password of a password-protected stream (sent with the watch-ticket request); +/// displayName is the lobby's name, used to title the password reprompt popup; delaySeconds is +/// the lobby's broadcast delay (-1 = unknown), which pre-seeds the countdown and join timeout +/// until GO's admission hold or the relay's ROLE pins the real value. +void StartLiveObserverSession(const AsciiString& lobbyId, + const AsciiString& password = AsciiString::TheEmptyString, + const AsciiString& displayName = AsciiString::TheEmptyString, + Int delaySeconds = -1); + +/// Abort a queued or connecting live-observer session. The only other way out is the join +/// timeout; the read-only lobby view needs LEAVE to cancel mid-wait. +void CancelLiveObserverPendingSession(void); + +/// TRUE while a live-observer session is queued or connecting (between +/// StartLiveObserverSession and playback starting). Used by the lobby-observer screen to tell +/// "still waiting" from "the join was abandoned" without owning the session's internals. +Bool LiveObserverPendingSessionActive(void); + +/// Start a queued live-observer session if one is pending and the shell has settled; a no-op +/// otherwise. Returns TRUE when playback actually started, which means the calling screen should +/// now stand itself down so the running game is visible. +/// +/// Must be pumped from every shell screen the browser can be reached from, because a session is +/// queued from whichever one the player happens to be on and only that screen can tear itself +/// down afterwards. +Bool LiveObserverStartPendingSession(void); + +#endif // GENERALS_ONLINE diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/LobbyObserverMenu.h b/GeneralsMD/Code/GameEngine/Include/GameClient/LobbyObserverMenu.h new file mode 100644 index 00000000000..4b1ff2b095e --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/LobbyObserverMenu.h @@ -0,0 +1,48 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +/////////////////////////////////////////////////////////////////////////////////////// +// FILE: LobbyObserverMenu.h +// Description: Read-only pre-game lobby view, rendered by the observer-mode branch of +// WOLGameSetupMenu, which owns the GameSpyGameOptionsMenu.wnd layout this screen reuses. +// Implemented here so the read-only mode never touches lobby/mesh/NGMP state. +/////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "GameClient/GameWindow.h" // WindowMsgHandledType / WindowMsgData + +/// Arm observer mode for the next push of GameSpyGameOptionsMenu.wnd (ReplayMenu.cpp). +void SetLobbyObserverMode(const char* lobbyId); + +/// Same, for a password-protected lobby: the password is sent with the watch-ticket request +/// when the stream goes live (pre-game watch is gated too). +void SetLobbyObserverModeWithPassword(const char* lobbyId, const char* password); + +/// TRUE while the setup menu is running in observer mode. +Bool LobbyObserverModeActive(void); + +/// Slot index (0..7) of the lobby member with this GO user id, or -1 when unknown. An observer +/// has no lobby roster, so the incoming-chat path uses this to colour a line by its sender. +Int LobbyObserverSlotForUserID(Int64 userID); + +void LobbyObserverInit(WindowLayout* layout, void* userData); +void LobbyObserverUpdate(WindowLayout* layout, void* userData); +void LobbyObserverShutdown(WindowLayout* layout, void* userData); +WindowMsgHandledType LobbyObserverInput(GameWindow* window, UnsignedInt msg, + WindowMsgData mData1, WindowMsgData mData2); diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h index c3a66f6c326..0813eeb188c 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h @@ -60,6 +60,7 @@ class ThingTemplate; class Team; class CommandList; class GameMessage; +class GameInfo; class LoadScreen; class WindowLayout; class TerrainLogic; @@ -172,6 +173,10 @@ class GameLogic : public SubsystemInterface, public Snapshot void startNewGame(Bool loadSaveGame); void loadMapINI(AsciiString mapName); + /// Resolve all random slots the way a game start would, without starting one, so the observer + /// lobby preview can show what the players will get. + void rollRandomSlots(GameInfo* game); + void updateLoadProgress( Int progress ); void deleteLoadScreen(); @@ -219,6 +224,10 @@ class GameLogic : public SubsystemInterface, public Snapshot Bool isIntroMoviePlaying(); + /// TRUE once a start has been requested but not yet run. The real start work happens inside + /// update(), so nothing may halt update() until this clears. + Bool isStartingNewGame() const { return m_startNewGame; } + void updateObjectsChangedTriggerAreas() {m_frameObjectsChangedTriggerAreas = m_frame;} UnsignedInt getFrameObjectsChangedTriggerAreas() {return m_frameObjectsChangedTriggerAreas;} diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h index 666758e45b9..1096535dbff 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h @@ -65,6 +65,19 @@ class GenOnlineSettings int GetChatLifeSeconds() const { return std::max(m_Chat_LifeSeconds, 10); } + /// Lead a live observer banks once at join, in milliseconds. Playback always runs at 100%, + /// so this is the only cushion against transport jitter; 0 means stall instead. + int LiveObserver_GetJitterBufferMs() const + { + return std::max(0, std::min(m_LiveObserver_JitterBufferMs, LIVE_OBSERVER_JITTER_BUFFER_MS_MAX)); + } + + void LiveObserver_SetJitterBufferMs(int ms) + { + m_LiveObserver_JitterBufferMs = std::max(0, std::min(ms, LIVE_OBSERVER_JITTER_BUFFER_MS_MAX)); + Save(); + } + void Initialize() { m_bInitialized = true; @@ -131,6 +144,12 @@ class GenOnlineSettings int m_Render_FramerateLimit_FPSVal = 60; int m_Chat_LifeSeconds = 30; + // Kept small on purpose: this only covers ordinary transport jitter, and on a + // WebSocket-over-TCP relay the floor is one RTT regardless. + static const int LIVE_OBSERVER_JITTER_BUFFER_MS_MAX = 2000; + const int m_LiveObserver_JitterBufferMs_default = 250; + int m_LiveObserver_JitterBufferMs = m_LiveObserver_JitterBufferMs_default; + bool m_Social_Notification_FriendComesOnline_Menus = true; bool m_Social_Notification_FriendComesOnline_Gameplay = true; bool m_Social_Notification_FriendGoesOffline_Menus = true; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 7b2e31001f8..6e290e47ba6 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -94,7 +94,15 @@ enum EWebSocketMessageID AC_REGISTER_PLAYER = 40, AC_DEREGISTER_PLAYER = 41, WS_KEEPALIVE = 42, - WS_KEEPALIVE_CLIENT = 43 + WS_KEEPALIVE_CLIENT = 43, + LOBBY_OBSERVER_SUBSCRIBE = 44, + LOBBY_OBSERVER_UNSUBSCRIBE = 45, + LOBBY_OBSERVER_LOBBY_CHANGED = 46, + LOBBY_OBSERVER_GAME_STARTING = 47, + LOBBY_OBSERVER_STREAM_LIVE = 48, + LOBBY_OBSERVER_GAME_STARTED = 49, + LOBBY_OBSERVER_CHAT_FROM_CLIENT = 50, + LOBBY_OBSERVER_LIST_REQUEST = 51 }; enum class EQoSRegions @@ -152,6 +160,10 @@ class WebSocket void SendData_RoomChatMessage(UnicodeString& msg, bool bIsAction); void SendData_FriendMessage(UnicodeString& msg, int64_t target_user_id); void SendData_LobbyChatMessage(UnicodeString& msg, bool bIsAction, bool bIsAnnouncement, bool bShowAnnouncementToHost); + void SendData_LobbyObserverSubscribe(int64_t lobbyID); + void SendData_LobbyObserverUnsubscribe(int64_t lobbyID); + void SendData_LobbyObserverChat(int64_t lobbyID, UnicodeString& msg); + void SendData_LobbyObserverListRequest(int64_t lobbyID); void SendData_JoinNetworkRoom(int roomID); void SendData_LeaveNetworkRoom(); void SendData_MarkReady(bool bReady); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h index 91304e02be5..c39b6ed78f2 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h @@ -77,8 +77,29 @@ struct LobbyEntry std::string region; int latency = 0; + + // Host-chosen live-stream broadcast delay in seconds; -1 when GO has not been told one. + int stream_delay_seconds = -1; + + // Host decision at lobby creation: may this game be watched live at all? + bool allow_streamers = true; + + // Host decision: may pre-game observers send chat into this lobby? Mirrors the server's + // default-on; old GO omits the field entirely (see UpdateCurrentLobby_AllowObserverChat). + bool allow_observer_chat = true; + + // Read-only observers parked in this lobby's pre-game view + int pending_observer_count = 0; + + // Latched by GO when a priority player created or joined the lobby. + bool priority = false; }; +/// Build the relay registration from the current lobby and leave it pending for the Recorder to +/// send on MSG_NEW_GAME. Idempotent; call it as late as possible before starting a match so the +/// roster and map are the ones actually played. No-op when streaming is off or not in a lobby. +void PrepareLiveStreamRegistration(); + enum class EJoinLobbyResult { JoinLobbyResult_Success, // The room was joined. @@ -171,6 +192,14 @@ class NGMP_OnlineServices_LobbyInterface void UpdateCurrentLobby_Map(AsciiString strMap, AsciiString strMapPath, bool bIsOfficial, int newMaxPlayers); void UpdateCurrentLobby_LimitSuperweapons(bool bLimitSuperweapons); void UpdateCurrentLobby_StartingCash(UnsignedInt startingCashValue); + /// Host-only: the broadcast delay is a lobby property, not a per-client option - GO stores it + /// and broadcasts it, so every member sees the same read-only value. + void UpdateCurrentLobby_StreamDelay(Int streamDelaySeconds); + + /// Host-only: the kill switch for pre-game observer chat. Deliberately does NOT reset the + /// auto-ready countdown - that preamble exists because gameplay settings reset ready flags, + /// and dropping the whole lobby's ready state to mute a chatty observer would be hostile. + void UpdateCurrentLobby_AllowObserverChat(bool bAllowObserverChat); void UpdateCurrentLobby_HasMap(); @@ -212,7 +241,7 @@ class NGMP_OnlineServices_LobbyInterface UnicodeString m_PendingCreation_LobbyName; UnicodeString m_PendingCreation_InitialMapDisplayName; AsciiString m_PendingCreation_InitialMapPath; - void CreateLobby(UnicodeString strLobbyName, UnicodeString strInitialMapName, AsciiString strInitialMapPath, bool bIsOfficial, int initialMaxSize, bool bVanillaTeamsOnly, bool bTrackStats, uint32_t startingCash, bool bPassworded, std::string strPassword, bool bAllowObservers); + void CreateLobby(UnicodeString strLobbyName, UnicodeString strInitialMapName, AsciiString strInitialMapPath, bool bIsOfficial, int initialMaxSize, bool bVanillaTeamsOnly, bool bTrackStats, uint32_t startingCash, bool bPassworded, std::string strPassword, bool bAllowObservers, bool bAllowStreamers); void OnJoinedOrCreatedLobby(bool bAlreadyUpdatedDetails, std::function fnCallback); @@ -222,6 +251,8 @@ class NGMP_OnlineServices_LobbyInterface void SendChatMessageToCurrentLobby(UnicodeString& strChatMsgUnicode, bool bIsAction); void SendAnnouncementMessageToCurrentLobby(UnicodeString& strAnnouncementMsgUnicode, bool bShowToHost); + void SendObserverChatMessage(int64_t lobbyId, UnicodeString& strChatMsgUnicode); + void SendObserverListRequest(int64_t lobbyId); void InvokeCreateLobbyCallback(bool bSuccess) { @@ -274,6 +305,29 @@ class NGMP_OnlineServices_LobbyInterface m_callbackStartGamePacket = nullptr; } + // Read-only lobby-observer events pushed by GO. + enum class ELobbyObserverEventType + { + LOBBY_CHANGED = 0, // lobby state changed - refetch GET /Lobby/{id} + GAME_STARTING = 1, // match is starting - run the countdown + STREAM_LIVE = 2, // stream is live - fetch a watch ticket and join + GAME_STARTED = 3, // match started - queue the join; the delay gate times the ticket + }; + std::function m_callbackLobbyObserverEvent = nullptr; + void RegisterForLobbyObserverEvent(std::function cb) + { + m_callbackLobbyObserverEvent = cb; + } + + void DeregisterForLobbyObserverEvent() + { + m_callbackLobbyObserverEvent = nullptr; + } + + // Join/leave the read-only pre-game observer queue for a lobby. + void SubscribeToLobbyObserver(int64_t lobbyID); + void UnsubscribeFromLobbyObserver(int64_t lobbyID); + // periodically force refresh the lobby for data accuracy int64_t m_lastForceRefresh = 0; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp index a5d5ca2e3f8..9016a09379e 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp @@ -61,6 +61,7 @@ #include "Common/DamageFX.h" #include "Common/MultiplayerSettings.h" #include "Common/Recorder.h" +#include "Common/LiveObserver.h" #include "Common/SpecialPower.h" #include "Common/TerrainTypes.h" #include "Common/Upgrade.h" @@ -1018,6 +1019,14 @@ void GameEngine::update() } } +#if defined(GENERALS_ONLINE) + // Polled outside the halted path on purpose: the buffering pause halts GameLogic::UPDATE(), + // and updatePlayback() - the only other caller - runs from inside that halt. + if (TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER + && TheLiveObserver && TheGameLogic) + TheLiveObserver->updatePlaybackGate(TheGameLogic->getFrame()); +#endif + // TheSuperHackers @info Ignores frozen time because the script engine needs updating in the logic update regardless. if (canUpdateGameLogic(FramePacer::IgnoreFrozenTime)) { diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index 1cb49c5ffa6..2a3c094450c 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -639,6 +639,9 @@ GlobalData::GlobalData() m_chipSetType = 0; m_headless = FALSE; m_exportStats = FALSE; + m_liveStreamEnabled = TRUE; + m_liveStreamCanStream = TRUE; + m_liveStreamDelaySeconds = LIVE_DELAY_SECONDS_DEFAULT; m_windowed = 0; m_xResolution = 800; m_yResolution = 600; @@ -1281,6 +1284,11 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_observerNotificationSpecialPowerUsage = optionPref.getObserverNotificationSpecialPowerUsage(); TheWritableGlobalData->m_observerNotificationSpecialPowerPurchase = optionPref.getObserverNotificationSpecialPowerPurchase(); TheWritableGlobalData->m_observerNotificationMilestone = optionPref.getObserverNotificationMilestone(); + + TheWritableGlobalData->m_liveStreamEnabled = optionPref.getLiveStreamEnabled(); + TheWritableGlobalData->m_liveStreamCanStream = optionPref.getLiveStreamCanStream(); + TheWritableGlobalData->m_liveStreamDelaySeconds = optionPref.getLiveStreamDelaySeconds(); + TheWritableGlobalData->m_antiAliasLevel = optionPref.getAntiAliasing(); #if !defined(GENERALS_ONLINE_DISABLE_TEXTURE_FILTERING_AND_AA) diff --git a/GeneralsMD/Code/GameEngine/Source/Common/LiveObserver.cpp b/GeneralsMD/Code/GameEngine/Source/Common/LiveObserver.cpp new file mode 100644 index 00000000000..af9a3306d3d --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/LiveObserver.cpp @@ -0,0 +1,2201 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +#include "PreRTS.h" + +#if defined(GENERALS_ONLINE) + +#include "Common/LiveObserver.h" +#include "Common/Recorder.h" +#include "Common/GlobalData.h" +#include "Common/FileSystem.h" +#include "Common/file.h" +#include "Common/FramePacer.h" // the pace controller drives the logic time scale; see updatePlaybackPace +#include "GameLogic/GameLogic.h" // the buffering gate pauses the game; see updatePlaybackGate +#include "GameClient/ClientInstance.h" +#include "GameClient/InGameUI.h" +#include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" +#include "GameNetwork/GeneralsOnline/json.hpp" + +#include "GameNetwork/GeneralsOnline/Vendor/libcurl/curl.h" +#include "GameNetwork/GeneralsOnline/Vendor/libcurl/multi.h" +#include "GameNetwork/GeneralsOnline/Vendor/libcurl/websockets.h" + +#include +#include +#include +#include +#include +#include // cacert.pem presence check, see connectToRelay +#include +#include +#include + +// ============================================================================ +// liveObserverLog +// ============================================================================ +// LIVE_OBSERVER_BUILD_TAG and the LIVE_OBSERVER_LOGGING gate both live in LiveObserver.h. + +void liveObserverLog(const char* fmt, ...) { +#if !defined(LIVE_OBSERVER_LOGGING) + (void)fmt; +#else + static FILE* logFile = NULL; + if (!logFile) { + // Per-instance name: streamer and observer are the same exe and can share an install + // directory, so a bare relative filename means both processes truncate the same log. + AsciiString path; + path.format("live_observer_debug_Instance%.2u.log", rts::ClientInstance::getInstanceId()); + logFile = fopen(path.str(), "w"); + if (logFile) + fprintf(logFile, "LIVE_OBSERVER_BUILD_TAG=%s\n", LIVE_OBSERVER_BUILD_TAG); + } + if (logFile) { + // Wall-clock prefix, so several instances' logs (and the relay's) can be aligned. + SYSTEMTIME st; + GetLocalTime(&st); + fprintf(logFile, "[%02u:%02u:%02u.%03u] ", st.wHour, st.wMinute, st.wSecond, st.wMilliseconds); + va_list args; + va_start(args, fmt); + vfprintf(logFile, fmt, args); + va_end(args); + fflush(logFile); + } +#endif // LIVE_OBSERVER_LOGGING +} + +void liveObserverInitLog(const char* lobbyId) { + liveObserverLog("=== Live Observer Init ===\n"); + liveObserverLog("Lobby: %s\n", lobbyId ? lobbyId : "(empty)"); + liveObserverLog("Observer mode activated\n"); +} + +// ============================================================================ +// LiveObserver +// ============================================================================ +LiveObserver* TheLiveObserver = nullptr; + +LiveObserver::LiveObserver() + : m_connected(false) + , m_shouldRun(false) + , m_headerReceived(false) + , m_streamEnded(false) + , m_maxCompleteFrame(0) + , m_safeReadOffset(0) + , m_parseAbsOffset(0) + , m_bodyStartOffset(0) + , m_parseCorrupt(false) + , m_parseGapPending(false) + , m_liveFrameHint(0) + , m_holdPlayback(FALSE) + , m_nearLiveHeld(FALSE) + , m_preRollComplete(FALSE) + , m_autoPaused(FALSE) + , m_userPaused(FALSE) + , m_stalled(FALSE) + , m_playbackStarted(FALSE) + , m_lastSeenLiveEdge(0) + , m_lastLiveEdgeChangeMs(timeGetTime()) + , m_desyncFrame(0) + , m_lastGateLogMs(0) + , m_lastGateLogFrame(0) + , m_lastGateLogEdge(0) + , m_underrunCount(0) + , m_paceSampleCount(0) + , m_sourceFps(0) + , m_paceFps(LOGICFRAMES_PER_SECOND) + , m_lastPaceApplyMs(0) + , m_paceMatchingEnabled(TRUE) + , m_pacerTouched(FALSE) + , m_savedLogicScaleFps(0) + , m_savedLogicScaleEnabled(FALSE) + , m_delaySeconds(LIVE_DELAY_SECONDS_DEFAULT) + , m_serverHeld(FALSE) + , m_delayWaitActive(FALSE) + , m_delayWaitDeadlineMs(0) + , m_expectedDelaySeconds(-1) + , m_spectatorChatMode(SPECTATOR_CHAT_AUTO) + , m_liveFile(nullptr) + , m_curlEasy(nullptr) + , m_curlMulti(nullptr) +{ + // Every field above is session state and this constructor is the only place it is ever + // initialised: a session begins when the object is created and ends when it is destroyed, + // so there is no "reset the previous session" step to forget. +} + +// ============================================================================ +// Standalone relay HTTP fetch (live game browser) +// ============================================================================ + +namespace +{ + std::mutex s_fetchMutex; + std::atomic s_fetchInFlight(false); + std::atomic s_fetchReady(false); + std::string s_fetchBody; + bool s_fetchSuccess = false; + long s_fetchStatus = 0; + + size_t liveRelayWriteCb(char* ptr, size_t size, size_t nmemb, void* userdata) + { + std::string* out = static_cast(userdata); + out->append(ptr, size * nmemb); + return size * nmemb; + } + + // Shared setup for every GO services call made from this file. Identical CA handling and + // timeouts whether the caller is the browser's background fetch, the observer asking for a + // watch ticket, or the streamer registering a stream - one place to get this right. + // + // Returns the header list, which the caller owns and must curl_slist_free_all(). + curl_slist* liveServicesConfigureCurl(CURL* easy, std::string* outBody, const std::string& authToken) + { + curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, liveRelayWriteCb); + curl_easy_setopt(easy, CURLOPT_WRITEDATA, outBody); + curl_easy_setopt(easy, CURLOPT_FOLLOWLOCATION, 1L); + // Keep this short: these run behind a menu the user is looking at, or in the moment a + // match starts, and a hung service must not leave either of them hanging. + curl_easy_setopt(easy, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(easy, CURLOPT_CONNECTTIMEOUT, 5L); + + // Same CA handling as connectToRelay - this libcurl is OpenSSL-backed and has + // no trust anchors of its own, so https:// fails without an explicit bundle. + std::ifstream certFile("cacert.pem"); + if (certFile.good()) + { + certFile.close(); + curl_easy_setopt(easy, CURLOPT_CAINFO, "cacert.pem"); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYHOST, 2L); + } + else + { + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYHOST, 0L); + } + + curl_slist* headers = curl_slist_append(nullptr, "Accept: application/json"); + if (!authToken.empty()) + { + const std::string authHeader = "Authorization: Bearer " + authToken; + headers = curl_slist_append(headers, authHeader.c_str()); + } + curl_easy_setopt(easy, CURLOPT_HTTPHEADER, headers); + return headers; + } + + void liveRelayFetchThread(std::string url, std::string authToken) + { + std::string body; + bool success = false; + long status = 0; + + CURL* easy = curl_easy_init(); + if (easy) + { + curl_easy_setopt(easy, CURLOPT_URL, url.c_str()); + curl_slist* headers = liveServicesConfigureCurl(easy, &body, authToken); + + CURLcode res = curl_easy_perform(easy); + curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &status); + success = (res == CURLE_OK); + if (!success) + liveObserverLog("liveRelayFetch: curl failed (result=%d) for %s\n", (int)res, url.c_str()); + curl_slist_free_all(headers); + curl_easy_cleanup(easy); + } + + { + std::lock_guard lock(s_fetchMutex); + s_fetchBody = body; + s_fetchSuccess = success; + s_fetchStatus = status; + } + s_fetchReady.store(true); + s_fetchInFlight.store(false); + } +} + +// The signed-in player's session token, or an empty string when not signed in. +static std::string liveServicesAuthToken() +{ + NGMP_OnlineServices_AuthInterface* pAuthInterface = + NGMP_OnlineServicesManager::GetInterface(); + if (pAuthInterface == nullptr || !pAuthInterface->IsLoggedIn()) + return std::string(); + + return pAuthInterface->GetAuthToken(); +} + +AsciiString liveServicesEndpoint(const char* szEndpoint) +{ + // Static on the manager, so this resolves whether or not the player has signed in. + return AsciiString(NGMP_OnlineServicesManager::GetAPIEndpoint(szEndpoint).c_str()); +} + +Bool liveServicesParseLivestreams(const AsciiString& body, std::vector& outGames) +{ + outGames.clear(); + + try + { + // GO answers with { "livestreams": [ ... ] }, already filtered to what this player may + // watch - lobbies in progress whose relay session is live. + nlohmann::json response = nlohmann::json::parse(body.str()); + if (!response.is_object() || !response.contains("livestreams")) + return FALSE; + + const nlohmann::json& games = response["livestreams"]; + if (!games.is_array()) + return FALSE; + + for (const auto& game : games) + { + if (!game.is_object()) + continue; + + LiveGameEntry entry; + + // lobby_id is a number in GO's JSON, and the relay keys its sessions by the same + // value as decimal text, so it is formatted, not read as a string. + if (game.contains("lobby_id") && game["lobby_id"].is_number_integer()) + entry.lobbyId.format("%lld", (long long)game["lobby_id"].get()); + if (entry.lobbyId.isEmpty()) + continue; + + // map_name is a display name, not a path, so it needs no leaf/extension stripping. + // A game missing metadata is still watchable, so fall back rather than drop the row. + const std::string mapName = game.value("map_name", std::string("")); + entry.mapName = mapName.empty() ? "(unknown map)" : mapName.c_str(); + + // The lobby's display name - used for the password popup title on passworded rows. + const std::string lobbyName = game.value("name", std::string("")); + entry.name = lobbyName.empty() ? entry.mapName : lobbyName.c_str(); + + // players[] arrives already reduced to the humans in the lobby - no empty slots. + std::string playerList; + if (game.contains("players") && game["players"].is_array()) + { + for (const auto& player : game["players"]) + { + if (!player.is_string()) + continue; + + const std::string name = player.get(); + if (name.empty()) + continue; + + if (!playerList.empty()) + playerList += ", "; + playerList += name; + } + } + entry.players = playerList.empty() ? "?" : playerList.c_str(); + + // delay_seconds and age_seconds are nullable in GO's contract, so present-but-null has + // to be treated as absent: value() would throw on it. + entry.observerCount = game.value("observer_count", 0); + entry.delaySeconds = (game.contains("delay_seconds") && game["delay_seconds"].is_number_integer()) + ? game["delay_seconds"].get() : (Int)LIVE_DELAY_SECONDS_DEFAULT; + entry.ageSeconds = (game.contains("age_seconds") && game["age_seconds"].is_number_integer()) + ? game["age_seconds"].get() : 0; + + // Defaults keep an older GO's live-only rows usable: live, not passworded, none waiting. + entry.state = (game.contains("state") && game["state"].is_number_integer()) + ? game["state"].get() : 1; + entry.passworded = game.value("passworded", false) ? TRUE : FALSE; + entry.pendingObserverCount = game.value("pending_observer_count", 0); + + // watch_action is GO's per-viewer directive (0 observe / 1 wait / 2 join). Absent on + // older GO, derive it from the state: live rows join, pre-game rows observe. + entry.watchAction = (game.contains("watch_action") && game["watch_action"].is_number_integer()) + ? game["watch_action"].get() : (entry.state == 1 ? 2 : 0); + entry.delayRemainingSeconds = (game.contains("delay_remaining_seconds") && + game["delay_remaining_seconds"].is_number_integer()) + ? game["delay_remaining_seconds"].get() : 0; + + // priority: GO latches the lobby when a user_priority = Player creates/joins. + // Absent on older GO = not priority. + entry.priority = game.value("priority", false) ? TRUE : FALSE; + + outGames.push_back(entry); + } + } + catch (const nlohmann::json::exception&) + { + outGames.clear(); + return FALSE; + } + + return TRUE; +} + +Bool liveServicesRequest(const AsciiString& url, Bool bPost, const char* szPostBody, + AsciiString& outBody, Int& outStatusCode) +{ + outBody = AsciiString::TheEmptyString; + outStatusCode = 0; + + const std::string authToken = liveServicesAuthToken(); + if (authToken.empty()) + { + liveObserverLog("liveServicesRequest: %s refused (not signed in)\n", url.str()); + return FALSE; + } + + CURL* easy = curl_easy_init(); + if (easy == nullptr) + { + liveObserverLog("liveServicesRequest: %s failed (curl init)\n", url.str()); + return FALSE; + } + + std::string body; + curl_easy_setopt(easy, CURLOPT_URL, url.str()); + curl_slist* headers = liveServicesConfigureCurl(easy, &body, authToken); + + if (bPost) + { + // GO reads the body itself rather than through a model binder, so an empty POST still + // needs a real (zero-length) body rather than no body at all. + const char* szBody = (szPostBody != nullptr) ? szPostBody : ""; + headers = curl_slist_append(headers, "Content-Type: application/json"); + curl_easy_setopt(easy, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(easy, CURLOPT_POST, 1L); + curl_easy_setopt(easy, CURLOPT_POSTFIELDS, szBody); + curl_easy_setopt(easy, CURLOPT_POSTFIELDSIZE, (long)strlen(szBody)); + } + + const CURLcode res = curl_easy_perform(easy); + long status = 0; + curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &status); + curl_slist_free_all(headers); + curl_easy_cleanup(easy); + + outBody = body.c_str(); + outStatusCode = (Int)status; + + if (res != CURLE_OK) + { + liveObserverLog("liveServicesRequest: %s failed (result=%d)\n", url.str(), (int)res); + return FALSE; + } + + return TRUE; +} + +Bool liveRelayBeginFetch(const AsciiString& url) +{ + bool expected = false; + if (!s_fetchInFlight.compare_exchange_strong(expected, true)) + return FALSE; // one already running + + // Read the token here, on the calling thread: the auth interface is not safe to reach from + // the fetch thread, and it is a plain string by the time it crosses over. + const std::string authToken = liveServicesAuthToken(); + if (authToken.empty()) + { + // GO gates the livestream list behind a GameClient session, so there is nothing to ask + // for when signed out. Release the in-flight flag rather than leaving the browser + // believing a request is running. + s_fetchInFlight.store(false); + liveObserverLog("liveRelayFetch: skipped %s (not signed in)\n", url.str()); + return FALSE; + } + + s_fetchReady.store(false); + liveObserverLog("liveRelayFetch: GET %s\n", url.str()); + + std::thread(liveRelayFetchThread, std::string(url.str()), authToken).detach(); + return TRUE; +} + +Bool liveRelayPollFetch(AsciiString& outBody, Bool& outSuccess, Int& outStatusCode) +{ + if (!s_fetchReady.load()) + return FALSE; + + std::lock_guard lock(s_fetchMutex); + // Re-check under the lock so two callers in one frame cannot both consume the result. + if (!s_fetchReady.load()) + return FALSE; + + outBody = s_fetchBody.c_str(); + outSuccess = s_fetchSuccess ? TRUE : FALSE; + outStatusCode = (Int)s_fetchStatus; + s_fetchReady.store(false); + return TRUE; +} + +Bool liveRelayFetchInFlight() +{ + return s_fetchInFlight.load() ? TRUE : FALSE; +} + +// ============================================================================ +// Replay-record scanner +// ============================================================================ +// +// One replay record on disk is laid out as: +// [UnsignedInt frame][GameMessage::Type type][Int playerIndex][UnsignedByte numTypes] +// { [UnsignedByte argType][UnsignedByte numArgs] } x numTypes +// [argument payload] +// +// This must agree byte-for-byte with RecorderClass::appendNextCommand(), which consumes these +// records during playback. If appendNextCommand() or readArgument() ever change what they read, +// change this with them. +// +// It fails closed: an unparseable record stalls the watermark rather than poisoning it. + +enum ScanRecordResult CPP_11(: Int) +{ + SCANRECORD_OK, ///< a complete record is present; outSize/outFrame are valid + SCANRECORD_INCOMPLETE, ///< the buffer holds a valid prefix - more bytes needed + SCANRECORD_CORRUPT ///< unparseable (e.g. unknown argument type) +}; + +// Size of one replay argument on disk. Must match RecorderClass::readArgument() exactly. Returns +// -1 for anything unrecognised, so callers fail closed instead of skipping zero bytes and +// desyncing the rest of the parse. +static Int replayArgumentSize(UnsignedByte argType) +{ + switch ((GameMessageArgumentDataType)argType) { + case ARGUMENTDATATYPE_INTEGER: return sizeof(Int); + case ARGUMENTDATATYPE_REAL: return sizeof(Real); + case ARGUMENTDATATYPE_BOOLEAN: return sizeof(Bool); + case ARGUMENTDATATYPE_OBJECTID: return sizeof(ObjectID); + case ARGUMENTDATATYPE_DRAWABLEID: return sizeof(DrawableID); + case ARGUMENTDATATYPE_TEAMID: return sizeof(UnsignedInt); + case ARGUMENTDATATYPE_LOCATION: return sizeof(Coord3D); + case ARGUMENTDATATYPE_PIXEL: return sizeof(ICoord2D); + case ARGUMENTDATATYPE_PIXELREGION: return sizeof(IRegion2D); + case ARGUMENTDATATYPE_TIMESTAMP: return sizeof(UnsignedInt); + case ARGUMENTDATATYPE_WIDECHAR: return sizeof(WideChar); + default: return -1; + } +} + +/// Scan one replay record from buf[0..len). Never reads past len. outSize/outFrame may be null. +static ScanRecordResult scanReplayRecord(const unsigned char* buf, Int len, Int* outSize, UnsignedInt* outFrame) +{ + // numTypes and numArgs are single bytes, so a well-formed record cannot exceed + // 9 + 255*2 + 255*255*sizeof(IRegion2D) bytes. Anything claiming more is misparsed data + // rather than a record still arriving, so report it instead of stalling forever. + const Int MAX_SANE_RECORD_SIZE = 2 * 1024 * 1024; + + const Int fixedSize = sizeof(UnsignedInt) + sizeof(GameMessage::Type) + sizeof(Int) + sizeof(UnsignedByte); + if (len < fixedSize) + return SCANRECORD_INCOMPLETE; + + UnsignedInt frame; + memcpy(&frame, buf, sizeof(frame)); + + Int pos = sizeof(UnsignedInt) + sizeof(GameMessage::Type) + sizeof(Int); + UnsignedByte numTypes = buf[pos]; + pos += sizeof(UnsignedByte); + + // All (argType, numArgs) pairs are written consecutively, and only then the argument payload + // for every type in order - see appendNextCommand(), which reads the full pair list before its + // readArgument() loop. So accumulate the payload size and add it once, after the pair list; + // skipping each type's payload inside this loop is correct only when numTypes == 1. + Int payloadSize = 0; + for (UnsignedByte i = 0; i < numTypes; ++i) { + if (pos + 2 > len) + return SCANRECORD_INCOMPLETE; + + UnsignedByte argType = buf[pos]; + UnsignedByte numArgs = buf[pos + 1]; + pos += 2; + + Int argSize = replayArgumentSize(argType); + if (argSize < 0) + return SCANRECORD_CORRUPT; + + payloadSize += argSize * (Int)numArgs; + if (payloadSize > MAX_SANE_RECORD_SIZE) + return SCANRECORD_CORRUPT; + } + + pos += payloadSize; + if (pos > len) + return SCANRECORD_INCOMPLETE; + + if (outSize) + *outSize = pos; + if (outFrame) + *outFrame = frame; + return SCANRECORD_OK; +} + +// ============================================================================ +// Parse cursor - publishes the live-edge and safe-read watermarks +// ============================================================================ + +void LiveObserver::resetParseCursor(Int bodyStartOffset) +{ + m_parseTail.clear(); + m_parseAbsOffset = bodyStartOffset; + m_bodyStartOffset = bodyStartOffset; + m_parseCorrupt = false; + m_parseGapPending = false; + m_maxCompleteFrame.store(0); + m_liveFrameHint.store(0); + m_srcLogicFps.store(0); + m_srcPingMs.store(0); + m_safeReadOffset.store(bodyStartOffset); +} + +void LiveObserver::advanceParseCursor(Int chunkOffset, const unsigned char* data, size_t dataLen) +{ + if (m_parseCorrupt || dataLen == 0) + return; + + // The relay appends body data strictly in order, so chunks normally arrive contiguously. The + // exception is the observer-join race: a live chunk can reach a freshly-registered observer + // before catch-up has sent the earlier ones, leaving a hole. Parsing across a hole would feed + // uninitialised bytes to the scanner, so stall the watermark instead; the cursor resumes by + // itself once the missing bytes are backfilled and a contiguous chunk arrives. + Int expected = m_parseAbsOffset + (Int)m_parseTail.size(); + if (chunkOffset != expected) + { + // Latch it: the frame heartbeat's "every record up to N was sent" guarantee is worthless + // while we know some of those records have not landed here yet. + m_parseGapPending = true; + liveObserverLog("LiveObserver: parse cursor gap - chunkOffset=%d expected=%d, watermark stalled\n", + chunkOffset, expected); + return; + } + + m_parseGapPending = false; + m_parseTail.insert(m_parseTail.end(), data, data + dataLen); + + Int consumed = 0; + UnsignedInt maxFrame = m_maxCompleteFrame.load(); + const Int tailSize = (Int)m_parseTail.size(); + + while (consumed < tailSize) + { + Int recSize = 0; + UnsignedInt recFrame = 0; + ScanRecordResult r = scanReplayRecord(&m_parseTail[consumed], tailSize - consumed, &recSize, &recFrame); + + if (r == SCANRECORD_INCOMPLETE) + break; + + if (r == SCANRECORD_CORRUPT) + { + // Fail closed: freezing the watermark stops playback advancing into data we cannot + // trust, which is recoverable and diagnosable. + m_parseCorrupt = true; + liveObserverLog("LiveObserver: parse cursor CORRUPT at abs offset %d - watermark frozen at frame %u\n", + m_parseAbsOffset + consumed, maxFrame); + break; + } + + consumed += recSize; + if (recFrame > maxFrame) + maxFrame = recFrame; + } + + if (consumed > 0) + { + m_parseTail.erase(m_parseTail.begin(), m_parseTail.begin() + consumed); + m_parseAbsOffset += consumed; + // Publish the frame before the offset: a reader that sees the new safe offset must + // never see a stale live edge for the records it is now allowed to read. + m_maxCompleteFrame.store(maxFrame); + m_safeReadOffset.store(m_parseAbsOffset); + } +} + +// ============================================================================ +// Buffering gate +// ============================================================================ +// +// The observer never plays closer to the live game than the broadcast delay. The decision is made +// here because every input to it - the delay, the live edge, whether the initial buffer has been +// built - belongs to this session; the Recorder only carries out what this decides. + +void LiveObserver::updatePlaybackGate(UnsignedInt curFrame) +{ + // Never hold the game before it has started, and not for the first few ticks after either. + // The map load and object creation run inside GameLogic::update(), which the pause stops from + // being called at all - so pausing this early means the game never starts while TheGameClient + // keeps updating above the halt. Likewise the scene is not composed until logic has run for a + // few ticks, so a hold at frame 1 renders nothing: not the map, and not the buffering + // countdown meant to explain the wait. Frames barely advance in this window, so nothing is + // lost by waiting it out. + if (TheGameLogic == nullptr || !TheGameLogic->isInGame() || TheGameLogic->isInShellGame() + || TheGameLogic->isStartingNewGame() + || curFrame < (UnsignedInt)LIVE_PREROLL_WARMUP_FRAMES) + { + if (TheGameLogic != nullptr && m_autoPaused && TheGameLogic->isGamePaused()) + { + TheGameLogic->setGamePaused(FALSE, FALSE, FALSE); + m_autoPaused = FALSE; + } + // The gate is not being evaluated, so it must not keep reporting a hold from the + // last tick it was. + m_holdPlayback = FALSE; + m_nearLiveHeld = FALSE; + return; + } + + const UnsignedInt liveEdge = getLiveEdge(); + const UnsignedInt gap = (liveEdge > curFrame) ? (liveEdge - curFrame) : 0; + const UnsignedInt delayFrames = getEffectiveDelaySeconds() * LOGICFRAMES_PER_SECOND; + const Bool streamEnded = m_streamEnded.load(); + + // The two bounds are deliberately asymmetric, because they answer different questions. + // + // engageBelow - when must we stop? Only at the broadcast delay, which is a hard promise (a + // normal viewer may never see closer to live than that). With no client-side delay it is 0: + // nothing forces a hold except running out of data altogether. + // + // releaseAbove - how much do we rebuild before resuming? The target lead plus a margin. + // Resuming the instant one frame is available leaves no cushion, so the next hiccup stalls + // again and the equilibrium lead grinds to zero. The margin is one heartbeat interval, so the + // observer plays a full tick's worth before nearing the engage bound again. + // + // The gate only ever chooses between running and waiting; playback is always exactly 100%. + const UnsignedInt targetLead = getTargetLeadFrames(); + const UnsignedInt engageBelow = delayFrames; + const UnsignedInt releaseAbove = targetLead + LIVE_GATE_RELEASE_MARGIN_FRAMES; + + // Fast-forward auto-disable. Uses the release bound so the join catch-up stops exactly + // where the gate will settle: any closer to the edge and a fast-forward would spoil the + // live game. + if (gap <= releaseAbove) + { + if (TheWritableGlobalData) + TheWritableGlobalData->m_TiVOFastMode = FALSE; + } + + // Pre-roll: hold playback until the initial buffer has been built once, then latch for the + // rest of the session - after this the lead is maintained by the near-live gate below. This + // is where the jitter buffer is paid for: once, at join, as a fixed offset. A finished stream + // is the escape hatch for a game that ends before ever buffering the full target; without it + // a short game would pre-roll-pause forever. + // + // Built to releaseAbove rather than targetLead, so a session starts with the same lead a hold + // resumes at. Pre-rolling to the bare target meant every session began on less than half the + // cushion it maintains for the rest of the match (8 frames against 18 at the default 250 ms + // jitter buffer), which makes the first hiccup an underrun by construction - a pause a few + // seconds in, then steady, exactly as reported 2026-08-15. The extra wait at join is one + // heartbeat interval. + if (!m_preRollComplete && (gap >= releaseAbove || streamEnded)) + m_preRollComplete = TRUE; + + // The gate is purely a function of the gap, deliberately not of whether a record happened to + // be readable this tick: an "did we hit EOF" term would make the two callers of this function + // disagree, because the poll runs before GameLogic::UPDATE() and cannot know. + const Bool wasNearLiveHeld = m_nearLiveHeld; + if (m_preRollComplete && !streamEnded) + { + if (gap <= engageBelow) + m_nearLiveHeld = TRUE; + else if (gap > releaseAbove) + m_nearLiveHeld = FALSE; + } + else + { + m_nearLiveHeld = FALSE; + } + // Every FALSE->TRUE is one underrun: the lead we banked was not enough for what just + // happened. The count is the honest measure of whether the buffer is sized right. + if (!wasNearLiveHeld && m_nearLiveHeld) + ++m_underrunCount; + + const Bool preRollGate = !m_preRollComplete; + m_holdPlayback = (preRollGate || m_nearLiveHeld) && !streamEnded; + + // Distinguish normal delay-holding from a genuine stall for the status bar's benefit. + // At the boundary the hold toggles constantly, which is healthy; what the observer + // actually wants flagged is the source having stopped producing data altogether. + const UnsignedInt nowMs = timeGetTime(); + if (liveEdge != m_lastSeenLiveEdge) + { + m_lastSeenLiveEdge = liveEdge; + m_lastLiveEdgeChangeMs = nowMs; + } + m_stalled = m_holdPlayback && !streamEnded && (nowMs - m_lastLiveEdgeChangeMs) > LIVE_STALL_THRESHOLD_MS; + + // Rate-matched playback. Runs after the gate has decided, so the pace reported alongside a + // hold is the one that goes with it, and so a paused tick cannot be paced off a frozen clock. + updatePlaybackPace(nowMs, gap, targetLead, streamEnded); + + // Gate trace, once a second. Playback and source rates are reported as frames per interval + // rather than as counters: if both read ~LOGICFRAMES_PER_SECOND the session is healthy, a + // source rate below it means the streamer's own simulation is running slow (nothing the + // buffer can fix - see the notes on rate-matched playback), and a source rate at nominal + // while the gap collapses means the transport is losing ground. + if (nowMs - m_lastGateLogMs >= 1000) + { + const UnsignedInt elapsedMs = (m_lastGateLogMs != 0) ? (nowMs - m_lastGateLogMs) : 0; + if (elapsedMs > 0) + { + const UnsignedInt playedFrames = (curFrame > m_lastGateLogFrame) ? (curFrame - m_lastGateLogFrame) : 0; + const UnsignedInt sourceFrames = (liveEdge > m_lastGateLogEdge) ? (liveEdge - m_lastGateLogEdge) : 0; + liveObserverLog("GATE: cur=%u (%u/s) edge=%u (%u/s) src=%u pace=%u rec=%u hb=%u gap=%u " + "engage=%u release=%u hold=%d preroll=%d stall=%d underruns=%u safeOff=%d\n", + curFrame, playedFrames * 1000 / elapsedMs, + liveEdge, sourceFrames * 1000 / elapsedMs, + m_sourceFps, m_paceFps, + m_maxCompleteFrame.load(), m_liveFrameHint.load(), + gap, engageBelow, releaseAbove, + m_holdPlayback ? 1 : 0, m_preRollComplete ? 1 : 0, m_stalled ? 1 : 0, + m_underrunCount, m_safeReadOffset.load()); + } + m_lastGateLogMs = nowMs; + m_lastGateLogFrame = curFrame; + m_lastGateLogEdge = liveEdge; + } + + // The user's intent and ours are independent inputs to one decision, so a manual pause + // can never be silently undone by buffering, nor vice versa. + const Bool shouldBePaused = m_userPaused || m_holdPlayback; + if (shouldBePaused != TheGameLogic->isGamePaused()) + { + TheGameLogic->setGamePaused(shouldBePaused, FALSE, FALSE); + m_autoPaused = shouldBePaused && !m_userPaused; + } +} + +// ============================================================================ +// Pace controller - rate-matched playback +// ============================================================================ +// +// See the notes on LIVE_PACE_* in LiveObserver.h for why this exists at all. In short: the source +// does not produce frames at a fixed rate, so consuming them at one is a losing race, and the gate +// answering that race with a full pause is the most visible possible way to lose it. + +void LiveObserver::updatePlaybackPace(UnsignedInt nowMs, UnsignedInt gap, UnsignedInt targetLead, + Bool streamEnded) +{ + if (TheFramePacer == nullptr) + return; + + // Record where the live edge is now. The slope of these samples is the source's own logic + // rate: how many frames the streamer's simulation actually produced per second of wall clock. + const UnsignedInt edge = getLiveEdge(); + if (m_paceSampleCount == LIVE_PACE_MAX_SAMPLES) + { + memmove(&m_paceSamples[0], &m_paceSamples[1], sizeof(PaceSample) * (LIVE_PACE_MAX_SAMPLES - 1)); + --m_paceSampleCount; + } + m_paceSamples[m_paceSampleCount].ms = nowMs; + m_paceSamples[m_paceSampleCount].edge = edge; + ++m_paceSampleCount; + + // Drop everything older than the window, but never the last two - a slope needs two points, + // and on a stalled stream no sample would otherwise stay young enough to qualify. + Int oldest = 0; + while (oldest < m_paceSampleCount - 2 + && (nowMs - m_paceSamples[oldest].ms) > (UnsignedInt)LIVE_PACE_WINDOW_MS) + { + ++oldest; + } + if (oldest > 0) + { + memmove(&m_paceSamples[0], &m_paceSamples[oldest], sizeof(PaceSample) * (m_paceSampleCount - oldest)); + m_paceSampleCount -= oldest; + } + + // The slope is only meaningful once it spans most of the window; before that a joining + // observer would be paced off two samples of noise. + const UnsignedInt spanMs = (m_paceSampleCount >= 2) + ? (m_paceSamples[m_paceSampleCount - 1].ms - m_paceSamples[0].ms) : 0; + if (spanMs >= (UnsignedInt)(LIVE_PACE_WINDOW_MS / 2)) + { + const UnsignedInt spanFrames = (m_paceSamples[m_paceSampleCount - 1].edge > m_paceSamples[0].edge) + ? (m_paceSamples[m_paceSampleCount - 1].edge - m_paceSamples[0].edge) : 0; + m_sourceFps = spanFrames * 1000 / spanMs; + } + + // Nominal in every case the controller has no business slowing: before the buffer is built, + // once the stream has ended (the tail is all local now, so play it at full speed), and while + // fast-forward is running, which bypasses the logic time scale anyway. + // + // The sampling above runs unconditionally, including while matching is switched off: the + // measured source rate is what the gate trace reports and what the viewer is being shown, so + // it must stay live whether or not playback is currently following it. + const Bool fastForwarding = (TheGlobalData != nullptr && TheGlobalData->m_TiVOFastMode); + if (!m_paceMatchingEnabled || !m_preRollComplete || streamEnded || fastForwarding || m_sourceFps == 0) + { + applyPaceFps(LOGICFRAMES_PER_SECOND); + return; + } + + // Follow the source, plus a correction that repays or spends the difference between the lead + // we hold and the lead we want. The correction is what keeps the buffer at its target instead + // of wherever the last hiccup left it. + const Int error = (Int)gap - (Int)targetLead; + Int correction = error / LIVE_PACE_CORRECTION_SECONDS; + + // Bound the correction relative to the source, not absolutely. The correction is meant to + // nudge the pace so the lead drifts back to target; at 60 fps a raw +8 is a 13% nudge, but at + // a source rate of 3 it is +267% and playback stops matching the match in any useful sense - + // it becomes a slow fast-forward (observed 2026-08-15: L: 3 against P: 11). Spending a + // backlog deliberately is what the F8 toggle is for, so the controller stays conservative. + Int maxCorrection = (Int)m_sourceFps / 2; + if (maxCorrection < LIVE_PACE_MIN_CORRECTION_FPS) + maxCorrection = LIVE_PACE_MIN_CORRECTION_FPS; + if (correction > maxCorrection) + correction = maxCorrection; + else if (correction < -maxCorrection) + correction = -maxCorrection; + + Int desired = (Int)m_sourceFps + correction; + + // Never above nominal: that would close the distance to the live game, which is the one thing + // the broadcast delay promises will not happen. Fast-forward owns catching up, and it already + // refuses inside the delay boundary. + if (desired > LOGICFRAMES_PER_SECOND) + desired = LOGICFRAMES_PER_SECOND; + if (desired < LIVE_PACE_MIN_FPS) + desired = LIVE_PACE_MIN_FPS; + + applyPaceFps(desired); +} + +void LiveObserver::applyPaceFps(Int paceFps) +{ + if (TheFramePacer == nullptr) + return; + + // Returning to nominal is always allowed through: it is the safe state, and making it wait on + // the hysteresis below would leave playback slowed after the reason for slowing had gone. + const Bool toNominal = (paceFps >= LOGICFRAMES_PER_SECOND); + if (!toNominal) + { + const Int delta = (paceFps > (Int)m_paceFps) ? (paceFps - (Int)m_paceFps) : ((Int)m_paceFps - paceFps); + if (delta < LIVE_PACE_MIN_STEP_FPS) + return; + const UnsignedInt nowMs = timeGetTime(); + if (m_lastPaceApplyMs != 0 && (nowMs - m_lastPaceApplyMs) < (UnsignedInt)LIVE_PACE_MIN_INTERVAL_MS) + return; + m_lastPaceApplyMs = nowMs; + } + + if (toNominal && m_paceFps == (UnsignedInt)LOGICFRAMES_PER_SECOND) + return; // already there; do not touch the pacer every tick + + // Latch what the pacer looked like before this session touched it, so ending the session can + // hand it back unchanged. Done here rather than at construction because the pacer may not be + // in its final state until the game is actually running. + if (!m_pacerTouched) + { + m_savedLogicScaleFps = TheFramePacer->getLogicTimeScaleFps(); + m_savedLogicScaleEnabled = TheFramePacer->isLogicTimeScaleEnabled(); + m_pacerTouched = TRUE; + } + + // The set/enable/set dance is the idiom the replay game-speed hotkey uses (CommandXlat.cpp): + // the value is written before and after toggling, so the scale can never re-enable carrying a + // stale one. enableLogicTimeScale is not bookkeeping - canUpdateRegularGameLogic runs logic + // every render frame when the scale is at or above the render cap, so the accumulator that + // actually slows playback only engages below it. + const Int maxRenderFps = TheFramePacer->getActualFramesPerSecondLimit(); + TheFramePacer->setLogicTimeScaleFps(paceFps); + TheFramePacer->enableLogicTimeScale(paceFps < maxRenderFps); + TheFramePacer->setLogicTimeScaleFps(paceFps); + + m_paceFps = (UnsignedInt)paceFps; +} + +void LiveObserver::restorePlaybackPace() +{ + if (!m_pacerTouched || TheFramePacer == nullptr) + return; + + TheFramePacer->setLogicTimeScaleFps(m_savedLogicScaleFps); + TheFramePacer->enableLogicTimeScale(m_savedLogicScaleEnabled); + TheFramePacer->setLogicTimeScaleFps(m_savedLogicScaleFps); + m_pacerTouched = FALSE; + m_paceFps = LOGICFRAMES_PER_SECOND; +} + +void LiveObserver::noteDesync(UnsignedInt frame) +{ + if (m_desyncFrame != 0) + return; + + m_desyncFrame = frame; + + // Logged with the gate's state and both edges: the questions about a divergence are whether + // we had run out of data, and whether the heartbeat let playback run past where the records + // actually reached (recordEdge far behind curFrame with heartbeat ahead of it). + liveObserverLog("DESYNC: observer diverged from the stream at frame %u. curFrame=%u liveEdge=%u " + "recordEdge=%u heartbeat=%u delayFrames=%u holdPlayback=%d stalled=%d preRoll=%d\n", + frame, TheGameLogic ? TheGameLogic->getFrame() : 0, getLiveEdge(), + getMaxCompleteFrame(), m_liveFrameHint.load(), getDelayFrames(), + m_holdPlayback ? 1 : 0, m_stalled ? 1 : 0, m_preRollComplete ? 1 : 0); +} + +UnsignedInt LiveObserver::getTargetLeadFrames() const +{ + const UnsignedInt delayFrames = getEffectiveDelaySeconds() * LOGICFRAMES_PER_SECOND; + + // Settings may not exist yet on very early calls (the countdown can be asked before the + // online services are up). Falling back to the delay alone never claims a cushion we have + // not established. + if (NGMP_OnlineServicesManager::GetInstance() == nullptr) + return delayFrames; + + const Int jitterMs = NGMP_OnlineServicesManager::Settings.LiveObserver_GetJitterBufferMs(); + const UnsignedInt jitterFrames = + (UnsignedInt)((jitterMs * LOGICFRAMES_PER_SECOND + 999) / 1000); // round up + + return jitterFrames > delayFrames ? jitterFrames : delayFrames; +} + +Bool LiveObserver::isWithinBroadcastDelay(UnsignedInt curFrame) const +{ + const UnsignedInt liveEdge = getLiveEdge(); + const UnsignedInt gap = (liveEdge > curFrame) ? (liveEdge - curFrame) : 0; + // The gate's release bound, so the join catch-up stops fast-forwarding exactly where the gate + // will settle. Inside it, a fast-forward would spoil the live game. + return gap <= getTargetLeadFrames() + LIVE_GATE_RELEASE_MARGIN_FRAMES; +} + +Bool LiveObserver::isPlaybackReady() const +{ + if (!m_headerReceived.load()) + return false; + if (m_streamEnded.load()) + return true; + + // Playback may only start once the file is safe to read: the header plus at least the + // first body record, and enough complete records to cover the whole broadcast delay. + // The delay boundary proves the buffer is built because records arrive in order - and + // it also guarantees the first record is present, which the Recorder's seeding read + // depends on. A zero delay still needs that first record, hence the offset check. + if (m_safeReadOffset.load() <= m_bodyStartOffset) + return false; + return getMaxCompleteFrame() >= getEffectiveDelaySeconds() * LOGICFRAMES_PER_SECOND; +} + +Int LiveObserver::getBroadcastDelayRemainingSeconds() const +{ + if (!m_delayWaitActive.load()) + return 0; + + const UnsignedInt nowMs = timeGetTime(); + const UnsignedInt deadline = m_delayWaitDeadlineMs.load(); + if (deadline <= nowMs) + return 0; + + // Round up, so the countdown only reads 0 when the hold is genuinely over. + return (Int)((deadline - nowMs + 999) / 1000); +} + +Int LiveObserver::getSecondsUntilPlaybackReady() const +{ + // The GO admission hold replaces the pre-roll wait: while the ticket itself is held + // behind the broadcast delay there is no file yet, so the countdown must come from the + // hold deadline, not from the buffer. + if (isWaitingForBroadcastDelay()) + return getBroadcastDelayRemainingSeconds(); + + if (isPlaybackReady()) + return 0; + + // Before the ticket/ROLE arrive there is no authoritative delay yet: use the expected + // lobby delay (pre-seeded at connect), which is what the countdown should show while + // GO is still holding. Once connected, the relay/GO values apply. + const UnsignedInt delaySeconds = m_connected.load() ? getEffectiveDelaySeconds() : getExpectedDelaySeconds(); + const UnsignedInt delayFrames = delaySeconds * LOGICFRAMES_PER_SECOND; + const UnsignedInt edge = getMaxCompleteFrame(); + const UnsignedInt remaining = (delayFrames > edge) ? (delayFrames - edge) : 0; + // Round up so the countdown only reads 0 when playback can genuinely start. + return (Int)((remaining + LOGICFRAMES_PER_SECOND - 1) / LOGICFRAMES_PER_SECOND); +} + +UnsignedInt LiveObserver::getJoinTimeoutMs() const +{ + // While GO holds the ticket behind the broadcast delay the wait can be minutes long + // (the host's delay, up to 600 s). The timeout must cover the remaining hold plus + // headroom, or the join pump would abandon a perfectly healthy wait. + if (m_delayWaitActive.load()) + { + return getBroadcastDelayRemainingSeconds() * 1000 + 60000; + } + + // A server-held stream never needs the client's pre-roll buffer (effective delay 0), + // so the whole wait is connection + first record - headroom only. + if (m_serverHeld.load()) + { + return 60000; + } + + // Before the ticket/ROLE arrive, time out on the expected lobby delay (pre-seeded at + // connect) rather than the ROLE default: the pre-live phase can legitimately last the + // whole delay once the join is queued at game start. + const UnsignedInt delaySeconds = m_connected.load() ? getDelaySeconds() : getExpectedDelaySeconds(); + + // Worst case is a freshly-started game, where the stream must produce a full delay's worth of + // records before playback may begin; a game already past the delay is playable the moment its + // catch-up arrives. The headroom covers the connection, ticket minting and the first record, + // and also the lobby-observer flow, where the ticket retry waits out the stream going live. + return delaySeconds * 1000 + 60000; +} + +UnsignedInt LiveObserver::getJoinDeadlineMs() const +{ + // While GO holds the ticket behind the broadcast delay the wait is the hold itself, so the + // deadline is the absolute hold end plus headroom, refreshed forward on every 423. + // + // The hold deadline is sticky: once any 423 has armed it, it governs the rest of the session, + // even after the ticket is granted and m_delayWaitActive clears. The post-admission phase + // (relay connect + first record) must not fall back to an elapsed budget measured from + // connect(), because a hold longer than the headroom would leave that budget already expired + // the moment the stream became watchable. + const UnsignedInt holdDeadline = m_delayWaitDeadlineMs.load(); + if (holdDeadline != 0) + { + return holdDeadline + 60000; + } + + // Never held: join start plus the ordinary timeout budget, measured once against the + // absolute baseline set at connect (and re-based forward at ticket grant). + return m_joinStartedAtMs.load() + getJoinTimeoutMs(); +} + +LiveObserver::~LiveObserver() +{ + // The pause is global game state, so unlike every other field here it does not disappear + // with the object - a session left holding it hands the next one an already-halted game. + if (m_autoPaused && TheGameLogic != nullptr && TheGameLogic->isGamePaused()) + TheGameLogic->setGamePaused(FALSE, FALSE, FALSE); + + // Same reasoning for the frame pacer: a slowed logic scale left behind would follow the + // player into their next game and look exactly like an engine bug. + restorePlaybackPace(); + + close(); +} + +LiveObserver* createLiveObserver() +{ + return new LiveObserver(); +} + +// One-shot "a live-observer game just ended" latch; see LiveObserverConsumeReturnedFromGame +// in LiveObserver.h. +static bool g_bLiveObserverReturnedFromGame = false; + +Bool LiveObserverConsumeReturnedFromGame(void) +{ + const Bool returned = g_bLiveObserverReturnedFromGame ? TRUE : FALSE; + g_bLiveObserverReturnedFromGame = false; + return returned; +} + +void liveObserverEndSession(void) +{ + liveObserverLog("liveObserverEndSession: observer=%s\n", TheLiveObserver ? "destroying" : "(none)"); + + if (TheLiveObserver) + { + TheLiveObserver->close(); + delete TheLiveObserver; + TheLiveObserver = nullptr; + } + + // Only latch when a game was actually running (a stream END or an in-game quit both end the + // session before the game clears). An aborted join ends the session from the shell, with no + // game, and must not re-route the next visit. + if (TheGameLogic != nullptr && TheGameLogic->isInInteractiveGame()) + g_bLiveObserverReturnedFromGame = true; + + // Closes the playback file and parks the playback cursor, but deliberately does not reset() + // the Recorder: the score screen runs immediately after and consults isMultiplayer() to pick + // between the multiplayer and single-player layouts, and the single-player one overrides the + // player names with "player". Keeping LIVE_OBSERVER mode and the header's game-info slots + // makes that report the streamer's game truthfully. A no-op when there was no session. + if (TheRecorder) + TheRecorder->endLivePlayback(); + + // Every way a session ends returns the player to the shell, and the shell map is the shell's + // backdrop. Restored here rather than in stopPlayback(), so that the clearGameData() path + // (the in-game exit button) does not leave a mapless shell. A session that is still starting + // never reaches this function - the guard in liveObserverOnGameCleared() returns first. + if (TheWritableGlobalData) + TheWritableGlobalData->m_shellMapOn = TRUE; +} + +void liveObserverOnGameCleared(void) +{ + if (TheLiveObserver == nullptr) + return; + + // A session that has not started playing is still being set up, and the thing clearing game + // data right now is that very setup: playbackFile() unloads the shell map before it reads the + // header. Ending the session here would destroy the observer that just finished connecting. + if (!TheLiveObserver->hasPlaybackStarted()) + { + liveObserverLog("liveObserverOnGameCleared: session is still starting, keeping it\n"); + return; + } + + liveObserverEndSession(); +} + +// ============================================================================ +// Network setup +// ============================================================================ + +bool LiveObserver::fetchWatchTicket(AsciiString& outConnectUrl) +{ + // GO owns admission to a livestream: it checks the session, confirms the lobby really is + // being streamed, and asks the relay for a single-use ticket on the player's behalf. What + // comes back is a complete connect URL, so nothing here needs to know the relay's address. + AsciiString url; + url.format("%s/observe/%s", liveServicesEndpoint("Livestreams").str(), m_gameId.str()); + + AsciiString body; + Int statusCode = 0; + + // The POST body carries the lobby password when one was supplied; unpassworded streams + // keep the empty body. Built once: nlohmann's dump() escapes it, so a quote in a + // password cannot break the JSON. + std::string postBody; + if (!m_password.empty()) + { + nlohmann::json pwPayload; + pwPayload["password"] = m_password; + postBody = pwPayload.dump(); + } + + // The observer can arrive just as the stream goes live, and GO answers 404 for that window + // (the relay may not hold the header yet, or GO may not have processed its liveness report), + // so retry on a short cadence for a bounded time instead of aborting. The loop aborts as soon + // as the session is cancelled, so LEAVE does not hang behind it. + // + // 401 must NOT retry: the stream is password-protected and the supplied password was missing + // or wrong. Latched and returned immediately, so the join pump can re-prompt. + // + // 423 is the broadcast-delay admission hold - GO will not mint the ticket until the stream + // has been live for the host's delay. See the 423 branch below. + const int64_t kTicketRetryWindowMs = 40000; + const auto retryStart = std::chrono::steady_clock::now(); + auto retryDeadline = retryStart + std::chrono::milliseconds(kTicketRetryWindowMs); + for (;;) + { + if (!m_shouldRun.load()) + { + liveObserverLog("LiveObserver::fetchWatchTicket: lobby=%s cancelled\n", + m_gameId.str()); + return false; + } + + if (!liveServicesRequest(url, TRUE, postBody.c_str(), body, statusCode)) + { + liveObserverLog("LiveObserver::fetchWatchTicket: lobby=%s failed (request not sent)\n", + m_gameId.str()); + return false; + } + + if (statusCode == 200) + { + // GO owns the broadcast delay: the ticket was only minted once the stream + // outlived it, so the relay stream is already delayed and this client must not + // hold playback itself. Absent field (older GO) keeps the client-side hold. + try + { + nlohmann::json ticketResponse = nlohmann::json::parse(body.str()); + if (ticketResponse.is_object() && ticketResponse.contains("server_held") + && ticketResponse["server_held"].is_boolean()) + { + m_serverHeld.store(ticketResponse["server_held"].get() ? TRUE : FALSE); + } + } + catch (const nlohmann::json::exception&) { } + m_delayWaitActive.store(FALSE); + + // Admission granted: re-base the join clock, because connect() ran before the hold + // and the hold can outlive the elapsed budget it started - without this the join + // pump's deadline would already be in the past the moment the hold ended. + m_joinStartedAtMs.store(timeGetTime()); + break; + } + + if (statusCode == 401) + { + // Wrong or missing password for a password-protected stream. + m_passwordRejected.store(TRUE); + liveObserverLog("LiveObserver::fetchWatchTicket: lobby=%s password rejected (status=%d)\n", + m_gameId.str(), statusCode); + return false; + } + + if (statusCode == 423) + { + Int holdRemainingSeconds = 0; + try + { + nlohmann::json holdResponse = nlohmann::json::parse(body.str()); + if (holdResponse.is_object() && holdResponse.contains("delay_remaining_seconds") + && holdResponse["delay_remaining_seconds"].is_number_integer()) + { + holdRemainingSeconds = holdResponse["delay_remaining_seconds"].get(); + } + } + catch (const nlohmann::json::exception&) { } + + if (holdRemainingSeconds > 0) + { + m_delayWaitDeadlineMs.store(timeGetTime() + (UnsignedInt)holdRemainingSeconds * 1000); + m_delayWaitActive.store(TRUE); + retryDeadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds((int64_t)holdRemainingSeconds * 1000 + 30000); + + // Sleep to the end of the hold plus a small margin, rather than polling. Capped + // at 30s so a hold that ends early (stream gone, viewer granted priority + // mid-wait) is still picked up within half a minute: each wake re-requests, gets + // the fresh remaining hold, and re-arms. + Int64 holdSleepMs = (Int64)holdRemainingSeconds * 1000 + 500; + if (holdSleepMs > 30000) + { + holdSleepMs = 30000; + } + liveObserverLog("LiveObserver::fetchWatchTicket: lobby=%s held behind the " + "broadcast delay (%ds remaining), retrying in %lldms\n", + m_gameId.str(), holdRemainingSeconds, holdSleepMs); + std::this_thread::sleep_for(std::chrono::milliseconds(holdSleepMs)); + continue; + } + } + + if (std::chrono::steady_clock::now() > retryDeadline) + { + // 404 is the ordinary "that stream is over" answer: the game was listed a moment + // ago, but the relay has closed it since. Anything else is a real failure. + liveObserverLog("LiveObserver::fetchWatchTicket: lobby=%s gave up (status=%d) %s\n", + m_gameId.str(), statusCode, body.str()); + return false; + } + + liveObserverLog("LiveObserver::fetchWatchTicket: lobby=%s not watchable yet (status=%d), retrying\n", + m_gameId.str(), statusCode); + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + } + + bool success = false; + try + { + nlohmann::json response = nlohmann::json::parse(body.str()); + if (response.is_object() && response.contains("url") && response["url"].is_string()) + { + const std::string ticketUrl = response["url"].get(); + if (!ticketUrl.empty()) + { + outConnectUrl = ticketUrl.c_str(); + success = true; + } + } + } + catch (const nlohmann::json::exception&) + { + } + + liveObserverLog("LiveObserver::fetchWatchTicket: lobby=%s %s (status=%d)\n", + m_gameId.str(), success ? "succeeded" : "failed", statusCode); + return success; +} + +void LiveObserver::connect(const AsciiString& lobbyId, const std::string& password, + Int expectedDelaySeconds) +{ + m_shouldRun.store(true); + m_password = password; + m_passwordRejected.store(FALSE); + m_expectedDelaySeconds = expectedDelaySeconds; + m_joinStartedAtMs = timeGetTime(); + + // The lobby id is all this needs: admission runs through GO, which mints a single-use ticket + // for this player and answers with the complete relay URL (see fetchWatchTicket). + m_gameId = lobbyId.isEmpty() ? AsciiString("unknown") : lobbyId; + if (lobbyId.isEmpty()) + { + // No id means no session to ask GO about. Keep the filename unique to this instance + // anyway: a shared "_live.rep" is the worst possible name to collide on. + m_liveFilename.format("unknown_Instance%.2u_live.rep", + rts::ClientInstance::getInstanceId()); + liveObserverLog("LiveObserver::connect: no lobby id supplied\n"); + } + else + { + m_liveFilename.format("%s_live.rep", m_gameId.str()); + } + + liveObserverLog("LiveObserver::connect game=%s (file=%s)\n", + m_gameId.str(), m_liveFilename.str()); + + m_networkThread = std::thread(&LiveObserver::networkThreadFunc, this); +} + +void LiveObserver::close() +{ + m_shouldRun.store(false); + + if (m_networkThread.joinable()) + m_networkThread.join(); + + if (m_liveFile) + { + m_liveFile->close(); + m_liveFile = nullptr; + } + + m_connected.store(false); + m_headerReceived.store(false); + m_streamEnded.store(false); +} + +// ============================================================================ +// Live file management +// ============================================================================ + +bool LiveObserver::openLiveFile() +{ + AsciiString filepath = RecorderClass::getReplayDir(); + filepath.concat(m_liveFilename); + + m_liveFilePath = filepath; + + // Delete before opening. These files are named by game id and never cleaned up, so a rejoin + // or a session after a crash must not inherit the previous session's bytes. A failed delete + // usually means another process still holds the file, since streamer and observer are the + // same exe - two writers at absolute offsets in one file corrupt each other, so refuse + // rather than proceed. + if (remove(filepath.str()) == 0) + { + liveObserverLog("LiveObserver::openLiveFile removed leftover %s (previous session did not clean up)\n", + filepath.str()); + } + else if (errno != ENOENT) + { + liveObserverLog("LiveObserver::openLiveFile could NOT remove %s (errno=%d) - refusing to reuse it\n", + filepath.str(), errno); + return false; + } + + m_liveFile = TheFileSystem->openFile(filepath.str(), + File::WRITE | File::CREATE | File::TRUNCATE | File::BINARY); + if (!m_liveFile) + { + liveObserverLog("LiveObserver::openLiveFile FAILED for %s\n", filepath.str()); + return false; + } + + liveObserverLog("LiveObserver::openLiveFile opened %s\n", filepath.str()); + return true; +} + +// ============================================================================ +// Chat helpers +// +// Mirrored in LiveStreamer.cpp as wideToUtf8/utf8ToWide/appendU32LE - keep both copies in sync. +// ============================================================================ + +static std::string chatWideToUtf8(const UnicodeString& text) +{ + const wchar_t* src = text.str(); + const int len = WideCharToMultiByte(CP_UTF8, 0, src, -1, nullptr, 0, nullptr, nullptr); + if (len <= 1) // nothing but the terminator, or a failure + return std::string(); + std::string out(static_cast(len - 1), '\0'); + WideCharToMultiByte(CP_UTF8, 0, src, -1, &out[0], len, nullptr, nullptr); + return out; +} + +static UnicodeString chatUtf8ToWide(const std::string& utf8) +{ + const int len = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), (int)utf8.size(), nullptr, 0); + if (len <= 0) + return UnicodeString::TheEmptyString; + std::wstring tmp(static_cast(len), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), (int)utf8.size(), &tmp[0], len); + return UnicodeString(tmp.c_str()); +} + +static void chatAppendU32LE(std::vector& out, unsigned int value) +{ + out.push_back((char)(value & 0xFF)); + out.push_back((char)((value >> 8) & 0xFF)); + out.push_back((char)((value >> 16) & 0xFF)); + out.push_back((char)((value >> 24) & 0xFF)); +} + +namespace +{ + /// The signed-in user's display name, for spectator chat sends. Cached: the auth + /// interface outlives every live-observer session. + UnicodeString observerDisplayName() + { + static UnicodeString s_cached; + if (s_cached.isEmpty()) + { + NGMP_OnlineServices_AuthInterface* auth = + NGMP_OnlineServicesManager::GetInterface(); + if (auth) + s_cached.set(auth->GetDisplayNameW().c_str()); + } + return s_cached; + } +} + +/// Fixed spectator-chat display color (light blue) - matches the streamer-side style. +static const unsigned int SPECTATOR_CHAT_COLOR = 0x72ADF2u; + +void LiveObserver::displayChat(const ChatEntry& entry) +{ + RGBColor c; + c.setFromInt((Int)entry.colorArgb); + TheInGameUI->messageColor(true, &c, UnicodeString(L"%ls"), entry.text.str()); +} + +Bool LiveObserver::isSpectatorGateOpen(UnsignedInt curFrame) const +{ + // The 5-second spoiler rule: live spectator chat is shown only while the observer is within + // ~5s of the broadcast-delay boundary, i.e. effectively watching live. Further behind + // (pre-roll, stalls, long pauses) it would spoil the game it describes. + const UnsignedInt liveEdge = m_maxCompleteFrame.load(); + if (liveEdge <= curFrame) + return TRUE; // at or past the live edge - as live as it gets + const UnsignedInt gap = liveEdge - curFrame; + return gap <= getEffectiveDelaySeconds() * LOGICFRAMES_PER_SECOND + 5 * LOGICFRAMES_PER_SECOND; +} + +void LiveObserver::pollChatMessages(UnsignedInt curFrame) +{ + if (!TheInGameUI) + return; + + std::deque batch; + { + std::lock_guard lock(m_chatMutex); + if (m_chatQueue.empty()) + return; + batch.swap(m_chatQueue); + } + + const Bool interactive = TheGameLogic && TheGameLogic->isInInteractiveGame(); + const Bool gateOpen = isSpectatorGateOpen(curFrame); + std::deque holdback; + for (auto& entry : batch) + { + if (entry.disaster) + { + // Stream-failure notice: the stream is gone, so there is nothing left to spoil. + displayChat(entry); + } + else if (entry.spectator) + { + // Live meta-chat, shown per the F7 mode: auto = inside the spoiler window only, + // forced ON = always, OFF = never. Outside the window in auto mode it is dropped, + // never held - if you were not watching live, you missed it. + Bool showSpectator = FALSE; + if (m_spectatorChatMode == SPECTATOR_CHAT_FORCED_ON) + showSpectator = interactive; + else if (m_spectatorChatMode == SPECTATOR_CHAT_AUTO) + showSpectator = interactive && gateOpen; + if (showSpectator) + displayChat(entry); + } + else if (interactive && entry.frame <= curFrame) + { + // Player chat is frame-gated: released exactly when the observed game reaches the + // moment the streamer sent it, so it sits behind the same broadcast delay. + displayChat(entry); + } + else + { + holdback.push_back(entry); + } + } + if (!holdback.empty()) + { + std::lock_guard lock(m_chatMutex); + // Reinsert at the FRONT: these are the oldest entries and must drain in order. + m_chatQueue.insert(m_chatQueue.begin(), holdback.begin(), holdback.end()); + } +} + +void LiveObserver::sendSpectatorChat(const UnicodeString& text) +{ + if (!m_connected.load() || !m_shouldRun.load()) + { + liveObserverLog("LiveObserver::sendSpectatorChat DROPPED (not connected)\n"); + return; + } + + // [nameLen u32 LE][UTF-8 name][textLen u32 LE][UTF-8 text]. + std::string utf8Name = chatWideToUtf8(observerDisplayName()); + std::string utf8Text = chatWideToUtf8(text); + std::vector payload; + payload.reserve(8 + utf8Name.size() + utf8Text.size()); + chatAppendU32LE(payload, (unsigned int)utf8Name.size()); + payload.insert(payload.end(), utf8Name.begin(), utf8Name.end()); + chatAppendU32LE(payload, (unsigned int)utf8Text.size()); + payload.insert(payload.end(), utf8Text.begin(), utf8Text.end()); + + std::lock_guard lock(m_outboundChatMutex); + if (m_outboundChatQueue.size() < 100) + m_outboundChatQueue.push_back(payload); +} + +// ============================================================================ +// Frame handler +// ============================================================================ + +void LiveObserver::handleFrame(unsigned char type, const char* payload, size_t len) +{ + switch (type) + { + case 1: // LIVE_MSG_HEADER + { + if (!openLiveFile()) + return; + + if (len > 0) + m_liveFile->write(payload, len); + m_liveFile->flush(); + + // Do NOT reopen here: the Recorder needs the file to itself to read the header during + // playbackFile(). The PATCH/BODY handlers reopen lazily. + m_liveFile->close(); + m_liveFile = nullptr; + + // Body records start immediately after the header, so that is where the parse cursor + // begins. Reset here rather than only in the constructor, so a second live-observer + // session in the same process cannot inherit a stale watermark. + resetParseCursor((Int)len); + + m_headerReceived.store(true); + liveObserverLog("LiveObserver: HEADER received (%zu bytes), ready for playback\n", len); + break; + } + + case 2: // LIVE_MSG_PATCH + { + if (len < 8) + return; + + // Lazy-open for read/write; the file exists and must not be truncated. + if (!m_liveFile) + { + m_liveFile = TheFileSystem->openFile(m_liveFilePath.str(), File::READWRITE | File::BINARY); + } + if (!m_liveFile) + return; + + const unsigned char* p = (const unsigned char*)payload; + Int offset = (Int)p[0] | ((Int)p[1] << 8) | ((Int)p[2] << 16) | ((Int)p[3] << 24); + Int dataLen = (Int)p[4] | ((Int)p[5] << 8) | ((Int)p[6] << 16) | ((Int)p[7] << 24); + + if (dataLen <= 0 || (size_t)(8 + dataLen) > len) + return; + + // Restore the append position afterwards: BODY writes seek absolutely, but the file + // handle is shared with them. + Int fileSize = (Int)m_liveFile->size(); + Int seekRes = m_liveFile->seek(offset, File::seekMode::START); + if (seekRes == offset) + { + m_liveFile->write(payload + 8, dataLen); + m_liveFile->seek(fileSize, File::seekMode::START); + } + break; + } + + case 3: // LIVE_MSG_BODY + { + // BODY payload: [8B offset uint64 LE][data] + if (len < 8) + { + liveObserverLog("LiveObserver: BODY frame too short (len=%d)\n", (int)len); + return; + } + + const unsigned char* p = (const unsigned char*)payload; + Int offset = (Int)(p[0] | ((unsigned long long)p[1] << 8) + | ((unsigned long long)p[2] << 16) | ((unsigned long long)p[3] << 24) + | ((unsigned long long)p[4] << 32) | ((unsigned long long)p[5] << 40) + | ((unsigned long long)p[6] << 48) | ((unsigned long long)p[7] << 56)); + size_t dataLen = len - 8; + if (dataLen == 0) + { + liveObserverLog("LiveObserver: BODY frame with zero dataLen at offset=%d\n", offset); + return; + } + + // Lazy-open for read/write; the file exists from the HEADER handler and must not be + // truncated. + if (!m_liveFile) + { + m_liveFile = TheFileSystem->openFile(m_liveFilePath.str(), File::READWRITE | File::BINARY); + } + if (!m_liveFile) + { + liveObserverLog("LiveObserver: BODY openFile FAILED for %s\n", m_liveFilePath.str()); + return; + } + + m_liveFile->seek(offset, File::seekMode::START); + m_liveFile->write(payload + 8, (Int)dataLen); + m_liveFile->flush(); + + // Scan the bytes just committed, so the game thread's live edge and safe-read limit are + // up to date the moment the data is readable. + advanceParseCursor(offset, (const unsigned char*)(payload + 8), dataLen); + break; + } + + case 5: // LIVE_MSG_ROLE - session config, sent by the relay ahead of the HEADER + { + // The broadcast delay must be applied before playback starts, because the pre-roll buffer + // latches against it and there is no un-latching once a session is running - which is why + // the relay sends this frame before the HEADER that triggers the game start. + std::string json(payload, len); + liveObserverLog("LiveObserver: ROLE received: %s\n", json.c_str()); + + const char* delayStart = strstr(json.c_str(), "\"delay_seconds\":"); + if (delayStart) + { + delayStart += 16; // skip "delay_seconds": + Int delaySeconds = (Int)strtol(delayStart, nullptr, 10); + if (delaySeconds >= 0 && delaySeconds <= LIVE_DELAY_SECONDS_MAX) + { + m_delaySeconds.store((UnsignedInt)delaySeconds); + liveObserverLog("LiveObserver: broadcast delay set to %d seconds\n", delaySeconds); + } + else + { + liveObserverLog("LiveObserver: ignoring out-of-range delay_seconds=%d, keeping %u\n", + delaySeconds, m_delaySeconds.load()); + } + } + // No delay_seconds (older relay) simply leaves the built-in default in place. + break; + } + + case 9: // LIVE_MSG_TICK - the streamer's current logic frame + { + if (len < 4) + return; + + const unsigned char* p = (const unsigned char*)payload; + const UnsignedInt frame = (UnsignedInt)p[0] | ((UnsignedInt)p[1] << 8) + | ((UnsignedInt)p[2] << 16) | ((UnsignedInt)p[3] << 24); + + // The tick only proves "every record up to this frame has arrived" while the byte stream + // behind it is whole. A gap or a corrupt record means records below this frame may be + // missing, and acting on the tick would let playback run past them - which does not + // stall, it silently executes those commands at the wrong frame later and diverges the + // simulation for good. Fail closed and fall back to the record-derived edge; the cursor + // repairs itself when the missing bytes are backfilled. + // + // Same thread as advanceParseCursor, so reading its state needs no synchronisation. + if (m_parseCorrupt || m_parseGapPending) + break; + + // Monotonic, mirroring the relay: a late or duplicated tick must not walk the edge + // backwards under a game thread that has already simulated past it. + if (frame > m_liveFrameHint.load()) + m_liveFrameHint.store(frame); + break; + } + + case 10: // LIVE_MSG_STATS - the streamer's logic frame rate and ping + { + if (len < 8) + return; + + const unsigned char* p = (const unsigned char*)payload; + const UnsignedInt logicFps = (UnsignedInt)p[0] | ((UnsignedInt)p[1] << 8) + | ((UnsignedInt)p[2] << 16) | ((UnsignedInt)p[3] << 24); + const UnsignedInt pingMs = (UnsignedInt)p[4] | ((UnsignedInt)p[5] << 8) + | ((UnsignedInt)p[6] << 16) | ((UnsignedInt)p[7] << 24); + + // Display only - nothing simulates off these, so unlike the tick there is no ordering + // requirement against the body. Sent on change, so the last value received stands until + // the next one arrives; the streamer's heartbeat bounds how stale that can get. + m_srcLogicFps.store(logicFps); + m_srcPingMs.store(pingMs); + break; + } + + case 4: // LIVE_MSG_END + { + liveObserverLog("LiveObserver: END received\n"); + m_streamEnded.store(true); + + if (m_liveFile) + { + m_liveFile->flush(); + m_liveFile->close(); + m_liveFile = nullptr; + } + break; + } + + case 6: // LIVE_MSG_ERROR - the relay killed the session (a disaster, not a game end). + // Sent instead of a plain END when the stream was reaped or the relay is shutting down. + // The player gets an in-game notice; a normal stream END stays silent. + { + AsciiString errMsg; + if (len > 0) + errMsg.set(payload, len); + liveObserverLog("LiveObserver: ERROR from relay: %s\n", errMsg.str()); + + // Payload is JSON {"reason":"...","msg":"..."} when the relay sends one; older + // relays send a bare text. Parse the reason for the player-facing line. + UnicodeString reasonText(L"stream ended unexpectedly"); + if (len > 0) + { + const std::string json(payload, len); + static const char REASON_KEY[] = "\"reason\":\""; + const char* reasonStart = strstr(json.c_str(), REASON_KEY); + if (reasonStart) + { + reasonStart += strlen(REASON_KEY); + const char* reasonEnd = strchr(reasonStart, '"'); + if (reasonEnd) + reasonText = chatUtf8ToWide(std::string(reasonStart, reasonEnd - reasonStart)); + } + } + + { + std::lock_guard lock(m_chatMutex); + if (m_chatQueue.size() < 1000) + { + ChatEntry entry; + entry.frame = 0; + entry.colorArgb = 0xFF7A5Au; // red: a failure, not a chat line + entry.spectator = FALSE; + entry.disaster = TRUE; + entry.text.format(L"Stream lost - the relay ended the session (%ls)", + reasonText.str()); + m_chatQueue.push_back(entry); + } + } + m_streamEnded.store(true); + break; + } + + case 7: // LIVE_MSG_CHAT - player chat, frame-stamped by the streamer + { + // [frame u32 LE][textLen u32 LE][UTF-8 text][color u32 LE] + if (len < 12) + break; + const unsigned char* p = (const unsigned char*)payload; + unsigned int frame = (unsigned int)p[0] + | ((unsigned int)p[1] << 8) | ((unsigned int)p[2] << 16) | ((unsigned int)p[3] << 24); + unsigned int textLen = (unsigned int)p[4] + | ((unsigned int)p[5] << 8) | ((unsigned int)p[6] << 16) | ((unsigned int)p[7] << 24); + if ((uint64_t)len < 12ull + textLen) + break; + unsigned int colorArgb = (unsigned int)p[8 + textLen] + | ((unsigned int)p[9 + textLen] << 8) + | ((unsigned int)p[10 + textLen] << 16) + | ((unsigned int)p[11 + textLen] << 24); + + ChatEntry entry; + entry.frame = frame; + entry.colorArgb = colorArgb; + entry.spectator = FALSE; + entry.text = chatUtf8ToWide(std::string(payload + 8, textLen)); + { + std::lock_guard lock(m_chatMutex); + if (m_chatQueue.size() < 1000) + m_chatQueue.push_back(entry); + } + break; + } + + case 8: // LIVE_MSG_SPECTATOR_CHAT - live spectator meta-chat + { + // [nameLen u32 LE][UTF-8 name][textLen u32 LE][UTF-8 text] + if (len < 8) + break; + const unsigned char* p = (const unsigned char*)payload; + unsigned int nameLen = (unsigned int)p[0] + | ((unsigned int)p[1] << 8) | ((unsigned int)p[2] << 16) | ((unsigned int)p[3] << 24); + if ((uint64_t)len < 8ull + nameLen) + break; + const unsigned char* t = p + 4 + nameLen; + unsigned int textLen = (unsigned int)t[0] + | ((unsigned int)t[1] << 8) | ((unsigned int)t[2] << 16) | ((unsigned int)t[3] << 24); + if ((uint64_t)len < 8ull + nameLen + textLen) + break; + + ChatEntry entry; + entry.frame = 0; + entry.colorArgb = SPECTATOR_CHAT_COLOR; + entry.spectator = TRUE; + UnicodeString name = chatUtf8ToWide(std::string(payload + 4, nameLen)); + UnicodeString text = chatUtf8ToWide(std::string((const char*)(t + 4), textLen)); + entry.text.format(L"[%ls] %ls", name.str(), text.str()); + { + std::lock_guard lock(m_chatMutex); + if (m_chatQueue.size() < 1000) + m_chatQueue.push_back(entry); + } + break; + } + + default: + break; + } +} + +// ============================================================================ +// WebSocket I/O +// ============================================================================ + +bool LiveObserver::wsSendBinary(const unsigned char* data, size_t len) +{ + if (!m_curlEasy) + return false; + + size_t sent = 0; + CURLcode rc = curl_ws_send(m_curlEasy, data, len, &sent, 0, CURLWS_BINARY); + return (rc == CURLE_OK && sent == len); +} + +LiveObserver::WsRecvResult LiveObserver::wsRecv(std::vector& outBuffer) +{ + if (!m_curlEasy) + return WS_RECV_NONE; + + outBuffer.clear(); + outBuffer.resize(65536); + + const struct curl_ws_frame* meta = nullptr; + size_t nread = 0; + CURLcode rc = curl_ws_recv(m_curlEasy, outBuffer.data(), outBuffer.size(), &nread, &meta); + if (rc == CURLE_AGAIN) + { + outBuffer.clear(); + return WS_RECV_NONE; + } + if (rc != CURLE_OK) + { + // The connection itself is gone (relay crash, socket closed without an ERROR + // frame). Wind the session down like a connection loss instead of spinning on a + // dead socket until the watchdog catches up. + liveObserverLog("LiveObserver::wsRecv error: %d - connection lost, winding down the session\n", (int)rc); + m_connected.store(false); + m_streamEnded.store(true); + outBuffer.clear(); + return WS_RECV_NONE; + } + + // Any frame - stream bytes or the relay's protocol-level keepalive pings - proves the relay + // is alive; the watchdog below keys off this timestamp. + m_lastFrameReceivedMs.store(timeGetTime()); + + // Only binary payloads belong in the reassembly buffer: a PING/PONG/TEXT/CLOSE payload + // appended into the byte stream misparses everything after it. + if (meta != nullptr && (meta->flags & CURLWS_BINARY) == 0) + { + outBuffer.clear(); + return WS_RECV_SKIPPED; + } + + outBuffer.resize(nread); + return (nread > 0) ? WS_RECV_DATA : WS_RECV_SKIPPED; +} + +bool LiveObserver::connectToRelay() +{ + if (m_curlEasy) + { + curl_easy_cleanup((CURL*)m_curlEasy); + m_curlEasy = nullptr; + } + if (m_curlMulti) + { + curl_multi_cleanup((CURLM*)m_curlMulti); + m_curlMulti = nullptr; + } + + // No ticket, no connection. There is deliberately no fallback: the relay refuses any /watch + // without a valid ?ticket=, so connecting anyway would turn "GO would not admit you" into a + // connect that opens and is then rejected, which reads as a relay fault. + AsciiString connectUrl; + if (!fetchWatchTicket(connectUrl)) + { + liveObserverLog("LiveObserver::connectToRelay game=%s aborted (no watch ticket)\n", + m_gameId.str()); + return false; + } + + CURL* easy = curl_easy_init(); + if (!easy) + { + liveObserverLog("LiveObserver::connectToRelay curl_easy_init failed\n"); + return false; + } + + curl_easy_setopt(easy, CURLOPT_URL, connectUrl.str()); + curl_easy_setopt(easy, CURLOPT_CONNECT_ONLY, 2L); + + // wss:// needs a CA bundle. This libcurl is built against OpenSSL, which unlike Schannel does + // not consult the Windows certificate store, so without trust anchors it rejects every + // certificate as CURLE_PEER_FAILED_VERIFICATION (60). Same approach as HTTPRequest.cpp. + { + std::ifstream certFile("cacert.pem"); + if (certFile.good()) + { + certFile.close(); + curl_easy_setopt(easy, CURLOPT_CAINFO, "cacert.pem"); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYHOST, 2L); + } + else + { + liveObserverLog("LiveObserver: cacert.pem not found - TLS certificate verification DISABLED\n"); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYHOST, 0L); + } + } + + CURLM* multi = curl_multi_init(); + if (!multi) + { + curl_easy_cleanup(easy); + liveObserverLog("LiveObserver::connectToRelay curl_multi_init failed\n"); + return false; + } + + curl_multi_add_handle(multi, easy); + + int stillRunning = 0; + CURLMcode mc = curl_multi_perform(multi, &stillRunning); + while (mc == CURLM_OK && stillRunning > 0) + { + mc = curl_multi_poll(multi, NULL, 0, 1000, NULL); + if (mc == CURLM_OK) + mc = curl_multi_perform(multi, &stillRunning); + } + + if (mc != CURLM_OK) + { + liveObserverLog("LiveObserver::connectToRelay failed: %d\n", (int)mc); + curl_multi_remove_handle(multi, easy); + curl_multi_cleanup(multi); + curl_easy_cleanup(easy); + return false; + } + + int infoRunning = 0; + CURLMsg* infoMsg = curl_multi_info_read(multi, &infoRunning); + if (!infoMsg || infoMsg->data.result != CURLE_OK) + { + int result = infoMsg ? (int)infoMsg->data.result : -1; + liveObserverLog("LiveObserver::connectToRelay: handshake failed (result=%d)\n", result); + curl_multi_remove_handle(multi, easy); + curl_multi_cleanup(multi); + curl_easy_cleanup(easy); + return false; + } + + m_curlEasy = easy; + m_curlMulti = multi; + m_connected.store(true); + liveObserverLog("LiveObserver::connectToRelay connected (game=%s)\n", m_gameId.str()); + return true; +} + +// ============================================================================ +// Background network thread +// ============================================================================ + +void LiveObserver::networkThreadFunc() +{ + liveObserverLog("LiveObserver::networkThreadFunc started\n"); + + if (!connectToRelay()) + { + liveObserverLog("LiveObserver::networkThreadFunc connectToRelay failed\n"); + m_shouldRun.store(false); + return; + } + + m_connected.store(true); + + // Persistent buffer: multiple frames may arrive in one wsRecv call, or a frame may be split + // across several. + std::vector buf; + size_t totalBytesReceived = 0; + size_t totalFramesProcessed = 0; + + // Set when a drain pass stopped on its own cap rather than on an empty buffer, so the next + // pass polls with a zero timeout instead of sleeping on a socket whose data is already in + // curl's hands. + Bool moreBuffered = FALSE; + + while (m_shouldRun.load() && m_connected.load()) + { + { + // curl_multi_poll's out-param is numfds, not "transfers still running", and can be 0 + // on a timeout while curl_multi_perform() still has work. Perform unconditionally, or + // arrived bytes sit unprocessed while curl_ws_recv() keeps returning CURLE_AGAIN. + int numfds = 0; + CURLMcode mpoll = curl_multi_poll(m_curlMulti, NULL, 0, moreBuffered ? 0 : 50, &numfds); + if (mpoll != CURLM_OK) + { + liveObserverLog("LiveObserver: curl_multi_poll failed (%d), connection lost\n", (int)mpoll); + m_connected.store(false); + break; + } + int runningHandles = 0; + curl_multi_perform((CURLM*)m_curlMulti, &runningHandles); + } + + // Drain everything curl already holds, not one message per pass. curl_ws_recv yields a + // single message per call, while curl_multi_poll waits on the *socket* - so once curl has + // messages buffered the socket falls quiet, the poll burns its full timeout, and the + // receive rate collapses to one message per 50 ms (20/s, measured). The relay sends one + // BODY frame per streamer append, ~50/s on an active match, so a recv-once loop falls + // behind by ~30 frames a second for the whole match: playback starves, the buffering gate + // reads it as "caught up" and pauses, and the backlog is never recoverable. Seen as an + // observer 96 s behind the relay at stream end (2026-08-15). + { + // Bounded so the outbound chat drain and the relay watchdog below still get their + // turn on a permanently busy socket; the zero-timeout poll above means hitting the + // cap costs a loop pass, not a stall. + const int MAX_MESSAGES_PER_PASS = 512; + int drained = 0; + std::vector tmp; + for (;;) + { + if (drained >= MAX_MESSAGES_PER_PASS) + { + moreBuffered = TRUE; + break; + } + const WsRecvResult rr = wsRecv(tmp); + if (rr == WS_RECV_NONE) + { + moreBuffered = FALSE; + break; + } + ++drained; + if (rr == WS_RECV_DATA && !tmp.empty()) + { + totalBytesReceived += tmp.size(); + buf.insert(buf.end(), tmp.begin(), tmp.end()); + } + } + } + + // Process as many complete frames as possible from the buffer + while (buf.size() >= 5) + { + // char is signed on MSVC, so a length byte >= 0x80 cast straight to unsigned int + // sign-extends (0x87 -> 0xFFFFFF87) and corrupts roughly half of all lengths. Every + // byte must zero-extend through unsigned char first. + unsigned char msgType = (unsigned char)buf[0]; + unsigned int msgLen = (unsigned int)(unsigned char)buf[1] + | ((unsigned int)(unsigned char)buf[2] << 8) + | ((unsigned int)(unsigned char)buf[3] << 16) + | ((unsigned int)(unsigned char)buf[4] << 24); + + if ((uint64_t)buf.size() < 5ull + msgLen) + break; // partial frame - wait for more data + + const char* payload = (msgLen > 0) ? buf.data() + 5 : nullptr; + handleFrame(msgType, payload, msgLen); + ++totalFramesProcessed; + + // Remove the processed frame from the buffer (the length was validated against + // buf.size() in 64-bit above, so this cannot wrap) + buf.erase(buf.begin(), buf.begin() + 5 + (size_t)msgLen); + } + + // Send any queued spectator chat. Drained here because the curl handle is + // network-thread-owned; chat is sparse, so one frame per loop pass is plenty. + { + std::vector outbound; + { + std::lock_guard lock(m_outboundChatMutex); + if (!m_outboundChatQueue.empty()) + { + outbound = m_outboundChatQueue.front(); + m_outboundChatQueue.pop_front(); + } + } + if (!outbound.empty()) + { + // The relay expects the binary envelope [1B type][4B length][payload], the same + // framing the streamer's sendBinaryFrame applies; a bare payload is read as + // type/length and silently dropped. + std::vector framed; + framed.reserve(5 + outbound.size()); + framed.push_back((char)8); // LIVE_MSG_SPECTATOR_CHAT (see LiveStreamer.h) + chatAppendU32LE(framed, (unsigned int)outbound.size()); + framed.insert(framed.end(), outbound.begin(), outbound.end()); + if (!wsSendBinary((const unsigned char*)framed.data(), framed.size())) + liveObserverLog("LiveObserver: FAILED to send spectator chat (%zu bytes)\n", framed.size()); + } + } + + // Relay liveness watchdog: the relay's websocket library pings us every ~20 s, so a long + // silence can only mean the relay - or the connection to it - is gone. End the session + // the same way a stream END does, instead of freezing on the last frame forever. Gated on + // the header, because before it (the join, the broadcast-delay hold) the relay may + // legitimately be silent for longer than the threshold. + if (m_headerReceived.load() + && (timeGetTime() - m_lastFrameReceivedMs.load()) > (UnsignedInt)LIVE_RELAY_WATCHDOG_MS) + { + liveObserverLog("LiveObserver: no data from relay for %ums - connection lost, winding down the session\n", + (unsigned)(timeGetTime() - m_lastFrameReceivedMs.load())); + m_streamEnded.store(true); + m_connected.store(false); + break; + } + } + + // Cleanup + if (m_liveFile) + { + m_liveFile->close(); + m_liveFile = nullptr; + } + if (m_curlMulti) + { + if (m_curlEasy) + curl_multi_remove_handle((CURLM*)m_curlMulti, (CURL*)m_curlEasy); + curl_multi_cleanup((CURLM*)m_curlMulti); + m_curlMulti = nullptr; + } + if (m_curlEasy) + { + curl_easy_cleanup((CURL*)m_curlEasy); + m_curlEasy = nullptr; + } + m_connected.store(false); + liveObserverLog("LiveObserver::networkThreadFunc ended - totalBytes=%zu totalFrames=%zu\n", totalBytesReceived, totalFramesProcessed); +} + +#endif // GENERALS_ONLINE diff --git a/GeneralsMD/Code/GameEngine/Source/Common/LiveStreamer.cpp b/GeneralsMD/Code/GameEngine/Source/Common/LiveStreamer.cpp new file mode 100644 index 00000000000..ef57d6b436a --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/LiveStreamer.cpp @@ -0,0 +1,1266 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +#include "PreRTS.h" + +#include "Common/LiveStreamer.h" +#include "Common/LiveObserver.h" // LIVE_OBSERVER_LOGGING gate + LIVE_OBSERVER_BUILD_TAG +#include "Common/GlobalData.h" +#include "Common/GameCommon.h" // LIVE_DELAY_SECONDS_DEFAULT / _MAX +#include "Common/FramePacer.h" // the logic rate reported by MSG_STATS; see publishStats +#include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" +#include "GameNetwork/NetworkInterface.h" // TheNetwork->getRunAhead(), this client's latency +#include "GameClient/ClientInstance.h" +#include "GameClient/InGameUI.h" +#include "GameNetwork/GameInfo.h" // PLAYERTEMPLATE_OBSERVER, for the REGISTER is_observer flag + +#include "GameNetwork/GeneralsOnline/json.hpp" // parses GO's register reply + +#include "GameNetwork/GeneralsOnline/Vendor/libcurl/curl.h" +#include "GameNetwork/GeneralsOnline/Vendor/libcurl/multi.h" +#include "GameNetwork/GeneralsOnline/Vendor/libcurl/websockets.h" + +#include +#include +#include +#include +#include // cacert.pem presence check, see connectToRelay +#include +#include + +// ============================================================================ +// liveStreamLog - write diagnostic messages to live_streamer_debug.log +// ============================================================================ +// LIVE_OBSERVER_BUILD_TAG and the LIVE_OBSERVER_LOGGING gate both come from LiveObserver.h, +// included above; without that include streamer logging silently stays off in a DEFAULT build. + +void liveStreamLog(const char* fmt, ...) { +#if !defined(LIVE_OBSERVER_LOGGING) + (void)fmt; +#else + static FILE* logFile = NULL; + if (!logFile) { + // Per-instance name: several clients run side by side during testing and would + // otherwise truncate each other's log. + AsciiString path; + path.format("live_streamer_debug_Instance%.2u.log", rts::ClientInstance::getInstanceId()); + logFile = fopen(path.str(), "w"); + if (logFile) + fprintf(logFile, "LIVE_OBSERVER_BUILD_TAG=%s\n", LIVE_OBSERVER_BUILD_TAG); + } + if (logFile) { + // Wall-clock prefix, so several instances' logs (and the relay's) can be aligned. + SYSTEMTIME st; + GetLocalTime(&st); + fprintf(logFile, "[%02u:%02u:%02u.%03u] ", st.wHour, st.wMinute, st.wSecond, st.wMilliseconds); + va_list args; + va_start(args, fmt); + vfprintf(logFile, fmt, args); + va_end(args); + fflush(logFile); + } +#endif // LIVE_OBSERVER_LOGGING +} + +// ============================================================================ +// UTF-8 helpers (chat payloads travel as UTF-8, like the rest of the GO wire format) +// +// Mirrored in LiveObserver.cpp as chatWideToUtf8/chatUtf8ToWide/chatAppendU32LE - keep both +// copies in sync. +// ============================================================================ + +static std::string wideToUtf8(const UnicodeString& text) +{ + const wchar_t* src = text.str(); + const int len = WideCharToMultiByte(CP_UTF8, 0, src, -1, nullptr, 0, nullptr, nullptr); + if (len <= 1) // nothing but the terminator, or a failure + return std::string(); + std::string out(static_cast(len - 1), '\0'); + WideCharToMultiByte(CP_UTF8, 0, src, -1, &out[0], len, nullptr, nullptr); + return out; +} + +static UnicodeString utf8ToWide(const std::string& utf8) +{ + const int len = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), (int)utf8.size(), nullptr, 0); + if (len <= 0) + return UnicodeString::TheEmptyString; + std::wstring tmp(static_cast(len), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), (int)utf8.size(), &tmp[0], len); + return UnicodeString(tmp.c_str()); +} + +static void appendU32LE(std::vector& out, unsigned int value) +{ + out.push_back((char)(value & 0xFF)); + out.push_back((char)((value >> 8) & 0xFF)); + out.push_back((char)((value >> 16) & 0xFF)); + out.push_back((char)((value >> 24) & 0xFF)); +} + +// ============================================================================ +// LiveStreamer +// ============================================================================ +LiveStreamer* TheLiveStreamer = nullptr; + +LiveStreamer::LiveStreamer() + : m_lastSentLogicFps(-1) + , m_lastSentPingMs(-1) + , m_lastStatsSentMs(0) + , m_statsLastFrame(0) + , m_statsLastFrameMs(0) + , m_isStreaming(false) + , m_isBackup(false) + , m_connected(false) + , m_queuedBytes(0) + , m_queueOverflowed(false) + , m_isHost(FALSE) + , m_delaySeconds(-1) + , m_shouldRun(false) + , m_curlEasy(nullptr) + , m_curlMulti(nullptr) + , m_bodySentOffset(0) + , m_sentBytes(0) + , m_sentFrames(0) + , m_endReason("shutdown") +{ + m_headerBuffer.reserve(4096); + m_bodyBuffer.reserve(64 * 1024); +} + +LiveStreamer::~LiveStreamer() +{ + close(); +} + +LiveStreamer* createLiveStreamer() +{ + return new LiveStreamer(); +} + +// ============================================================================ +// Pending registration - the pre-game lobby - Recorder handover +// ============================================================================ + +// Only ever touched from the main thread: the lobby fills it in from a UI callback, the Recorder +// consumes it from MSG_NEW_GAME. The network thread never sees it - by the time any of this +// reaches the wire it has been copied into the REGISTER payload. +static LiveStreamRegistration s_pendingRegistration; + +void liveStreamSetPendingRegistration(const LiveStreamRegistration& registration) +{ + s_pendingRegistration = registration; + liveStreamLog("liveStreamSetPendingRegistration lobbyId=%s player='%s' canStream=%d lobbyJsonLen=%u\n", + registration.lobbyId.str(), registration.playerName.str(), + (int)registration.canStream, (unsigned int)registration.lobbyJson.length()); +} + +void liveStreamClearPendingRegistration() +{ + if (s_pendingRegistration.isValid()) + liveStreamLog("liveStreamClearPendingRegistration dropping lobbyId=%s\n", + s_pendingRegistration.lobbyId.str()); + + s_pendingRegistration = LiveStreamRegistration(); +} + +// Ceiling on replay bytes held while waiting for a relay connection. Roughly a few minutes of +// a busy match: enough that a slow registration costs nothing, small enough that a refused one +// cannot grow without bound for the rest of the game. +static const size_t LIVE_STREAM_MAX_QUEUED_BYTES = 8u * 1024u * 1024u; + +LiveStreamer* liveStreamStartPendingSession() +{ + if (TheGlobalData == nullptr || !TheGlobalData->m_liveStreamEnabled) + return nullptr; + + if (!s_pendingRegistration.isValid()) + { + // Normal for skirmish, replays and LAN - there is no lobby to have registered one. + liveStreamLog("liveStreamStartPendingSession: nothing pending, not streaming this game\n"); + return nullptr; + } + + if (TheLiveStreamer == nullptr) + TheLiveStreamer = createLiveStreamer(); + + if (TheLiveStreamer == nullptr) + return nullptr; + + // Register first, then start the thread: registerForGame only fills in fields and queues the + // REGISTER frame, and the network thread needs those fields to ask GO for a token. The relay + // address is not chosen here - GO returns the connect URL (see requestStreamUrl). + TheLiveStreamer->registerForGame(s_pendingRegistration); + TheLiveStreamer->init(); + + // Consumed. A second recording without a fresh lobby visit must not re-register this one + // under the same lobby id - that would merge two unrelated matches into one relay session. + liveStreamClearPendingRegistration(); + + return TheLiveStreamer; +} + +// ============================================================================ +// IReplayStreamSink implementation +// ============================================================================ + +void LiveStreamer::onHeaderBytes(const void* data, Int size) +{ + if (size <= 0) + return; + + const char* p = static_cast(data); + m_headerBuffer.insert(m_headerBuffer.end(), p, p + size); +} + +void LiveStreamer::onHeaderComplete() +{ + if (m_headerBuffer.empty()) + return; + + // Demoted (backup) streamers do not send the header - the relay already has the session's + // canonical one. Defensive: the header normally goes out at match start, before any demotion. + if (m_isBackup.load()) + { + m_headerBuffer.clear(); + return; + } + + queueFrame(LIVE_MSG_HEADER, m_headerBuffer.data(), m_headerBuffer.size()); + m_headerBuffer.clear(); +} + +void LiveStreamer::onHeaderPatch(Int offset, const void* data, Int size) +{ + if (size <= 0) + return; + + // Demoted: header mutations are not sent (see onHeaderComplete). + if (m_isBackup.load()) + return; + + // Encode: 4 bytes offset (LE) + 4 bytes length (LE) + data + unsigned char patchBuf[8]; + patchBuf[0] = (unsigned char)(offset & 0xFF); + patchBuf[1] = (unsigned char)((offset >> 8) & 0xFF); + patchBuf[2] = (unsigned char)((offset >> 16) & 0xFF); + patchBuf[3] = (unsigned char)((offset >> 24) & 0xFF); + patchBuf[4] = (unsigned char)(size & 0xFF); + patchBuf[5] = (unsigned char)((size >> 8) & 0xFF); + patchBuf[6] = (unsigned char)((size >> 16) & 0xFF); + patchBuf[7] = (unsigned char)((size >> 24) & 0xFF); + + std::vector payload; + payload.reserve(8 + size); + payload.insert(payload.end(), patchBuf, patchBuf + 8); + payload.insert(payload.end(), static_cast(data), static_cast(data) + size); + + queueFrame(LIVE_MSG_PATCH, payload.data(), payload.size()); +} + +void LiveStreamer::onBodyBytes(const void* data, Int size) +{ + if (size <= 0) + return; + + const char* p = static_cast(data); + + // One buffer, both roles: while streaming it is flushed to the wire by onBodyFlush; while + // backup onBodyFlush does nothing, so the same buffer accumulates the body from the demotion + // point onward - the backfill source a later takeover needs. Its first byte sits at absolute + // offset m_bodySentOffset (frozen while backup), so takeover offsets stay correct. Guarded by + // m_sendMutex because onTakeover (network thread) reads it while this (game thread) appends. + std::lock_guard lock(m_sendMutex); + if (m_bodyBuffer.size() + size > BODY_BUFFER_MAX) + { + // Drop the oldest bytes and advance the buffer's start offset, so the m_bodySentOffset + // invariant holds. Only a long backup session grows this large. + size_t drop = m_bodyBuffer.size() + size - BODY_BUFFER_MAX; + m_bodyBuffer.erase(m_bodyBuffer.begin(), m_bodyBuffer.begin() + drop); + m_bodySentOffset += drop; + } + m_bodyBuffer.insert(m_bodyBuffer.end(), p, p + size); +} + +void LiveStreamer::onBodyFlush() +{ + // Backup: keep the bytes but send nothing; the buffer becomes the backfill source on + // takeover. END still goes out via onRecordingEnded, so the relay knows we are done. + if (m_isBackup.load()) + return; + + std::vector framed; + { + std::lock_guard lock(m_sendMutex); + if (m_bodyBuffer.empty()) + return; + + // Build framed BODY: [8B offset LE][data] + uint64_t off = m_bodySentOffset; + unsigned char offBuf[8]; + offBuf[0] = (unsigned char)(off & 0xFF); + offBuf[1] = (unsigned char)((off >> 8) & 0xFF); + offBuf[2] = (unsigned char)((off >> 16) & 0xFF); + offBuf[3] = (unsigned char)((off >> 24) & 0xFF); + offBuf[4] = (unsigned char)((off >> 32) & 0xFF); + offBuf[5] = (unsigned char)((off >> 40) & 0xFF); + offBuf[6] = (unsigned char)((off >> 48) & 0xFF); + offBuf[7] = (unsigned char)((off >> 56) & 0xFF); + + framed.reserve(8 + m_bodyBuffer.size()); + framed.insert(framed.end(), offBuf, offBuf + 8); + framed.insert(framed.end(), m_bodyBuffer.begin(), m_bodyBuffer.end()); + + m_bodySentOffset += m_bodyBuffer.size(); + m_bodyBuffer.clear(); + } + + queueFrame(LIVE_MSG_BODY, framed.data(), framed.size()); +} + +void LiveStreamer::onRecordingEnded() +{ + onBodyFlush(); + queueFrame(LIVE_MSG_END, nullptr, 0); +} + +// ============================================================================ +// Network setup +// ============================================================================ + +void LiveStreamer::init() +{ + m_shouldRun.store(true); + + liveStreamLog("LiveStreamer::init lobby=%s, asking GO for a stream URL\n", m_lobbyId.str()); + + m_networkThread = std::thread(&LiveStreamer::networkThreadFunc, this); +} + +void LiveStreamer::close() +{ + m_shouldRun.store(false); + + if (m_networkThread.joinable()) + m_networkThread.join(); + + m_connected.store(false); + m_isStreaming.store(false); + m_isBackup.store(false); + + // Release the body buffer (the streaming/backup accumulation). + std::lock_guard lock(m_sendMutex); + m_bodyBuffer.clear(); + m_bodyBuffer.shrink_to_fit(); + m_bodySentOffset = 0; +} + +// ============================================================================ +// Registration +// ============================================================================ + +std::string liveStreamJsonEscape(const char* str) +{ + std::string out; + if (str == nullptr) + return out; + + for (const unsigned char* pc = (const unsigned char*)str; *pc; ++pc) + { + switch (*pc) + { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (*pc < 0x20) + { + char esc[8]; + snprintf(esc, sizeof(esc), "\\u%04x", (unsigned int)*pc); + out += esc; + } + else + { + // Includes every byte >= 0x80: a UTF-8 sequence is already legal JSON. + out += (char)*pc; + } + break; + } + } + + return out; +} + +void LiveStreamer::registerForGame(const LiveStreamRegistration& registration) +{ + m_lobbyId = registration.lobbyId; + m_playerName = registration.playerName; + m_isHost = registration.isHost; + m_delaySeconds = registration.delaySeconds; + + liveStreamLog("LiveStreamer::registerForGame lobbyId=%s player='%s' isHost=%d canStream=%d lobbyJsonLen=%u\n", + registration.lobbyId.str(), registration.playerName.str(), (int)registration.isHost, + (int)registration.canStream, (unsigned int)registration.lobbyJson.length()); + + // Built into a std::string rather than a fixed buffer: the GO-shaped lobby block carries a + // lobby name, two map paths and up to eight members, and a truncated payload is unparseable + // rather than merely lossy. + char scratch[64]; + std::string regJson = "{\"type\":\"register\""; + + regJson += ",\"lobbyid\":\"" + liveStreamJsonEscape(registration.lobbyId.str()) + "\""; + regJson += ",\"player_name\":\"" + liveStreamJsonEscape(registration.playerName.str()) + "\""; + regJson += registration.canStream ? ",\"can_stream\":true" : ",\"can_stream\":false"; + // is_host is sent for the relay's logs only. It no longer grants anything: the relay + // compares our stream token's user against the owner GO recorded for the session, so a + // client cannot claim host authority by asserting it here. + regJson += registration.isHost ? ",\"is_host\":true" : ",\"is_host\":false"; + // In-game observer (side = PLAYERTEMPLATE_OBSERVER)? The relay delivers spectator chat to + // observer-mode sources only. Read from the GameInfo slot list, because at MSG_NEW_GAME time + // the player list has not been rebuilt yet while the slots are already populated. + Bool isObserver = FALSE; + if (TheGameInfo) + { + const Int localSlot = TheGameInfo->getLocalSlotNum(); + if (localSlot >= 0) + { + const GameSlot* slot = TheGameInfo->getConstSlot(localSlot); + if (slot && slot->getPlayerTemplate() == PLAYERTEMPLATE_OBSERVER) + isObserver = TRUE; + } + } + regJson += isObserver ? ",\"is_observer\":true" : ",\"is_observer\":false"; + + // Both host-only. The relay ignores them from anyone else, but not sending them at all from + // a non-host keeps the payload honest about who is claiming to describe the game. + if (registration.isHost) + { + if (registration.delaySeconds >= 0) + { + snprintf(scratch, sizeof(scratch), ",\"delay_seconds\":%d", registration.delaySeconds); + regJson += scratch; + } + + if (!registration.lobbyJson.empty()) + { + regJson += ",\"lobby\":"; + regJson += registration.lobbyJson; + } + } + + regJson += "}"; + + // Queued, not sent: m_connected may still be false, so the network thread sends this once + // the relay accepts the connection. + queueFrame(LIVE_MSG_REGISTER, regJson.c_str(), regJson.length()); +} + +void LiveStreamer::onRoleAssigned(const AsciiString& role, const AsciiString& lobbyId, uint64_t bodyOffset) +{ + m_lobbyId = lobbyId; + + // Only the initial streamer ROLE (fresh connect or mid-match reconnect) may establish the + // send offset from the relay's value. While backup, m_bodySentOffset is frozen at the + // absolute offset of the accumulating body buffer and the relay's body length is ahead of it, + // so clobbering it would mislabel the buffered bytes; a takeover ROLE must not either, + // because onTakeover computes its backfill slice against that same frozen offset. + Bool wasBackup = m_isBackup.load(); + m_isStreaming.store(role == "streamer"); + m_isBackup.store(role == "backup"); + if (!m_isBackup.load() && !wasBackup) + m_bodySentOffset = bodyOffset; + + liveStreamLog("LiveStreamer::onRoleAssigned role=%s lobbyId=%s streaming=%d bodyOff=%llu\n", + role.str(), lobbyId.str(), (int)m_isStreaming.load(), (unsigned long long)bodyOffset); +} + +void LiveStreamer::onTakeover(uint64_t bodyOffset) +{ + liveStreamLog("LiveStreamer::onTakeover promoted to streamer, bodyOff=%llu\n", + (unsigned long long)bodyOffset); + + // Resume live buffering FIRST: any body bytes that arrive while the backfill below is + // being sent must land in m_bodyBuffer (framed at m_bodySentOffset after the snapshot), + // not be silently dropped by the backup gate. + m_isStreaming.store(true); + m_isBackup.store(false); + + // Backfill the relay's gap from the same body buffer that accumulated while backup: + // m_bodyBuffer holds every body byte from the demotion point onward, with m_bodySentOffset + // frozen at the absolute offset of buffer[0]. The relay is missing [bodyOffset..buffer_end]; + // live data continues from buffer_end. Snapshot under the lock, against onBodyBytes. + uint64_t backfillStart = bodyOffset; + std::vector backfill; + { + std::lock_guard lock(m_sendMutex); + if (bodyOffset < m_bodySentOffset) + { + // The cap trimmed past the requested offset, so the hole cannot be filled. Degrade + // to skip-forward; the relay logs a gap, as it does for any missing chunk. + liveStreamLog("LiveStreamer::onTakeover cannot backfill from %llu " + "(buffer starts at %llu) - skipping forward\n", + (unsigned long long)bodyOffset, (unsigned long long)m_bodySentOffset); + backfillStart = m_bodySentOffset; + } + size_t rel = (size_t)(backfillStart - m_bodySentOffset); + if (rel < m_bodyBuffer.size()) + { + backfill.assign(m_bodyBuffer.begin() + rel, m_bodyBuffer.end()); + } + // Live data continues from the end of what was just taken; onBodyFlush frames the next + // flush at this offset. + m_bodySentOffset = m_bodySentOffset + m_bodyBuffer.size(); + m_bodyBuffer.clear(); + } + + // Send the backfill in bounded chunks. This runs on the network thread, the same one that + // drains the send queue, so a direct send here cannot interleave with it. + if (!backfill.empty()) + { + const size_t BACKFILL_CHUNK = 64 * 1024; + uint64_t absOff = backfillStart; + for (size_t i = 0; i < backfill.size(); i += BACKFILL_CHUNK) + { + size_t n = backfill.size() - i; + if (n > BACKFILL_CHUNK) + n = BACKFILL_CHUNK; + + std::vector framed; + framed.reserve(8 + n); + unsigned char offBuf[8]; + offBuf[0] = (unsigned char)(absOff & 0xFF); + offBuf[1] = (unsigned char)((absOff >> 8) & 0xFF); + offBuf[2] = (unsigned char)((absOff >> 16) & 0xFF); + offBuf[3] = (unsigned char)((absOff >> 24) & 0xFF); + offBuf[4] = (unsigned char)((absOff >> 32) & 0xFF); + offBuf[5] = (unsigned char)((absOff >> 40) & 0xFF); + offBuf[6] = (unsigned char)((absOff >> 48) & 0xFF); + offBuf[7] = (unsigned char)((absOff >> 56) & 0xFF); + framed.insert(framed.end(), offBuf, offBuf + 8); + framed.insert(framed.end(), backfill.data() + i, backfill.data() + i + n); + + WsSendResult sendRes = sendBinaryFrame(LIVE_MSG_BODY, framed.data(), framed.size()); + if (sendRes != WsSendResult::Sent) + { + liveStreamLog("LiveStreamer::onTakeover backfill send failed at offset %llu (result %d)\n", + (unsigned long long)absOff, (int)sendRes); + break; + } + absOff += n; + } + liveStreamLog("LiveStreamer::onTakeover backfilled %zu bytes from offset %llu\n", + backfill.size(), (unsigned long long)backfillStart); + } +} + +// ============================================================================ +// Binary frame helpers +// ============================================================================ + +void LiveStreamer::onChat(UnsignedInt frame, const UnicodeString& text, UnsignedInt colorArgb) +{ + // Payload: [frame u32 LE][textLen u32 LE][UTF-8 text][color u32 LE] - opaque to the + // relay; the observer frame-gates on `frame` and recolors from `colorArgb`. + std::string utf8 = wideToUtf8(text); + std::vector payload; + payload.reserve(12 + utf8.size()); + appendU32LE(payload, frame); + appendU32LE(payload, (unsigned int)utf8.size()); + payload.insert(payload.end(), utf8.begin(), utf8.end()); + appendU32LE(payload, colorArgb); + queueFrame(LIVE_MSG_CHAT, payload.data(), payload.size()); +} + +void LiveStreamer::onTick(UnsignedInt frame) +{ + // Backup: say nothing. A demoted source has stopped pushing body data (see onBodyFlush), + // so a tick from it would assert an edge for bytes it is not sending - the observer would + // be told the game is at frame N while nothing behind N is arriving. + if (m_isBackup.load()) + return; + + // Payload: [frame u32 LE]. Opaque to the relay, which only forwards it and remembers the + // latest value for observers joining later. + std::vector payload; + payload.reserve(4); + appendU32LE(payload, frame); + queueFrame(LIVE_MSG_TICK, payload.data(), payload.size()); + + // Sampled here rather than off its own timer: onTick already runs on a fixed frame cadence, + // and the telemetry then describes the same moment as the tick it travels with. + publishStats(frame); +} + +void LiveStreamer::publishStats(UnsignedInt frame) +{ + // Same reasoning as onTick: a demoted source is not the one describing this match. + if (m_isBackup.load()) + return; + + const UnsignedInt nowMs = timeGetTime(); + + // Frames actually advanced per wall-clock second - the achieved rate, not the negotiated one. + // TheFramePacer->getActualLogicTimeScaleFps() resolves to TheNetwork->getFrameRate() in a + // network match, which is what the mesh agreed to run at and stays at 60 on a host that is + // really stepping 11. See m_statsLastFrame. + Int logicFps = m_lastSentLogicFps; // hold the last reading until a fresh one can be measured + if (m_statsLastFrameMs != 0 && nowMs > m_statsLastFrameMs && frame > m_statsLastFrame) + { + const UnsignedInt elapsedMs = nowMs - m_statsLastFrameMs; + logicFps = (Int)((frame - m_statsLastFrame) * 1000 / elapsedMs); + } + m_statsLastFrame = frame; + m_statsLastFrameMs = nowMs; + + if (logicFps < 0) + return; // nothing measured yet, and nothing worth sending + if (logicFps > 255) + logicFps = 255; + + // This client's own latency, derived exactly as the in-game counter derives the number it + // shows the player: the run-ahead window in milliseconds (InGameUI::drawNetworkLatency). + // + // Deliberately not NetworkMesh::getMaximumLatency(), which was tried first and reports 0 + // during a match (2026-08-15) - its latency table is not maintained in-game. Matching the + // counter's own derivation also means an observer and a player quote the same number. + Int pingMs = 0; + if (TheNetwork != nullptr) + pingMs = (Int)(TheNetwork->getRunAhead() * (1000 / GENERALS_ONLINE_HIGH_FPS_LIMIT)); + if (pingMs < 0) + pingMs = 0; + if (pingMs > LIVE_STATS_PING_MAX_MS) + pingMs = LIVE_STATS_PING_MAX_MS; + + // Quantise before comparing, or "changed" is true on essentially every sample and sending on + // change degenerates into sending every tick. + pingMs = ((pingMs + LIVE_STATS_PING_QUANTUM_MS / 2) / LIVE_STATS_PING_QUANTUM_MS) + * LIVE_STATS_PING_QUANTUM_MS; + + const Bool neverSent = (m_lastStatsSentMs == 0); + const Bool changed = (logicFps != m_lastSentLogicFps) || (pingMs != m_lastSentPingMs); + const Bool heartbeatDue = neverSent || (nowMs - m_lastStatsSentMs) >= (UnsignedInt)LIVE_STATS_HEARTBEAT_MS; + + if (!changed && !heartbeatDue) + return; + + // A change that arrives too soon after the last send is not dropped, only deferred - the next + // tick re-evaluates against the same still-current value and sends it then. + if (changed && !heartbeatDue && !neverSent + && (nowMs - m_lastStatsSentMs) < (UnsignedInt)LIVE_STATS_MIN_INTERVAL_MS) + { + return; + } + + m_lastSentLogicFps = logicFps; + m_lastSentPingMs = pingMs; + m_lastStatsSentMs = nowMs; + + // Payload: [logicFps u32 LE][pingMs u32 LE]. Opaque to the relay, which forwards it and - for + // observers held behind the broadcast delay - releases it on the same delayed boundary as + // body bytes, because a live stats frame would otherwise state something about the match now. + std::vector statsPayload; + statsPayload.reserve(8); + appendU32LE(statsPayload, (UnsignedInt)logicFps); + appendU32LE(statsPayload, (UnsignedInt)pingMs); + queueFrame(LIVE_MSG_STATS, statsPayload.data(), statsPayload.size()); +} + +void LiveStreamer::pumpSpectatorChat() +{ + if (!TheInGameUI) + return; + + std::deque batch; + { + std::lock_guard lock(m_spectatorChatMutex); + if (m_spectatorChatQueue.empty()) + return; + batch.swap(m_spectatorChatQueue); + } + + // Distinct fixed style so spectator chat is never confused with player chat. + static const RGBColor spectatorColor = { 0.45f, 0.68f, 0.95f }; + for (auto& entry : batch) + { + UnicodeString line; + line.format(L"[%ls] %ls", entry.displayName.str(), entry.text.str()); + TheInGameUI->messageColor(true, &spectatorColor, UnicodeString(L"%ls"), line.str()); + } +} + +void LiveStreamer::queueFrame(LiveMsgType type, const void* data, size_t len) +{ + QueuedFrame frame; + frame.type = (unsigned char)type; + if (data && len > 0) + { + frame.data.assign(static_cast(data), static_cast(data) + len); + } + { + std::lock_guard lock(m_sendMutex); + + // Everything queued before the connection exists is held in memory, which is what lets + // the replay sink attach at match start and stream the header the moment the relay + // accepts us. Bounded, because a registration GO refuses means nothing ever drains this. + // REGISTER itself is always kept: dropping it would waste a connection that succeeds. + if (type != LIVE_MSG_REGISTER && + m_queuedBytes + frame.data.size() > LIVE_STREAM_MAX_QUEUED_BYTES) + { + if (!m_queueOverflowed) + { + m_queueOverflowed = true; + liveStreamLog("LiveStreamer::queueFrame queue exceeded %u bytes with no relay " + "connection - dropping stream data from here on\n", + (unsigned int)LIVE_STREAM_MAX_QUEUED_BYTES); + } + return; + } + + m_queuedBytes += frame.data.size(); + m_outgoingQueue.push_back(std::move(frame)); + } +} + +LiveStreamer::WsSendResult LiveStreamer::sendBinaryFrame(LiveMsgType type, const void* payload, size_t payloadLen) +{ + if (!m_connected.load()) + return WsSendResult::Error; + + // Envelope: 1 byte type + 4 bytes length (LE) + payload + // Must be sent as ONE WebSocket frame - curl_ws_send writes a frame per call. + unsigned int len = (unsigned int)payloadLen; + size_t totalSize = 5 + (payload ? len : 0); + std::vector buf(totalSize); + buf[0] = (unsigned char)type; + buf[1] = (unsigned char)(len & 0xFF); + buf[2] = (unsigned char)((len >> 8) & 0xFF); + buf[3] = (unsigned char)((len >> 16) & 0xFF); + buf[4] = (unsigned char)((len >> 24) & 0xFF); + if (payload && len > 0) + memcpy(buf.data() + 5, payload, len); + + return wsSendBinary(buf.data(), totalSize); +} + +LiveStreamer::WsSendResult LiveStreamer::sendBinaryFrame(const QueuedFrame& frame) +{ + return sendBinaryFrame((LiveMsgType)frame.type, + frame.data.empty() ? nullptr : frame.data.data(), + frame.data.size()); +} + +// ============================================================================ +// WebSocket I/O (libcurl, background thread) +// ============================================================================ + +LiveStreamer::WsSendResult LiveStreamer::wsSendBinary(const unsigned char* data, size_t len) +{ + if (!m_curlEasy) + return WsSendResult::Error; + + size_t sent = 0; + CURLcode rc = curl_ws_send(m_curlEasy, data, len, &sent, 0, CURLWS_BINARY); + if (rc == CURLE_AGAIN) + { + // Backpressure, not failure: the socket buffer is full and nothing was sent, so the + // caller keeps the frame and retries on the next loop pass. + return WsSendResult::WouldBlock; + } + if (rc != CURLE_OK) + { + liveStreamLog("LiveStreamer::wsSendBinary failed: %d\n", (int)rc); + return WsSendResult::Error; + } + if (sent != len) + { + // curl_ws_send sends whole frames - a short send would leave the relay holding a + // truncated frame, which is worse than ending the session cleanly. + liveStreamLog("LiveStreamer::wsSendBinary short send: %zu of %zu bytes\n", sent, len); + return WsSendResult::Error; + } + return WsSendResult::Sent; +} + +bool LiveStreamer::wsRecv(std::vector& outBuffer) +{ + if (!m_curlEasy) + return false; + + outBuffer.clear(); + outBuffer.resize(65536); + + const struct curl_ws_frame* meta = nullptr; + size_t nread = 0; + CURLcode rc = curl_ws_recv(m_curlEasy, outBuffer.data(), outBuffer.size(), &nread, &meta); + if (rc == CURLE_AGAIN) + { + outBuffer.clear(); + return false; + } + if (rc != CURLE_OK) + { + // The connection itself is gone (relay crash/restart, socket closed without an ERROR + // frame). Wind down instead of spinning on a dead socket until the watchdog catches up. + liveStreamLog("STREAM DEAD: wsRecv error: %d - connection lost, winding down the session\n", (int)rc); + m_endReason = "recv-failed"; + m_connected.store(false); + outBuffer.clear(); + return false; + } + + // Any frame - stream data or the relay's protocol-level keepalive pings - proves the relay + // is alive; the watchdog in networkThreadFunc keys off this timestamp. + m_lastFrameReceivedMs.store(timeGetTime()); + + // Only binary payloads belong in the reassembly buffer: a PING/PONG/TEXT/CLOSE payload + // appended into the byte stream misparses everything after it. + if (meta != nullptr && (meta->flags & CURLWS_BINARY) == 0) + { + outBuffer.clear(); + return false; + } + + outBuffer.resize(nread); + return nread > 0; +} + +bool LiveStreamer::requestStreamUrl(AsciiString& outUrl) +{ + // The host reports the broadcast delay here rather than in the REGISTER frame: GO forwards it + // to the relay when the session is created, before any source connects, so every observer is + // held behind the same number. A non-host sends no delay at all, so a second source cannot + // redefine the host's spoiler window. + std::string postBody = "{}"; + if (m_isHost && m_delaySeconds >= 0) + { + char scratch[64]; + snprintf(scratch, sizeof(scratch), "{\"delay_seconds\":%d}", m_delaySeconds); + postBody = scratch; + } + + AsciiString url; + url.format("%s/register", liveServicesEndpoint("Livestreams").str()); + + AsciiString body; + Int statusCode = 0; + if (!liveServicesRequest(url, TRUE, postBody.c_str(), body, statusCode)) + { + liveStreamLog("LiveStreamer::requestStreamUrl lobby=%s failed (request not sent)\n", + m_lobbyId.str()); + return false; + } + + if (statusCode != 200) + { + // 404 means GO does not think we are in an in-progress match, 503 that the deployment + // has no relay configured. Neither is retryable from here: the match simply records + // locally, as it would with streaming switched off. + liveStreamLog("LiveStreamer::requestStreamUrl lobby=%s refused (status=%d) %s\n", + m_lobbyId.str(), statusCode, body.str()); + return false; + } + + try + { + nlohmann::json response = nlohmann::json::parse(body.str()); + if (response.is_object() && response.contains("url") && response["url"].is_string()) + { + const std::string streamUrl = response["url"].get(); + if (!streamUrl.empty()) + { + outUrl = streamUrl.c_str(); + liveStreamLog("LiveStreamer::requestStreamUrl lobby=%s got a stream URL\n", + m_lobbyId.str()); + return true; + } + } + } + catch (const nlohmann::json::exception&) + { + } + + liveStreamLog("LiveStreamer::requestStreamUrl lobby=%s failed (no url in reply)\n", + m_lobbyId.str()); + return false; +} + +bool LiveStreamer::connectToRelay() +{ + if (m_curlEasy) + { + curl_easy_cleanup((CURL*)m_curlEasy); + m_curlEasy = nullptr; + } + if (m_curlMulti) + { + curl_multi_cleanup((CURLM*)m_curlMulti); + m_curlMulti = nullptr; + } + + CURL* easy = curl_easy_init(); + if (!easy) + { + liveStreamLog("LiveStreamer::connectToRelay curl_easy_init failed\n"); + return false; + } + + // The relay does not accept an unauthenticated /register. GO registers the livestream, mints + // a single-use stream token for this player, and hands back the complete connect URL, so the + // relay's address is GO's to decide rather than ours to assemble. + AsciiString url; + if (!requestStreamUrl(url)) + { + curl_easy_cleanup(easy); + return false; + } + + curl_easy_setopt(easy, CURLOPT_URL, url.str()); + curl_easy_setopt(easy, CURLOPT_CONNECT_ONLY, 2L); + + // wss:// needs a CA bundle. This libcurl is built against OpenSSL, which unlike Schannel does + // not consult the Windows certificate store, so without trust anchors it rejects every + // certificate as CURLE_PEER_FAILED_VERIFICATION (60). Same approach as HTTPRequest.cpp. + { + std::ifstream certFile("cacert.pem"); + if (certFile.good()) + { + certFile.close(); + curl_easy_setopt(easy, CURLOPT_CAINFO, "cacert.pem"); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYHOST, 2L); + } + else + { + liveStreamLog("LiveStreamer: cacert.pem not found - TLS certificate verification DISABLED\n"); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYHOST, 0L); + } + } + + CURLM* multi = curl_multi_init(); + if (!multi) + { + curl_easy_cleanup(easy); + liveStreamLog("LiveStreamer::connectToRelay curl_multi_init failed\n"); + return false; + } + + curl_multi_add_handle(multi, easy); + + int stillRunning = 0; + CURLMcode mc = curl_multi_perform(multi, &stillRunning); + while (mc == CURLM_OK && stillRunning > 0) + { + mc = curl_multi_poll(multi, NULL, 0, 1000, NULL); + if (mc == CURLM_OK) + mc = curl_multi_perform(multi, &stillRunning); + } + + if (mc != CURLM_OK) + { + liveStreamLog("LiveStreamer::connectToRelay curl_multi_perform failed: %d\n", (int)mc); + curl_multi_remove_handle(multi, easy); + curl_multi_cleanup(multi); + curl_easy_cleanup(easy); + return false; + } + + // A 404 or any other HTTP error still leaves stillRunning == 0, so the WebSocket upgrade has + // to be verified explicitly through the transfer result. + int infoRunning = 0; + CURLMsg* infoMsg = curl_multi_info_read(multi, &infoRunning); + if (!infoMsg || infoMsg->data.result != CURLE_OK) + { + int result = infoMsg ? (int)infoMsg->data.result : -1; + liveStreamLog("LiveStreamer::connectToRelay: handshake failed (result=%d)\n", result); + curl_multi_remove_handle(multi, easy); + curl_multi_cleanup(multi); + curl_easy_cleanup(easy); + return false; + } + + m_curlEasy = easy; + m_curlMulti = multi; + m_connected.store(true); + liveStreamLog("LiveStreamer::connectToRelay connected\n"); + return true; +} + +// ============================================================================ +// Background network thread +// ============================================================================ + +void LiveStreamer::networkThreadFunc() +{ + liveStreamLog("LiveStreamer::networkThreadFunc started\n"); + + if (!connectToRelay()) + { + liveStreamLog("LiveStreamer::networkThreadFunc connectToRelay failed\n"); + m_shouldRun.store(false); + return; + } + + m_connected.store(true); + + // m_connected is the wind-down signal: a send failure, a relay ERROR frame or the liveness + // watchdog all clear it, which exits this loop and - because the final drain is gated on it + // too - stops any further upload. + while (m_shouldRun.load() && m_connected.load()) + { + // Pop under the lock, send outside it. curl_ws_send is blocking network I/O and the game + // thread takes the same mutex every frame in onBodyBytes/onBodyFlush, so holding it + // across a send stalls the whole game frame. + std::vector toSend; + { + std::lock_guard lock(m_sendMutex); + while (!m_outgoingQueue.empty() && m_connected.load()) + { + toSend.push_back(std::move(m_outgoingQueue.front())); + m_outgoingQueue.pop_front(); + m_queuedBytes -= toSend.back().data.size(); + } + } + + for (size_t i = 0; i < toSend.size(); ++i) + { + if (!m_connected.load()) + break; + const QueuedFrame& frame = toSend[i]; + WsSendResult res = sendBinaryFrame(frame); + if (res == WsSendResult::WouldBlock) + { + // Put every unsent frame back at the FRONT of the queue - stream order is data - + // and retry on the next pass. Backpressure slows the upload, it must not kill it. + std::lock_guard lock(m_sendMutex); + for (size_t j = toSend.size(); j-- > i; ) + { + m_queuedBytes += toSend[j].data.size(); + m_outgoingQueue.push_front(std::move(toSend[j])); + } + break; + } + if (res == WsSendResult::Error) + { + liveStreamLog("STREAM DEAD: send of type=%d failed (%zu bytes) - connection to relay lost, winding down the session\n", + (int)frame.type, frame.data.size()); + m_endReason = "send-failed"; + m_connected.store(false); + break; + } + m_sentBytes += frame.data.size(); + m_sentFrames += 1; + if (frame.type == LIVE_MSG_HEADER) + liveStreamLog("LiveStreamer: sent HEADER (%zu bytes)\n", frame.data.size()); + else if (frame.type == LIVE_MSG_END) + liveStreamLog("LiveStreamer: sent END\n"); + } + + if (!m_connected.load()) + break; + + // Receive incoming messages + std::vector recvBuf; + while (wsRecv(recvBuf) && m_shouldRun.load() && m_connected.load()) + { + if (recvBuf.size() < 5) + continue; + + // char is signed on MSVC, so every length byte must zero-extend through unsigned char. + unsigned char msgType = (unsigned char)recvBuf[0]; + unsigned int msgLen = (unsigned int)(unsigned char)recvBuf[1] + | ((unsigned int)(unsigned char)recvBuf[2] << 8) + | ((unsigned int)(unsigned char)recvBuf[3] << 16) + | ((unsigned int)(unsigned char)recvBuf[4] << 24); + + if (msgType == LIVE_MSG_ROLE && msgLen > 0 && (uint64_t)recvBuf.size() >= 5ull + msgLen) + { + std::string json(recvBuf.data() + 5, msgLen); + liveStreamLog("LiveStreamer: received role: %s\n", json.c_str()); + + // Each key advances by the literal's own strlen, never a hand-counted constant. + static const char ROLE_KEY[] = "\"role\":\""; + static const char ACTION_KEY[] = "\"action\":\""; + static const char LOBBY_ID_KEY[] = "\"lobbyid\":\""; + static const char BODY_OFF_KEY[] = "\"body_offset\":"; + + const char* roleStart = strstr(json.c_str(), ROLE_KEY); + const char* actionStart = strstr(json.c_str(), ACTION_KEY); + const char* lobbyIdStart = strstr(json.c_str(), LOBBY_ID_KEY); + const char* bodyOffStart = strstr(json.c_str(), BODY_OFF_KEY); + + AsciiString role("none"); + AsciiString lobbyId; + uint64_t bodyOffset = 0; + + if (roleStart) + { + roleStart += strlen(ROLE_KEY); + const char* roleEnd = strchr(roleStart, '"'); + if (roleEnd) + role.set(roleStart, roleEnd - roleStart); + } + if (lobbyIdStart) + { + lobbyIdStart += strlen(LOBBY_ID_KEY); + const char* idEnd = strchr(lobbyIdStart, '"'); + if (idEnd) + lobbyId.set(lobbyIdStart, idEnd - lobbyIdStart); + } + if (bodyOffStart) + { + bodyOffStart += strlen(BODY_OFF_KEY); + bodyOffset = (uint64_t)strtoull(bodyOffStart, nullptr, 10); + } + // Order matters: onRoleAssigned applies the flags and offset, then onTakeover + // overrides m_bodySentOffset with the backfill position it establishes. + onRoleAssigned(role, lobbyId, bodyOffset); + + if (actionStart) + { + const char* actPtr = actionStart + strlen(ACTION_KEY); + if (strncmp(actPtr, "takeover", 8) == 0) + onTakeover(bodyOffset); + } + } + else if (msgType == LIVE_MSG_ERROR) + { + // The relay says our session is gone (reaped/refused) while the socket is still + // open. Wind down, so the UI stops claiming to stream into nothing. + std::string errText; + if (msgLen > 0 && (uint64_t)recvBuf.size() >= 5ull + msgLen) + errText.assign(recvBuf.data() + 5, msgLen); + liveStreamLog("STREAM DEAD: relay ERROR frame received (%s) - session is gone, winding down\n", + errText.empty() ? "no detail" : errText.c_str()); + m_endReason = "relay-error"; + m_connected.store(false); + } + else if (msgType == LIVE_MSG_SPECTATOR_CHAT && msgLen >= 8 + && (uint64_t)recvBuf.size() >= 5ull + msgLen) + { + // [nameLen u32 LE][UTF-8 name][textLen u32 LE][UTF-8 text] - live spectator + // chat for in-game observers (we are a source). + const unsigned char* p = (const unsigned char*)recvBuf.data() + 5; + unsigned int nameLen = (unsigned int)p[0] + | ((unsigned int)p[1] << 8) | ((unsigned int)p[2] << 16) | ((unsigned int)p[3] << 24); + if ((uint64_t)msgLen >= 8ull + nameLen) + { + const unsigned char* t = p + 4 + nameLen; + unsigned int textLen = (unsigned int)t[0] + | ((unsigned int)t[1] << 8) | ((unsigned int)t[2] << 16) | ((unsigned int)t[3] << 24); + if ((uint64_t)msgLen >= 8ull + nameLen + textLen) + { + SpectatorChatEntry entry; + entry.displayName = utf8ToWide(std::string((const char*)(p + 4), nameLen)); + entry.text = utf8ToWide(std::string((const char*)(t + 4), textLen)); + { + std::lock_guard lock(m_spectatorChatMutex); + if (m_spectatorChatQueue.size() < 1000) + m_spectatorChatQueue.push_back(entry); + } + } + } + } + } + + // Relay liveness watchdog, mirroring LiveObserver's: the relay pings this websocket every + // ~20 s, so a long silence can only mean the relay - or the path to it - is gone. Gated + // on having seen at least one frame, because the pre-ROLE join may legitimately be quiet. + if (m_connected.load() + && m_lastFrameReceivedMs.load() != 0 + && (timeGetTime() - m_lastFrameReceivedMs.load()) > (UnsignedInt)LIVE_STREAM_WATCHDOG_MS) + { + liveStreamLog("STREAM DEAD: no frame from relay for %ums - relay stopped consuming our stream, winding down the session\n", + (unsigned)(timeGetTime() - m_lastFrameReceivedMs.load())); + m_endReason = "relay-silent"; + m_connected.store(false); + break; + } + + // Also the loop's sleep. curl_multi_poll's out-param is numfds, not "still running", so + // curl_multi_perform() must run unconditionally or incoming ROLE/ERROR frames stop + // being received. + { + int numfds = 0; + curl_multi_poll(m_curlMulti, NULL, 0, 10, &numfds); + int runningHandles = 0; + curl_multi_perform((CURLM*)m_curlMulti, &runningHandles); + } + } + + // Final drain: frames queued after the last loop iteration (PATCH + END from stopRecording) + // must still be sent. Same pop-under-lock / send-outside-lock split as the main loop. + std::vector toSend; + { + std::lock_guard lock(m_sendMutex); + while (!m_outgoingQueue.empty() && m_connected.load()) + { + toSend.push_back(std::move(m_outgoingQueue.front())); + m_outgoingQueue.pop_front(); + m_queuedBytes -= toSend.back().data.size(); + } + } + for (size_t i = 0; i < toSend.size(); ++i) + { + if (!m_connected.load()) + break; + const QueuedFrame& frame = toSend[i]; + WsSendResult res = sendBinaryFrame(frame); + if (res == WsSendResult::WouldBlock) + { + // Same as the main loop: keep the unsent frames in order, in case the relay + // catches up before the process goes away. + std::lock_guard lock(m_sendMutex); + for (size_t j = toSend.size(); j-- > i; ) + { + m_queuedBytes += toSend[j].data.size(); + m_outgoingQueue.push_front(std::move(toSend[j])); + } + break; + } + if (res == WsSendResult::Error) + { + liveStreamLog("LiveStreamer: final drain send failed, type=%d\n", (int)frame.type); + break; + } + m_sentBytes += frame.data.size(); + m_sentFrames += 1; + } + + // Cleanup + if (m_curlMulti) + { + if (m_curlEasy) + curl_multi_remove_handle((CURLM*)m_curlMulti, (CURL*)m_curlEasy); + curl_multi_cleanup((CURLM*)m_curlMulti); + m_curlMulti = nullptr; + } + if (m_curlEasy) + { + curl_easy_cleanup((CURL*)m_curlEasy); + m_curlEasy = nullptr; + } + m_connected.store(false); + m_isStreaming.store(false); + liveStreamLog("LiveStreamer::networkThreadFunc ended - lobby=%s reason=%s sentFrames=%zu sentBytes=%zu queuedLeft=%zu\n", + m_lobbyId.str(), m_endReason.str(), m_sentFrames, m_sentBytes, m_queuedBytes); +} diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp index c53ffced7dc..044d179869e 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp @@ -25,6 +25,9 @@ #include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine #include "Common/Recorder.h" +#include "Common/ReplayStreamSink.h" +#include "Common/LiveStreamer.h" +#include "Common/LiveObserver.h" #include "Common/file.h" #include "Common/FileSystem.h" #include "Common/PlayerList.h" @@ -86,6 +89,16 @@ static void writeAtOffset(File* file, Int offset, const void* data, Int dataSize MAYBE_UNUSED Int res = file->seek(fileSize, File::seekMode::START); (void)res; DEBUG_ASSERTCRASH(res == fileSize, ("Could not seek to end of file!")); + + if (TheRecorder && TheRecorder->getStreamSink()) + TheRecorder->getStreamSink()->onHeaderPatch(offset, data, dataSize); +} + +static void writeBodyBytes(File* file, const void* data, Int size) +{ + file->write(data, size); + if (TheRecorder && TheRecorder->getStreamSink()) + TheRecorder->getStreamSink()->onBodyBytes(data, size); } #if defined(RTS_DEBUG) @@ -350,6 +363,8 @@ RecorderClass::RecorderClass() m_archiveReplays = FALSE; m_nextFrame = 0; m_wasDesync = FALSE; + m_streamSink = nullptr; + m_lastStreamTickFrame = 0; init(); // just for the heck of it. } @@ -382,6 +397,8 @@ void RecorderClass::init() { m_wasDesync = FALSE; m_doingAnalysis = FALSE; m_playbackFrameCount = 0; + m_streamSink = nullptr; + m_lastStreamTickFrame = 0; OptionPreferences optionPref; m_archiveReplays = optionPref.getArchiveReplaysEnabled(); @@ -407,6 +424,11 @@ void RecorderClass::reset() { void RecorderClass::update() { if (m_mode == RECORDERMODETYPE_RECORD || m_mode == RECORDERMODETYPE_NONE) { updateRecord(); + // Drain spectator chat received from the relay into the HUD log. +#if defined(GENERALS_ONLINE) + if (TheLiveStreamer) + TheLiveStreamer->pumpSpectatorChat(); +#endif } else if (isPlaybackMode()) { updatePlayback(); @@ -437,11 +459,71 @@ void RecorderClass::updatePlayback() { if (m_doingAnalysis) curFrame = m_nextFrame; - // While there are commands to be queued up for this frame, do it. - while (m_nextFrame == curFrame) { + const Bool isLive = (m_mode == RECORDERMODETYPE_LIVE_OBSERVER); + + // A live observer's clock starts at connect, which can be well before the game does. Records + // consumed in that window are dispatched into the shell world and the cursor moves past them, + // so the game would begin already missing the stream's opening records and their CRCs. + if (isLive && TheGameLogic && !TheGameLogic->isInInteractiveGame()) + { + // The gate still needs its tick: it already decides not to hold before the game starts. + if (TheLiveObserver) + TheLiveObserver->updatePlaybackGate(curFrame); + // Chat still drains: player chat is held until its frame, spectator chat hits the + // spoiler gate. + if (TheLiveObserver) + TheLiveObserver->pollChatMessages(curFrame); + return; + } + + // While there are commands to be queued up for this frame or a past frame (live observer may be behind), process them. + while (m_nextFrame != (UnsignedInt)-1 && m_nextFrame <= curFrame) { + if (isLive) { + // In live mode: consume the frame number first. May rewind if the frame is in + // the future, or report that no complete record is available yet. + ReadFrameResult r = readNextFrame(); + if (r == READFRAME_EOF_WAITING) { + // Breaking here is what bounds the loop: forcing m_nextFrame to curFrame instead + // satisfied no exit condition and re-read EOF forever. + break; + } + if (r == READFRAME_STREAM_STOPPED) + break; + } + if (m_nextFrame > curFrame) + break; // readNextFrame saw a future frame - wait for game to catch up appendNextCommand(); // append the next command to TheCommandQueue - readNextFrame(); // Read the next command's frame number for playback. + if (!isLive) + readNextFrame(); // Read the next command's frame number for playback. + } + + // Whether the observer may keep running is LiveObserver's call. m_mode is re-tested rather + // than reusing isLive because readNextFrame() above can end the session, and gating a torn-down + // session would resurrect its pause. + if (m_mode == RECORDERMODETYPE_LIVE_OBSERVER && TheLiveObserver) + { + TheLiveObserver->pollChatMessages(curFrame); + TheLiveObserver->updatePlaybackGate(curFrame); + } +} + +Bool RecorderClass::liveStreamEnded() const { + return TheLiveObserver ? TheLiveObserver->isStreamEnded() : TRUE; +} + +/** + * Teardown for a live-observer session that keeps the recorder looking like a finished replay. + */ +void RecorderClass::endLivePlayback() +{ + if (m_file != nullptr) + { + m_file->close(); + m_file = nullptr; } + m_fileName.clear(); + m_currentReplayFilename.clear(); + m_nextFrame = (UnsignedInt)-1; } /** @@ -449,12 +531,20 @@ void RecorderClass::updatePlayback() { * reaching the end of the playback file. */ void RecorderClass::stopPlayback() { + Bool wasLiveObserver = (m_mode == RECORDERMODETYPE_LIVE_OBSERVER); if (m_file != nullptr) { m_file->close(); m_file = nullptr; } m_fileName.clear(); + if (wasLiveObserver) + { + // The one way a live session ends. liveObserverEndSession() also restores the shell map, + // so every exit path loads the shell, not just this one. + liveObserverEndSession(); + } + if (!m_doingAnalysis) { TheGameLogic->exitGame(); @@ -517,6 +607,21 @@ void RecorderClass::updateRecord() DEBUG_ASSERTCRASH(m_file != nullptr, ("RecorderClass::updateRecord() - unexpected call to fflush(m_file)")); m_file->flush(); } + + if (m_streamSink) + { + m_streamSink->onBodyFlush(); + + // Deliberately after the flush: everything this frame produced is already on its way, so + // a receiver may read the tick as "all records up to here have been sent". + const UnsignedInt curFrame = TheGameLogic ? TheGameLogic->getFrame() : 0; + if (TheGameLogic != nullptr + && curFrame - m_lastStreamTickFrame >= (UnsignedInt)LIVE_TICK_INTERVAL_FRAMES) + { + m_lastStreamTickFrame = curFrame; + m_streamSink->onTick(curFrame); + } + } } /** @@ -538,7 +643,7 @@ void RecorderClass::startRecording(GameDifficulty diff, Int originalGameMode, In m_fileName = getLastReplayFileName(); m_fileName.concat(getReplayExtention()); filepath.concat(m_fileName); - m_file = TheFileSystem->openFile(filepath.str(), File::WRITE | File::BINARY); + m_file = TheFileSystem->openFile(filepath.str(), File::READWRITE | File::BINARY | File::CREATE); if (m_file == nullptr) { DEBUG_ASSERTCRASH(m_file != nullptr, ("Failed to create replay file")); return; @@ -695,6 +800,57 @@ void RecorderClass::startRecording(GameDifficulty diff, Int originalGameMode, In */ /// @todo Need to write game options when there are some to be written. + +#if defined(GENERALS_ONLINE) + // Live streaming - initialize and register with relay server + try + { + // The pre-game lobby assembled the registration; it is the only place that can see a + // GeneralsOnline lobby in full, which is what keeps this file free of GO includes. + LiveStreamer* streamer = liveStreamStartPendingSession(); + if (streamer) + { + // From now on the streamer receives raw header/body/patch bytes. + m_streamSink = streamer; + DEBUG_LOG(("RecorderClass::startRecording() - Live stream registered, lobbyId=%s", + streamer->getLobbyId().str())); + } + } + catch (...) + { + // Live streamer init failed - game continues without streaming + DEBUG_LOG(("RecorderClass::startRecording() - Live streamer init failed, continuing without streaming")); + liveStreamLog("RecorderClass::startRecording() - Live streamer init EXCEPTION, continuing without streaming\n"); + } +#endif + + // Snapshot the finished header as one byte-for-byte blob an observer can write to disk. + if (m_streamSink) + { + m_file->flush(); + UnsignedInt headerSize = m_file->size(); + if (headerSize > 0) + { + char* headerBuf = new char[headerSize]; + Int seekRes = m_file->seek(0, File::seekMode::START); + Int bytesRead = (seekRes == 0) ? m_file->read(headerBuf, headerSize) : 0; + if (bytesRead == (Int)headerSize) + { + m_streamSink->onHeaderBytes(headerBuf, headerSize); + } + else + { + // No header means no observer can ever open the file, and every later body chunk + // is dropped silently - so say so here rather than let the session die quietly. + DEBUG_LOG(("RecorderClass::startRecording() - Header snapshot failed (seek=%d, read %d of %d), stream will have no header", + seekRes, bytesRead, headerSize)); + liveStreamLog("RecorderClass::startRecording() - Header snapshot FAILED, stream will have no header\n"); + } + m_file->seek(headerSize, File::seekMode::START); + delete[] headerBuf; + } + m_streamSink->onHeaderComplete(); + } } /** @@ -703,6 +859,10 @@ void RecorderClass::startRecording(GameDifficulty diff, Int originalGameMode, In */ void RecorderClass::stopRecording() { logGameEnd(); + + if (m_streamSink) + m_streamSink->onRecordingEnded(); + if (TheNetwork) { //if (TheLAN) @@ -734,6 +894,17 @@ void RecorderClass::stopRecording() { #endif } m_fileName.clear(); + +#if defined(GENERALS_ONLINE) + // Live streaming - shut down the streamer when recording stops + if (TheLiveStreamer) + { + TheLiveStreamer->close(); + delete TheLiveStreamer; + TheLiveStreamer = nullptr; + m_streamSink = nullptr; + } +#endif } /** @@ -771,15 +942,15 @@ void RecorderClass::archiveReplay(AsciiString fileName) void RecorderClass::writeToFile(GameMessage* msg) { // Write the frame number for this command. UnsignedInt frame = TheGameLogic->getFrame(); - m_file->write(&frame, sizeof(frame)); + writeBodyBytes(m_file, &frame, sizeof(frame)); // Write the command type GameMessage::Type type = msg->getType(); - m_file->write(&type, sizeof(type)); + writeBodyBytes(m_file, &type, sizeof(type)); // Write the player index Int playerIndex = msg->getPlayerIndex(); - m_file->write(&playerIndex, sizeof(playerIndex)); + writeBodyBytes(m_file, &playerIndex, sizeof(playerIndex)); #ifdef DEBUG_LOGGING AsciiString commandName = msg->getCommandAsString(); @@ -800,15 +971,15 @@ void RecorderClass::writeToFile(GameMessage* msg) { GameMessageParser* parser = newInstance(GameMessageParser)(msg); UnsignedByte numTypes = parser->getNumTypes(); - m_file->write(&numTypes, sizeof(numTypes)); + writeBodyBytes(m_file, &numTypes, sizeof(numTypes)); GameMessageParserArgumentType* argType = parser->getFirstArgumentType(); while (argType != nullptr) { UnsignedByte type = (UnsignedByte)(argType->getType()); - m_file->write(&type, sizeof(type)); + writeBodyBytes(m_file, &type, sizeof(type)); UnsignedByte argTypeCount = (UnsignedByte)(argType->getArgCount()); - m_file->write(&argTypeCount, sizeof(argTypeCount)); + writeBodyBytes(m_file, &argTypeCount, sizeof(argTypeCount)); argType = argType->getNext(); } @@ -831,37 +1002,37 @@ void RecorderClass::writeArgument(GameMessageArgumentDataType type, const GameMe switch (type) { case ARGUMENTDATATYPE_INTEGER: - m_file->write(&(arg.integer), sizeof(arg.integer)); + writeBodyBytes(m_file, &(arg.integer), sizeof(arg.integer)); break; case ARGUMENTDATATYPE_REAL: - m_file->write(&(arg.real), sizeof(arg.real)); + writeBodyBytes(m_file, &(arg.real), sizeof(arg.real)); break; case ARGUMENTDATATYPE_BOOLEAN: - m_file->write(&(arg.boolean), sizeof(arg.boolean)); + writeBodyBytes(m_file, &(arg.boolean), sizeof(arg.boolean)); break; case ARGUMENTDATATYPE_OBJECTID: - m_file->write(&(arg.objectID), sizeof(arg.objectID)); + writeBodyBytes(m_file, &(arg.objectID), sizeof(arg.objectID)); break; case ARGUMENTDATATYPE_DRAWABLEID: - m_file->write(&(arg.drawableID), sizeof(arg.drawableID)); + writeBodyBytes(m_file, &(arg.drawableID), sizeof(arg.drawableID)); break; case ARGUMENTDATATYPE_TEAMID: - m_file->write(&(arg.teamID), sizeof(arg.teamID)); + writeBodyBytes(m_file, &(arg.teamID), sizeof(arg.teamID)); break; case ARGUMENTDATATYPE_LOCATION: - m_file->write(&(arg.location), sizeof(arg.location)); + writeBodyBytes(m_file, &(arg.location), sizeof(arg.location)); break; case ARGUMENTDATATYPE_PIXEL: - m_file->write(&(arg.pixel), sizeof(arg.pixel)); + writeBodyBytes(m_file, &(arg.pixel), sizeof(arg.pixel)); break; case ARGUMENTDATATYPE_PIXELREGION: - m_file->write(&(arg.pixelRegion), sizeof(arg.pixelRegion)); + writeBodyBytes(m_file, &(arg.pixelRegion), sizeof(arg.pixelRegion)); break; case ARGUMENTDATATYPE_TIMESTAMP: - m_file->write(&(arg.timestamp), sizeof(arg.timestamp)); + writeBodyBytes(m_file, &(arg.timestamp), sizeof(arg.timestamp)); break; case ARGUMENTDATATYPE_WIDECHAR: - m_file->write(&(arg.wChar), sizeof(arg.wChar)); + writeBodyBytes(m_file, &(arg.wChar), sizeof(arg.wChar)); break; default: DEBUG_LOG(("Unknown GameMessageArgumentDataType in RecorderClass::writeArgument")); @@ -977,6 +1148,37 @@ Bool RecorderClass::simulateReplay(AsciiString filename) return success; } +Bool RecorderClass::startLiveObserverPlayback(AsciiString filename) +{ + Bool success = playbackFile(filename); + if (!success) + { + m_mode = RECORDERMODETYPE_NONE; + liveObserverLog("startLiveObserverPlayback: FAILED for %s\n", filename.str()); + return FALSE; + } + + // playbackFile() clears the shell map on its way, which resets this class - so claim the mode + // afterwards, not before. + m_mode = RECORDERMODETYPE_LIVE_OBSERVER; + m_nextFrame = 0; + + // playbackFile()'s seeding read left the cursor on the first record's type field. The live + // loop reads the frame itself before appending, so the cursor has to go back to the frame + // field or every record from the first one on is misparsed. + if (m_file != nullptr && TheLiveObserver != nullptr) + m_file->seek(TheLiveObserver->getBodyStartOffset(), File::START); + + // From here on, clearing game data means the game ended rather than that this session is + // still being set up. See liveObserverOnGameCleared(). + if (TheLiveObserver) + TheLiveObserver->notePlaybackStarted(); + + liveObserverLog("startLiveObserverPlayback: OK for %s, observer=%p\n", + filename.str(), (void*)TheLiveObserver); + return TRUE; +} + #if defined(RTS_DEBUG) Bool RecorderClass::analyzeReplay(AsciiString filename) { @@ -1025,47 +1227,67 @@ void RecorderClass::handleCRCMessage(UnsignedInt newCRC, Int playerIndex, Bool f samePlayer = TRUE; if (samePlayer || (localPlayerIndex < 0)) { - UnsignedInt playbackCRC = m_crcInfo.readCRC(); - //DEBUG_LOG(("RecorderClass::handleCRCMessage() - Comparing CRCs of InGame:%8.8X Replay:%8.8X Frame:%d from Player %d", - // playbackCRC, newCRC, TheGameLogic->getFrame()-m_crcInfo.GetQueueSize()-1, playerIndex)); - if (TheGameLogic->getFrame() > 0 && newCRC != playbackCRC && !m_crcInfo.sawCRCMismatch()) + // A live observer queues its own CRCs through the fromPlayback path above, and the recorded + // CRC from the stream pops them. An empty queue means the own CRC has not been processed + // yet, and reading it would return 0 and report a DESYNC on a healthy session. + if (m_crcInfo.GetQueueSize() > 0) { - //Kris: Patch 1.01 November 10, 2003 (integrated changes from Matt Campbell) - // Since we don't seem to have any *visible* desyncs when replaying games, but get this warning - // virtually every replay, the assumption is our CRC checking is faulty. Since we're at the - // tail end of patch season, let's just disable the message, and hope the users believe the - // problem is fixed. -MDC 3/20/2003 - // - // TheSuperHackers @tweak helmutbuhler 03/04/2025 - // More than 20 years later, but finally fixed and re-enabled! - TheInGameUI->message("GUI:CRCMismatch"); - - // TheSuperHackers @info helmutbuhler 03/04/2025 - // Note: We subtract the queue size from the frame number. This way we calculate the correct frame - // the mismatch first happened in case the NetCRCInterval is set to 1 during the game. - const UnsignedInt mismatchFrame = TheGameLogic->getFrame() - m_crcInfo.GetQueueSize() - 1; - - // Now also prints a UI message for it. - const UnicodeString mismatchDetailsStr = TheGameText->FETCH_OR_SUBSTITUTE("GUI:CRCMismatchDetails", L"InGame:%8.8X Replay:%8.8X Frame:%d"); - TheInGameUI->message(mismatchDetailsStr, playbackCRC, newCRC, mismatchFrame); - - DEBUG_LOG(("Replay has gone out of sync!\nInGame:%8.8X Replay:%8.8X\nFrame:%d", - playbackCRC, newCRC, mismatchFrame)); - - // Print Mismatch in case we are simulating replays from console. - printf("CRC Mismatch in Frame %d\n", mismatchFrame); - - // TheSuperHackers @tweak Pause the game on mismatch. - // But not when a window with focus is opened, because that can make resuming difficult. - if (TheWindowManager->winGetFocus() == nullptr) + UnsignedInt playbackCRC = m_crcInfo.readCRC(); + //DEBUG_LOG(("RecorderClass::handleCRCMessage() - Comparing CRCs of InGame:%8.8X Replay:%8.8X Frame:%d from Player %d", + // playbackCRC, newCRC, TheGameLogic->getFrame()-m_crcInfo.GetQueueSize()-1, playerIndex)); + if (TheGameLogic->getFrame() > 0 && newCRC != playbackCRC && !m_crcInfo.sawCRCMismatch()) { - Bool pause = TRUE; - Bool pauseMusic = FALSE; - Bool pauseInput = FALSE; - TheGameLogic->setGamePaused(pause, pauseMusic, pauseInput); + //Kris: Patch 1.01 November 10, 2003 (integrated changes from Matt Campbell) + // Since we don't seem to have any *visible* desyncs when replaying games, but get this warning + // virtually every replay, the assumption is our CRC checking is faulty. Since we're at the + // tail end of patch season, let's just disable the message, and hope the users believe the + // problem is fixed. -MDC 3/20/2003 + // + // TheSuperHackers @tweak helmutbuhler 03/04/2025 + // More than 20 years later, but finally fixed and re-enabled! + TheInGameUI->message("GUI:CRCMismatch"); + + // TheSuperHackers @info helmutbuhler 03/04/2025 + // Note: We subtract the queue size from the frame number. This way we calculate the correct frame + // the mismatch first happened in case the NetCRCInterval is set to 1 during the game. + const UnsignedInt mismatchFrame = TheGameLogic->getFrame() - m_crcInfo.GetQueueSize() - 1; + + // Now also prints a UI message for it. + const UnicodeString mismatchDetailsStr = TheGameText->FETCH_OR_SUBSTITUTE("GUI:CRCMismatchDetails", L"InGame:%8.8X Replay:%8.8X Frame:%d"); + TheInGameUI->message(mismatchDetailsStr, playbackCRC, newCRC, mismatchFrame); + + DEBUG_LOG(("Replay has gone out of sync!\nInGame:%8.8X Replay:%8.8X\nFrame:%d", + playbackCRC, newCRC, mismatchFrame)); + + // Print Mismatch in case we are simulating replays from console. + printf("CRC Mismatch in Frame %d\n", mismatchFrame); + + // The stream keeps arriving and playback keeps running, so without this the observer + // has no way of knowing its view stopped being the real game. + if (m_mode == RECORDERMODETYPE_LIVE_OBSERVER && TheLiveObserver) + { + liveObserverLog("CRC mismatch - own=%08X recorded=%08X frame=%d\n", + newCRC, playbackCRC, mismatchFrame); + TheLiveObserver->noteDesync(mismatchFrame); + + // Report once, then stop comparing - a desynced simulation diverges further every + // frame, so everything after the first mismatch is noise. + m_crcInfo.setSawCRCMismatch(); + return; + } + + // TheSuperHackers @tweak Pause the game on mismatch. + // But not when a window with focus is opened, because that can make resuming difficult. + if (TheWindowManager->winGetFocus() == nullptr) + { + Bool pause = TRUE; + Bool pauseMusic = FALSE; + Bool pauseInput = FALSE; + TheGameLogic->setGamePaused(pause, pauseMusic, pauseInput); - // Mark this mismatch as seen when we had the chance to pause once. - m_crcInfo.setSawCRCMismatch(); + // Mark this mismatch as seen when we had the chance to pause once. + m_crcInfo.setSawCRCMismatch(); + } } } return; @@ -1196,6 +1418,11 @@ Bool RecorderClass::playbackFile(AsciiString filename) m_file->read(&maxFPS, sizeof(maxFPS)); Bool isMultiplayer = (m_originalGameMode == GAME_INTERNET || m_originalGameMode == GAME_LAN); + // The network-game skip of the first received CRC exists because that value "doesn't make + // it through the network". An observer generates its CRCs locally, so nothing is lost and + // skipping one skews every later comparison by a full interval. + if (TheLiveObserver != nullptr && !TheLiveObserver->hasPlaybackStarted()) + isMultiplayer = FALSE; m_crcInfo = CRCInfo(header.localPlayerIndex, isMultiplayer); DEBUG_LOG(("Player index is %d, replay CRC interval is %d, isMultiplayer is %d", m_crcInfo.getLocalPlayer(), REPLAY_CRC_INTERVAL, isMultiplayer)); @@ -1302,18 +1529,76 @@ AsciiString RecorderClass::readAsciiString() { * Read the frame number for the next command in the playback file. If the end of the file is reached, the playback * is stopped and the next frame is said to be -1. */ -void RecorderClass::readNextFrame() { +RecorderClass::ReadFrameResult RecorderClass::readNextFrame() { + if (m_mode == RECORDERMODETYPE_LIVE_OBSERVER) { + // No observer means the session is already torn down; nothing more can arrive. + if (TheLiveObserver == nullptr) { + liveObserverLog("readNextFrame: live stream with no observer - stopping playback\n"); + m_nextFrame = -1; + stopPlayback(); + return READFRAME_STREAM_STOPPED; + } + + const Int savedPos = m_file->seek(0, File::CURRENT); + const Int safeOffset = TheLiveObserver->getSafeReadOffset(); + + // Never read past the last complete record: the network thread appends at arbitrary byte + // offsets, so a read at the growing tail can return a partial record, yielding a garbage + // frame number and stranding the file position mid-record. This is also what lets + // appendNextCommand() read a whole record without bounds checks of its own. + // + // This is also what lets appendNextCommand() read a whole record without any bounds + // checks of its own: savedPos and safeOffset are both record boundaries, so passing + // this test means at least one complete record is already on disk. + if (savedPos + (Int)sizeof(m_nextFrame) > safeOffset) { + if (!liveStreamEnded()) + return READFRAME_EOF_WAITING; + + // Stream ended and every complete record has been consumed. Do not fall through to + // the read: whatever sits at this offset is not part of this session. + liveObserverLog("readNextFrame: end of stream at offset %d (safe=%d) - stopping playback\n", + savedPos, safeOffset); + m_nextFrame = -1; + stopPlayback(); + return READFRAME_STREAM_STOPPED; + } + + // The bound above says these bytes are on disk, so a short read means the file and the + // watermark disagree - carrying on from a mid-record cursor misparses the rest. + if (m_file->read(&m_nextFrame, sizeof(m_nextFrame)) != sizeof(m_nextFrame)) { + liveObserverLog("readNextFrame: short read at %d below the safe offset %d - stopping playback\n", + savedPos, safeOffset); + m_nextFrame = -1; + stopPlayback(); + return READFRAME_STREAM_STOPPED; + } + + if (m_nextFrame > TheGameLogic->getFrame()) { + // Future frame - rewind so appendNextCommand() does not see the data yet. + m_file->seek(savedPos, File::START); + } + return READFRAME_OK; + } + Int bytesRead = m_file->read(&m_nextFrame, sizeof(m_nextFrame)); if (bytesRead != sizeof(m_nextFrame)) { DEBUG_LOG(("RecorderClass::readNextFrame - read failed on frame %d", TheGameLogic->getFrame())); m_nextFrame = -1; stopPlayback(); + return READFRAME_STREAM_STOPPED; } + return READFRAME_OK; } /** * This reads the next command from the replay file and appends it to TheCommandList. */ +// The record layout this reads is mirrored by scanReplayRecord() in LiveObserver.cpp, which +// scans arriving bytes to publish the live-edge and safe-read watermarks. Change one, change +// the other. +// +// It needs no bounds checks of its own, live or not: readNextFrame() only lets playback reach a +// record that is complete on disk (see the safe-offset test there). void RecorderClass::appendNextCommand() { GameMessage::Type type; Int bytesRead = m_file->read(&type, sizeof(type)); @@ -1382,6 +1667,9 @@ void RecorderClass::appendNextCommand() { if (argsLeftForType == 0) { DEBUG_ASSERTCRASH(parserArgType != nullptr, ("parserArgType was null when it shouldn't have been.")); if (parserArgType == nullptr) { + // This early return owns both allocations. + deleteInstance(parser); + deleteInstance(msg); return; } @@ -1568,6 +1856,14 @@ RecorderClass::CullBadCommandsResult RecorderClass::cullBadCommands() { deleteInstance(msg); } + // These sit outside the network-message range, so the filter above let them through, and + // they are the only surviving messages that write GameLogic statics. A local keypress must + // not reach simulation state during playback. + else if (msg->getType() == GameMessage::MSG_META_BEGIN_PATH_BUILD || + msg->getType() == GameMessage::MSG_META_END_PATH_BUILD) { + + deleteInstance(msg); + } else if (msg->getType() == GameMessage::MSG_CLEAR_GAME_DATA) { result.hasClearGameDataMessage = true; diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/InGameChat.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/InGameChat.cpp index 61cf0d4f8a6..2432eb3a0eb 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/InGameChat.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/InGameChat.cpp @@ -42,10 +42,19 @@ #include "GameClient/GUICallbacks.h" #include "GameClient/InGameUI.h" #include "GameClient/LanguageFilter.h" +#include "Common/Recorder.h" +#include "Common/LiveObserver.h" #include "GameLogic/GameLogic.h" #include "GameNetwork/GameInfo.h" #include "GameNetwork/NetworkInterface.h" +// A live-observer session is a replay game as far as GameLogic is concerned, but its chat window +// is live: the checks below must let it through, and Enter routes to the spectator channel. +static Bool IsLiveObserverSession() +{ + return TheRecorder != nullptr && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER; +} + static GameWindow *chatWindow = nullptr; static GameWindow *chatTextEntry = nullptr; static GameWindow *chatTypeStaticText = nullptr; @@ -59,7 +68,7 @@ extern NGMPGame* TheNGMPGame; // ------------------------------------------------------------------------------------------------ void ShowInGameChat( Bool immediate ) { - if (TheGameLogic->isInReplayGame()) + if (TheGameLogic->isInReplayGame() && !IsLiveObserverSession()) return; if (TheInGameUI->isQuitMenuVisible()) @@ -199,13 +208,13 @@ void ToggleInGameChat( Bool immediate ) return; } - if (TheGameLogic->isInReplayGame()) + if (TheGameLogic->isInReplayGame() && !IsLiveObserverSession()) return; #if defined(GENERALS_ONLINE) - if (TheNGMPGame == nullptr) + if (TheNGMPGame == nullptr && !IsLiveObserverSession()) #else - if (!TheGameInfo->isMultiPlayer() && TheGlobalData->m_netMinPlayers) + if (!TheGameInfo->isMultiPlayer() && TheGlobalData->m_netMinPlayers && !IsLiveObserverSession()) #endif return; @@ -252,7 +261,17 @@ void ToggleInGameChat( Bool immediate ) } } TheLanguageFilter->filterLine(msg); - TheNetwork->sendChat(msg, playerMask); + if (IsLiveObserverSession()) + { + // Never the mesh: an observer is not a network peer. The relay fans + // this out to the other watchers. + if (TheLiveObserver) + TheLiveObserver->sendSpectatorChat(msg); + } + else + { + TheNetwork->sendChat(msg, playerMask); + } } GadgetTextEntrySetText( chatTextEntry, UnicodeString::TheEmptyString ); HideInGameChat( immediate ); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LiveGamesMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LiveGamesMenu.cpp new file mode 100644 index 00000000000..b23afb541be --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LiveGamesMenu.cpp @@ -0,0 +1,552 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +/////////////////////////////////////////////////////////////////////////////////////// +// FILE: LiveGamesMenu.cpp +// The Watch Live browser. Reached from Online -> Watch Live, which pushes Menus/ReplayMenu.wnd +// in live-games mode; this module owns that mode. The layout is shared with the replay-file +// menu, so the mode is guarded by s_liveGamesMode and restored on shutdown. +/////////////////////////////////////////////////////////////////////////////////////// + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#if defined(GENERALS_ONLINE) + +#include "GameClient/LiveGamesMenu.h" + +#include "Common/LiveObserver.h" +#include "GameClient/GameText.h" +#include "GameClient/GameWindowManager.h" +#include "GameClient/Gadget.h" +#include "GameClient/GadgetListBox.h" +#include "GameClient/GadgetStaticText.h" +#include "GameClient/GUICallbacks.h" // liveWatchOpenPasswordPopup +#include "GameClient/LiveObserverSession.h" // StartLiveObserverSession +#include "GameClient/MessageBox.h" +#include "GameClient/Shell.h" +#include "GameClient/WindowLayout.h" +#include "GameClient/LobbyObserverMenu.h" // SetLobbyObserverMode opens the read-only lobby view + +#include +#include + +// ============================================================================ +// State +// ============================================================================ + +// This screen is reused rather than duplicated: the .wnd layouts live inside an archive we cannot +// read to copy one from, and borrowing this one gives the real frame, listbox, scrollbar and +// hover states for free. The cost is that both modes share one control set, so everything below +// is guarded by s_liveGamesMode and the replay behaviour is untouched when it is off. +static Bool s_liveGamesMode = FALSE; +static std::vector s_liveGameIds; ///< game id per listbox row +static std::vector s_liveGameIsLive; ///< TRUE = join now (watch_action 2) +static std::vector s_liveGamePassworded; ///< stream (or lobby) is password protected +static std::vector s_liveGameNames; ///< lobby display name, for the password popup +static std::vector s_liveGameDelaySeconds; ///< broadcast delay per row, -1 = unknown +static UnsignedInt s_lastLiveFetchMs = 0; +static GameWindow* s_liveTitleWindow = nullptr; +static UnicodeString s_savedTitleText; +static UnicodeString s_savedLoadText; +static UnicodeString s_savedDeleteText; + +enum { LIVE_GAMES_REFRESH_INTERVAL_MS = 5000 }; + +// The module's own view of the shared controls, resolved in LiveGamesMenuInit by the same +// name keys ReplayMenuInit uses - both sides address the same windows, no conflict. +static GameWindow* s_listbox = nullptr; +static GameWindow* s_buttonLoad = nullptr; +static GameWindow* s_buttonDelete = nullptr; +static GameWindow* s_buttonCopy = nullptr; +static Int s_listboxID = 0; +static Int s_buttonLoadID = 0; +static Int s_buttonDeleteID = 0; +static Int s_buttonCopyID = 0; + +// ============================================================================ +// Helpers +// ============================================================================ + +void LiveGamesMenuEnterLiveGamesMode(void) { s_liveGamesMode = TRUE; } +Bool LiveGamesMenuIsLiveGamesMode(void) { return s_liveGamesMode; } + +static void liveGamesRequestList(void); +static void liveGamesApplyResponse(Bool success, Int statusCode, const AsciiString& body); + +/// Depth-first search for the first static-text window carrying any text. Must recurse: the +/// heading is not a direct child of ParentReplayMenu, since the layout nests controls under +/// GadgetParent. +static GameWindow* findFirstStaticTextWithText(GameWindow* parent) +{ + if (parent == nullptr) + return nullptr; + + for (GameWindow* child = parent->winGetChild(); child != nullptr; child = child->winGetNext()) + { + WinInstanceData* data = child->winGetInstanceData(); + if (data != nullptr && + (data->m_style & GWS_STATIC_TEXT) != 0 && + !child->winGetText().isEmpty()) + { + return child; + } + + GameWindow* nested = findFirstStaticTextWithText(child); + if (nested != nullptr) + return nested; + } + + return nullptr; +} + +/// Find the screen's heading. The layout is inside an unreadable archive, so the control name +/// cannot be confirmed here; try the conventional names first and only then fall back to +/// searching the subtree. The fallback is a guess - "first static text carrying text" is the +/// heading by luck, not by rule - so it logs what it settled on. +static GameWindow* findTitleWindow(GameWindow* parent) +{ + static const char* const TITLE_CONTROL_NAMES[] = { + "ReplayMenu.wnd:StaticTextTitle", + "ReplayMenu.wnd:StaticTextHeader", + // ReplayMenu.wnd's heading has an empty control name - the layout declares it as + // NAME = "ReplayMenu.wnd:" - so the two conventional names above can never match it. + "ReplayMenu.wnd:", + nullptr + }; + + for (Int i = 0; TITLE_CONTROL_NAMES[i] != nullptr; ++i) + { + GameWindow* win = TheWindowManager->winGetWindowFromId( + parent, (Int)TheNameKeyGenerator->nameToKey(TITLE_CONTROL_NAMES[i])); + // "ReplayMenu.wnd:" is shared by TWO controls - the heading static text and a large + // panel. Only a static text can be the heading; accepting the first name match would + // retitle the panel and the screen would stay LOAD REPLAY. + if (win != nullptr && win->winGetInstanceData() != nullptr && + (win->winGetInstanceData()->m_style & GWS_STATIC_TEXT) != 0) + { + liveObserverLog("ReplayMenu: title control found by name '%s'\n", TITLE_CONTROL_NAMES[i]); + return win; + } + } + + GameWindow* fallback = findFirstStaticTextWithText(parent); + if (fallback != nullptr) + { + AsciiString text; + text.translate(fallback->winGetText()); + liveObserverLog("ReplayMenu: title control not found by name; fell back to id='%s' text='%s'\n", + KEYNAME((NameKeyType)fallback->winGetWindowId()).str(), text.str()); + } + else + { + liveObserverLog("ReplayMenu: no title control found at all - heading will not be retitled\n"); + } + + return fallback; +} + +static void liveGamesRequestList(void) +{ + if (liveRelayFetchInFlight()) + return; + + // GO owns the list of what is being streamed: the relay cannot tell which of its sessions a + // given player is allowed to see. + s_lastLiveFetchMs = timeGetTime(); + liveRelayBeginFetch(liveServicesEndpoint("Livestreams")); +} + +static void liveGamesApplyResponse(Bool success, Int statusCode, const AsciiString& body) +{ + if (s_listbox == nullptr || !s_liveGamesMode) + return; + + // Repopulating clears the selection, so remember it and restore by game id afterwards - + // by id and not row, since a game ending shifts every row beneath it. + AsciiString previouslySelected; + { + Int wasSelected = -1; + GadgetListBoxGetSelected(s_listbox, &wasSelected); + if (wasSelected >= 0 && wasSelected < (Int)s_liveGameIds.size()) + previouslySelected = s_liveGameIds[wasSelected]; + } + + GadgetListBoxReset(s_listbox); + s_liveGameIds.clear(); + + if (!success || statusCode != 200) + { + // This screen is only reachable from the Online welcome menu, so a session always exists + // by the time the list is fetched: a failure here is a failure to reach GO, and saying + // anything else would send the player looking for a login they have already done. + GadgetListBoxAddEntryText(s_listbox, + UnicodeString(L"Could not reach GeneralsOnline"), + GameMakeColor(255, 120, 120, 255), -1); + return; + } + + // The wire format is LiveObserver's business; this only lays rows out. + std::vector games; + if (!liveServicesParseLivestreams(body, games)) + { + GadgetListBoxAddEntryText(s_listbox, + UnicodeString(L"Unexpected reply from GeneralsOnline"), + GameMakeColor(255, 120, 120, 255), -1); + return; + } + + if (games.empty()) + { + GadgetListBoxAddEntryText(s_listbox, + UnicodeString(L"No games right now"), + GameMakeColor(200, 200, 200, 255), -1); + return; + } + + // GO owns the row order (priority-player matches first, then join -> wait -> pre-game), so + // the list is rendered exactly as delivered. Re-sorting here would undo the server's + // priority ordering. + for (std::vector::const_iterator it = games.begin(); it != games.end(); ++it) + { + const LiveGameEntry& game = *it; + const Bool isLive = (game.watchAction == 2); + const Bool isWaiting = (game.watchAction == 1); + // Priority-player matches render gold (all row kinds) so they stand out on top of + // the priority-first sort. + const Color rowColor = game.priority ? GameMakeColor(255, 215, 0, 255) + : isLive ? GameMakeColor(255, 255, 255, 255) + : GameMakeColor(200, 200, 200, 255); + UnicodeString text; + AsciiString tmp; + + // Four columns, laid out for the replay list, reused as map / running-for / delay / + // players. Append column 0 and use the row index it returns for the rest, exactly as + // PopulateReplayFileListbox does; a precomputed row merges the cells together. + text.translate(game.mapName); + const Int row = GadgetListBoxAddEntryText(s_listbox, text, rowColor, -1, 0); + if (row < 0) + continue; + + if (isLive) + { + tmp.format("%dm in", game.ageSeconds / 60); + text.translate(tmp); + GadgetListBoxAddEntryText(s_listbox, text, rowColor, row, 1); + + tmp.format("%ds delay", game.delaySeconds); + text.translate(tmp); + GadgetListBoxAddEntryText(s_listbox, text, rowColor, row, 2); + + tmp.format("%s (%d watching)", game.players.str(), game.observerCount); + text.translate(tmp); + GadgetListBoxAddEntryText(s_listbox, text, rowColor, row, 3); + } + else if (isWaiting) + { + // Started game, but this viewer cannot join yet: the stream is not live, or the + // ticket is held behind the broadcast delay. The columns read + // map / STARTED / hold or PASSWORDED / players (N waiting). + text = UnicodeString(L"STARTED"); + GadgetListBoxAddEntryText(s_listbox, text, rowColor, row, 1); + + if (game.delayRemainingSeconds > 0) + { + tmp.format("starts in %ds", game.delayRemainingSeconds); + text.translate(tmp); + GadgetListBoxAddEntryText(s_listbox, text, rowColor, row, 2); + } + else + { + text = game.passworded ? UnicodeString(L"PASSWORDED") : UnicodeString(L""); + GadgetListBoxAddEntryText(s_listbox, text, rowColor, row, 2); + } + + tmp.format("%s (%d waiting)", game.players.str(), game.pendingObserverCount); + text.translate(tmp); + GadgetListBoxAddEntryText(s_listbox, text, rowColor, row, 3); + } + else + { + // Pre-game lobby: nothing is running yet, so the columns read + // map / PRE-GAME / PASSWORDED? / players (N waiting). + text = UnicodeString(L"PRE-GAME"); + GadgetListBoxAddEntryText(s_listbox, text, rowColor, row, 1); + + text = game.passworded ? UnicodeString(L"PASSWORDED") : UnicodeString(L""); + GadgetListBoxAddEntryText(s_listbox, text, rowColor, row, 2); + + tmp.format("%s (%d waiting)", game.players.str(), game.pendingObserverCount); + text.translate(tmp); + GadgetListBoxAddEntryText(s_listbox, text, rowColor, row, 3); + } + + // Index by the row the listbox actually used, so a lookup on selection cannot + // drift out of step with the rows if one is ever skipped. + if ((Int)s_liveGameIds.size() <= row) + { + s_liveGameIds.resize(row + 1); + s_liveGameIsLive.resize(row + 1); + s_liveGamePassworded.resize(row + 1); + s_liveGameNames.resize(row + 1); + s_liveGameDelaySeconds.resize(row + 1); + } + s_liveGameIds[row] = game.lobbyId; + s_liveGameIsLive[row] = isLive; + s_liveGamePassworded[row] = game.passworded; + s_liveGameNames[row] = game.name; + s_liveGameDelaySeconds[row] = game.delaySeconds; + } + + if (!previouslySelected.isEmpty()) + { + for (Int i = 0; i < (Int)s_liveGameIds.size(); ++i) + { + if (s_liveGameIds[i] == previouslySelected) + { + GadgetListBoxSetSelected(s_listbox, i); + break; + } + } + } +} + +/// Connect to the selected game, or open the read-only observer view for a pre-game lobby. +/// Returns TRUE if an action was started. +static Bool liveGamesConnectSelected(void) +{ + Int selected = -1; + GadgetListBoxGetSelected(s_listbox, &selected); + if (selected < 0 || selected >= (Int)s_liveGameIds.size()) + return FALSE; + + // A password-protected live stream asks for the password before the session is queued. The + // browser stays up behind the modal popup, exactly like custom games. + if (selected < (Int)s_liveGameIsLive.size() && s_liveGameIsLive[selected] && + selected < (Int)s_liveGamePassworded.size() && s_liveGamePassworded[selected]) + { + const AsciiString& displayName = (selected < (Int)s_liveGameNames.size()) + ? s_liveGameNames[selected] : AsciiString::TheEmptyString; + liveWatchOpenPasswordPopup(s_liveGameIds[selected], displayName, TRUE); + return TRUE; + } + + if (selected < (Int)s_liveGameIsLive.size() && !s_liveGameIsLive[selected]) + { + // Pre-game lobby, or a started game this viewer cannot join yet: open the read-only + // observer view as the waiting room. It subscribes to the pending-observer queue itself + // and hands off to StartLiveObserverSession once the stream goes live or the hold ends. + // A passworded lobby is gated here too, with the password sent at that handoff. + if (selected < (Int)s_liveGamePassworded.size() && s_liveGamePassworded[selected]) + { + const AsciiString& displayName = (selected < (Int)s_liveGameNames.size()) + ? s_liveGameNames[selected] : AsciiString::TheEmptyString; + liveWatchOpenObservePasswordPopup(s_liveGameIds[selected], displayName); + return TRUE; + } + + SetLobbyObserverMode(s_liveGameIds[selected].str()); + TheShell->push("Menus/GameSpyGameOptionsMenu.wnd"); + return TRUE; + } + + // Hand over the lobby id alone: GO mints the single-use watch ticket and returns the relay + // URL that carries it. The row's delay pre-seeds the countdown and join timeout while GO + // holds the ticket behind the delay gate. + StartLiveObserverSession(s_liveGameIds[selected], AsciiString::TheEmptyString, + AsciiString::TheEmptyString, + (selected < (Int)s_liveGameDelaySeconds.size()) ? s_liveGameDelaySeconds[selected] : -1); + TheShell->pop(); + return TRUE; +} + +// ============================================================================ +// Public entry points (called by ReplayMenu's callbacks) +// ============================================================================ + +void LiveGamesMenuInit(void) +{ + GameWindow* parent = TheWindowManager->winGetWindowFromId(nullptr, + TheNameKeyGenerator->nameToKey("ReplayMenu.wnd:ParentReplayMenu")); + s_listbox = TheWindowManager->winGetWindowFromId(parent, + TheNameKeyGenerator->nameToKey("ReplayMenu.wnd:ListboxReplayFiles")); + s_buttonLoad = TheWindowManager->winGetWindowFromId(parent, + TheNameKeyGenerator->nameToKey("ReplayMenu.wnd:ButtonLoadReplay")); + s_buttonDelete = TheWindowManager->winGetWindowFromId(parent, + TheNameKeyGenerator->nameToKey("ReplayMenu.wnd:ButtonDeleteReplay")); + s_buttonCopy = TheWindowManager->winGetWindowFromId(parent, + TheNameKeyGenerator->nameToKey("ReplayMenu.wnd:ButtonCopyReplay")); + + if (s_listbox != nullptr) + s_listboxID = s_listbox->winGetWindowId(); + if (s_buttonLoad != nullptr) + s_buttonLoadID = s_buttonLoad->winGetWindowId(); + if (s_buttonDelete != nullptr) + s_buttonDeleteID = s_buttonDelete->winGetWindowId(); + if (s_buttonCopy != nullptr) + s_buttonCopyID = s_buttonCopy->winGetWindowId(); + + // Retitle and repurpose the action buttons. Copy is hidden rather than relabelled: + // a browser has no sensible third action, and a dead button is worse than a gap. + s_liveTitleWindow = findTitleWindow(parent); + if (s_liveTitleWindow) + { + s_savedTitleText = s_liveTitleWindow->winGetText(); + // GadgetStaticTextSetText, not winSetText: a static text draws from its TextData display + // string, which only the gadget's GGM_SET_LABEL handler updates. winSetText changes the + // instance-data text, which the static-text draw never reads. + GadgetStaticTextSetText(s_liveTitleWindow, UnicodeString(L"LIVE GAMES")); + } + if (s_buttonLoad) + { + s_savedLoadText = s_buttonLoad->winGetText(); + // One label for both row kinds: connecting to a live stream and parking in a + // pre-game lobby are the same act (observing), so the button must not flip + // between CONNECT and OBSERVE as the selection moves. + s_buttonLoad->winSetText(UnicodeString(L"OBSERVE")); + } + if (s_buttonDelete) + { + s_savedDeleteText = s_buttonDelete->winGetText(); + s_buttonDelete->winSetText(UnicodeString(L"REFRESH")); + } + if (s_buttonCopy) + s_buttonCopy->winHide(TRUE); + + // The replay tooltip reads the hovered file off disk; these rows are not files. + if (s_listbox != nullptr) + s_listbox->winSetTooltipFunc(nullptr); + + // The caller has already reset the listbox; show the loading row and start the first fetch. + if (s_listbox != nullptr) + { + GadgetListBoxAddEntryText(s_listbox, + UnicodeString(L"Loading games..."), GameMakeColor(200, 200, 200, 255), -1); + } + liveGamesRequestList(); +} + +void LiveGamesMenuShutdown(void) +{ + if (!s_liveGamesMode) + return; + + // Leave the screen as we found it. The controls belong to the shared layout, so a + // retitled heading or a hidden Copy button would otherwise persist into the next visit + // to the real replay menu. + if (s_liveTitleWindow) + GadgetStaticTextSetText(s_liveTitleWindow, s_savedTitleText); + if (s_buttonLoad && !s_savedLoadText.isEmpty()) + s_buttonLoad->winSetText(s_savedLoadText); + if (s_buttonDelete && !s_savedDeleteText.isEmpty()) + s_buttonDelete->winSetText(s_savedDeleteText); + if (s_buttonCopy) + s_buttonCopy->winHide(FALSE); + + s_liveTitleWindow = nullptr; + s_liveGameIds.clear(); + s_liveGameIsLive.clear(); + s_liveGamePassworded.clear(); + s_liveGameNames.clear(); + s_liveGameDelaySeconds.clear(); + s_liveGamesMode = FALSE; + + s_listbox = nullptr; + s_buttonLoad = nullptr; + s_buttonDelete = nullptr; + s_buttonCopy = nullptr; + s_listboxID = 0; + s_buttonLoadID = 0; + s_buttonDeleteID = 0; + s_buttonCopyID = 0; +} + +void LiveGamesMenuUpdate(void) +{ + if (!s_liveGamesMode) + return; + + // The relay fetch runs on its own thread, so collect its result here rather than via + // a callback - nothing off the main thread may touch gadget state. + AsciiString body; + Bool fetchOk = FALSE; + Int statusCode = 0; + if (liveRelayPollFetch(body, fetchOk, statusCode)) + liveGamesApplyResponse(fetchOk, statusCode, body); + + // Keep the list current while it is open, so games starting and ending appear on + // their own without the user thinking about refreshing. + if (!liveRelayFetchInFlight() && + timeGetTime() - s_lastLiveFetchMs >= LIVE_GAMES_REFRESH_INTERVAL_MS) + { + liveGamesRequestList(); + } +} + +Bool LiveGamesMenuHandleSystemMessage(UnsignedInt msg, WindowMsgData mData1, WindowMsgData mData2) +{ + if (!s_liveGamesMode) + return FALSE; + + switch (msg) + { + case GLM_DOUBLE_CLICKED: + { + GameWindow* control = (GameWindow*)mData1; + if (control != nullptr && control->winGetWindowId() == s_listboxID) + { + // Connects the listbox's current selection, not the clicked row. + if ((Int)mData2 >= 0) + liveGamesConnectSelected(); + return TRUE; + } + break; + } + + case GBM_SELECTED: + { + GameWindow* control = (GameWindow*)mData1; + if (control == nullptr) + break; + + const Int controlID = control->winGetWindowId(); + if (controlID == s_buttonLoadID) + { + if (!liveGamesConnectSelected()) + { + MessageBoxOk(UnicodeString(L"No game selected"), + UnicodeString(L"Please select a live game to watch."), nullptr); + } + return TRUE; + } + else if (controlID == s_buttonDeleteID) + { + liveGamesRequestList(); // this button is REFRESH here + return TRUE; + } + else if (controlID == s_buttonCopyID) + { + return TRUE; // hidden in this mode; nothing to copy + } + break; + } + } + + return FALSE; +} + +#endif // defined(GENERALS_ONLINE) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LobbyObserverMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LobbyObserverMenu.cpp new file mode 100644 index 00000000000..a160656bfbc --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LobbyObserverMenu.cpp @@ -0,0 +1,1418 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +/////////////////////////////////////////////////////////////////////////////////////// +// FILE: LobbyObserverMenu.cpp +// Read-only pre-game lobby view. The observer is never a lobby member: this screen subscribes to +// GO's pending-observer queue over the existing websocket, renders from GET /Lobby/{id} fetches +// triggered by pushes, and hands off to the normal join machinery once the stream is live. +/////////////////////////////////////////////////////////////////////////////////////// + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#if defined(GENERALS_ONLINE) + +#include "GameClient/LobbyObserverMenu.h" + +#include "Common/GameEngine.h" +#include "Common/LiveObserver.h" +#include "Common/MultiplayerSettings.h" +#include "Common/PlayerTemplate.h" +#include "GameClient/GameText.h" +#include "GameClient/GameWindowManager.h" +#include "GameClient/Gadget.h" +#include "GameClient/GadgetCheckBox.h" +#include "GameClient/GadgetComboBox.h" +#include "GameClient/GadgetListBox.h" +#include "GameClient/GadgetPushButton.h" // GadgetButtonSetText (map start buttons) +#include "GameClient/GadgetStaticText.h" +#include "GameClient/GadgetTextEntry.h" +#include "GameClient/KeyDefs.h" +#include "GameClient/LiveGamesMenu.h" // LiveGamesMenuEnterLiveGamesMode: re-arm the browser +#include "GameClient/LiveObserverSession.h" // pending-session queue/cancel +#include "GameClient/MapUtil.h" // TheMapCache / MapMetaData +#include "GameClient/Shell.h" +#include "GameClient/WindowLayout.h" +#include "GameNetwork/GameSpyOverlay.h" // GameSpyIsOverlayOpen (password popup guard) +#include "GameNetwork/GeneralsOnline/NGMP_include.h" // from_utf8 +#include "GameNetwork/GeneralsOnline/OnlineServices_Init.h" +#include "GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h" +#include "GameNetwork/GeneralsOnline/json.hpp" +#include "GameNetwork/GUIUtil.h" // GetTeamUiColor +#include "GameNetwork/GameInfo.h" // GameInfo / GameSlot / SlotState (roll prediction) +#include "GameLogic/GameLogic.h" // TheGameLogic->rollRandomSlots (roll prediction) + +#include +#include +#include +#include +#include + +// The map preview + start-position buttons are drawn by the same helper the real lobby +// screens use (WOLGameSetupMenu -> positionStartSpots). Defined in SkirmishGameOptionsMenu. +void positionStartSpots(AsciiString mapName, GameWindow* buttonMapStartPositions[], GameWindow* mapWindow); +UnsignedInt GetTeamUiColor(Int teamNumber); + +// ============================================================================ +// Mode flag +// ============================================================================ + +static Bool s_observerModeActive = FALSE; +static AsciiString s_observerLobbyId; +static AsciiString s_observerLobbyName; +static AsciiString s_observerPassword; // for a password-protected lobby; sent at stream-live handoff + +void SetLobbyObserverMode(const char* lobbyId) +{ + s_observerModeActive = TRUE; + s_observerLobbyId = (lobbyId == nullptr) ? AsciiString::TheEmptyString : AsciiString(lobbyId); +} + +void SetLobbyObserverModeWithPassword(const char* lobbyId, const char* password) +{ + SetLobbyObserverMode(lobbyId); + s_observerPassword = (password == nullptr) ? AsciiString::TheEmptyString : AsciiString(password); +} + +Bool LobbyObserverModeActive(void) +{ + return s_observerModeActive; +} + +// ============================================================================ +// Phase machine +// ============================================================================ + +enum class LobbyObserverPhase +{ + kIdle, // parked in the lobby view, waiting for the match to start + kCountdown, // match starting: 5..0 ticked into the chat box + kWaiting, // match started; waiting for the stream to go live + kJoining, // join queued; the shell screens pump the pending-session machinery +}; + +static LobbyObserverPhase s_phase = LobbyObserverPhase::kIdle; +static Int s_countdownValue = 5; +static UnsignedInt s_countdownNextMs = 0; +static UnsignedInt s_lastLobbyFetchMs = 0; +static UnsignedInt s_gameStartedAtMs = 0; +static Bool s_warnedNoStream = FALSE; +static Bool s_streamNotStartedShown = FALSE; // amber "not started yet" line printed once +static Bool s_joinQueued = FALSE; +static UnsignedInt s_joinQueuedAtMs = 0; +static Bool s_streamLivePending = FALSE; // stream-live arrived mid-countdown; join at 0 +static Bool s_lobbyGone = FALSE; +static UnsignedInt s_lobbyGoneAtMs = 0; +static Bool s_loadingStatusShown = FALSE; // "Loading game..." printed once, pre-roll +static Int s_lastStartCountdown = -1; // last "Starting in Ns" printed, pre-roll + +// Written by the websocket receive thread, consumed by the main thread's Update. The +// receive thread never touches gadgets, so the handoff is flags only. +static std::atomic s_refetchRequested{false}; +static std::atomic s_gameStartSignal{false}; +static std::atomic s_streamLiveSignal{false}; + +// ============================================================================ +// Gadgets +// ============================================================================ + +static const Int MAX_OBSERVER_SLOTS = 8; + +static GameWindow* s_parent = nullptr; +static GameWindow* s_mapLabel = nullptr; +static GameWindow* s_mapWindow = nullptr; +static GameWindow* s_startButtons[MAX_OBSERVER_SLOTS] = {}; +static GameWindow* s_chatListbox = nullptr; +static GameWindow* s_backButton = nullptr; +static GameWindow* s_titleLabel = nullptr; +static GameWindow* s_cashCombo = nullptr; +static GameWindow* s_checkUseStats = nullptr; +static GameWindow* s_checkLimitArmies = nullptr; +static GameWindow* s_checkLimitSuperweapons = nullptr; +static Int s_streamDelay = -1; // GO's StreamDelaySeconds; -1 = never set +static Bool s_firstLobbyFetchDone = FALSE; // first successful /Lobby/{id} fetch happened +static Int s_delayRemaining = 0; // this viewer's remaining broadcast-delay hold, 0 = none +static Bool s_delayHoldShown = FALSE; // the hold line was announced in the chat +static GameWindow* s_slotCombos[MAX_OBSERVER_SLOTS] = {}; +static GameWindow* s_colorCombos[MAX_OBSERVER_SLOTS] = {}; +static GameWindow* s_templateCombos[MAX_OBSERVER_SLOTS] = {}; +static GameWindow* s_teamCombos[MAX_OBSERVER_SLOTS] = {}; + +// ============================================================================ +// Last known lobby state (rendered on every fetch) +// ============================================================================ + +static AsciiString s_mapName; +static AsciiString s_mapPath; // raw MapPath as GO sends it (relative) +static AsciiString s_mapPathLocal; // rewritten to a path this machine can open +static UnicodeString s_slotNames[MAX_OBSERVER_SLOTS]; +static Bool s_slotOccupied[MAX_OBSERVER_SLOTS]; // player or AI +static Int s_slotUserIds[MAX_OBSERVER_SLOTS]; // GO user ids, -1 = not a player (chat colour) +static Int s_slotStates[MAX_OBSERVER_SLOTS]; // raw EPlayerType int from the JSON +static Int s_slotSides[MAX_OBSERVER_SLOTS]; // player template ids, -1 = none +static Int s_slotColors[MAX_OBSERVER_SLOTS]; // color ids, -1 = none +static Int s_slotTeams[MAX_OBSERVER_SLOTS]; // team numbers, -1 = none +static Int s_slotStartPos[MAX_OBSERVER_SLOTS]; // start positions, -1 = none +static Int s_rolledSides[MAX_OBSERVER_SLOTS]; // predicted template ids, -1 = none +static Int s_rolledStartPos[MAX_OBSERVER_SLOTS]; // predicted start positions, -1 = none +static Bool s_rollPredicted = FALSE; // the last fetch could compute a roll prediction +static Int s_startingCash = -1; +static Bool s_trackStats = FALSE; +static Bool s_vanillaTeams = FALSE; +static Bool s_limitSuperweapons = FALSE; +static Int s_lobbyState = -1; // ELobbyState as int; -1 = unknown +static Bool s_isStreaming = FALSE; +static Bool s_countdownStarted = FALSE; // lobby JSON CountdownStarted (host's countdown) +static Bool s_countdownKnown = FALSE; // the JSON carried CountdownStarted at all (old GO omits it) +// Host kill switch for observer chat. Defaults OFF here on purpose: absent on an old GO means +// the message can never be delivered, so the box must stay dead rather than swallow input. +// (Opposite of the server's own default, deliberately - absent-means-off is the safe read.) +static Bool s_allowObserverChat = FALSE; +static GameWindow* s_chatEntry = nullptr; // the chat entry, enabled/disabled with s_allowObserverChat +static UnsignedInt s_lastObserverChatMs = 0; // client-side slowmode gate (courtesy; server re-checks) + + + +// ============================================================================ +// Small helpers +// ============================================================================ + +static void observerChat(const UnicodeString& text, Color color) +{ + if (s_chatListbox != nullptr) + GadgetListBoxAddEntryText(s_chatListbox, text, color, -1, -1); +} + +// Client-side slowmode for observer chat, mirroring WOLLobbyMenu's LobbyChatSlowmodeAllowsSend +// (3 s, red "sending too quickly" line). Purely courtesy - the server re-checks per session. +static bool observerChatSlowmodeAllowsSend() +{ + const UnsignedInt nowMs = timeGetTime(); + const UnsignedInt delta = (nowMs >= s_lastObserverChatMs) ? (nowMs - s_lastObserverChatMs) : 0; + if (s_lastObserverChatMs != 0 && delta < 3000) + { + observerChat(UnicodeString(L"You are sending messages too quickly. Please wait a moment."), + GameMakeColor(255, 0, 0, 255)); + return false; + } + s_lastObserverChatMs = nowMs; + return true; +} + +// Drive the chat entry from the host's AllowObserverChat flag. The entry starts dead and only +// this (via applyLobbyFetch) can bring it to life. +static void applyObserverChatEntryState() +{ + if (s_chatEntry == nullptr) + return; + s_chatEntry->winEnable(s_allowObserverChat ? TRUE : FALSE); + if (!s_allowObserverChat) + GadgetTextEntrySetText(s_chatEntry, UnicodeString::TheEmptyString); +} +// The lobby chat's receive path colours each line by the sender's slot, which it resolves from +// the lobby roster - and an observer is not a member, so that roster is empty. Resolve it from +// this screen's own /Lobby/{id} fetch instead, or every line arrives in the generic colour. +// Same thread as the fetch that fills it: the websocket dispatch runs inside WebSocket::Tick(). +Int LobbyObserverSlotForUserID(Int64 userID) +{ + if (!s_observerModeActive || userID < 0) + return -1; + + for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) + { + if (s_slotUserIds[i] >= 0 && (Int64)s_slotUserIds[i] == userID) + return i; + } + + return -1; +} + +// GO's lobby DTOs serialize PascalCase; tolerate the camelCase spelling too so a +// serializer change cannot silently blank the screen. +static bool jsonGetInt(const nlohmann::json& obj, const char* name, int& out) +{ + if (obj.contains(name) && obj[name].is_number_integer()) + { + out = obj[name].get(); + return true; + } + return false; +} + +static bool jsonGetBool(const nlohmann::json& obj, const char* name, bool& out) +{ + if (obj.contains(name) && obj[name].is_boolean()) + { + out = obj[name].get(); + return true; + } + return false; +} + +static std::string jsonGetString(const nlohmann::json& obj, const char* name) +{ + if (obj.contains(name) && obj[name].is_string()) + return obj[name].get(); + return std::string(); +} + +static bool jsonGetIntFlexible(const nlohmann::json& obj, const char* pascalCase, const char* lowerCase, int& out) +{ + return jsonGetInt(obj, pascalCase, out) || jsonGetInt(obj, lowerCase, out); +} + +static bool jsonGetBoolFlexible(const nlohmann::json& obj, const char* pascalCase, const char* lowerCase, bool& out) +{ + return jsonGetBool(obj, pascalCase, out) || jsonGetBool(obj, lowerCase, out); +} + +static std::string jsonGetStringFlexible(const nlohmann::json& obj, const char* pascalCase, const char* lowerCase) +{ + std::string value = jsonGetString(obj, pascalCase); + if (value.empty()) + value = jsonGetString(obj, lowerCase); + return value; +} + +/// Parse an ISO-8601 UTC timestamp ("2026-08-11T12:34:56.789Z") into a time_t (UTC epoch). +static bool parseIsoUtc(const std::string& text, time_t& out) +{ + if (text.empty()) + return false; + + int year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0; + if (std::sscanf(text.c_str(), "%d-%d-%dT%d:%d:%d", + &year, &month, &day, &hour, &minute, &second) < 6) + { + return false; + } + + std::tm t = {}; + t.tm_year = year - 1900; + t.tm_mon = month - 1; + t.tm_mday = day; + t.tm_hour = hour; + t.tm_min = minute; + t.tm_sec = second; + out = _mkgmtime(&t); // treat as UTC, like the Z suffix says + return out != (time_t)-1; +} + +/// Fill a combo with a single read-only entry and select it. +static void setReadOnlyComboEntry(GameWindow* combo, const UnicodeString& text) +{ + if (combo == nullptr) + return; + + GadgetComboBoxReset(combo); + GadgetComboBoxAddEntry(combo, text, GameMakeColor(255, 255, 255, 255)); + GadgetComboBoxSetSelectedPos(combo, 0); +} + +/// Select the entry carrying itemData; fall back to fallbackPos (usually the "Random" / +/// unassigned entry) when nothing matches - exactly how the real lobby settles unset slots. +static void selectComboByItemData(GameWindow* combo, void* itemData, Int fallbackPos = 0) +{ + if (combo == nullptr) + return; + + const Int count = GadgetComboBoxGetLength(combo); + for (Int i = 0; i < count; ++i) + { + if (GadgetComboBoxGetItemData(combo, i) == itemData) + { + GadgetComboBoxSetSelectedPos(combo, i); + return; + } + } + + GadgetComboBoxSetSelectedPos(combo, fallbackPos); +} + +/// Faction combo, read-only: mirror PopulatePlayerTemplateComboBox's entry list (Random + +/// one entry per SIDE, deduped), then select the slot's template by item data. +static void populateTemplateComboReadOnly(GameWindow* combo, Int side) +{ + if (combo == nullptr) + return; + + GadgetComboBoxReset(combo); + + const Color entryColor = GameMakeColor(255, 255, 255, 255); + Int idx = GadgetComboBoxAddEntry(combo, TheGameText->fetch("GUI:Random"), entryColor); + GadgetComboBoxSetItemData(combo, idx, (void*)PLAYERTEMPLATE_RANDOM); + + std::set seenSides; + if (ThePlayerTemplateStore != nullptr) + { + for (Int c = 0; c < ThePlayerTemplateStore->getPlayerTemplateCount(); ++c) + { + const PlayerTemplate* fac = ThePlayerTemplateStore->getNthPlayerTemplate(c); + if (fac == nullptr || fac->getStartingBuilding().isEmpty()) + continue; + + AsciiString sideKey; + sideKey.format("SIDE:%s", fac->getSide().str()); + if (seenSides.find(sideKey) != seenSides.end()) + continue; + seenSides.insert(sideKey); + + idx = GadgetComboBoxAddEntry(combo, TheGameText->fetch(sideKey), entryColor); + GadgetComboBoxSetItemData(combo, idx, (void*)c); + } + } + + selectComboByItemData(combo, (void*)(intptr_t)side); +} + +/// Team combo, read-only: mirror PopulateTeamComboBox's entries (Team:0 = unassigned, +/// then Team:1..4), select the slot's team by item data. +static void populateTeamComboReadOnly(GameWindow* combo, Int team) +{ + if (combo == nullptr) + return; + + GadgetComboBoxReset(combo); + + Int idx = GadgetComboBoxAddEntry(combo, TheGameText->fetch("Team:0"), GameMakeColor(255, 255, 255, 255)); + GadgetComboBoxSetItemData(combo, idx, (void*)-1); + + for (Int c = 0; c < MAX_SLOTS / 2; ++c) + { + AsciiString teamKey; + teamKey.format("Team:%d", c + 1); + idx = GadgetComboBoxAddEntry(combo, TheGameText->fetch(teamKey.str()), GetTeamUiColor(c)); + GadgetComboBoxSetItemData(combo, idx, (void*)(intptr_t)c); + } + + selectComboByItemData(combo, (void*)(intptr_t)team); +} + +/// Color combo, read-only: mirror PopulateColorComboBox's entries (??? = unassigned, then +/// one entry per color), select the slot's color by item data. +static void populateColorComboReadOnly(GameWindow* combo, Int color) +{ + if (combo == nullptr) + return; + + GadgetComboBoxReset(combo); + + Int idx = GadgetComboBoxAddEntry(combo, TheGameText->fetch("GUI:???"), GameMakeColor(255, 255, 255, 255)); + GadgetComboBoxSetItemData(combo, idx, (void*)-1); + + if (TheMultiplayerSettings != nullptr) + { + for (Int c = 0; c < TheMultiplayerSettings->getNumColors(); ++c) + { + MultiplayerColorDefinition* def = TheMultiplayerSettings->getColor(c); + if (def == nullptr) + continue; + + UnicodeString colorName; + Bool found = FALSE; + colorName = TheGameText->fetch(def->getTooltipName().str(), &found); + if (!found) + colorName.format(L"%hs", def->getTooltipName().str()); + + idx = GadgetComboBoxAddEntry(combo, colorName, def->getColor()); + GadgetComboBoxSetItemData(combo, idx, (void*)(intptr_t)c); + } + } + + selectComboByItemData(combo, (void*)(intptr_t)color); +} + +// ============================================================================ +// Phase transitions +// ============================================================================ + +static void beginCountdown(void) +{ + if (s_phase != LobbyObserverPhase::kIdle || s_lobbyGone) + return; + + s_countdownValue = 5; + s_countdownNextMs = timeGetTime() + 1000; + s_gameStartedAtMs = timeGetTime(); + s_phase = LobbyObserverPhase::kCountdown; +} + +static void queueObserverJoin(void) +{ + if (s_joinQueued || s_lobbyGone || s_observerLobbyId.isEmpty()) + return; + + observerChat(UnicodeString(L"Stream is live - connecting..."), GameMakeColor(120, 255, 120, 255)); + + // Queue the standard observer join, which fetches its own watch ticket. The lobby name rides + // along so a password reprompt is titled, the password entered when this view was gated is + // sent with the ticket request, and the delay pre-seeds the countdown during GO's hold. + StartLiveObserverSession(s_observerLobbyId, s_observerPassword, s_observerLobbyName, + s_streamDelay); + s_joinQueued = TRUE; + s_joinQueuedAtMs = timeGetTime(); + s_phase = LobbyObserverPhase::kJoining; +} + +static void onStreamLive(void) +{ + if (s_lobbyGone || s_observerLobbyId.isEmpty()) + return; + + // The countdown is the player's "the match is starting" cue, so never cut it short: join the + // moment it hits zero, or immediately when none is running. + if (s_phase == LobbyObserverPhase::kCountdown) + { + s_streamLivePending = TRUE; + return; + } + + if (s_phase == LobbyObserverPhase::kIdle || s_phase == LobbyObserverPhase::kWaiting) + queueObserverJoin(); +} + +static void onCountdownCancelled(void) +{ + // The host aborted the match-start countdown: stand this screen's countdown down too and + // forget any stream-live that arrived mid-countdown, or it would wait forever for a stream + // that is not coming. The lobby view stays open - the host may restart the match. + if (s_phase != LobbyObserverPhase::kCountdown && s_phase != LobbyObserverPhase::kWaiting) + return; + + s_streamLivePending = FALSE; + s_phase = LobbyObserverPhase::kIdle; + observerChat(UnicodeString(L"The match start was cancelled - waiting for the host to start"), + GameMakeColor(255, 194, 15, 255)); +} + +static void doLeave(void) +{ + // Cancel a queued/connecting join first: the screen is the only place the queue can + // be abandoned before the join timeout. + if (s_joinQueued && LiveObserverPendingSessionActive()) + CancelLiveObserverPendingSession(); + + if (!s_observerLobbyId.isEmpty()) + { + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = + NGMP_OnlineServicesManager::GetInterface(); + if (pLobbyInterface != nullptr) + pLobbyInterface->UnsubscribeFromLobbyObserver(_atoi64(s_observerLobbyId.str())); + } + + // The pop re-runs ReplayMenuInit on the Watch Live browser below. The browser was + // shut down - clearing its live-games mode - when this lobby view was pushed on top + // of it, so re-arm the mode or the browser comes back as the legacy replay-file list. + LiveGamesMenuEnterLiveGamesMode(); + + // The shutdown hook (LobbyObserverShutdown) resets all the statics. + TheShell->pop(); +} + +// ============================================================================ +// Lobby state fetch + render +// ============================================================================ + +static void renderLobby(void) +{ + // Map name label - the same lookup the real lobby does: prefer the local map cache's display + // name for the locally resolved map path, fall back to the path-stripped raw name. The names + // arrive from GO as UTF-8, so convert rather than translate(), which mangles non-ASCII. + if (s_mapLabel != nullptr) + { + UnicodeString displayName; + const MapMetaData* md = TheMapCache->findMap(s_mapPathLocal); + if (md != nullptr) + displayName = md->m_displayName; + else if (!s_mapName.isEmpty()) + { + AsciiString raw = s_mapName; + const char* slash = raw.reverseFind('\\'); + if (slash != nullptr) + raw = AsciiString(slash + 1); + displayName = from_utf8(raw.str()).c_str(); + } + + if (!displayName.isEmpty()) + GadgetStaticTextSetText(s_mapLabel, displayName); + } + + // Map preview + start positions - the same call the real lobby makes. It draws the preview + // image (or the UnknownMap placeholder) and the numbered start buttons. + if (s_mapWindow != nullptr && !s_mapPathLocal.isEmpty()) + positionStartSpots(s_mapPathLocal, s_startButtons, s_mapWindow); + + // Paint the start buttons the way the real lobby's updateMapStartSpots does: each occupied + // slot shows its player number (1..8) at its resolved start position, in the player's team + // color. With a roll prediction the position comes from it (that is what the load screen will + // show); without one, fall back to the slot index and leave random slots blank. + for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) + { + if (s_startButtons[i] == nullptr) + continue; + GadgetButtonSetText(s_startButtons[i], UnicodeString::TheEmptyString); + if (!s_slotOccupied[i]) + continue; + + Int posIdx = i; + if (s_rollPredicted) + { + if (s_rolledStartPos[i] < 0) + continue; + posIdx = s_rolledStartPos[i]; + } + else if (s_slotSides[i] < 0) + { + continue; + } + + if (posIdx < 0 || posIdx >= MAX_OBSERVER_SLOTS || s_startButtons[posIdx] == nullptr) + continue; + + AsciiString displayNumber; + displayNumber.format("NUMBER:%d", i + 1); + GadgetButtonSetText(s_startButtons[posIdx], TheGameText->fetch(displayNumber)); + + const UnsignedInt col = (s_slotTeams[i] >= 0) + ? GetTeamUiColor(s_slotTeams[i]) + : GameMakeColor(255, 255, 255, 255); + GadgetTextEntrySetTextColor(s_startButtons[posIdx], col); + } + + for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) + { + // Player combo: every slot shows something - a name, an AI level or Open/Closed - + // exactly like the real lobby. + if (s_slotCombos[i] != nullptr) + { + GadgetComboBoxReset(s_slotCombos[i]); + if (!s_slotNames[i].isEmpty()) + { + GadgetComboBoxAddEntry(s_slotCombos[i], s_slotNames[i], GameMakeColor(255, 255, 255, 255)); + GadgetComboBoxSetSelectedPos(s_slotCombos[i], 0); + } + } + + // Faction/color/team only mean something for an occupied slot. An empty slot gets the + // real lobby's look - Random army, no color, no team - not the layout's placeholders. + if (!s_slotOccupied[i]) + { + populateTemplateComboReadOnly(s_templateCombos[i], -1); + populateColorComboReadOnly(s_colorCombos[i], -1); + populateTeamComboReadOnly(s_teamCombos[i], -1); + continue; + } + + // A random side shows the predicted roll (what the load screen will display); + // concrete sides show themselves. + const Int displaySide = (s_slotSides[i] < 0 && s_rollPredicted) ? s_rolledSides[i] : s_slotSides[i]; + populateTemplateComboReadOnly(s_templateCombos[i], displaySide); + populateColorComboReadOnly(s_colorCombos[i], s_slotColors[i]); + populateTeamComboReadOnly(s_teamCombos[i], s_slotTeams[i]); + } + + if (s_cashCombo != nullptr && s_startingCash >= 0) + { + UnicodeString text; + text.format(TheGameText->fetch("GUI:StartingMoneyFormat"), s_startingCash); + setReadOnlyComboEntry(s_cashCombo, text); + } + + // Lobby options, read-only display (the controls themselves are disabled). + if (s_checkUseStats != nullptr) + GadgetCheckBoxSetChecked(s_checkUseStats, s_trackStats); + if (s_checkLimitArmies != nullptr) + GadgetCheckBoxSetChecked(s_checkLimitArmies, s_vanillaTeams); + if (s_checkLimitSuperweapons != nullptr) + GadgetCheckBoxSetChecked(s_checkLimitSuperweapons, s_limitSuperweapons); + + if (s_titleLabel != nullptr && !s_observerLobbyName.isEmpty()) + { + UnicodeString lobbyName(from_utf8(s_observerLobbyName.str()).c_str()); + GadgetStaticTextSetText(s_titleLabel, lobbyName); + } + + // Fallbacks for a missed push: the state/IsStreaming flags arrive on every fetch. + if (s_lobbyState == 1 /* ELobbyState::INGAME */) + beginCountdown(); + if (s_isStreaming) + onStreamLive(); + + // The countdown is lobby state (CountdownStarted in the JSON), so the refetch drives this + // screen: start when the host starts, stand down when cancelled. The INGAME guard stops the + // start-of-match clear reading as a cancel, and the grace window stops a refetch that was + // already in flight when the countdown started from standing it down on stale state. + if (s_countdownKnown && s_countdownStarted) + { + if (s_phase == LobbyObserverPhase::kIdle) + beginCountdown(); + } + else if (s_countdownKnown && s_lobbyState != 1 + && (s_phase == LobbyObserverPhase::kCountdown || s_phase == LobbyObserverPhase::kWaiting) + && s_gameStartedAtMs != 0 && (timeGetTime() - s_gameStartedAtMs) > 1500) + { + onCountdownCancelled(); + } + + // Broadcast-delay hold: GO holds this viewer's ticket until the match has run for the host's + // delay, and the queued join waits it out in its retry loop. Announced once, so the wait in + // the lobby view has something to look at. + if (s_delayRemaining > 0 && s_phase != LobbyObserverPhase::kIdle && !s_delayHoldShown) + { + s_delayHoldShown = TRUE; + UnicodeString text; + text.format(L"Broadcast delay: %ds - joining automatically when it ends", s_delayRemaining); + observerChat(text, GameMakeColor(255, 194, 15, 255)); + } +} + +// ============================================================================ +// Roll prediction +// ============================================================================ + +// The game start resolves random slots deterministically from the lobby's RNG seed plus the +// member list. The stream header cannot help: it arrives only once the stream is live, and even +// then carries the pre-roll slot list. So replay the same resolution from the lobby JSON, which +// gives exactly what the players will see on the load screen. +static void predictLobbyRoll(const nlohmann::json& response) +{ + s_rollPredicted = FALSE; + for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) + { + s_rolledSides[i] = -1; + s_rolledStartPos[i] = -1; + } + + if (TheGameLogic == nullptr || s_mapPathLocal.isEmpty()) + return; + + // The position part needs the map's waypoints, exactly like the game start. + AsciiString lowerMap = s_mapPathLocal; + lowerMap.toLower(); + if (TheMapCache->findMap(lowerMap) == nullptr) + return; + + // The seed the game start will re-seed from (the lobby's RNGSeed). + int seed = 0; + if (!jsonGetIntFlexible(response, "RNGSeed", "rngSeed", seed)) + return; + + GameInfo scratch; + GameSlot scratchSlots[MAX_SLOTS]; + for (Int i = 0; i < MAX_SLOTS; ++i) + scratch.setSlotPointer(i, &scratchSlots[i]); + scratch.setSeed(seed); + scratch.setMap(lowerMap); + + for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) + { + if (!s_slotOccupied[i]) + continue; + + GameSlot slot; + slot.setState((SlotState)s_slotStates[i], s_slotNames[i], i); + slot.setPlayerTemplate(s_slotSides[i] < 0 ? PLAYERTEMPLATE_RANDOM : s_slotSides[i]); + slot.setColor(s_slotColors[i]); + slot.setTeamNumber(s_slotTeams[i]); + slot.setStartPos(s_slotStartPos[i]); + scratch.setSlot(i, slot); + } + + TheGameLogic->rollRandomSlots(&scratch); + + for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) + { + if (!s_slotOccupied[i]) + continue; + const GameSlot* slot = scratch.getConstSlot(i); + if (slot == nullptr) + continue; + s_rolledSides[i] = slot->getPlayerTemplate(); + s_rolledStartPos[i] = slot->getStartPos(); + } + s_rollPredicted = TRUE; +} + +static void applyLobbyFetch(Bool success, Int statusCode, const AsciiString& body) +{ + if (statusCode == 404) + { + // The lobby is gone - the pre-game was cancelled or the lobby closed. Leave the + // view after a short pause so the player sees why. + if (!s_lobbyGone) + { + s_lobbyGone = TRUE; + s_lobbyGoneAtMs = timeGetTime(); + observerChat(UnicodeString(L"The lobby is no longer available - returning to Watch Live"), + GameMakeColor(255, 120, 120, 255)); + } + return; + } + + if (!success || statusCode != 200) + { + observerChat(UnicodeString(L"Could not reach GeneralsOnline"), GameMakeColor(255, 120, 120, 255)); + return; + } + + try + { + nlohmann::json response = nlohmann::json::parse(body.str()); + if (!response.is_object()) + return; + + // GET /Lobby/{id} wraps the lobby in a result envelope: { "lobby": {...} }. + if (response.contains("lobby") && response["lobby"].is_object()) + response = response["lobby"]; + + const std::string mapName = jsonGetStringFlexible(response, "MapName", "mapName"); + if (!mapName.empty()) + s_mapName = mapName.c_str(); + + const std::string mapPath = jsonGetStringFlexible(response, "MapPath", "mapPath"); + if (!mapPath.empty()) + s_mapPath = mapPath.c_str(); + + // GO's MapPath is relative; the local map cache keys on full paths. Rewrite it the + // same way the real lobby does (UpdateRoomDataCache): official maps live under + // maps\, custom maps under the user map directory. + bool isOfficial = false; + jsonGetBoolFlexible(response, "IsMapOfficial", "isMapOfficial", isOfficial); + s_mapPathLocal.clear(); + if (!s_mapPath.isEmpty()) + { + if (isOfficial) + s_mapPathLocal.format("maps\\%s", s_mapPath.str()); + else + { + AsciiString userMapDir = TheMapCache->getUserMapDir(true); + userMapDir.toLower(); + s_mapPathLocal.format("%s\\%s", userMapDir.str(), s_mapPath.str()); + } + } + + const std::string lobbyName = jsonGetStringFlexible(response, "Name", "name"); + if (!lobbyName.empty()) + s_observerLobbyName = lobbyName.c_str(); + + for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) + { + s_slotNames[i].clear(); + s_slotOccupied[i] = FALSE; + s_slotStates[i] = -1; + s_slotSides[i] = -1; + s_slotColors[i] = -1; + s_slotTeams[i] = -1; + s_slotStartPos[i] = -1; + s_slotUserIds[i] = -1; + } + + const nlohmann::json* members = nullptr; + if (response.contains("Members") && response["Members"].is_array()) + members = &response["Members"]; + else if (response.contains("members") && response["members"].is_array()) + members = &response["members"]; + + if (members != nullptr) + { + for (const auto& member : *members) + { + if (!member.is_object()) + continue; + + Int slotIndex = -1; + Int slotState = -1; + Int userID = -1; + jsonGetIntFlexible(member, "SlotIndex", "slotIndex", slotIndex); + jsonGetIntFlexible(member, "SlotState", "slotState", slotState); + jsonGetIntFlexible(member, "UserID", "userID", userID); + + if (slotIndex < 0 || slotIndex >= MAX_OBSERVER_SLOTS) + continue; + + s_slotStates[slotIndex] = slotState; + + // EPlayerType: SLOT_OPEN=0, SLOT_CLOSED=1, EASY/MED/BRUTAL_AI=2/3/4, + // SLOT_PLAYER=5. Mirror the stock lobby's slot labels. + switch (slotState) + { + case 5: // SLOT_PLAYER + if (userID >= 0) + { + const std::string displayName = jsonGetStringFlexible(member, "DisplayName", "displayName"); + if (!displayName.empty()) + s_slotNames[slotIndex] = from_utf8(displayName).c_str(); + s_slotOccupied[slotIndex] = TRUE; + s_slotUserIds[slotIndex] = userID; + jsonGetIntFlexible(member, "Side", "side", s_slotSides[slotIndex]); + jsonGetIntFlexible(member, "Color", "color", s_slotColors[slotIndex]); + jsonGetIntFlexible(member, "Team", "team", s_slotTeams[slotIndex]); + jsonGetIntFlexible(member, "StartingPosition", "startingPosition", s_slotStartPos[slotIndex]); + } + break; + case 2: // SLOT_EASY_AI + case 3: // SLOT_MED_AI + case 4: // SLOT_BRUTAL_AI + s_slotNames[slotIndex] = TheGameText->fetch( + slotState == 2 ? "GUI:EasyAI" : (slotState == 3 ? "GUI:MediumAI" : "GUI:HardAI")); + s_slotOccupied[slotIndex] = TRUE; + jsonGetIntFlexible(member, "Side", "side", s_slotSides[slotIndex]); + jsonGetIntFlexible(member, "Color", "color", s_slotColors[slotIndex]); + jsonGetIntFlexible(member, "Team", "team", s_slotTeams[slotIndex]); + jsonGetIntFlexible(member, "StartingPosition", "startingPosition", s_slotStartPos[slotIndex]); + break; + case 0: // SLOT_OPEN + s_slotNames[slotIndex] = TheGameText->fetch("GUI:Open"); + break; + case 1: // SLOT_CLOSED + s_slotNames[slotIndex] = TheGameText->fetch("GUI:Closed"); + break; + } + } + } + + // Slots GO did not enumerate at all read as open, so nothing shows "entry". + for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) + { + if (s_slotNames[i].isEmpty()) + s_slotNames[i] = TheGameText->fetch("GUI:Open"); + } + + jsonGetIntFlexible(response, "State", "state", s_lobbyState); + jsonGetIntFlexible(response, "StartingCash", "startingCash", s_startingCash); + // StreamDelaySeconds is null until the host has chosen a broadcast delay; the JSON + // null fails the is_number_integer check and leaves -1. + jsonGetIntFlexible(response, "StreamDelaySeconds", "streamDelaySeconds", s_streamDelay); + bool bCountdown = false; + // An older GO omits CountdownStarted entirely, and the state rules must stay inert then - + // otherwise every refetch reads "not counting" and instantly stands the countdown down. + // The GAME_STARTING push still drives it in that case. + s_countdownKnown = jsonGetBoolFlexible(response, "CountdownStarted", "countdownStarted", bCountdown) ? TRUE : FALSE; + s_countdownStarted = bCountdown ? TRUE : FALSE; + bool bTrackStats = false; + jsonGetBoolFlexible(response, "IsTrackingStats", "isTrackingStats", bTrackStats); + s_trackStats = bTrackStats ? TRUE : FALSE; + bool bVanillaTeams = false; + jsonGetBoolFlexible(response, "IsVanillaTeamsOnly", "isVanillaTeamsOnly", bVanillaTeams); + s_vanillaTeams = bVanillaTeams ? TRUE : FALSE; + bool bLimitSuperweapons = false; + jsonGetBoolFlexible(response, "IsLimitSuperweapons", "isLimitSuperweapons", bLimitSuperweapons); + s_limitSuperweapons = bLimitSuperweapons ? TRUE : FALSE; + bool streaming = false; + jsonGetBoolFlexible(response, "IsStreaming", "isStreaming", streaming); + s_isStreaming = streaming ? TRUE : FALSE; + // Host kill switch for observer chat. Absent (old GO) -> stays false, so the entry + // remains dead; a refetch that flips it enables/disables the entry and says so. + bool bAllowObserverChat = false; + jsonGetBoolFlexible(response, "AllowObserverChat", "allowObserverChat", bAllowObserverChat); + if (s_allowObserverChat != (bAllowObserverChat ? TRUE : FALSE)) + { + s_allowObserverChat = bAllowObserverChat ? TRUE : FALSE; + observerChat(s_allowObserverChat + ? UnicodeString(L"Observer chat is enabled") + : UnicodeString(L"Observer chat has been disabled by the host"), + GameMakeColor(255, 194, 15, 255)); + applyObserverChatEntryState(); + } + + // The remaining hold is derived, not serialized: GO computes it in the livestream + // controller as TimeMatchStarted + StreamDelaySeconds, and this repeats that from the two + // fields the lobby JSON does carry. Zero when not held. + s_delayRemaining = 0; + time_t matchStarted = 0; + if (s_streamDelay > 0 && + parseIsoUtc(jsonGetStringFlexible(response, "TimeMatchStarted", "timeMatchStarted"), matchStarted)) + { + const Int elapsed = (Int)(time(nullptr) - matchStarted); + s_delayRemaining = (elapsed < s_streamDelay) ? (s_streamDelay - elapsed) : 0; + } + + // Entered a game that is already started (the "STARTED / wait" row from the browser): skip + // the match-starting countdown and wait for the stream, or for the end of the hold, + // directly. The GAME_STARTING push fired before we subscribed and cannot arrive any more. + if (!s_firstLobbyFetchDone) + { + s_firstLobbyFetchDone = TRUE; + if (s_lobbyState == 1 /* ELobbyState::INGAME */ && s_phase == LobbyObserverPhase::kIdle) + { + observerChat(UnicodeString(L"Game already started - waiting for the stream"), + GameMakeColor(200, 200, 200, 255)); + s_gameStartedAtMs = timeGetTime(); + s_phase = LobbyObserverPhase::kWaiting; + } + } + + // The assignment the game start will produce (factions + start positions), computed + // from the same seed and member list the game will use. + predictLobbyRoll(response); + + renderLobby(); + } + catch (const nlohmann::json::exception&) + { + } +} + +// ============================================================================ +// Public entry points (called by the observer-mode branch of WOLGameSetupMenu) +// ============================================================================ + +void LobbyObserverInit(WindowLayout* layout, void* userData) +{ + s_parent = TheWindowManager->winGetWindowFromId(nullptr, + TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:GameSpyGameOptionsMenuParent")); + if (s_parent == nullptr) + return; + + s_mapLabel = TheWindowManager->winGetWindowFromId(s_parent, + TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:TextEntryMapDisplay")); + s_mapWindow = TheWindowManager->winGetWindowFromId(s_parent, + TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:MapWindow")); + s_chatListbox = TheWindowManager->winGetWindowFromId(s_parent, + TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:ListboxChatWindowGameSpyGameSetup")); + s_backButton = TheWindowManager->winGetWindowFromId(s_parent, + TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:ButtonBack")); + s_titleLabel = TheWindowManager->winGetWindowFromId(s_parent, + TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:StaticTextGameName")); + s_cashCombo = TheWindowManager->winGetWindowFromId(s_parent, + TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:ComboBoxStartingCash")); + s_checkUseStats = TheWindowManager->winGetWindowFromId(s_parent, + TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:CheckBoxUseStats")); + s_checkLimitArmies = TheWindowManager->winGetWindowFromId(s_parent, + TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:CheckBoxLimitArmies")); + s_checkLimitSuperweapons = TheWindowManager->winGetWindowFromId(s_parent, + TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:CheckboxLimitSuperweapons")); + + for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) + { + AsciiString tmp; + tmp.format("GameSpyGameOptionsMenu.wnd:ComboBoxPlayer%d", i); + s_slotCombos[i] = TheWindowManager->winGetWindowFromId(s_parent, TheNameKeyGenerator->nameToKey(tmp)); + + tmp.format("GameSpyGameOptionsMenu.wnd:ComboBoxColor%d", i); + s_colorCombos[i] = TheWindowManager->winGetWindowFromId(s_parent, TheNameKeyGenerator->nameToKey(tmp)); + + tmp.format("GameSpyGameOptionsMenu.wnd:ComboBoxPlayerTemplate%d", i); + s_templateCombos[i] = TheWindowManager->winGetWindowFromId(s_parent, TheNameKeyGenerator->nameToKey(tmp)); + + tmp.format("GameSpyGameOptionsMenu.wnd:ComboBoxTeam%d", i); + s_teamCombos[i] = TheWindowManager->winGetWindowFromId(s_parent, TheNameKeyGenerator->nameToKey(tmp)); + + tmp.format("GameSpyGameOptionsMenu.wnd:ButtonMapStartPosition%d", i); + s_startButtons[i] = TheWindowManager->winGetWindowFromId(s_parent, TheNameKeyGenerator->nameToKey(tmp)); + } + + // The observer screen is a read-only lobby: every interactive control is dead, the + // Back button is the one live button (relabelled LEAVE). + static const char* const kDisabledControls[] = + { + "GameSpyGameOptionsMenu.wnd:ButtonStart", + "GameSpyGameOptionsMenu.wnd:ButtonSelectMap", + "GameSpyGameOptionsMenu.wnd:ButtonEmote", + "GameSpyGameOptionsMenu.wnd:ButtonCommunicator", + "GameSpyGameOptionsMenu.wnd:CheckBoxUseStats", + "GameSpyGameOptionsMenu.wnd:CheckboxLimitSuperweapons", + "GameSpyGameOptionsMenu.wnd:CheckBoxLimitArmies", + nullptr + }; + for (Int i = 0; kDisabledControls[i] != nullptr; ++i) + { + GameWindow* win = TheWindowManager->winGetWindowFromId(s_parent, + TheNameKeyGenerator->nameToKey(kDisabledControls[i])); + if (win != nullptr) + win->winEnable(FALSE); + } + for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) + { + if (s_colorCombos[i] != nullptr) + s_colorCombos[i]->winEnable(FALSE); + if (s_templateCombos[i] != nullptr) + s_templateCombos[i]->winEnable(FALSE); + if (s_teamCombos[i] != nullptr) + s_teamCombos[i]->winEnable(FALSE); + + AsciiString tmp; + tmp.format("GameSpyGameOptionsMenu.wnd:ButtonAccept%d", i); + GameWindow* win = TheWindowManager->winGetWindowFromId(s_parent, TheNameKeyGenerator->nameToKey(tmp)); + if (win != nullptr) + win->winHide(TRUE); + } + + if (s_backButton != nullptr) + s_backButton->winSetText(UnicodeString(L"LEAVE")); + // The click arrives via WOLGameSetupMenuSystem -> LobbyObserverInput: a button's GBM_SELECTED + // goes to its owner chain, never to the button's own system function, so the button must keep + // GadgetPushButtonSystem. + + // Subscribe to the pending-observer queue and to the pushes. The callback runs on the + // websocket thread: it only raises flags, all UI work happens in Update. + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = + NGMP_OnlineServicesManager::GetInterface(); + if (pLobbyInterface != nullptr) + { + pLobbyInterface->RegisterForLobbyObserverEvent( + [](NGMP_OnlineServices_LobbyInterface::ELobbyObserverEventType eventType, int64_t lobbyId) + { + if (s_observerLobbyId.isEmpty() || lobbyId != _atoi64(s_observerLobbyId.str())) + return; + + switch (eventType) + { + case NGMP_OnlineServices_LobbyInterface::ELobbyObserverEventType::LOBBY_CHANGED: + s_refetchRequested.store(true); + break; + case NGMP_OnlineServices_LobbyInterface::ELobbyObserverEventType::GAME_STARTING: + s_gameStartSignal.store(true); + break; + case NGMP_OnlineServices_LobbyInterface::ELobbyObserverEventType::STREAM_LIVE: + // The relay confirmed it holds the streamer's header. This is the only + // liveness signal; never guess "live" from the match having started. + s_streamLiveSignal.store(true); + break; + case NGMP_OnlineServices_LobbyInterface::ELobbyObserverEventType::GAME_STARTED: + // Deliberately ignored: a started match says nothing about a stream, since + // everyone may have streaming off. Wait for STREAM_LIVE or the IsStreaming + // flag on the lobby fetch. + break; + } + }); + + // The members' lobby chat, read-only. GO fans it out to pending observers as well, and the + // receive path has no membership check, so this listbox is the only thing that was missing. + // The dispatch runs inside WebSocket::Tick(), i.e. on this thread, exactly like the real + // lobby's own chat callback (WOLGameSetupMenu) - no handoff needed. + pLobbyInterface->RegisterForChatCallback([](UnicodeString strMessage, Color color) + { + observerChat(strMessage, color); + }); + + pLobbyInterface->SubscribeToLobbyObserver(_atoi64(s_observerLobbyId.str())); + } + + // The chat entry is the observer's own send box, live only while the host allows observer + // chat; keep it empty rather than the layout's placeholder text. Starts dead until the + // first /Lobby/{id} fetch says otherwise (s_allowObserverChat defaults FALSE). + s_chatEntry = TheWindowManager->winGetWindowFromId(s_parent, + TheNameKeyGenerator->nameToKey("GameSpyGameOptionsMenu.wnd:TextEntryChat")); + if (s_chatEntry != nullptr) + GadgetTextEntrySetText(s_chatEntry, UnicodeString::TheEmptyString); + applyObserverChatEntryState(); + + // Intro line, then the first fetch. + observerChat(UnicodeString(L"Waiting for the match to start..."), GameMakeColor(200, 200, 200, 255)); + + s_lastLobbyFetchMs = 0; + layout->hide(FALSE); + TheWindowManager->winSetFocus(s_parent); +} + +void LobbyObserverUpdate(WindowLayout* layout, void* userData) +{ + const UnsignedInt now = timeGetTime(); + + // -- Lobby fetch pump (triggered by pings, throttled, with a 30s safety net) -- + AsciiString body; + Bool fetchOk = FALSE; + Int statusCode = 0; + if (liveRelayPollFetch(body, fetchOk, statusCode)) + applyLobbyFetch(fetchOk, statusCode, body); + + const Bool refetchNow = s_refetchRequested.exchange(false); + const UnsignedInt sinceLast = (now > s_lastLobbyFetchMs) ? (now - s_lastLobbyFetchMs) : 0; + if (!liveRelayFetchInFlight() && (refetchNow ? sinceLast >= 1000 : sinceLast >= 30000)) + { + s_lastLobbyFetchMs = now; + AsciiString url; + url.format("%s/%s", liveServicesEndpoint("Lobby").str(), s_observerLobbyId.str()); + liveRelayBeginFetch(url); + } + + // -- Push signals -- + if (s_gameStartSignal.exchange(false)) + beginCountdown(); + if (s_streamLiveSignal.exchange(false)) + onStreamLive(); + + // -- Countdown ticks -- + if (s_phase == LobbyObserverPhase::kCountdown && now >= s_countdownNextMs) + { + --s_countdownValue; + if (s_countdownValue > 0) + { + s_countdownNextMs = now + 1000; + } + else + { + observerChat(UnicodeString(L"Match starting - waiting for the stream"), + GameMakeColor(255, 194, 15, 255)); + s_phase = LobbyObserverPhase::kWaiting; + if (s_streamLivePending) + { + s_streamLivePending = FALSE; + onStreamLive(); + } + } + } + + // Join handoff. The pending-session pump belongs to the shell screens below, so this screen + // must neither consume it nor pop itself when playback starts: a normal game start never pops + // its setup menu (MSG_NEW_GAME's hideShell hides it, and the game-end re-init handles the + // stale screen). Popping here re-inits the Watch Live browser underneath, which then renders + // on top of the running game. + if (s_phase == LobbyObserverPhase::kJoining) + { + // While GO holds the watch ticket the observer is not connected yet, so the pre-roll + // block below has nothing to show. Report the remaining hold in the chat instead. + if (TheLiveObserver != nullptr && TheLiveObserver->isWaitingForBroadcastDelay()) + { + const Int seconds = TheLiveObserver->getBroadcastDelayRemainingSeconds(); + if (seconds != s_lastStartCountdown) + { + s_lastStartCountdown = seconds; + UnicodeString text; + text.format(L"Broadcast delay: %ds", seconds); + observerChat(text, GameMakeColor(255, 194, 15, 255)); + } + } + // Pre-roll status in the chat: while the relayed stream has no complete frames yet the + // players are still loading, and once frames flow this reports the remaining broadcast + // delay. This screen stays up for the whole wait, so the chat is where it belongs. + else if (TheLiveObserver != nullptr && TheLiveObserver->isConnected() && !TheLiveObserver->hasPlaybackStarted()) + { + if (TheLiveObserver->getMaxCompleteFrame() == 0) + { + if (!s_loadingStatusShown) + { + s_loadingStatusShown = TRUE; + observerChat(UnicodeString(L"Loading game..."), GameMakeColor(255, 194, 15, 255)); + } + } + else + { + const Int seconds = TheLiveObserver->getSecondsUntilPlaybackReady(); + if (seconds != s_lastStartCountdown) + { + s_lastStartCountdown = seconds; + UnicodeString text; + text.format(L"Starting in %ds", seconds); + observerChat(text, GameMakeColor(255, 194, 15, 255)); + } + } + } + + if (!LiveObserverPendingSessionActive() && (now - s_joinQueuedAtMs) > 3000 && + !GameSpyIsOverlayOpen(GSOVERLAY_GAMEPASSWORD)) + { + // The pump gave up (join timeout, or the stream never materialised) and cleared the + // queue itself, so drop back to the lobby view. The password reprompt popup + // suppresses this for its own duration. + observerChat(UnicodeString(L"Timed out waiting for the stream - the host may not be streaming"), + GameMakeColor(255, 120, 120, 255)); + s_joinQueued = FALSE; + s_phase = LobbyObserverPhase::kIdle; + } + } + + // Host-never-streams warning: the match started but the relay reports no stream. Two stages, + // because GO's match-start is the host's START_GAME - before any client has loaded the map - + // while the streamer registers only once in-game, so a slow load easily outlasts 20s. At 60s + // it is not coming, which is also when GO drops the game from Watch Live. A live stream or a + // held join keeps s_joinQueued / s_isStreaming true and trips neither. + if (s_gameStartedAtMs != 0 && !s_joinQueued && + s_lobbyState == 1 /* ELobbyState::INGAME */ && !s_isStreaming) + { + if ((now - s_gameStartedAtMs) > 60000) + { + if (!s_warnedNoStream) + { + s_warnedNoStream = TRUE; + observerChat(UnicodeString(L"The stream has not started - the host may not be streaming. LEAVE to stop waiting."), + GameMakeColor(255, 120, 120, 255)); + } + } + else if ((now - s_gameStartedAtMs) > 20000 && !s_streamNotStartedShown) + { + s_streamNotStartedShown = TRUE; + observerChat(UnicodeString(L"The stream has not started yet - waiting for the host to start streaming"), + GameMakeColor(255, 194, 15, 255)); + } + } + + // -- Lobby gone -> auto-return -- + if (s_lobbyGone && (now - s_lobbyGoneAtMs) > 2000) + doLeave(); +} + +void LobbyObserverShutdown(WindowLayout* layout, void* userData) +{ + // The join hand-off also lands here, because the shell tears down when the game starts. By + // then the pending-session pump has cleared its own queue, so the cancel below only fires for + // a real leave or an aborted join. + if (s_joinQueued && LiveObserverPendingSessionActive()) + CancelLiveObserverPendingSession(); + + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = + NGMP_OnlineServicesManager::GetInterface(); + if (pLobbyInterface != nullptr) + { + pLobbyInterface->DeregisterForLobbyObserverEvent(); + // The listbox this writes into is torn down below, so drop the callback first. + pLobbyInterface->DeregisterForChatCallback(); + if (!s_observerLobbyId.isEmpty()) + pLobbyInterface->UnsubscribeFromLobbyObserver(_atoi64(s_observerLobbyId.str())); + } + + s_observerModeActive = FALSE; + s_observerLobbyId.clear(); + s_observerLobbyName.clear(); + s_observerPassword.clear(); + s_phase = LobbyObserverPhase::kIdle; + s_joinQueued = FALSE; + s_streamLivePending = FALSE; + s_lobbyGone = FALSE; + s_warnedNoStream = FALSE; + s_streamNotStartedShown = FALSE; + s_loadingStatusShown = FALSE; + s_lastStartCountdown = -1; + s_gameStartedAtMs = 0; + s_mapName.clear(); + s_mapPath.clear(); + s_mapPathLocal.clear(); + s_startingCash = -1; + s_trackStats = FALSE; + s_vanillaTeams = FALSE; + s_limitSuperweapons = FALSE; + s_lobbyState = -1; + s_isStreaming = FALSE; + s_countdownStarted = FALSE; + s_countdownKnown = FALSE; + s_allowObserverChat = FALSE; + s_lastObserverChatMs = 0; + s_streamDelay = -1; + s_firstLobbyFetchDone = FALSE; + s_delayRemaining = 0; + s_delayHoldShown = FALSE; + s_rollPredicted = FALSE; + s_refetchRequested.store(false); + s_gameStartSignal.store(false); + s_streamLiveSignal.store(false); + + s_parent = nullptr; + s_mapLabel = nullptr; + if (s_mapWindow != nullptr) + s_mapWindow->winSetUserData(nullptr); + s_mapWindow = nullptr; + s_chatListbox = nullptr; + s_backButton = nullptr; + s_titleLabel = nullptr; + s_cashCombo = nullptr; + s_checkUseStats = nullptr; + s_checkLimitArmies = nullptr; + s_checkLimitSuperweapons = nullptr; + s_chatEntry = nullptr; + for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) + { + s_slotCombos[i] = nullptr; + s_colorCombos[i] = nullptr; + s_templateCombos[i] = nullptr; + s_teamCombos[i] = nullptr; + s_startButtons[i] = nullptr; + s_slotNames[i].clear(); + s_slotOccupied[i] = FALSE; + s_slotSides[i] = -1; + s_slotColors[i] = -1; + s_slotTeams[i] = -1; + s_slotStates[i] = -1; + s_slotStartPos[i] = -1; + s_slotUserIds[i] = -1; + s_rolledSides[i] = -1; + s_rolledStartPos[i] = -1; + } + + // Complete the shell's pop/shutdown. The stock setup menu finishes this from its own update + // loop (reverseAnimateWindow -> isAnimFinished -> shutdownComplete), but observer mode skips + // the animations, so without this the pending pop never completes and the layout stays on the + // stack on top of the running game. + if (layout != nullptr) + { + layout->hide(TRUE); + TheShell->shutdownComplete(layout); + } +} + +WindowMsgHandledType LobbyObserverInput(GameWindow* window, UnsignedInt msg, + WindowMsgData mData1, WindowMsgData mData2) +{ + switch (msg) + { + case GBM_SELECTED: + { + GameWindow* control = (GameWindow*)mData1; + if (control != nullptr && control == s_backButton) + { + doLeave(); + return MSG_HANDLED; + } + break; + } + + case GEM_EDIT_DONE: + { + // The observer's own chat entry. The server re-broadcasts the line as an ordinary + // LOBBY_CHAT_FROM_SERVER with [Name] formatting, so no local echo is needed. + GameWindow* control = (GameWindow*)mData1; + if (control != nullptr && s_chatEntry != nullptr && control == s_chatEntry) + { + if (!s_allowObserverChat) + return MSG_HANDLED; + + UnicodeString txtInput; + txtInput.set(GadgetTextEntryGetText(s_chatEntry)); + GadgetTextEntrySetText(s_chatEntry, UnicodeString::TheEmptyString); + txtInput.trim(); + if (!txtInput.isEmpty() && observerChatSlowmodeAllowsSend()) + { + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = + NGMP_OnlineServicesManager::GetInterface(); + if (pLobbyInterface != nullptr && !s_observerLobbyId.isEmpty()) + { + pLobbyInterface->SendObserverChatMessage(_atoi64(s_observerLobbyId.str()), txtInput); + } + } + return MSG_HANDLED; + } + break; + } + + case GWM_CHAR: + { + UnsignedByte key = (UnsignedByte)mData1; + UnsignedByte state = (UnsignedByte)mData2; + if (key == KEY_ESC && BitIsSet(state, KEY_STATE_UP)) + { + doLeave(); + return MSG_HANDLED; + } + break; + } + } + + return MSG_IGNORED; +} + +#endif // defined(GENERALS_ONLINE) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/MainMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/MainMenu.cpp index 425298f0a4b..96de8a94c3c 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/MainMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/MainMenu.cpp @@ -76,6 +76,13 @@ #include "GameClient/InGameUI.h" #include "../OnlineServices_Init.h" +#if defined(GENERALS_ONLINE) +#include "Common/LiveObserver.h" +#include "Common/Recorder.h" +#include "GameClient/LiveObserverSession.h" +#include "GameClient/LiveGamesMenu.h" +#endif + // PRIVATE DATA /////////////////////////////////////////////////////////////////////////////////// @@ -308,6 +315,16 @@ static void doGameStart() if (TheGameLogic->isInGame()) TheGameLogic->clearGameData(); +#if defined(GENERALS_ONLINE) + // Live playback already sent MSG_NEW_GAME with GAME_REPLAY; a second one would start a + // duplicate game. + if (TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER) + { + isShuttingDown = TRUE; + return; + } +#endif + // send a message to the logic for a new game GameMessage *msg = TheMessageStream->appendMessage( GameMessage::MSG_NEW_GAME ); msg->appendIntegerArgument(GAME_SINGLE_PLAYER); @@ -434,6 +451,17 @@ static void initLabelVersion() //------------------------------------------------------------------------------------------------- void MainMenuInit( WindowLayout *layout, void *userData ) { +#if defined(GENERALS_ONLINE) + // A live-observer game just ended. A direct join popped the Watch Live browser when the + // session started, so re-arm the browser and push it back on top of this screen. + if (LiveObserverConsumeReturnedFromGame()) + { + LiveGamesMenuEnterLiveGamesMode(); + TheShell->push("Menus/ReplayMenu.wnd", TRUE); + return; + } +#endif + TheWritableGlobalData->m_breakTheMovie = FALSE; TheShell->showShellMap(TRUE); @@ -908,6 +936,13 @@ void MainMenuUpdate( WindowLayout *layout, void *userData ) +#if defined(GENERALS_ONLINE) + // Reached when the player got back to the main menu before the queued session fired. Joins + // the normal start path only so doGameStart's live-observer branch stands this menu down. + if (LiveObserverStartPendingSession()) + startGame = TRUE; +#endif + if (startGame && TheShell->isAnimFinished() && TheTransitionHandler->isFinished()) { doGameStart(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupHostGame.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupHostGame.cpp index 949bc28edbf..0e81f6c418c 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupHostGame.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupHostGame.cpp @@ -101,6 +101,7 @@ static GameWindow *parentPopup = nullptr; static GameWindow *textEntryGameName = nullptr; static GameWindow *buttonCreateGame = nullptr; static GameWindow *checkBoxAllowObservers = nullptr; +static GameWindow *checkBoxAllowStreamers = nullptr; static GameWindow *textEntryGameDescription = nullptr; static GameWindow *buttonCancel = nullptr; static GameWindow *comboBoxLadderName = nullptr; @@ -411,6 +412,52 @@ void PopupHostGameInit( WindowLayout *layout, void *userData ) checkBoxAllowObservers->winHide(false); GadgetCheckBoxSetChecked(checkBoxAllowObservers, true); + // Game-level broadcast intent: when off the game never appears in Watch Live, whatever any + // individual player's Enable Stream toggle says. Built in code - the .wnd has no such control. + { + int wObs = 0; + int hObs = 0; + checkBoxAllowObservers->winGetSize(&wObs, &hObs); + + // Sits in the Limit Armies column on the Allow Observers row, so the two rows read as + // two columns: [Allow observers][Allow streamers] over [Use stats][Limit armies]. + int xLimit = 0; + int yLimit = 0; + checkBoxLimitArmies->winGetPosition(&xLimit, &yLimit); + + // The stock checkbox rows are spaced closer than the checkboxes are tall. Allow Observers + // escapes the overlap because nothing sits below it; this one shares Limit Armies' + // column, so push that row down by the overlap rather than shrink the checkbox. + const Int rowShift = (yObs + hObs) - yLimit; + if (rowShift > 0) + { + int xStat = 0; + int yStat = 0; + checkBoxUseStats->winGetPosition(&xStat, &yStat); + checkBoxUseStats->winSetPosition(xStat, yStat + rowShift); + checkBoxLimitArmies->winSetPosition(xLimit, yLimit + rowShift); + } + + WinInstanceData streamInstData; + streamInstData.init(); + streamInstData.m_style = GWS_CHECK_BOX | GWS_MOUSE_TRACK; + streamInstData.m_textLabelString = "Allow streamers"; + streamInstData.setTooltipText(L"Let this game be watched live: it appears in Watch Live and observers can wait in the pre-game lobby"); + + checkBoxAllowStreamers = TheWindowManager->gogoGadgetCheckbox(parentPopup, + WIN_STATUS_ENABLED | WIN_STATUS_IMAGE, + xLimit, yObs, wObs, hObs, + &streamInstData, nullptr, TRUE); + + if (checkBoxAllowStreamers != nullptr) + { + checkBoxAllowStreamers->winCopyVisualsFrom(checkBoxAllowObservers); + + // Default ON, like Allow Observers above: watchable unless the host opts out. + GadgetCheckBoxSetChecked(checkBoxAllowStreamers, TRUE); + } + } + // hide password for streams EntryData* e = (EntryData*)textEntryGamePassword->winGetUserData(); e->secretText = true; @@ -665,6 +712,8 @@ void createGame() Bool limitArmies = GadgetCheckBoxIsChecked(checkBoxLimitArmies); Bool useStats = GadgetCheckBoxIsChecked(checkBoxUseStats); Bool bAllowObservers = GadgetCheckBoxIsChecked(checkBoxAllowObservers); + Bool bAllowStreamers = checkBoxAllowStreamers != nullptr + ? GadgetCheckBoxIsChecked(checkBoxAllowStreamers) : FALSE; UnicodeString gameName = GadgetTextEntryGetText(textEntryGameName); @@ -683,7 +732,7 @@ void createGame() return; } - pLobbyInterface->CreateLobby(gameName, md->m_displayName, md->m_fileName, md->m_isOfficial, md->m_numPlayers, limitArmies, useStats, TheGlobalData->m_defaultStartingCash.countMoney(), passwd.isNotEmpty(), std::string(passwd.str()), bAllowObservers); + pLobbyInterface->CreateLobby(gameName, md->m_displayName, md->m_fileName, md->m_isOfficial, md->m_numPlayers, limitArmies, useStats, TheGlobalData->m_defaultStartingCash.countMoney(), passwd.isNotEmpty(), std::string(passwd.str()), bAllowObservers, bAllowStreamers); GSMessageBoxCancel(UnicodeString(L"Creating Lobby"), UnicodeString(L"Lobby Creation is in progress..."), nullptr); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupJoinGame.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupJoinGame.cpp index 6da24515013..76036f743bc 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupJoinGame.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupJoinGame.cpp @@ -59,6 +59,10 @@ #include "GameClient/KeyDefs.h" #include "GameClient/GadgetTextEntry.h" #include "GameClient/GadgetStaticText.h" +#include "GameClient/GUICallbacks.h" // liveWatchOpenPasswordPopup +#include "GameClient/LiveObserverSession.h" // StartLiveObserverSession +#include "GameClient/LobbyObserverMenu.h" // SetLobbyObserverModeWithPassword +#include "GameClient/Shell.h" #include "GameNetwork/GameSpy/PeerDefs.h" #include "GameNetwork/GameSpy/PeerThread.h" #include "GameNetwork/GameSpyOverlay.h" @@ -79,6 +83,38 @@ static GameWindow *textEntryGamePassword = nullptr; static void joinGame( AsciiString password ); +// Watch-live mode: this popup doubles as the password gate for a password-protected livestream, +// where the "join" queues an observer session instead of joining the lobby. Observe mode is the +// pre-game variant, which opens the read-only lobby view carrying the password. +static Bool s_watchLiveMode = FALSE; +static Bool s_watchLiveObserveMode = FALSE; +static Bool s_watchLivePopShellOnSubmit = FALSE; +static AsciiString s_watchLiveLobbyId; +static AsciiString s_watchLiveDisplayName; + +void liveWatchOpenPasswordPopup(const AsciiString& lobbyId, const AsciiString& displayName, + Bool bPopShellOnSubmit) +{ + s_watchLiveMode = TRUE; + s_watchLiveObserveMode = FALSE; + s_watchLivePopShellOnSubmit = bPopShellOnSubmit; + s_watchLiveLobbyId = lobbyId; + s_watchLiveDisplayName = displayName; + + GameSpyOpenOverlay(GSOVERLAY_GAMEPASSWORD); +} + +void liveWatchOpenObservePasswordPopup(const AsciiString& lobbyId, const AsciiString& displayName) +{ + s_watchLiveMode = TRUE; + s_watchLiveObserveMode = TRUE; + s_watchLivePopShellOnSubmit = FALSE; + s_watchLiveLobbyId = lobbyId; + s_watchLiveDisplayName = displayName; + + GameSpyOpenOverlay(GSOVERLAY_GAMEPASSWORD); +} + //----------------------------------------------------------------------------- // PUBLIC FUNCTIONS /////////////////////////////////////////////////////////// //----------------------------------------------------------------------------- @@ -95,12 +131,31 @@ void PopupJoinGameInit( WindowLayout *layout, void *userData ) textEntryGamePassword = TheWindowManager->winGetWindowFromId(parentPopup, textEntryGamePasswordID); GadgetTextEntrySetText(textEntryGamePassword, UnicodeString::TheEmptyString); + // Mask the password input; secretText makes the gadget draw asterisks while keeping the real + // text. Applies to the lobby join and the watch-live gate alike - both share this popup. + EntryData *entryData = (EntryData *)textEntryGamePassword->winGetUserData(); + if (entryData) + entryData->secretText = TRUE; + NameKeyType staticTextGameNameID = TheNameKeyGenerator->nameToKey("PopupJoinGame.wnd:StaticTextGameName"); GameWindow *staticTextGameName = TheWindowManager->winGetWindowFromId(parentPopup, staticTextGameNameID); GadgetStaticTextSetText(staticTextGameName, UnicodeString::TheEmptyString); buttonCancelID = NAMEKEY("PopupJoinGame.wnd:ButtonCancel"); + if (s_watchLiveMode) + { + // Skip the lobby-join setup entirely; the submit handler queues an observer session. + UnicodeString lobbyName(from_utf8(s_watchLiveDisplayName.str()).c_str()); + if (lobbyName.isEmpty()) + lobbyName = UnicodeString(L"Enter password to watch"); + GadgetStaticTextSetText(staticTextGameName, lobbyName); + + TheWindowManager->winSetFocus(textEntryGamePassword); + TheWindowManager->winSetModal( parentPopup ); + return; + } + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); if (pLobbyInterface == nullptr) { @@ -147,7 +202,18 @@ WindowMsgHandledType PopupJoinGameInput( GameWindow *window, UnsignedInt msg, Wi if( BitIsSet( state, KEY_STATE_UP ) ) { GameSpyCloseOverlay(GSOVERLAY_GAMEPASSWORD); - SetLobbyAttemptHostJoin( FALSE ); + if (s_watchLiveMode) + { + s_watchLiveMode = FALSE; + s_watchLiveObserveMode = FALSE; + s_watchLivePopShellOnSubmit = FALSE; + s_watchLiveLobbyId.clear(); + s_watchLiveDisplayName.clear(); + } + else + { + SetLobbyAttemptHostJoin( FALSE ); + } parentPopup = nullptr; } @@ -197,7 +263,18 @@ WindowMsgHandledType PopupJoinGameSystem( GameWindow *window, UnsignedInt msg, W if (controlID == buttonCancelID) { GameSpyCloseOverlay(GSOVERLAY_GAMEPASSWORD); - SetLobbyAttemptHostJoin( FALSE ); + if (s_watchLiveMode) + { + s_watchLiveMode = FALSE; + s_watchLiveObserveMode = FALSE; + s_watchLivePopShellOnSubmit = FALSE; + s_watchLiveLobbyId.clear(); + s_watchLiveDisplayName.clear(); + } + else + { + SetLobbyAttemptHostJoin( FALSE ); + } parentPopup = nullptr; } break; @@ -252,6 +329,40 @@ WindowMsgHandledType PopupJoinGameSystem( GameWindow *window, UnsignedInt msg, W static void joinGame( AsciiString password ) { + if (s_watchLiveMode) + { + // Snapshot before closing the overlay, which resets the mode statics. Popping the shell + // hands the pending session to the Welcome screen's pump; the pre-game lobby view stays + // up and pumps its own handoff, so it does not pop. + const AsciiString lobbyId = s_watchLiveLobbyId; + const AsciiString displayName = s_watchLiveDisplayName; + const Bool bPopShellOnSubmit = s_watchLivePopShellOnSubmit; + const Bool bObserveMode = s_watchLiveObserveMode; + + s_watchLiveMode = FALSE; + s_watchLiveObserveMode = FALSE; + s_watchLivePopShellOnSubmit = FALSE; + s_watchLiveLobbyId.clear(); + s_watchLiveDisplayName.clear(); + + GameSpyCloseOverlay(GSOVERLAY_GAMEPASSWORD); + parentPopup = nullptr; + + if (bObserveMode) + { + // The lobby view holds the password until the stream goes live; a wrong one + // reprompts from there. + SetLobbyObserverModeWithPassword(lobbyId.str(), password.str()); + TheShell->push("Menus/GameSpyGameOptionsMenu.wnd"); + return; + } + + StartLiveObserverSession(lobbyId, password, displayName); + if (bPopShellOnSubmit) + TheShell->pop(); + return; + } + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); if (pLobbyInterface == nullptr) { diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/ReplayMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/ReplayMenu.cpp index 4188d26ba91..0953f7db357 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/ReplayMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/ReplayMenu.cpp @@ -37,6 +37,7 @@ #include "Common/GameState.h" #include "Common/Recorder.h" #include "Common/version.h" +#include "GameClient/LiveGamesMenu.h" #include "GameClient/WindowLayout.h" #include "GameClient/Gadget.h" #include "GameClient/GadgetListBox.h" @@ -65,6 +66,9 @@ static NameKeyType buttonCopyID = NAMEKEY_INVALID; static Bool isShuttingDown = false; +// Screen to push once this one has popped, as WOLLobbyMenu does. Empty means a plain pop. +static AsciiString s_nextScreen; + // window pointers -------------------------------------------------------------------------------- static GameWindow *parentReplayMenu = nullptr; static GameWindow *buttonLoad = nullptr; @@ -391,6 +395,8 @@ void PopulateReplayFileListbox(GameWindow *listbox) //------------------------------------------------------------------------------------------------- void ReplayMenuInit( WindowLayout *layout, void *userData ) { + s_nextScreen.clear(); + TheShell->showShellMap(TRUE); // get ids for our children controls @@ -420,6 +426,12 @@ void ReplayMenuInit( WindowLayout *layout, void *userData ) //Load the listbox shiznit GadgetListBoxReset(listboxReplayFiles); +#if defined(GENERALS_ONLINE) + // The Watch Live browser reuses this layout and fills the listbox itself. + if (LiveGamesMenuIsLiveGamesMode()) + LiveGamesMenuInit(); + else +#endif PopulateReplayFileListbox(listboxReplayFiles); #if defined(RTS_DEBUG) @@ -449,18 +461,43 @@ void ReplayMenuInit( WindowLayout *layout, void *userData ) } +//------------------------------------------------------------------------------------------------- +/** Complete the shutdown, optionally pushing the next screen (mirrors WOLLobbyMenu). */ +//------------------------------------------------------------------------------------------------- +static void shutdownComplete( WindowLayout *layout ) +{ + isShuttingDown = FALSE; + + // hide the layout + layout->hide( TRUE ); + + // our shutdown is complete; if a push is coming, the screen below is not re-inited + TheShell->shutdownComplete( layout, s_nextScreen.isNotEmpty() ); + + if (s_nextScreen.isNotEmpty()) + { + TheShell->push(s_nextScreen); + } + + s_nextScreen.clear(); + +} + //------------------------------------------------------------------------------------------------- /** single player menu shutdown method */ //------------------------------------------------------------------------------------------------- void ReplayMenuShutdown( WindowLayout *layout, void *userData ) { +#if defined(GENERALS_ONLINE) + // The Watch Live browser restores the shared layout; a no-op outside live mode. + LiveGamesMenuShutdown(); +#endif Bool popImmediate = *(Bool *)userData; if( popImmediate ) { - layout->hide( TRUE ); - TheShell->shutdownComplete( layout ); + shutdownComplete( layout ); return; } @@ -475,6 +512,11 @@ void ReplayMenuShutdown( WindowLayout *layout, void *userData ) //------------------------------------------------------------------------------------------------- void ReplayMenuUpdate( WindowLayout *layout, void *userData ) { +#if defined(GENERALS_ONLINE) + // The Watch Live browser pumps its own fetch + label state; a no-op outside live mode. + LiveGamesMenuUpdate(); +#endif + if(justEntered) { if(initialGadgetDelay == 1) @@ -494,7 +536,7 @@ void ReplayMenuUpdate( WindowLayout *layout, void *userData ) deleteReplay(); // We'll only be successful if we've requested to if(isShuttingDown && TheShell->isAnimFinished()&& TheTransitionHandler->isFinished()) - TheShell->shutdownComplete( layout ); + shutdownComplete( layout ); } @@ -669,6 +711,13 @@ WindowMsgHandledType ReplayMenuSystem( GameWindow *window, UnsignedInt msg, WindowMsgData mData1, WindowMsgData mData2 ) { +#if defined(GENERALS_ONLINE) + // In live-games mode the Watch Live browser consumes its own messages; the rest fall + // through to the replay handling below. + if (LiveGamesMenuHandleSystemMessage(msg, mData1, mData2)) + return MSG_HANDLED; +#endif + switch( msg ) { @@ -770,6 +819,14 @@ WindowMsgHandledType ReplayMenuSystem( GameWindow *window, UnsignedInt msg, } else if( controlID == buttonBackID ) { +#if defined(GENERALS_ONLINE) + // Leaving the Watch Live browser pops to a fresh Welcome rather than the + // instance underneath it, the same pop-then-push the custom lobby uses. + if (LiveGamesMenuIsLiveGamesMode()) + s_nextScreen = "Menus/WOLWelcomeMenu.wnd"; + else + s_nextScreen.clear(); +#endif // thou art directed to return to thy known solar system immediately! TheShell->pop(); 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 3861888802f..8951c0a4a86 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp @@ -31,7 +31,9 @@ #include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine #include "Common/GameEngine.h" +#include "Common/GameCommon.h" // LIVE_DELAY_SECONDS_DEFAULT / _MAX #include "Common/GameState.h" +#include "Common/GlobalData.h" #include "Common/MultiplayerSettings.h" #include "Common/OptionPreferences.h" #include "GameClient/GameText.h" @@ -56,6 +58,12 @@ #include "GameClient/GameWindowTransitions.h" #include "GameNetwork/GameSpy/LobbyUtils.h" +#if defined(GENERALS_ONLINE) +#include "Common/LiveObserver.h" +#include "GameClient/LobbyObserverMenu.h" +#include "GameClient/LiveGamesMenu.h" +#endif + #include "GameNetwork/GameSpy/BuddyDefs.h" #include "GameNetwork/GameSpy/PeerDefs.h" #include "GameNetwork/GameSpy/PeerThread.h" @@ -227,6 +235,55 @@ static GameWindow *checkBoxLimitSuperweapons = NULL; static GameWindow *comboBoxStartingCash = NULL; static GameWindow *checkBoxLimitArmies = NULL; +#if defined(GENERALS_ONLINE) +// The live-stream controls (Enable Stream checkbox, Delay field, "N observers waiting" label) +// were code-created widgets; they are commands now: /stream, /delay, /observers. + +/// Validate and store a broadcast delay. Rejects rather than clamps: silently turning "6000" +/// into 600 would leave the streamer believing they had a 100-minute buffer. The server copy +/// is host-only (UpdateCurrentLobby_StreamDelay checks the role on its own). +static void applyLiveStreamDelay(Int seconds) +{ + if (seconds < 0 || seconds > (Int)LIVE_DELAY_SECONDS_MAX) + { + if (TheInGameUI) + { + UnicodeString msg; + msg.format(L"Delay must be between 0 and %d seconds", (Int)LIVE_DELAY_SECONDS_MAX); + TheInGameUI->messageNoFormat(msg); + } + return; + } + + TheWritableGlobalData->m_liveStreamDelaySeconds = seconds; + + OptionPreferences optionPref; + optionPref.setLiveStreamDelaySeconds(seconds); + optionPref.write(); + + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = + NGMP_OnlineServicesManager::GetInterface(); + if (pLobbyInterface != nullptr && pLobbyInterface->IsInLobby() && pLobbyInterface->IsHost()) + { + pLobbyInterface->UpdateCurrentLobby_StreamDelay(seconds); + } +} + +/// Each player's own "will this client broadcast" switch, shared with the Recorder at match +/// start via TheGlobalData (same field the old checkbox wrote). Any player flips their own. +static void applyLiveStreamEnabled(Bool enabled) +{ + if (TheWritableGlobalData) + { + TheWritableGlobalData->m_liveStreamEnabled = enabled; + + OptionPreferences optionPref; + optionPref.setLiveStreamEnabled(enabled); + optionPref.write(); + } +} +#endif + static GameWindow *comboBoxPlayer[MAX_SLOTS] = {NULL,NULL,NULL,NULL, NULL,NULL,NULL,NULL }; static GameWindow *staticTextPlayer[MAX_SLOTS] = {NULL,NULL,NULL,NULL, @@ -1358,6 +1415,7 @@ void WOLDisplayGameOptions() } DEBUG_ASSERTCRASH( index < itemCount, ("Could not find new starting cash amount %d in list", theGame->getStartingCash().countMoney() ) ); + } @@ -1541,6 +1599,12 @@ void InitWOLGameGadgets() checkBoxLimitArmies = TheWindowManager->winGetWindowFromId( parentWOLGameSetup, checkBoxLimitArmiesID ); DEBUG_ASSERTCRASH(windowMap, ("Could not find the GameSpyGameOptionsMenu.wnd:CheckBoxLimitArmies" )); +#if defined(GENERALS_ONLINE) + // The live-stream controls used to be code-created widgets here (Enable Stream checkbox, + // Delay field, "N observers waiting" label). They are slash commands now: /stream, /delay, + // /observers - see handleGameSetupSlashCommands. +#endif + // Limit Armies can ONLY be set in the Host Game window (PopupHostGame.wnd) checkBoxLimitArmies->winEnable( false ); // Ditto use stats @@ -1734,7 +1798,7 @@ void DeinitWOLGameGadgets() windowMap->winSetUserData(NULL); windowMap = NULL; } - checkBoxUseStats = NULL; + checkBoxUseStats = NULL; checkBoxLimitSuperweapons = NULL; comboBoxStartingCash = NULL; @@ -1762,6 +1826,26 @@ Bool initialAcceptEnable = FALSE; //------------------------------------------------------------------------------------------------- void WOLGameSetupMenuInit( WindowLayout *layout, void *userData ) { +#if defined(GENERALS_ONLINE) + // Returning from a live-observer game whose waiting room was this layout. It must not come + // back as a real lobby - the observer is not a member of one, and the in-progress check + // below cannot catch this because the observer never set the NGMP game in progress. + if (LiveObserverConsumeReturnedFromGame()) + { + LiveGamesMenuEnterLiveGamesMode(); + TheShell->popImmediate(); + return; + } + + // Read-only pre-game lobby view: same layout, different screen. Must run before any + // lobby/mesh/NGMP setup below - an observer never joins a lobby and must not touch it. + if (LobbyObserverModeActive()) + { + LobbyObserverInit(layout, userData); + return; + } +#endif + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); if (pLobbyInterface == nullptr) { @@ -1908,6 +1992,10 @@ void WOLGameSetupMenuInit( WindowLayout *layout, void *userData ) GameSpyCloseOverlay(GSOVERLAY_BUDDY); GameSpyCloseOverlay(GSOVERLAY_PLAYERINFO); + // Last moment the full LobbyEntry (name, map, region, members) exists; the Recorder + // that actually registers the stream runs long after this screen is gone. + PrepareLiveStreamRegistration(); + *TheNGMPGame = *myGame; TheNGMPGame->startGame(0); }); @@ -2274,6 +2362,14 @@ static void shutdownComplete( WindowLayout *layout ) //------------------------------------------------------------------------------------------------- void WOLGameSetupMenuShutdown( WindowLayout *layout, void *userData ) { +#if defined(GENERALS_ONLINE) + if (LobbyObserverModeActive()) + { + LobbyObserverShutdown(layout, userData); + return; + } +#endif + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); if (pLobbyInterface != nullptr) @@ -2344,6 +2440,14 @@ static void fillPlayerInfo(const PeerResponse *resp, PlayerInfo *info) //------------------------------------------------------------------------------------------------- void WOLGameSetupMenuUpdate( WindowLayout * layout, void *userData) { +#if defined(GENERALS_ONLINE) + if (LobbyObserverModeActive()) + { + LobbyObserverUpdate(layout, userData); + return; + } +#endif + // Refresh only the fast-changing connection indicators each frame. WOLRefreshConnectionIndicators(); @@ -2718,6 +2822,9 @@ void WOLGameSetupMenuUpdate( WindowLayout * layout, void *userData) buttonBuddy->winEnable(FALSE); GameSpyCloseOverlay(GSOVERLAY_BUDDY); + // See the game-start packet handler above - same reason, other entry point. + PrepareLiveStreamRegistration(); + *TheNGMPGame = *myGame; TheNGMPGame->startGame(0); } @@ -3416,6 +3523,13 @@ void WOLGameSetupMenuUpdate( WindowLayout * layout, void *userData) WindowMsgHandledType WOLGameSetupMenuInput( GameWindow *window, UnsignedInt msg, WindowMsgData mData1, WindowMsgData mData2 ) { +#if defined(GENERALS_ONLINE) + if (LobbyObserverModeActive()) + { + return LobbyObserverInput(window, msg, mData1, mData2); + } +#endif + /* switch( msg ) { @@ -3513,6 +3627,10 @@ Bool handleGameSetupSlashCommands(UnicodeString uText) { GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"The following commands are available:"), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/maxcameraheight - Sets the maximum camera zoom out level - Example: /maxcameraheight 650"), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/observerchat - Lets pre-game observers send chat into this lobby"), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/delay - Sets how far behind live observers are held (host only)"), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/observers - Announces who is watching this lobby (host only)"), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/stream - Broadcast this game so others can watch it live"), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/friendsonly - Sets the lobby to only be joinable by friends"), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/public - Sets the lobby to be joinable by anyone"), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); return TRUE; // was a slash command @@ -3625,6 +3743,167 @@ Bool handleGameSetupSlashCommands(UnicodeString uText) } } + return TRUE; // was a slash command + } + else if (token == "observerchat" && uText.getLength() > 14) + { + NGMP_OnlineServicesManager* pOnlineServicesMgr = NGMP_OnlineServicesManager::GetInstance(); + if (pOnlineServicesMgr != nullptr) + { + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + + if (pLobbyInterface != nullptr) + { + if (pLobbyInterface->IsInLobby()) + { + if (pLobbyInterface->IsHost()) + { + UnicodeString val = UnicodeString(uText.str() + 14); // skip the command + + AsciiString asciiVal; + asciiVal.translate(val); + asciiVal.trim(); + asciiVal.toLower(); + + if (asciiVal == "on") + { + pLobbyInterface->UpdateCurrentLobby_AllowObserverChat(true); + } + else if (asciiVal == "off") + { + pLobbyInterface->UpdateCurrentLobby_AllowObserverChat(false); + } + else + { + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Usage: /observerchat "), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + } + } + else + { + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"You must be the lobby host to toggle observer chat."), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + return TRUE; // was a slash command + } + } + } + } + + return TRUE; // was a slash command + } + else if (token == "delay" && uText.getLength() > 6) + { + NGMP_OnlineServicesManager* pOnlineServicesMgr = NGMP_OnlineServicesManager::GetInstance(); + if (pOnlineServicesMgr != nullptr) + { + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + + if (pLobbyInterface != nullptr) + { + if (pLobbyInterface->IsInLobby()) + { + if (pLobbyInterface->IsHost()) + { + UnicodeString val = UnicodeString(uText.str() + 7); // skip the command + + AsciiString asciiVal; + asciiVal.translate(val); + asciiVal.trim(); + + bool bIsNumber = !asciiVal.isEmpty(); + for (int i = 0; i < asciiVal.getLength(); ++i) + { + char thisChar = asciiVal.getCharAt(i); + if (!std::isdigit((unsigned char)thisChar)) + { + bIsNumber = false; + break; + } + } + + if (bIsNumber) + { + Int seconds = atoi(asciiVal.str()); + if (seconds >= 0 && seconds <= (Int)LIVE_DELAY_SECONDS_MAX) + { + applyLiveStreamDelay(seconds); + UnicodeString msg; + msg.format(L"Broadcast delay set to %ds", seconds); + GadgetListBoxAddEntryText(listboxGameSetupChat, msg, GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + } + else + { + UnicodeString msg; + msg.format(L"Delay must be between 0 and %d seconds", (Int)LIVE_DELAY_SECONDS_MAX); + GadgetListBoxAddEntryText(listboxGameSetupChat, msg, GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + } + } + else + { + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Usage: /delay "), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + } + } + else + { + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"You must be the lobby host to set the broadcast delay."), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + } + } + } + } + + return TRUE; // was a slash command + } + else if (token == "observers") + { + NGMP_OnlineServicesManager* pOnlineServicesMgr = NGMP_OnlineServicesManager::GetInstance(); + if (pOnlineServicesMgr != nullptr) + { + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + + if (pLobbyInterface != nullptr) + { + if (pLobbyInterface->IsInLobby()) + { + if (pLobbyInterface->IsHost()) + { + // GO announces the observer roster into the lobby chat; every player + // sees it, the host included. + pLobbyInterface->SendObserverListRequest(pLobbyInterface->GetCurrentLobby().lobbyID); + } + else + { + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"You must be the lobby host to list observers."), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + } + } + } + } + + return TRUE; // was a slash command + } + else if (token == "stream" && uText.getLength() > 7) + { + // Any player, for themselves: this client's own "will I broadcast" switch, the same + // field the old Enable Stream checkbox wrote. + UnicodeString val = UnicodeString(uText.str() + 8); // skip the command + + AsciiString asciiVal; + asciiVal.translate(val); + asciiVal.trim(); + asciiVal.toLower(); + + if (asciiVal == "on") + { + applyLiveStreamEnabled(true); + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Streaming enabled - you will connect to the relay as a streamer"), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + } + else if (asciiVal == "off") + { + applyLiveStreamEnabled(false); + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Streaming disabled - you will not connect to the relay as a streamer"), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + } + else + { + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Usage: /stream "), GameSpyColor[GSCOLOR_CHAT_NORMAL], -1, -1); + } + return TRUE; // was a slash command } #endif @@ -3798,6 +4077,17 @@ static Int getFirstSelectablePlayer(const GameInfo *game) WindowMsgHandledType WOLGameSetupMenuSystem( GameWindow *window, UnsignedInt msg, WindowMsgData mData1, WindowMsgData mData2 ) { +#if defined(GENERALS_ONLINE) + // Button clicks arrive here as GBM_SELECTED, not in the Input callback. The stock back + // button could not match anyway (buttonBackID stays invalid because the observer branch + // skips the rest of Init), and its PopBackToLobby is wrong for a non-member watcher. + // GEM_EDIT_DONE too, so the observer screen receives its own chat-entry Enter. + if (LobbyObserverModeActive() && (msg == GBM_SELECTED || msg == GEM_EDIT_DONE)) + { + return LobbyObserverInput(window, msg, mData1, mData2); + } +#endif + UnicodeString txtInput; static int buttonCommunicatorID = NAMEKEY_INVALID; 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 bea1ddea001..5f0eddc9902 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp @@ -1270,6 +1270,10 @@ void WOLQuickMatchMenuInit( WindowLayout *layout, void *userData ) GameSpyCloseOverlay(GSOVERLAY_BUDDY); GameSpyCloseOverlay(GSOVERLAY_PLAYERINFO); + // Quick Match never shows the pre-game lobby screen that normally registers the + // stream, so it has to register here or QM matches would never stream. + PrepareLiveStreamRegistration(); + *TheNGMPGame = *myGame; TheNGMPGame->startGame(0); }); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLWelcomeMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLWelcomeMenu.cpp index 64c535a6387..ddded683114 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLWelcomeMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLWelcomeMenu.cpp @@ -69,6 +69,11 @@ #include "GameNetwork/WOLBrowser/WebBrowser.h" #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" +#if defined(GENERALS_ONLINE) +#include "GameClient/LiveGamesMenu.h" +#include "GameClient/LiveObserverSession.h" +#endif + // PRIVATE DATA /////////////////////////////////////////////////////////////////////////////////// static Bool isShuttingDown = FALSE; static Bool buttonPushed = FALSE; @@ -85,6 +90,11 @@ static NameKeyType buttonMyInfoID = NAMEKEY_INVALID; static NameKeyType listboxInfoID = NAMEKEY_INVALID; static NameKeyType buttonOptionsID = NAMEKEY_INVALID; +#if defined(GENERALS_ONLINE) +// The Watch Live entry point lives behind the login rather than on the main menu, because GO +// gates /Livestreams behind a signed-in session. +static NameKeyType buttonWatchLiveID = NAMEKEY_INVALID; +#endif // Window Pointers ------------------------------------------------------------------------ static GameWindow *parentWOLWelcome = nullptr; static GameWindow *buttonBack = nullptr; @@ -94,6 +104,9 @@ static GameWindow *buttonBuddies = nullptr; static GameWindow *buttonLadder = nullptr; static GameWindow *buttonMyInfo = nullptr; static GameWindow *buttonbuttonOptions = nullptr; +#if defined(GENERALS_ONLINE) +static GameWindow *buttonWatchLive = nullptr; +#endif static WindowLayout *welcomeLayout = nullptr; static GameWindow *listboxInfo = nullptr; @@ -182,6 +195,78 @@ static void enableControls( Bool state ) buttonLobby->winEnable(state); } +#if defined(GENERALS_ONLINE) +//------------------------------------------------------------------------------------------------- +/** Find a window with the given id among this parent's descendants only. Not + * winGetWindowFromId(), which also walks the passed window's siblings and so can hand back a + * still-pending-destruction screen's button. */ +//------------------------------------------------------------------------------------------------- +static GameWindow* findDescendantById( GameWindow *parent, Int id ) +{ + if( parent == nullptr ) + return nullptr; + + for( GameWindow *child = parent->winGetChild(); child != nullptr; child = child->winGetNext() ) + { + if( child->winGetWindowId() == id ) + return child; + + GameWindow *nested = findDescendantById( child, id ); + if( nested != nullptr ) + return nested; + } + + return nullptr; +} + +//------------------------------------------------------------------------------------------------- +/** Build the Watch Live button in code: WOLWelcomeMenu.wnd has no such control and the layout + * lives inside an archive that cannot be edited. */ +//------------------------------------------------------------------------------------------------- +static void createWatchLiveButton( void ) +{ + buttonWatchLiveID = TheNameKeyGenerator->nameToKey( "WOLWelcomeMenu.wnd:ButtonWatchLive" ); + buttonWatchLive = findDescendantById( parentWOLWelcome, (Int)buttonWatchLiveID ); + if (buttonWatchLive != nullptr) + return; + + // Position and size in the layout's 800x600 design space, scaled from the parent's runtime + // size the same way the .wnd parser scales SCREENRECTs. + Int pw = 800, ph = 600; + parentWOLWelcome->winGetSize(&pw, &ph); + const Real xScale = (Real)pw / 800.0f; + const Real yScale = (Real)ph / 600.0f; + + Int liveX = (Int)(63 * xScale), liveY = (Int)(520 * yScale); + Int liveW = (Int)(176 * xScale), liveH = (Int)(36 * yScale); + GameWindow *anchor = (buttonbuttonOptions != nullptr) ? buttonbuttonOptions + : (buttonLobby != nullptr) ? buttonLobby : buttonQuickMatch; + if (anchor == nullptr) + return; // no anchor means no idea where to put it; better absent than misplaced + + WinInstanceData instData; + instData.init(); + BitSet(instData.m_style, GWS_PUSH_BUTTON | GWS_MOUSE_TRACK); + instData.m_textLabelString = "Watch Live"; + instData.setTooltipText(L"Watch a game that is being streamed live"); + + buttonWatchLive = TheWindowManager->gogoGadgetPushButton( parentWOLWelcome, + WIN_STATUS_ENABLED | WIN_STATUS_IMAGE, + liveX, liveY, liveW, liveH, + &instData, nullptr, TRUE ); + + if (buttonWatchLive != nullptr) + { + // gogoGadget* leaves a code-created gadget with no id, so without this the lookup above + // could never find it and the button would be rebuilt on every init. + buttonWatchLive->winSetWindowId((Int)buttonWatchLiveID); + + // Adopt the real menu's look instead of the placeholder defaultVisual leaves behind. + buttonWatchLive->winCopyVisualsFrom(anchor); + } +} +#endif + //------------------------------------------------------------------------------------------------- /** This is called when a shutdown is complete for this menu */ //------------------------------------------------------------------------------------------------- @@ -591,6 +676,12 @@ void WOLWelcomeMenuInit( WindowLayout *layout, void *userData ) buttonLadderID = TheNameKeyGenerator->nameToKey( "WOLWelcomeMenu.wnd:ButtonLadder" ); buttonLadder = TheWindowManager->winGetWindowFromId( parentWOLWelcome, buttonLadderID ); +#if defined(GENERALS_ONLINE) + // Offered unconditionally: GO's live-games list answers whether there is anything to watch + // and hands out a fully-formed relay URL per stream, so no local relay address is needed. + createWatchLiveButton(); +#endif + #if !defined(GENERALS_ONLINE) if (TheFirewallHelper == nullptr) { TheFirewallHelper = createFirewallHelper(); @@ -698,6 +789,13 @@ void WOLWelcomeMenuShutdown( WindowLayout *layout, void *userData ) { listboxInfo = nullptr; +#if defined(GENERALS_ONLINE) + // Drop the reference only; the layout owns this window and destroys it with its other + // children. Init cannot rely on this nulling - the layout sometimes survives shutdown and + // keeps its button, so init looks the button up by id on the parent instead. + buttonWatchLive = nullptr; +#endif + delete TheFirewallHelper; TheFirewallHelper = nullptr; @@ -726,6 +824,16 @@ void WOLWelcomeMenuShutdown( WindowLayout *layout, void *userData ) //------------------------------------------------------------------------------------------------- void WOLWelcomeMenuUpdate( WindowLayout * layout, void *userData) { +#if defined(GENERALS_ONLINE) + // Sessions queued from the Watch Live browser start here, since the browser pops back to this + // screen. A TRUE return means playback already sent MSG_NEW_GAME; only stand this screen down. + if (LiveObserverStartPendingSession()) + { + buttonPushed = TRUE; + isShuttingDown = TRUE; + } +#endif + // We'll only be successful if we've requested to if(isShuttingDown && TheShell->isAnimFinished() && TheTransitionHandler->isFinished()) shutdownComplete(layout); @@ -1048,6 +1156,17 @@ WindowMsgHandledType WOLWelcomeMenuSystem( GameWindow *window, UnsignedInt msg, { TheShell->push("Menus/WOLLadderScreen.wnd"); } +#if defined(GENERALS_ONLINE) + else if (buttonWatchLive != nullptr && controlID == (Int)buttonWatchLiveID) + { + // Pop this layout and let shutdownComplete push the browser, as the other + // pages here do - otherwise this instance lingers under the browser. + buttonPushed = TRUE; + LiveGamesMenuEnterLiveGamesMode(); + nextScreen = "Menus/ReplayMenu.wnd"; + TheShell->pop(); + } +#endif break; } diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/LiveObserverSession.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/LiveObserverSession.cpp new file mode 100644 index 00000000000..c6b3098ffc2 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/LiveObserverSession.cpp @@ -0,0 +1,244 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +/////////////////////////////////////////////////////////////////////////////////////// +// FILE: LiveObserverSession.cpp +// The live-observer join state machine. Every shell screen that can queue or pump a session calls +// in here - the Watch Live browser, the pre-game lobby view, the Online welcome screen, the main +// menu and the password popup - and none of them owns the state. +/////////////////////////////////////////////////////////////////////////////////////// + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#if defined(GENERALS_ONLINE) + +#include "GameClient/LiveObserverSession.h" + +#include "Common/GlobalData.h" +#include "Common/LiveObserver.h" +#include "Common/Recorder.h" +#include "GameClient/ClientInstance.h" +#include "GameClient/GameText.h" +#include "GameClient/GameWindowTransitions.h" +#include "GameClient/GUICallbacks.h" // liveWatchOpenPasswordPopup +#include "GameClient/Shell.h" +#include "GameNetwork/GameSpyOverlay.h" + +#include // timeGetTime + +// The queued session. Written by StartLiveObserverSession from whichever screen picked the +// game, read by the pump below from whichever screen the player is standing on. +static Bool startLiveObserverGame = FALSE; +static AsciiString m_liveObserverStartLobbyId; +static AsciiString s_liveObserverPassword; // password for a password-protected stream +static AsciiString s_liveObserverDisplayName; // lobby name, for the password reprompt title +static Int s_liveObserverDelaySeconds = -1; // lobby broadcast delay, -1 = unknown + +// Records the intent only. doLiveObserverGameStart() blocks waiting for the relay's HEADER, so it +// must not run while another screen is up; the pump fires it once the shell has settled. +void StartLiveObserverSession(const AsciiString& lobbyId, + const AsciiString& password, const AsciiString& displayName, Int delaySeconds) +{ + if (lobbyId.isEmpty()) + return; + + // Deliberately does not set m_afterIntro: that re-enters the intro/movie machinery, which sets + // m_breakTheMovie and disables rendering until a menu or load screen clears it, freezing the + // screen while the logic keeps running. The shell map stays off so nothing competes with the + // replay about to start. + if (TheWritableGlobalData) + { + TheWritableGlobalData->m_playIntro = FALSE; + TheWritableGlobalData->m_playSizzle = FALSE; + TheWritableGlobalData->m_shellMapOn = FALSE; + } + + // Multi-instance support, like replay mode. + rts::ClientInstance::setMultiInstance(TRUE); + rts::ClientInstance::skipPrimaryInstance(); + + m_liveObserverStartLobbyId = lobbyId; + s_liveObserverPassword = password; + s_liveObserverDisplayName = displayName; + s_liveObserverDelaySeconds = delaySeconds; + startLiveObserverGame = TRUE; + + liveObserverLog("StartLiveObserverSession: queued lobby %s\n", lobbyId.str()); +} + +// The join splits into a non-blocking connect and a playback start. The wait between them, for +// the relay to deliver the header plus enough body to cover the broadcast delay, must not block +// the main loop or the shell cannot draw the countdown explaining it. Readiness itself lives on +// LiveObserver::isPlaybackReady; this file only sequences it. +enum ObserverJoinPhase +{ + kObserverJoinIdle, // nothing pending + kObserverJoinWaiting, // connected; waiting for the file to cover the delay +}; +static ObserverJoinPhase s_observerJoinPhase = kObserverJoinIdle; + +// Mirrors the timeout path in LiveObserverStartPendingSession. +void CancelLiveObserverPendingSession(void) +{ + liveObserverLog("CancelLiveObserverPendingSession: cancelling queued observer join\n"); + + if (s_observerJoinPhase == kObserverJoinWaiting && TheLiveObserver != nullptr) + { + liveObserverEndSession(); + } + + s_observerJoinPhase = kObserverJoinIdle; + startLiveObserverGame = FALSE; + m_liveObserverStartLobbyId.clear(); + + // A cancelled session must not leak its password into the next join. + s_liveObserverPassword.clear(); + s_liveObserverDisplayName.clear(); + s_liveObserverDelaySeconds = -1; +} + +Bool LiveObserverPendingSessionActive(void) +{ + return startLiveObserverGame; +} + +// Phase 1: end any previous session and start connecting. Non-blocking - the network thread +// does the work and publishes the header and watermarks as it goes. +static Bool doLiveObserverConnect(void) +{ + liveObserverInitLog(m_liveObserverStartLobbyId.str()); + + // End any previous session outright. This destroys the old LiveObserver and releases the + // Recorder's read handle on its file - both are needed, because the live file is named after + // the streamer's game, so rejoining a game already watched targets the same path, which + // openLiveFile() cannot delete or recreate while either handle is open. + liveObserverEndSession(); + + TheLiveObserver = createLiveObserver(); + if (!TheLiveObserver) + { + liveObserverLog("doLiveObserverConnect: createLiveObserver() returned NULL!\n"); + return FALSE; + } + + liveObserverLog("doLiveObserverConnect: connecting to relay for lobby %s\n", + m_liveObserverStartLobbyId.str()); + TheLiveObserver->connect(m_liveObserverStartLobbyId, s_liveObserverPassword.str(), + s_liveObserverDelaySeconds); + return TRUE; +} + +// Phase 2: the file is playable (LiveObserver::isPlaybackReady), so start playback. Returns TRUE +// only when playback actually started, so the shell screen that pumped this can tear itself down +// and reveal the game. +static Bool doLiveObserverStartPlayback(void) +{ + if (TheLiveObserver == nullptr) + return FALSE; + + const AsciiString filename = TheLiveObserver->getLiveReplayFilename(); + if (!TheRecorder->startLiveObserverPlayback(filename)) + { + liveObserverLog("doLiveObserverStartPlayback: FAILED - playbackFile returned false\n"); + liveObserverEndSession(); + return FALSE; + } + + liveObserverLog("doLiveObserverStartPlayback: playback started\n"); + return TRUE; +} + +// Fire a queued session once the shell has settled, then keep pumping while the relay builds the +// file. Returns FALSE while still buffering, so the shell keeps drawing the countdown, and TRUE +// once playback has started, so the caller can stand itself down and reveal the game. +Bool LiveObserverStartPendingSession(void) +{ + if (!startLiveObserverGame) + return FALSE; + + if (s_observerJoinPhase == kObserverJoinIdle) + { + if (!TheShell->isAnimFinished() || !TheTransitionHandler->isFinished()) + return FALSE; + + if (!doLiveObserverConnect()) + { + startLiveObserverGame = FALSE; + return FALSE; + } + s_observerJoinPhase = kObserverJoinWaiting; + liveObserverLog("LiveObserverStartPendingSession: connected, waiting for the file to cover the delay (up to %ums)\n", + TheLiveObserver->getJoinTimeoutMs()); + return FALSE; + } + + // kObserverJoinWaiting - pump the wait. Every failure path clears the join state. + if (TheLiveObserver == nullptr) + { + s_observerJoinPhase = kObserverJoinIdle; + startLiveObserverGame = FALSE; + return FALSE; + } + + if (TheLiveObserver->isPlaybackReady()) + { + s_observerJoinPhase = kObserverJoinIdle; + startLiveObserverGame = FALSE; + return doLiveObserverStartPlayback(); + } + + // The one reprompt path: it covers both a wrong password typed into the browser popup and the + // pre-game handoff of a passworded lobby. + if (TheLiveObserver->isPasswordRejected()) + { + liveObserverLog("LiveObserverStartPendingSession: password rejected for lobby %s\n", + m_liveObserverStartLobbyId.str()); + + liveObserverEndSession(); + s_observerJoinPhase = kObserverJoinIdle; + startLiveObserverGame = FALSE; + + // The queue statics still hold the lobby id and display name - they are cleared by + // CancelLiveObserverPendingSession or overwritten by the next StartLiveObserverSession. + GSMessageBoxOk(TheGameText->fetch("GUI:JoinFailedDefault"), + TheGameText->fetch("GUI:JoinFailedBadPassword"), []() + { + liveWatchOpenPasswordPopup(m_liveObserverStartLobbyId, s_liveObserverDisplayName, FALSE); + }); + + return FALSE; + } + + if (timeGetTime() > TheLiveObserver->getJoinDeadlineMs()) + { + liveObserverLog("LiveObserverStartPendingSession: timed out waiting for a playable file - abandoning the join " + "(now=%ums deadline=%ums connected=%d serverHeld=%d delayWait=%d)\n", + timeGetTime(), TheLiveObserver->getJoinDeadlineMs(), + TheLiveObserver->isConnected() ? 1 : 0, + TheLiveObserver->isServerHeld() ? 1 : 0, + TheLiveObserver->isWaitingForBroadcastDelay() ? 1 : 0); + liveObserverEndSession(); + s_observerJoinPhase = kObserverJoinIdle; + startLiveObserverGame = FALSE; + return FALSE; + } + + return FALSE; +} + +#endif // GENERALS_ONLINE diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 899f0254005..92042e6bf16 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -47,6 +47,9 @@ #include "Common/ThingTemplate.h" #include "Common/BuildAssistant.h" #include "Common/Recorder.h" +#include "Common/LiveStreamer.h" +#include "Common/LiveObserver.h" +#include "GameClient/LobbyObserverMenu.h" #include "Common/SpecialPower.h" #include "GameClient/Anim2D.h" @@ -1166,6 +1169,10 @@ InGameUI::InGameUI() // TheSuperHackers @info the default font, size and positions of the various counters were chosen based on GenTools implementation m_networkLatencyString = nullptr; + m_observerLogicFpsString = nullptr; + m_lastObserverPingMs = 0; + m_lastObserverLogicFps = 0; + m_lastObserverPaceFps = 0; m_networkLatencyFont = "Tahoma"; m_networkLatencyPointSize = TheGlobalData->m_networkLatencyFontSize; m_networkLatencyBold = TRUE; @@ -1282,20 +1289,25 @@ InGameUI::InGameUI() m_moveRMBScrollAnchor = FALSE; m_displayedMaxWarning = FALSE; - m_idleWorkerWin = nullptr; - m_currentIdleWorkerDisplay = -1; + m_idleWorkerWin = nullptr; + m_currentIdleWorkerDisplay = -1; - m_waypointMode = false; - m_forceAttackMode = false; - m_forceMoveToMode = false; - m_attackMoveToMode = false; - m_preferSelection = false; + m_waypointMode = false; + m_forceAttackMode = false; + m_forceMoveToMode = false; + m_attackMoveToMode = false; + m_preferSelection = false; - m_curRcType = RADIUSCURSOR_NONE; + m_curRcType = RADIUSCURSOR_NONE; - m_soloNexusSelectedDrawableID = INVALID_DRAWABLE_ID; + m_soloNexusSelectedDrawableID = INVALID_DRAWABLE_ID; -} + m_liveObserverStatusVisible = FALSE; + + m_liveStatusString = nullptr; + m_liveStatusFontSize = 0; + m_liveStatusLabel.clear(); + } //------------------------------------------------------------------------------------------------- //------------------------------------------------------------------------------------------------- @@ -2214,6 +2226,8 @@ void InGameUI::reset() { m_isQuitMenuVisible = FALSE; m_inputEnabled = true; + // Not persisted: every live-observer session starts with the status bar hidden (F6 shows it). + m_liveObserverStatusVisible = FALSE; // reset the command bar TheControlBar->reset(); @@ -2341,6 +2355,8 @@ void InGameUI::freeCustomUiResources() { TheDisplayStringManager->freeDisplayString(m_networkLatencyString); m_networkLatencyString = nullptr; + TheDisplayStringManager->freeDisplayString(m_observerLogicFpsString); + m_observerLogicFpsString = nullptr; TheDisplayStringManager->freeDisplayString(m_renderFpsString); m_renderFpsString = nullptr; TheDisplayStringManager->freeDisplayString(m_renderFpsLimitString); @@ -2356,6 +2372,11 @@ void InGameUI::freeCustomUiResources() TheDisplayStringManager->freeDisplayString(m_observerStatsString); m_observerStatsString = nullptr; + + TheDisplayStringManager->freeDisplayString(m_liveStatusString); + m_liveStatusString = nullptr; + m_liveStatusFontSize = 0; + m_liveStatusLabel.clear(); } //------------------------------------------------------------------------------------------------- @@ -3847,7 +3868,21 @@ void InGameUI::postWindowDraw() Int hudOffsetX = 0; Int hudOffsetY = 0; - if (m_networkLatencyPointSize > 0 && TheGameLogic->isInMultiplayerGame()) + Bool drawLatencyCounter = TheGameLogic->isInMultiplayerGame(); +#if defined(GENERALS_ONLINE) + // A live-observer session is replay playback, not a multiplayer game, so this counter would + // never draw for a viewer - but the *match being watched* is a multiplayer game, and its + // latency and logic rate are exactly the numbers the viewer wants. drawNetworkLatency has an + // observer branch that fills them from the streamer's MSG_STATS instead of from TheNetwork, + // which is null here. + if (!drawLatencyCounter && TheLiveObserver != NULL && TheLiveObserver->isConnected() + && TheLiveObserver->hasPlaybackStarted()) + { + drawLatencyCounter = TRUE; + } +#endif + + if (m_networkLatencyPointSize > 0 && drawLatencyCounter) { drawNetworkLatency(hudOffsetX, hudOffsetY); } @@ -3875,6 +3910,8 @@ void InGameUI::postWindowDraw() hudOffsetX = 0; hudOffsetY += 250; + drawLiveStatus(); + if (m_observerStatsPointSize > 0) drawObserverStats(hudOffsetX, hudOffsetY); @@ -6525,11 +6562,11 @@ void InGameUI::drawObserverStats(Int & x, Int & y) if (!localPlayer || (TheGameLogic && TheGameLogic->getFrame() <= 1)) return; - if (!localPlayer->isPlayerObserver() && !localPlayer->isPlayerDead()) - return; + if (!localPlayer->isPlayerObserver() && !localPlayer->isPlayerDead()) + return; - if (!isAtHudAnchorPos(m_observerStatsPosition) || m_observerStatsHidden) - return; + if (!isAtHudAnchorPos(m_observerStatsPosition) || m_observerStatsHidden) + return; // couldn't allocate memory, early out if (m_observerStatsString == nullptr) @@ -6753,7 +6790,7 @@ void InGameUI::drawObserverStats(Int & x, Int & y) Int lineHeight = (m_observerStatsLineStep > 0) ? m_observerStatsLineStep : Int(16 * scale); Int rowSpacing = Int(2 * scale); - totalHeight = (lineHeight + rowSpacing) * (1 + Int(actualNumPlayers)); + totalHeight = (lineHeight + rowSpacing) * (1 + Int(actualNumPlayers)); if (actualNumPlayers == 0) return; @@ -6870,10 +6907,17 @@ void InGameUI::refreshNetworkLatencyResources() m_lastNetworkLatencyFrames = ~0u; } + if (!m_observerLogicFpsString) + { + m_observerLogicFpsString = TheDisplayStringManager->newDisplayString(); + m_lastObserverLogicFps = ~0u; + } + m_networkLatencyPointSize = TheGlobalData->m_networkLatencyFontSize; Int adjustedNetworkLatencyFontSize = TheGlobalLanguageData->adjustFontSize(m_networkLatencyPointSize); GameFont* latencyFont = TheWindowManager->winFindFont(m_networkLatencyFont, adjustedNetworkLatencyFontSize, m_networkLatencyBold); m_networkLatencyString->setFont(latencyFont); + m_observerLogicFpsString->setFont(latencyFont); } void InGameUI::refreshRenderFpsResources() @@ -7012,6 +7056,76 @@ void InGameUI::updateRenderFpsString() void InGameUI::drawNetworkLatency(Int& x, Int& y) { #if defined(GENERALS_ONLINE) + // Observer: report the match being watched. TheNetwork is null in replay playback, so every + // term here comes from the streamer's own reading (MSG_STATS) rather than from a local mesh - + // same counter, same format, the streamer's numbers. Values stay 0 until the first stats frame + // arrives (an older streamer never sends one; a held observer gets nothing until its delay has + // elapsed), and a 0 ping reads as "no measurement", so the counter waits rather than showing a + // fabricated zero. + if (TheLiveObserver != NULL && TheLiveObserver->isConnected() + && TheLiveObserver->hasPlaybackStarted()) + { + const UnsignedInt streamerPingMs = TheLiveObserver->getStreamerPingMs(); + const UnsignedInt streamerLogicFps = TheLiveObserver->getStreamerLogicFps(); + + if (streamerPingMs == 0 && streamerLogicFps == 0) + return; + + // The observer's own playback rate. Shown next to the match's rate because the pair is the + // whole story: equal means playback is tracking the match, and a P far above L is the + // observer outrunning the source and about to stall - which is otherwise invisible, since + // the render FPS counter beside this one reports frames drawn, not frames simulated. + const UnsignedInt paceFps = TheLiveObserver->getPaceFps(); + + if (streamerPingMs != m_lastObserverPingMs || streamerLogicFps != m_lastObserverLogicFps + || paceFps != m_lastObserverPaceFps) + { + // One layout, not the live counter's two. The alternate ("GenTool frames") form is + // only chosen when both conversions agree, which on a 60 fps build means only when + // the latency is zero - so mirroring both branches would add a case nobody sees, at + // the cost of moving the logic rate to the far end of the string where it cannot + // carry its own colour. + const UnsignedInt actualFrames = ConvertMSLatencyToFrames((int)streamerPingMs); + + // Labelled, unlike the live counter's bare leading [60]. That counter is read by a + // player who knows the first bracket is their own frame rate; an observer is reading + // two rates side by side - the match's and their own playback's - and an unlabelled + // one next to a labelled [P: n] just invites the question "which is which". + UnicodeString logicStr; + logicStr.format(L"[L: %u]", streamerLogicFps); + m_observerLogicFpsString->setText(logicStr); + + UnicodeString latencyStr; + latencyStr.format(L" - [%ums - %u] [P: %u]", streamerPingMs, actualFrames, paceFps); + m_networkLatencyString->setText(latencyStr); + + m_lastObserverPingMs = streamerPingMs; + m_lastObserverLogicFps = streamerLogicFps; + m_lastObserverPaceFps = paceFps; + } + + // Red while rate-matching is off: the match ran at this rate, but the picture in front of + // you is running faster and eating the backlog, so the number no longer describes what is + // on screen. Green-through-normal when matching is on and the two agree. + const Color logicColor = TheLiveObserver->isPaceMatchingEnabled() + ? m_networkLatencyColor : 0xFFFF4040; + + if (isAtHudAnchorPos(m_networkLatencyPosition)) + { + m_observerLogicFpsString->draw(kHudAnchorX + x, kHudAnchorY + y, logicColor, m_networkLatencyDropColor); + x += m_observerLogicFpsString->getWidth(); + m_networkLatencyString->draw(kHudAnchorX + x, kHudAnchorY + y, m_networkLatencyColor, m_networkLatencyDropColor); + x += m_networkLatencyString->getWidth() + kHudGapPx; + } + else + { + m_observerLogicFpsString->draw(m_networkLatencyPosition.x, m_networkLatencyPosition.y, logicColor, m_networkLatencyDropColor); + m_networkLatencyString->draw(m_networkLatencyPosition.x + m_observerLogicFpsString->getWidth(), + m_networkLatencyPosition.y, m_networkLatencyColor, m_networkLatencyDropColor); + } + return; + } + const UnsignedInt actualLatencyInMS = TheNetwork->getRunAhead() * (1000 / GENERALS_ONLINE_HIGH_FPS_LIMIT); const UnsignedInt actualFrames = ConvertMSLatencyToFrames(actualLatencyInMS); const UnsignedInt gentoolFrames = ConvertMSLatencyToGenToolFrames(actualLatencyInMS); @@ -7375,6 +7489,132 @@ void InGameUI::drawPlayerInfoList() m_playerInfoList.values[PlayerInfoList::ValueType_Name][row]->draw(labelX, drawY, rowColors[row], m_playerInfoListDropColor); - drawY += lineH; + drawY += lineH; + } + } + +//------------------------------------------------------------------------------------------------- +// Live streaming / live-observer status banner, top-centre. Called from postWindowDraw() so it +// covers every session type: the streamer, who is a normal player, as well as an observer. +//------------------------------------------------------------------------------------------------- +void InGameUI::drawLiveStatus() +{ +#if defined(GENERALS_ONLINE) + if (TheDisplay == NULL || TheGameLogic == NULL || TheGameLogic->getFrame() <= 1) + return; + + if (TheDisplayStringManager == NULL || TheFontLibrary == NULL) + return; + + Real baseScale = (Real)TheDisplay->getWidth() / 1920.0f; + baseScale = (baseScale < 0.7f) ? 0.7f : (baseScale > 2.0f) ? 2.0f : baseScale; + const Int statusY = Int(10 * baseScale); + + // One banner string reused across frames. The streamer and observer banners never draw in the + // same frame - a client either broadcasts its own game or watches someone else's - and both + // land at the same spot, so a single cached DisplayString serves them. Allocating and freeing + // per frame churned the display-string list; the string is freed by freeCustomUiResources(). + if (m_liveStatusString == NULL) + m_liveStatusString = TheDisplayStringManager->newDisplayString(); + if (m_liveStatusString == NULL) + return; + + // The font depends only on baseScale, which changes on window resize, so it is applied only + // when the pixel size actually changed. + const Int fontPx = Int(14 * baseScale); + if (fontPx != m_liveStatusFontSize) + { + m_liveStatusString->setFont(TheFontLibrary->getFont("ArialFont", fontPx, false)); + m_liveStatusFontSize = fontPx; + } + + // Streamer side: are we the source of a relayed game? Behind F6 like the observer labels - + // "STREAMING"/"BACKUP" tells the player nothing and must not be clutter in every match. + if (TheLiveStreamer && (TheLiveStreamer->isStreaming() || TheLiveStreamer->isBackup()) + && m_liveObserverStatusVisible) + { + drawLiveStatusBanner(AsciiString(TheLiveStreamer->isStreaming() ? "STREAMING" : "BACKUP"), 0xFF00FF00, statusY); + } + + // Observer side: connection and playback state of the relayed game we are watching. + if (TheLiveObserver && TheLiveObserver->isConnected()) + { + // Split by what each state leaks. Observer-local state (connected? buffering?) says + // nothing about the live game and stays visible; anything derived from the live game - + // above all LIVE - ENDED - is a spoiler ~15s early, so it hides behind F6. + const Bool showLiveState = m_liveObserverStatusVisible; + + AsciiString label; + UnsignedInt colour = 0; + + if (TheLiveObserver->isDesynced()) + { + // Not gated: a diverged simulation is a fact about this client, not the match, and + // the observer must be told regardless of the spoiler setting. + label.format("DESYNCED AT FRAME %d - NO LONGER THE REAL GAME", TheLiveObserver->getDesyncFrame()); + colour = 0xFFFF4040; // red + } + else if (!TheLiveObserver->isReady() && !LobbyObserverModeActive()) + { + // Only when the read-only lobby view is down: that screen reports the same states + // itself, so a banner would overlap it. Direct Watch Live joins keep the banner. + label = "LIVE - CONNECTING..."; + colour = 0xFFFFFF00; // yellow + } + else if (!TheLiveObserver->hasPlaybackStarted() && !LobbyObserverModeActive()) + { + // Countdown while the join waits in the shell for header + enough body to cover the + // delay. Observer-local, so shown regardless of F6. Must be tested before the hold + // state, which is true throughout pre-roll and would mask it. + if (TheLiveObserver->getMaxCompleteFrame() == 0) + { + // No complete frames yet: players are still loading, so the delay is not + // elapsing and a countdown would sit frozen at its full value. + label = "LOADING GAME..."; + colour = 0xFFFFFF00; // yellow + } + else + { + label.format("LIVE GAME - STARTS IN %ds", TheLiveObserver->getSecondsUntilPlaybackReady()); + colour = 0xFFFFFF00; // yellow + } + } + else if (TheLiveObserver->isStreamEnded()) + { + if (showLiveState) { label = "LIVE - ENDED"; colour = 0xFF00FF00; } // green + } + else if (TheLiveObserver->isStalled()) + { + // isStalled(), not shouldHoldPlayback(): the latter toggles constantly while the + // delay is held at the boundary, which is healthy playback, not a problem. + if (showLiveState) { label = "WAITING FOR FRAMES"; colour = 0xFF00FFFF; } // cyan + } + else + { + if (showLiveState) { label = "LIVE"; colour = 0xFF00FF00; } // green + } + + if (!label.isEmpty()) + drawLiveStatusBanner(label, colour, statusY); } +#endif +} + +//------------------------------------------------------------------------------------------------- +/** Draw one live-status banner line with the shared cached string. */ +//------------------------------------------------------------------------------------------------- +void InGameUI::drawLiveStatusBanner(const AsciiString& label, UnsignedInt colour, Int y) +{ +#if defined(GENERALS_ONLINE) + if (label != m_liveStatusLabel) + { + UnicodeString text; + text.translate(label); + m_liveStatusString->setText(text); + m_liveStatusLabel = label; + } + + const Int x = (TheDisplay->getWidth() - m_liveStatusString->getWidth()) / 2; + m_liveStatusString->draw(x, y, colour, 0); +#endif } diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp index 1fa00d1b2e6..3271d522254 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp @@ -1178,6 +1178,22 @@ static void populateRandomStartPosition(GameInfo* game) } } +// ------------------------------------------------------------------------------------------------ +/** Resolve all random slots the way a game start would, without starting a game. Mirrors the + * sequence in tryStartNewGame() so the result is what the game will actually roll, which is what + * lets the observer lobby preview show the players' real assignment before the match begins. */ +// ------------------------------------------------------------------------------------------------ +void GameLogic::rollRandomSlots(GameInfo* game) +{ + if (!game) + return; + + checkForDuplicateColors(game); + InitRandom(game->getSeed()); + populateRandomSideAndColor(game); + populateRandomStartPosition(game); +} + // ------------------------------------------------------------------------------------------------ /** Update the load screen progress */ // ------------------------------------------------------------------------------------------------ @@ -1431,6 +1447,12 @@ void GameLogic::tryStartNewGame( Bool loadingSaveGame ) } } + // Seed the game-logic RNG here, where the simulation begins, so the streamer and the observer + // start from the same state whatever their pre-game paths consumed. The random slot assignment + // below draws from it, and one differing draw diverges the factions and every later AI decision. + if (game) + InitRandom(game->getSeed()); + populateRandomSideAndColor(game); populateRandomStartPosition(game); @@ -1600,7 +1622,12 @@ void GameLogic::tryStartNewGame( Bool loadingSaveGame ) d.setInt(TheKey_multiplayerStartIndex, slot->getStartPos()); // d.setBool(TheKey_multiplayerIsLocal, slot->isLocalPlayer()); // d.setBool(TheKey_multiplayerIsLocal, slot->getIP() == game->getLocalIP()); - d.setBool(TheKey_multiplayerIsLocal, slot->isHuman() && (slot->getName().compare(game->getSlot(game->getLocalSlotNum())->getName().str()) == 0)); + // An observer occupies no slot, so getLocalSlotNum() returns -1 and getSlot() null. + Bool isLocalPlayer = FALSE; + const GameSlot* localSlotPtr = game->getSlot(game->getLocalSlotNum()); + if (localSlotPtr) + isLocalPlayer = slot->isHuman() && (slot->getName().compare(localSlotPtr->getName().str()) == 0); + d.setBool(TheKey_multiplayerIsLocal, isLocalPlayer); /* if (slot->getIP() == game->getLocalIP()) @@ -1815,6 +1842,16 @@ void GameLogic::tryStartNewGame( Bool loadingSaveGame ) Player* localPlayer = ThePlayerList->getLocalPlayer(); Player* observerPlayer = ThePlayerList->findPlayerWithNameKey(TheNameKeyGenerator->nameToKey("ReplayObserver")); + // Without TheNetwork, PlayerList::newGame() picks the first human side as local, and the + // "ReplayObserver" side is always added with multiplayerIsLocal=FALSE - so an observer would + // render for a real participant whose map was never revealed, i.e. a black screen. + if (TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER && + observerPlayer && localPlayer != observerPlayer) + { + ThePlayerList->setLocalPlayer(observerPlayer); + localPlayer = observerPlayer; + } + // set the radar as on a new map TheRadar->newMap(TheTerrainLogic); @@ -2150,6 +2187,10 @@ void GameLogic::tryStartNewGame( Bool loadingSaveGame ) { const PlayerTemplate* pt = NULL; pt = ThePlayerTemplateStore->getNthPlayerTemplate(slot->getPlayerTemplate()); + // A live observer takes its slot templates from relay metadata, so the index + // is attacker-controlled and may not name a template at all. + if (!pt) + continue; // Prevent from loading the disabled Generals, in case your game peer hacked their GUI. // The game will start, but the cheater will be instantly defeated because he has no troops. @@ -2780,7 +2821,11 @@ void GameLogic::processCommandList(CommandList* list) logicMessageDispatcher(msg, NULL); } - if (m_shouldValidateCRCs && !TheNetwork->sawCRCMismatch()) + // A live observer is not a network peer, so the per-player CRC agreement check below has + // nothing to agree with. Its own divergence from the stream is caught by + // RecorderClass::handleCRCMessage instead. + if (m_shouldValidateCRCs && !TheNetwork->sawCRCMismatch() + && !(TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER)) { Bool sawCRCMismatch = FALSE; Int numPlayers = 0; @@ -3995,14 +4040,17 @@ void GameLogic::update() // force CRC calculation, so we can keep a cache of the last N CRCs. We do this right where the recorder // would be getting the CRC anyway, so replays can get the CRCs from the exact instant in time as the original. + // Frame 0 is excluded, as the DEBUG_CRC branch below already does: a replay client is in + // GAME_REPLAY at its frame 0 and would emit a CRC the original game never did, skewing every + // later comparison by one interval. Bool isMPGameOrReplay = (TheRecorder && TheRecorder->isMultiplayer() && getGameMode() != GAME_SHELL && getGameMode() != GAME_NONE); Bool isSoloGameOrReplay = (TheRecorder && !TheRecorder->isMultiplayer() && getGameMode() != GAME_SHELL && getGameMode() != GAME_NONE); - Bool generateForMP = (isMPGameOrReplay && TheGameInfo->getCRCInterval() > 0 && (m_frame % TheGameInfo->getCRCInterval()) == 0); + Bool generateForMP = (isMPGameOrReplay && TheGameInfo->getCRCInterval() > 0 && m_frame > 0 && (m_frame % TheGameInfo->getCRCInterval()) == 0); #ifdef DEBUG_CRC Bool generateForSolo = isSoloGameOrReplay && ((m_frame && (m_frame % 100 == 0)) || (getFrame() >= TheCRCFirstFrameToLog && getFrame() < TheCRCLastFrameToLog && (REPLAY_CRC_INTERVAL > 0 && (m_frame % REPLAY_CRC_INTERVAL) == 0))); #else - Bool generateForSolo = isSoloGameOrReplay && (REPLAY_CRC_INTERVAL > 0 && (m_frame % REPLAY_CRC_INTERVAL) == 0); + Bool generateForSolo = isSoloGameOrReplay && (REPLAY_CRC_INTERVAL > 0 && m_frame > 0 && (m_frame % REPLAY_CRC_INTERVAL) == 0); #endif // DEBUG_CRC if (generateForSolo || generateForMP) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp index d8c249e5c82..613e29d188b 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp @@ -26,6 +26,9 @@ #define SETTINGS_KEY_SOCIAL_NOTIFICATIONS_PLAYER_SENDS_REQUEST_MENUS "notification_player_sends_request_menus" #define SETTINGS_KEY_SOCIAL_NOTIFICATIONS_PLAYER_SENDS_REQUEST_GAMEPLAY "notification_player_sends_request_gameplay" +#define SETTINGS_KEY_LIVE_OBSERVER "live_observer" +#define SETTINGS_KEY_LIVE_OBSERVER_JITTER_BUFFER_MS "jitter_buffer_ms" + #define SETTINGS_KEY_DEBUG "debug" #define SETTINGS_KEY_DEBUG_VERBOSE_LOGGING "verbose_logging" @@ -207,6 +210,16 @@ void GenOnlineSettings::Load(void) } } + if (jsonSettings.contains(SETTINGS_KEY_LIVE_OBSERVER)) + { + auto liveObserverSettings = jsonSettings[SETTINGS_KEY_LIVE_OBSERVER]; + + if (liveObserverSettings.contains(SETTINGS_KEY_LIVE_OBSERVER_JITTER_BUFFER_MS)) + { + m_LiveObserver_JitterBufferMs = liveObserverSettings[SETTINGS_KEY_LIVE_OBSERVER_JITTER_BUFFER_MS]; + } + } + if (jsonSettings.contains(SETTINGS_KEY_SOCIAL)) { auto socialSettings = jsonSettings[SETTINGS_KEY_SOCIAL]; @@ -287,6 +300,7 @@ void GenOnlineSettings::Load(void) m_Render_FramerateLimit_FPSVal = 60; m_Render_DrawStatsOverlay = true; m_Chat_LifeSeconds = 30; + m_LiveObserver_JitterBufferMs = m_LiveObserver_JitterBufferMs_default; m_Social_Notification_FriendComesOnline_Menus = true; m_Social_Notification_FriendComesOnline_Gameplay = true; @@ -339,6 +353,13 @@ void GenOnlineSettings::Save() } }, + { + SETTINGS_KEY_LIVE_OBSERVER, + { + {SETTINGS_KEY_LIVE_OBSERVER_JITTER_BUFFER_MS, m_LiveObserver_JitterBufferMs} + } + }, + { SETTINGS_KEY_DEBUG, { diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index c1f140b0c9d..c3b48b4361f 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -1172,6 +1172,54 @@ void WebSocket::SendData_LobbyChatMessage(UnicodeString& msg, bool bIsAction, bo Send(strBody.c_str()); } +// A client watching a lobby it is not a member of subscribes for the same pushes a member +// gets, so the read-only lobby view stays current and can queue its watch at match start. +void WebSocket::SendData_LobbyObserverSubscribe(int64_t lobbyID) +{ + nlohmann::json j; + j["msg_id"] = EWebSocketMessageID::LOBBY_OBSERVER_SUBSCRIBE; + j["lobby_id"] = lobbyID; + std::string strBody = j.dump(); + + Send(strBody.c_str()); +} + +void WebSocket::SendData_LobbyObserverUnsubscribe(int64_t lobbyID) +{ + nlohmann::json j; + j["msg_id"] = EWebSocketMessageID::LOBBY_OBSERVER_UNSUBSCRIBE; + j["lobby_id"] = lobbyID; + std::string strBody = j.dump(); + + Send(strBody.c_str()); +} + +// A pre-game observer sending chat into a lobby it watches. Own lane from +// SendData_LobbyChatMessage because that path is gated on being a lobby member; the server +// re-broadcasts this as an ordinary LOBBY_CHAT_FROM_SERVER with its own formatting. +void WebSocket::SendData_LobbyObserverChat(int64_t lobbyID, UnicodeString& msg) +{ + nlohmann::json j; + j["msg_id"] = EWebSocketMessageID::LOBBY_OBSERVER_CHAT_FROM_CLIENT; + j["lobby_id"] = lobbyID; + j["message"] = to_utf8(msg.str()); + std::string strBody = j.dump(); + + Send(strBody.c_str()); +} + +// Host-only: ask GO to announce the current pre-game observer roster into the lobby chat. +// GO formats and broadcasts it; this client just renders the announcement like any other. +void WebSocket::SendData_LobbyObserverListRequest(int64_t lobbyID) +{ + nlohmann::json j; + j["msg_id"] = EWebSocketMessageID::LOBBY_OBSERVER_LIST_REQUEST; + j["lobby_id"] = lobbyID; + std::string strBody = j.dump(); + + Send(strBody.c_str()); +} + void WebSocket::SendData_LeaveNetworkRoom() { SendData_JoinNetworkRoom(-1); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index fcc2e1d2d16..ded461afc42 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -4,6 +4,8 @@ #include "GameNetwork/GeneralsOnline/OnlineServices_Init.h" #include "GameClient/MapUtil.h" #include "GameLogic/GameLogic.h" +#include "Common/GlobalData.h" +#include "Common/LiveStreamer.h" extern void OnKickedFromLobby(); @@ -18,6 +20,126 @@ struct JoinLobbyResponse NLOHMANN_DEFINE_TYPE_INTRUSIVE(JoinLobbyResponse, success, turn_username, turn_token) }; +/** + * Undo the local prefix SearchForLobbies()/UpdateRoomDataCache() prepend to a lobby's map path, + * putting it back into the relative form GO reports. The custom-map prefix is the user's maps + * directory under their Windows profile, which must not be published to viewers. + */ +static std::string LiveStreamRelativeMapPath(const std::string& mapPath) +{ + AsciiString lowerPath = mapPath.c_str(); + lowerPath.toLower(); + + std::string prefixes[2]; + if (TheMapCache != nullptr) + { + AsciiString userMapDir = TheMapCache->getUserMapDir(true); + userMapDir.toLower(); + prefixes[0] = std::string(userMapDir.str()) + "\\"; + } + prefixes[1] = "maps\\"; + + for (int i = 0; i < 2; ++i) + { + const size_t prefixLen = prefixes[i].length(); + if (prefixLen > 1 && mapPath.length() >= prefixLen && + strncmp(lowerPath.str(), prefixes[i].c_str(), prefixLen) == 0) + { + return mapPath.substr(prefixLen); + } + } + + return mapPath; +} + +/** + * Build the "lobby" block of the relay REGISTER payload. Keys mirror GO's own /lobby JSON so a + * client parses the same shape whichever source served the live-game list. Carries only what a + * third-party viewer needs - never the password, the per-member ports or the anticheat id. + * Empty member slots (userid -1) are kept, since GO reports them too and filtering is display work. + */ +static std::string BuildLiveStreamLobbyJson(const LobbyEntry& lobby) +{ + char scratch[128]; + std::string json = "{"; + + // No lobbyid here: it is the session key and already travels at the top level of REGISTER, + // as a string. Repeating it as a number would give one field two types. + snprintf(scratch, sizeof(scratch), "\"lobbytype\":%d,", (int)lobby.lobby_type); + json += scratch; + snprintf(scratch, sizeof(scratch), "\"rngseed\":%d,", lobby.rng_seed); + json += scratch; + snprintf(scratch, sizeof(scratch), "\"owner\":%lld,", (long long)lobby.owner); + json += scratch; + + json += "\"region\":\"" + liveStreamJsonEscape(lobby.region.c_str()) + "\","; + json += "\"name\":\"" + liveStreamJsonEscape(lobby.name.c_str()) + "\","; + json += "\"mapname\":\"" + liveStreamJsonEscape(lobby.map_name.c_str()) + "\","; + json += "\"mappath\":\"" + + liveStreamJsonEscape(LiveStreamRelativeMapPath(lobby.map_path).c_str()) + "\","; + + json += "\"members\":["; + for (size_t i = 0; i < lobby.members.size(); ++i) + { + const LobbyMemberEntry& member = lobby.members[i]; + if (i > 0) + json += ","; + + snprintf(scratch, sizeof(scratch), "{\"userid\":%lld,\"displayname\":\"", + (long long)member.user_id); + json += scratch; + json += liveStreamJsonEscape(member.display_name.c_str()); + json += "\"}"; + } + json += "]}"; + + return json; +} + +void PrepareLiveStreamRegistration() +{ + if (TheGlobalData == nullptr || !TheGlobalData->m_liveStreamEnabled) + return; + + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = + NGMP_OnlineServicesManager::GetInterface(); + if (pLobbyInterface == nullptr || !pLobbyInterface->IsInLobby()) + { + // Clear, or the next recording would open a relay session under a stale lobby id. + liveStreamClearPendingRegistration(); + return; + } + + const LobbyEntry& lobby = pLobbyInterface->GetCurrentLobby(); + + LiveStreamRegistration registration; + // LobbyID as plain decimal is the relay's session key: every peer in the lobby gets the same + // text from the service, so relay session and GO lobby line up without conversion. + registration.lobbyId.format("%lld", (long long)lobby.lobbyID); + registration.canStream = TheGlobalData->m_liveStreamCanStream; + registration.isHost = pLobbyInterface->IsHost(); + + // Every player registers - each is a potential source of replay bytes - but only the host + // describes the game, so no two clients can race over what the relay publishes. + if (registration.isHost) + { + registration.lobbyJson = BuildLiveStreamLobbyJson(lobby); + // Fall back to the local preference only when GO has never been told a lobby delay. + registration.delaySeconds = (lobby.stream_delay_seconds >= 0) + ? lobby.stream_delay_seconds + : TheGlobalData->m_liveStreamDelaySeconds; + } + + NGMP_OnlineServices_AuthInterface* pAuthInterface = + NGMP_OnlineServicesManager::GetInterface(); + if (pAuthInterface != nullptr) + { + registration.playerName = pAuthInterface->GetDisplayName().c_str(); + } + + liveStreamSetPendingRegistration(registration); +} + UnicodeString NGMP_OnlineServices_LobbyInterface::GetCurrentLobbyDisplayName() { UnicodeString strDisplayName; @@ -74,7 +196,9 @@ enum class ELobbyUpdateField AI_TEAM = 15, AI_START_POS = 16, MAX_CAMERA_HEIGHT = 17, - JOINABILITY = 18 + JOINABILITY = 18, + LOBBY_STREAM_DELAY = 20, + LOBBY_ALLOW_OBSERVER_CHAT = 21 }; void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_Map(AsciiString strMap, AsciiString strMapPath, bool bIsOfficial, int newMaxPlayers) @@ -162,6 +286,95 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_StartingCash(Unsigne }); } +void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_StreamDelay(Int streamDelaySeconds) +{ + // reset autostart if host changes anything (because ready flag will reset too) +#if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) + ClearAutoReadyCountdown(); +#endif + + if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + TheNGMPGame->StopCountdown(); + + std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); + std::map mapHeaders; + + nlohmann::json j; + j["field"] = ELobbyUpdateField::LOBBY_STREAM_DELAY; + j["delay_seconds"] = streamDelaySeconds; + std::string strPostData = j.dump(); + + NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendPOSTRequest(strURI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, strPostData.c_str(), [=](bool bSuccess, int statusCode, std::string strBody, HTTPRequest* pReq) + { + // A GO that does not know this field still answers 200, with success:false. Only + // the flag says the value was stored, and the local cache must not claim otherwise. + bool bStored = false; + if (bSuccess && statusCode == 200 && !strBody.empty()) + { + try + { + bStored = nlohmann::json::parse(strBody).value("success", false); + } + catch (...) + { + } + } + + if (bStored) + { + m_CurrentLobby.stream_delay_seconds = streamDelaySeconds; + + // The lobby property change resets every ready flag; the host is always ready. + ApplyLocalUserPropertiesToCurrentNetworkRoom(); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[LOBBY_STREAM_DELAY] GO did not confirm delay %d (HTTP %d): treating lobby value as unset", streamDelaySeconds, statusCode); + } + }); +} + +void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_AllowObserverChat(bool bAllowObserverChat) +{ + // No ClearAutoReadyCountdown / StopCountdown preamble here on purpose: those exist because + // gameplay-setting changes reset every ready flag, and dropping the lobby's ready state to + // mute a chatty observer would be a hostile side effect. + + std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); + std::map mapHeaders; + + nlohmann::json j; + j["field"] = ELobbyUpdateField::LOBBY_ALLOW_OBSERVER_CHAT; + j["allow_observer_chat"] = bAllowObserverChat; + std::string strPostData = j.dump(); + + NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendPOSTRequest(strURI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, strPostData.c_str(), [=](bool bSuccess, int statusCode, std::string strBody, HTTPRequest* pReq) + { + // A GO that does not know this field still answers 200, with success:false. Only + // the flag says the value was stored, and the local cache must not claim otherwise. + bool bStored = false; + if (bSuccess && statusCode == 200 && !strBody.empty()) + { + try + { + bStored = nlohmann::json::parse(strBody).value("success", false); + } + catch (...) + { + } + } + + if (bStored) + { + m_CurrentLobby.allow_observer_chat = bAllowObserverChat; + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[LOBBY_ALLOW_OBSERVER_CHAT] GO did not confirm observer chat %d (HTTP %d): treating lobby value as unset", bAllowObserverChat ? 1 : 0, statusCode); + } + }); +} + void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_HasMap() { // do we have the map? @@ -502,6 +715,29 @@ void NGMP_OnlineServices_LobbyInterface::SendChatMessageToCurrentLobby(UnicodeSt } } +// A pre-game observer sending chat into a lobby it watches but is not a member of. The server +// re-broadcasts it as an ordinary LOBBY_CHAT_FROM_SERVER with its own [Name] formatting, so no +// local echo and no receive-side work anywhere. +void NGMP_OnlineServices_LobbyInterface::SendObserverChatMessage(int64_t lobbyId, UnicodeString& strChatMsgUnicode) +{ + std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); + if (pWS != nullptr) + { + pWS->SendData_LobbyObserverChat(lobbyId, strChatMsgUnicode); + } +} + +// Host-only: GO announces the pre-game observer roster into the lobby chat. The host's own +// client sees the announcement arrive like any other member's. +void NGMP_OnlineServices_LobbyInterface::SendObserverListRequest(int64_t lobbyId) +{ + std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); + if (pWS != nullptr) + { + pWS->SendData_LobbyObserverListRequest(lobbyId); + } +} + // TODO_NGMP: Just send a separate packet for each announce, more efficient and less hacky void NGMP_OnlineServices_LobbyInterface::SendAnnouncementMessageToCurrentLobby(UnicodeString& strAnnouncementMsgUnicode, bool bShowToHost) { @@ -512,6 +748,24 @@ void NGMP_OnlineServices_LobbyInterface::SendAnnouncementMessageToCurrentLobby(U } } +void NGMP_OnlineServices_LobbyInterface::SubscribeToLobbyObserver(int64_t lobbyID) +{ + std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); + if (pWS != nullptr) + { + pWS->SendData_LobbyObserverSubscribe(lobbyID); + } +} + +void NGMP_OnlineServices_LobbyInterface::UnsubscribeFromLobbyObserver(int64_t lobbyID) +{ + std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); + if (pWS != nullptr) + { + pWS->SendData_LobbyObserverUnsubscribe(lobbyID); + } +} + NGMP_OnlineServices_LobbyInterface::NGMP_OnlineServices_LobbyInterface() { @@ -579,9 +833,26 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function lobbyEntryIter["MaximumCameraHeight"].get_to(lobbyEntry.max_cam_height); lobbyEntryIter["ExeCRC"].get_to(lobbyEntry.exe_crc); lobbyEntryIter["IniCRC"].get_to(lobbyEntry.ini_crc); + lobbyEntryIter["RNGSeed"].get_to(lobbyEntry.rng_seed); lobbyEntryIter["MatchID"].get_to(lobbyEntry.match_id); lobbyEntryIter["LobbyType"].get_to(lobbyEntry.lobby_type); lobbyEntryIter["Region"].get_to(lobbyEntry.region); + // StreamDelaySeconds is null until the host has chosen a broadcast delay. + if (lobbyEntryIter.contains("StreamDelaySeconds") && + lobbyEntryIter["StreamDelaySeconds"].is_number()) + { + lobbyEntryIter["StreamDelaySeconds"].get_to(lobbyEntry.stream_delay_seconds); + } + + // /Lobbies spells this "IsPriority", /Livestreams spells it "priority". + if (lobbyEntryIter.contains("IsPriority") && lobbyEntryIter["IsPriority"].is_boolean()) + { + lobbyEntryIter["IsPriority"].get_to(lobbyEntry.priority); + } + else if (lobbyEntryIter.contains("priority") && lobbyEntryIter["priority"].is_boolean()) + { + lobbyEntryIter["priority"].get_to(lobbyEntry.priority); + } // attach latency if (latencyIndex < vecLatencies.size()) @@ -849,6 +1120,29 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::functionm_exeCRC; j["ini_crc"] = TheGlobalData->m_iniCRC; j["max_cam_height"] = NGMP_OnlineServicesManager::Settings.Camera_GetMaxHeight_WhenLobbyHost(); @@ -1432,6 +1731,14 @@ void NGMP_OnlineServices_LobbyInterface::CreateLobby(UnicodeString strLobbyName, // Set our properties pLobbyInterface->ApplyLocalUserPropertiesToCurrentNetworkRoom(); + + // Seed the new lobby's delay from the host's saved preference, + // so joiners see the host's value and not their own fallback. + if (TheGlobalData != nullptr && + pLobbyInterface->GetCurrentLobby().stream_delay_seconds < 0) + { + pLobbyInterface->UpdateCurrentLobby_StreamDelay(TheGlobalData->m_liveStreamDelaySeconds); + } }); } else diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index 2c2e1d394e5..82cc7493122 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -5,6 +5,8 @@ #include "GameNetwork/GeneralsOnline/json.hpp" #include "../OnlineServices_Init.h" #include "../HTTP/HTTPManager.h" +#include "Common/Recorder.h" +#include "GameClient/LobbyObserverMenu.h" // slot lookup for colouring an observer's chat #include "GameNetwork/GameSpy/PeerDefs.h" // ----------------------------- @@ -99,6 +101,13 @@ std::vector GetLoadedModules() { return modules; } +// A live watch is a replay, so TheNGMPGame is never "in progress" for it; the recorder mode is +// the only in-game signal in that case. +static bool isGOWebsocketInGame() +{ + return (TheNGMPGame != nullptr && TheNGMPGame->isGameInProgress()) + || (TheRecorder != nullptr && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER); +} WebSocket::WebSocket() { @@ -567,7 +576,7 @@ void WebSocket::Tick() { int64_t currTime = std::chrono::duration_cast(std::chrono::utc_clock::now().time_since_epoch()).count(); - int maxReconnectAttempts = (TheNGMPGame != nullptr && TheNGMPGame->isGameInProgress()) ? maxReconnectAttempts_Ingame : maxReconnectAttempts_Frontend; + int maxReconnectAttempts = isGOWebsocketInGame() ? maxReconnectAttempts_Ingame : maxReconnectAttempts_Frontend; if (m_numReconnectAttempts >= maxReconnectAttempts) { // fully disconnect @@ -583,7 +592,7 @@ void WebSocket::Tick() } else { - int timeBetweenReconnectAttempts = (TheNGMPGame != nullptr && TheNGMPGame->isGameInProgress()) ? timeBetweenReconnectAttempts_Ingame : timeBetweenReconnectAttempts_Frontend; + int timeBetweenReconnectAttempts = isGOWebsocketInGame() ? timeBetweenReconnectAttempts_Ingame : timeBetweenReconnectAttempts_Frontend; if (currTime - m_lastReconnectAttempt >= timeBetweenReconnectAttempts) { @@ -654,7 +663,7 @@ void WebSocket::Tick() // reconnecting? give up eventually if (m_bReconnecting) { - int maxReconnectAttempts = (TheNGMPGame != nullptr && TheNGMPGame->isGameInProgress()) ? maxReconnectAttempts_Ingame : maxReconnectAttempts_Frontend; + int maxReconnectAttempts = isGOWebsocketInGame() ? maxReconnectAttempts_Ingame : maxReconnectAttempts_Frontend; if (m_numReconnectAttempts >= maxReconnectAttempts || (m->data.result == CURLE_HTTP_RETURNED_ERROR && httpResponseCode == 205)) // 205 = need full teardown { @@ -1232,6 +1241,14 @@ void WebSocket::Tick() } } + // A pre-game observer is not a lobby member, so the roster above is + // empty and every line would land in the generic colour. The read-only + // lobby view knows the slots from its own fetch - ask it instead. + if (lobbySlot == -1 && LobbyObserverModeActive()) + { + lobbySlot = LobbyObserverSlotForUserID(chatData.user_id); + } + // no admin chat in lobby Color color = DetermineColorForChatMessage(EChatMessageType::CHAT_MESSAGE_TYPE_LOBBY, true, chatData.action, false, false, lobbySlot); @@ -1422,6 +1439,43 @@ void WebSocket::Tick() } break; + case EWebSocketMessageID::LOBBY_OBSERVER_LOBBY_CHANGED: + case EWebSocketMessageID::LOBBY_OBSERVER_GAME_STARTING: + case EWebSocketMessageID::LOBBY_OBSERVER_STREAM_LIVE: + case EWebSocketMessageID::LOBBY_OBSERVER_GAME_STARTED: + { + // Push to the read-only lobby observer screen. Four + // events, one payload shape: { msg_id, lobby_id }. + int64_t observerLobbyID = -1; + if (jsonObject.contains("lobby_id")) + { + jsonObject["lobby_id"].get_to(observerLobbyID); + } + + NGMP_OnlineServices_LobbyInterface* pObserverLobbyInterface = + NGMP_OnlineServicesManager::GetInterface(); + if (pObserverLobbyInterface != nullptr && pObserverLobbyInterface->m_callbackLobbyObserverEvent != nullptr) + { + NGMP_OnlineServices_LobbyInterface::ELobbyObserverEventType eventType = + NGMP_OnlineServices_LobbyInterface::ELobbyObserverEventType::LOBBY_CHANGED; + if (msgID == EWebSocketMessageID::LOBBY_OBSERVER_GAME_STARTING) + { + eventType = NGMP_OnlineServices_LobbyInterface::ELobbyObserverEventType::GAME_STARTING; + } + else if (msgID == EWebSocketMessageID::LOBBY_OBSERVER_STREAM_LIVE) + { + eventType = NGMP_OnlineServices_LobbyInterface::ELobbyObserverEventType::STREAM_LIVE; + } + else if (msgID == EWebSocketMessageID::LOBBY_OBSERVER_GAME_STARTED) + { + eventType = NGMP_OnlineServices_LobbyInterface::ELobbyObserverEventType::GAME_STARTED; + } + + pObserverLobbyInterface->m_callbackLobbyObserverEvent(eventType, observerLobbyID); + } + } + break; + default: NetworkLog(ELogVerbosity::LOG_RELEASE, "Unhandled WebSocketMessage: %d", (int)msgID); break; diff --git a/cmake/config-debug.cmake b/cmake/config-debug.cmake index 13eececeeb8..5e64521a83c 100644 --- a/cmake/config-debug.cmake +++ b/cmake/config-debug.cmake @@ -10,6 +10,11 @@ set_property(CACHE RTS_DEBUG_STACKTRACE PROPERTY STRINGS DEFAULT ON OFF) set(RTS_DEBUG_PROFILE "DEFAULT" CACHE STRING "Enables debug profiling. When DEFAULT, this option is enabled with DEBUG or INTERNAL") set_property(CACHE RTS_DEBUG_PROFILE PROPERTY STRINGS DEFAULT ON OFF) +# Live observer/streamer file logging. Separate from RTS_DEBUG_LOGGING because it is far +# noisier and flushed per line, so it survives a crash. +set(RTS_DEBUG_LIVE_OBSERVER "DEFAULT" CACHE STRING "Enables live observer/streamer debug logging. When DEFAULT, this option is enabled with DEBUG or INTERNAL") +set_property(CACHE RTS_DEBUG_LIVE_OBSERVER PROPERTY STRINGS DEFAULT ON OFF) + option(RTS_DEBUG_CHEATS "Enables debug cheats in release builds" OFF) option(RTS_DEBUG_INCLUDE_DEBUG_LOG_IN_CRC_LOG "Includes normal debug log in crc log" OFF) option(RTS_DEBUG_MULTI_INSTANCE "Enables multi client instance support" OFF) @@ -35,6 +40,7 @@ define_debug_option(RTS_DEBUG_LOGGING DEBUG_LOGGING DISABLE_DEBUG_LOGGING define_debug_option(RTS_DEBUG_CRASHING DEBUG_CRASHING DISABLE_DEBUG_CRASHING DebugCrashing "Build with Debug Crashing") define_debug_option(RTS_DEBUG_STACKTRACE DEBUG_STACKTRACE DISABLE_DEBUG_STACKTRACE DebugStacktrace "Build with Debug Stacktracing") define_debug_option(RTS_DEBUG_PROFILE DEBUG_PROFILE DISABLE_DEBUG_PROFILE DebugProfile "Build with Debug Profiling") +define_debug_option(RTS_DEBUG_LIVE_OBSERVER LIVE_OBSERVER_LOGGING DISABLE_LIVE_OBSERVER_LOGGING LiveObserverLogging "Build with Live Observer Logging") add_feature_info(DebugCheats RTS_DEBUG_CHEATS "Build with Debug Cheats in release builds") add_feature_info(DebugIncludeDebugLogInCrcLog RTS_DEBUG_INCLUDE_DEBUG_LOG_IN_CRC_LOG "Build with Debug Logging in CRC log") From 9a433f214dc729a402a2db46cbf52398ecc752d9 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Mon, 17 Aug 2026 22:27:51 +0200 Subject: [PATCH 2/2] feat(live): Load the observer's map at header time on a real load screen An observer could not begin loading until the streamer's match had produced its first replay record, which only happens after the streamer finished its own map load - roughly four seconds that had to be made up by fast-forwarding. The stream header is queued one logic frame before the streamer's map load and already carries everything needed, so start there instead. - RecorderClass::playbackFile skips the cursor-seeding read for a live start. Its result was discarded by startLiveObserverPlayback anyway, and with no body on disk it failed and closed the file. - LiveObserver::isPlaybackReady drops the "at least one body record" condition, which existed only to satisfy that read. Delay coverage still gates and is trivially true at delay 0. - updatePlaybackGate holds whenever getLiveEdge() == 0, regardless of the pre-roll warmup exemption, so a frame is never simulated before its records have arrived when the observer wins the load race. - tryStartNewGame keeps the load screen up until the stream produces frame 1, on the same principle as the network game's isProgressComplete() wait. A replay has neither that wait nor the 2 s minimum display time, so the observer used to finish first and sit on a black screen for ~500 ms. - GAME_REPLAY uses MultiPlayerLoadScreen for a live observer - map preview, players, factions, colours, start positions - and keeps ShellGameLoadScreen for an ordinary replay. Not GameSpyLoadScreen: its update() drives TheNetwork, which is null during playback. Load screen and lobby correctness, found while testing the above: - isSlotLocalAlly now recognises a live observer. It resolved against TheGameInfo's local slot, which for a replayed stream is the streamer's, so everything outside the streamer's own team was masked to its pre-roll value and a random start position read back as -1. This also removes the per-call-site workaround in MultiPlayerLoadScreen::init. - updateMapStartSpots' load-screen branch applies the guards its sibling branch always had. Indexing the button array with -1 was not a harmless out-of-bounds read: m_buttonMapStartPosition is preceded by m_mapPreview, so [-1] yielded a live window and the player number was stamped onto the map preview. Observer slots are skipped too - populateRandomStartPosition parks them on an already-taken spot for their camera, which is not a claim on the map. - The observer lobby no longer predicts the random roll. It revealed factions and start positions before the players who were about to play them, and the pre-game lobby has no broadcast delay to soften that. GameLogic:: rollRandomSlots existed only for the prediction and is removed. Co-Authored-By: Claude Opus 5 --- .../Source/GameClient/GUI/LoadScreen.cpp | 45 ++++--- .../Source/GameNetwork/GameInfo.cpp | 14 +++ .../GameEngine/Include/GameLogic/GameLogic.h | 4 - .../GameEngine/Source/Common/LiveObserver.cpp | 47 +++++--- .../GameEngine/Source/Common/Recorder.cpp | 29 +++-- .../GUICallbacks/Menus/LobbyObserverMenu.cpp | 114 +++--------------- .../Menus/SkirmishGameOptionsMenu.cpp | 21 +++- .../GUICallbacks/Menus/WOLGameSetupMenu.cpp | 8 -- .../GameEngine/Source/GameClient/InGameUI.cpp | 11 +- .../Source/GameLogic/System/GameLogic.cpp | 78 +++++++++--- 10 files changed, 205 insertions(+), 166 deletions(-) diff --git a/Core/GameEngine/Source/GameClient/GUI/LoadScreen.cpp b/Core/GameEngine/Source/GameClient/GUI/LoadScreen.cpp index d74fd687c7a..225c85dfefe 100644 --- a/Core/GameEngine/Source/GameClient/GUI/LoadScreen.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/LoadScreen.cpp @@ -1269,7 +1269,10 @@ void MultiPlayerLoadScreen::init( GameInfo *game ) m_mapPreview = TheWindowManager->winGetWindowFromId( m_loadScreen,TheNameKeyGenerator->nameToKey( "MultiplayerLoadScreen.wnd:WinMapPreview")); GameSlot *lSlot = game->getSlot(game->getLocalSlotNum()); const PlayerTemplate* pt; - if (lSlot->getPlayerTemplate() >= 0) + // A live observer is not in the slot list, so getLocalSlotNum() can legitimately find nobody and + // hand back -1, which getSlot() answers with NULL. FactionObserver is the right general to show + // for exactly that case, and it is already the fallback below. + if (lSlot && lSlot->getPlayerTemplate() >= 0) pt = ThePlayerTemplateStore->getNthPlayerTemplate(lSlot->getPlayerTemplate()); else pt = ThePlayerTemplateStore->findPlayerTemplate( TheNameKeyGenerator->nameToKey("FactionObserver") ); @@ -1391,19 +1394,10 @@ void MultiPlayerLoadScreen::init( GameInfo *game ) const PlayerTemplate* pt = ThePlayerTemplateStore->getNthPlayerTemplate(slot->getPlayerTemplate()); GadgetStaticTextSetText(m_playerSide[netSlot], pt ? pt->getDisplayName() : slot->getApparentPlayerTemplateDisplayName()); #else -#if defined(GENERALS_ONLINE) - // A live observer is nobody's ally, so the apparent-name masking would print "Random" - // for every random-picked side on the board. They watch the whole game anyway. - if (TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER) - { - const PlayerTemplate* pt = ThePlayerTemplateStore->getNthPlayerTemplate(slot->getPlayerTemplate()); - GadgetStaticTextSetText(m_playerSide[netSlot], pt ? pt->getDisplayName() : slot->getApparentPlayerTemplateDisplayName()); - } - else -#endif - { - GadgetStaticTextSetText(m_playerSide[netSlot], slot->getApparentPlayerTemplateDisplayName()); - } + // A live observer needs no special case here: isSlotLocalAlly() now recognises one, so the + // apparent-* accessors already hand back the real side, colour and start position instead of + // masking the whole board to "Random". + GadgetStaticTextSetText(m_playerSide[netSlot], slot->getApparentPlayerTemplateDisplayName()); #endif m_playerSide[netSlot]->winSetEnabledTextColors(houseColor, m_playerSide[netSlot]->winGetEnabledTextBorderColor()); @@ -1475,10 +1469,29 @@ void MultiPlayerLoadScreen::update( Int percent ) TheNetwork->updateLoadProgress( percent ); TheNetwork->liteupdate(); } - else + else if (percent <= 100) { - if (percent <= 100) +#if defined(GENERALS_ONLINE) + // A live observer loads alone: there is no network to carry anyone else's progress, and it + // is not in the slot list to have a bar of its own, so every bar would sit at zero for the + // whole load. Our own percentage is the only figure there is, so show it for each player - + // the bars then read as "the load is this far along", which is what they are here for. + const Bool liveObserver = (TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER); +#else + const Bool liveObserver = FALSE; +#endif + if (liveObserver) + { + for (Int slot = 0; slot < MAX_SLOTS; ++slot) + { + if (m_playerLookup[slot] != -1) + TheGameLogic->processProgress( slot, percent ); + } + } + else + { TheGameLogic->processProgress( TheGameInfo->getLocalSlotNum(), percent ); + } } //GadgetProgressBarSetProgress(m_progressBars[TheNetwork->getLocalPlayerID()], percent ); diff --git a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp index 731953f831e..fcb5b22b9ce 100644 --- a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp @@ -36,6 +36,7 @@ #include "GameClient/MapUtil.h" #include "Common/MultiplayerSettings.h" #include "Common/PlayerTemplate.h" +#include "Common/Recorder.h" #include "Common/Xfer.h" #include "GameNetwork/FileTransfer.h" #include "GameNetwork/GameInfo.h" @@ -101,6 +102,19 @@ static Int getSlotIndex(const GameSlot *slot) static Bool isSlotLocalAlly(const GameSlot *slot) { +#if defined(GENERALS_ONLINE) + // A live observer is not in the slot list at all, so every check below resolves against + // TheGameInfo's local slot - which for a replayed stream is the *streamer's*. That masks + // everything outside the streamer's own team, and masks it to the pre-roll value: a random side + // reads "Random", and a random start position reads m_origStartPos, which is -1. Callers treat + // that -1 as a real position. + // + // The rule below already says an observer sees all; it simply cannot recognise one that holds no + // slot. Say so here, once, rather than at each getApparent* call site. + if (TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER) + return TRUE; +#endif + Int slotIndex = getSlotIndex(slot); Int localIndex = TheGameInfo->getLocalSlotNum(); const GameSlot *localSlot = TheGameInfo->getConstSlot(localIndex); diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h index 0813eeb188c..07be69d30dd 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h @@ -173,10 +173,6 @@ class GameLogic : public SubsystemInterface, public Snapshot void startNewGame(Bool loadSaveGame); void loadMapINI(AsciiString mapName); - /// Resolve all random slots the way a game start would, without starting one, so the observer - /// lobby preview can show what the players will get. - void rollRandomSlots(GameInfo* game); - void updateLoadProgress( Int progress ); void deleteLoadScreen(); diff --git a/GeneralsMD/Code/GameEngine/Source/Common/LiveObserver.cpp b/GeneralsMD/Code/GameEngine/Source/Common/LiveObserver.cpp index af9a3306d3d..c46ec724ca0 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/LiveObserver.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/LiveObserver.cpp @@ -634,16 +634,25 @@ void LiveObserver::advanceParseCursor(Int chunkOffset, const unsigned char* data void LiveObserver::updatePlaybackGate(UnsignedInt curFrame) { - // Never hold the game before it has started, and not for the first few ticks after either. - // The map load and object creation run inside GameLogic::update(), which the pause stops from - // being called at all - so pausing this early means the game never starts while TheGameClient - // keeps updating above the halt. Likewise the scene is not composed until logic has run for a - // few ticks, so a hold at frame 1 renders nothing: not the map, and not the buffering - // countdown meant to explain the wait. Frames barely advance in this window, so nothing is - // lost by waiting it out. + // Never hold the game before it has started. The map load and object creation run inside + // GameLogic::update(), which the pause stops from being called at all - so pausing this early + // means the game never starts while TheGameClient keeps updating above the halt. + // + // The warmup exemption below is separate, and conditional: the scene is not composed until logic + // has run for a few ticks, so a hold at frame 1 renders nothing - not the map, and not the + // buffering countdown meant to explain the wait. Skipping the gate for those ticks is worth it + // only while there is data to play. Since playback now starts on the header alone, an observer + // that loads faster than the streamer arrives here with getLiveEdge() == 0, and exempting it + // would simulate up to LIVE_PREROLL_WARMUP_FRAMES frames whose records do not exist yet. That + // breaks the one invariant this class exists to keep, and it breaks it silently: the same shape + // of bug as the pre-start gate fix, where consuming the stream's opening records early produced + // a false DESYNC and real divergence. So an empty edge holds regardless of warmup, and pays for + // it with a brief unrendered window in the one case where the observer wins the load race. + const Bool nothingToPlay = (getLiveEdge() == 0); + if (TheGameLogic == nullptr || !TheGameLogic->isInGame() || TheGameLogic->isInShellGame() || TheGameLogic->isStartingNewGame() - || curFrame < (UnsignedInt)LIVE_PREROLL_WARMUP_FRAMES) + || (curFrame < (UnsignedInt)LIVE_PREROLL_WARMUP_FRAMES && !nothingToPlay)) { if (TheGameLogic != nullptr && m_autoPaused && TheGameLogic->isGamePaused()) { @@ -981,13 +990,21 @@ Bool LiveObserver::isPlaybackReady() const if (m_streamEnded.load()) return true; - // Playback may only start once the file is safe to read: the header plus at least the - // first body record, and enough complete records to cover the whole broadcast delay. - // The delay boundary proves the buffer is built because records arrive in order - and - // it also guarantees the first record is present, which the Recorder's seeding read - // depends on. A zero delay still needs that first record, hence the offset check. - if (m_safeReadOffset.load() <= m_bodyStartOffset) - return false; + // The header alone is enough to start, and starting there is the point: the header is queued a + // logic frame before the streamer loads its own map, so an observer that begins here loads + // alongside the players instead of after them - about four seconds that used to be made up by + // fast-forwarding. + // + // There is deliberately no "at least one body record" condition. It existed only to satisfy the + // Recorder's seeding read in playbackFile(), which a live start now skips outright, and it is + // exactly what forced the observer to wait for the streamer's load to finish. + // + // What remains is the delay-coverage check, which is the real gate: a client-side delay may not + // be undercut by starting early. It is trivially true at delay 0 (a server-held session, where + // the relay's data edge *is* the delay), so it costs the fast path nothing. + // + // Playback starting before any record has arrived is safe because updatePlaybackGate() holds the + // simulation while getLiveEdge() is 0 - a frame is still never simulated ahead of its records. return getMaxCompleteFrame() >= getEffectiveDelaySeconds() * LOGICFRAMES_PER_SECOND; } diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp index 044d179869e..461c15269e3 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp @@ -1163,9 +1163,10 @@ Bool RecorderClass::startLiveObserverPlayback(AsciiString filename) m_mode = RECORDERMODETYPE_LIVE_OBSERVER; m_nextFrame = 0; - // playbackFile()'s seeding read left the cursor on the first record's type field. The live - // loop reads the frame itself before appending, so the cursor has to go back to the frame - // field or every record from the first one on is misparsed. + // playbackFile() leaves the cursor wherever reading the header ended, which is the start of the + // body only as long as the header is the whole file. Seek explicitly: the live loop reads each + // record's frame field itself before appending, so a cursor even one field off misparses every + // record from the first one on. if (m_file != nullptr && TheLiveObserver != nullptr) m_file->seek(TheLiveObserver->getBodyStartOffset(), File::START); @@ -1417,11 +1418,15 @@ Bool RecorderClass::playbackFile(AsciiString filename) Int maxFPS = 0; m_file->read(&maxFPS, sizeof(maxFPS)); + // This call is opening the file for a live-observer session rather than replaying one from + // disk. Latched once here because two decisions below turn on it. + const Bool liveObserverStart = (TheLiveObserver != nullptr && !TheLiveObserver->hasPlaybackStarted()); + Bool isMultiplayer = (m_originalGameMode == GAME_INTERNET || m_originalGameMode == GAME_LAN); // The network-game skip of the first received CRC exists because that value "doesn't make // it through the network". An observer generates its CRCs locally, so nothing is lost and // skipping one skews every later comparison by a full interval. - if (TheLiveObserver != nullptr && !TheLiveObserver->hasPlaybackStarted()) + if (liveObserverStart) isMultiplayer = FALSE; m_crcInfo = CRCInfo(header.localPlayerIndex, isMultiplayer); DEBUG_LOG(("Player index is %d, replay CRC interval is %d, isMultiplayer is %d", m_crcInfo.getLocalPlayer(), REPLAY_CRC_INTERVAL, isMultiplayer)); @@ -1433,11 +1438,19 @@ Bool RecorderClass::playbackFile(AsciiString filename) // Otherwise a crc message remains and messes up the crc calculation on the restarted replay. TheCommandList->reset(); - readNextFrame(); - // readNextFrame() closes m_file via stopPlayback() if the first frame cannot be read. - if(m_file == nullptr) + // A live-observer start deliberately does not seed the cursor. It begins on the header alone - + // before the streamer's own map load has produced a single body record - so there is nothing to + // read yet, and the seeding read would fail and close the file. Nothing is lost: the result is + // discarded regardless, because startLiveObserverPlayback() seeks back to getBodyStartOffset() + // and resets m_nextFrame to 0. + if (!liveObserverStart) { - return FALSE; + readNextFrame(); + // readNextFrame() closes m_file via stopPlayback() if the first frame cannot be read. + if(m_file == nullptr) + { + return FALSE; + } } TheWritableGlobalData->m_pendingFile = m_gameInfo.getMap(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LobbyObserverMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LobbyObserverMenu.cpp index a160656bfbc..f17ea87e130 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LobbyObserverMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LobbyObserverMenu.cpp @@ -54,8 +54,7 @@ #include "GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h" #include "GameNetwork/GeneralsOnline/json.hpp" #include "GameNetwork/GUIUtil.h" // GetTeamUiColor -#include "GameNetwork/GameInfo.h" // GameInfo / GameSlot / SlotState (roll prediction) -#include "GameLogic/GameLogic.h" // TheGameLogic->rollRandomSlots (roll prediction) +#include "GameNetwork/GameInfo.h" // PLAYERTEMPLATE_RANDOM / PLAYERTEMPLATE_MIN #include #include @@ -168,9 +167,6 @@ static Int s_slotSides[MAX_OBSERVER_SLOTS]; // player template ids, -1 = none static Int s_slotColors[MAX_OBSERVER_SLOTS]; // color ids, -1 = none static Int s_slotTeams[MAX_OBSERVER_SLOTS]; // team numbers, -1 = none static Int s_slotStartPos[MAX_OBSERVER_SLOTS]; // start positions, -1 = none -static Int s_rolledSides[MAX_OBSERVER_SLOTS]; // predicted template ids, -1 = none -static Int s_rolledStartPos[MAX_OBSERVER_SLOTS]; // predicted start positions, -1 = none -static Bool s_rollPredicted = FALSE; // the last fetch could compute a roll prediction static Int s_startingCash = -1; static Bool s_trackStats = FALSE; static Bool s_vanillaTeams = FALSE; @@ -557,10 +553,15 @@ static void renderLobby(void) if (s_mapWindow != nullptr && !s_mapPathLocal.isEmpty()) positionStartSpots(s_mapPathLocal, s_startButtons, s_mapWindow); - // Paint the start buttons the way the real lobby's updateMapStartSpots does: each occupied - // slot shows its player number (1..8) at its resolved start position, in the player's team - // color. With a roll prediction the position comes from it (that is what the load screen will - // show); without one, fall back to the slot index and leave random slots blank. + // Paint the start buttons the way the real lobby's updateMapStartSpots does on its non-load-screen + // branch: each occupied slot with an *assigned* position shows its player number (1..8) there, in + // the player's team color. A slot set to random shows nothing. + // + // Deliberately only what the lobby itself has decided. This screen used to resolve the random + // roll here and show the outcome, which handed observers the factions and start positions before + // the players who are about to play them - and before the broadcast delay means anything, since + // the pre-game lobby has no delay. The roll belongs on the load screen, where the players see it + // too. for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) { if (s_startButtons[i] == nullptr) @@ -569,18 +570,12 @@ static void renderLobby(void) if (!s_slotOccupied[i]) continue; - Int posIdx = i; - if (s_rollPredicted) - { - if (s_rolledStartPos[i] < 0) - continue; - posIdx = s_rolledStartPos[i]; - } - else if (s_slotSides[i] < 0) - { + // An observer in the lobby holds no start position, whatever the JSON carries for it - + // same rule the load screen applies. + if (s_slotSides[i] <= PLAYERTEMPLATE_MIN) continue; - } + const Int posIdx = s_slotStartPos[i]; if (posIdx < 0 || posIdx >= MAX_OBSERVER_SLOTS || s_startButtons[posIdx] == nullptr) continue; @@ -618,10 +613,9 @@ static void renderLobby(void) continue; } - // A random side shows the predicted roll (what the load screen will display); - // concrete sides show themselves. - const Int displaySide = (s_slotSides[i] < 0 && s_rollPredicted) ? s_rolledSides[i] : s_slotSides[i]; - populateTemplateComboReadOnly(s_templateCombos[i], displaySide); + // Whatever the lobby says, and nothing more: a slot set to random reads "Random" here, exactly + // as it does for the players sitting in it. What it rolls into is load-screen news. + populateTemplateComboReadOnly(s_templateCombos[i], s_slotSides[i]); populateColorComboReadOnly(s_colorCombos[i], s_slotColors[i]); populateTeamComboReadOnly(s_teamCombos[i], s_slotTeams[i]); } @@ -681,73 +675,6 @@ static void renderLobby(void) } } -// ============================================================================ -// Roll prediction -// ============================================================================ - -// The game start resolves random slots deterministically from the lobby's RNG seed plus the -// member list. The stream header cannot help: it arrives only once the stream is live, and even -// then carries the pre-roll slot list. So replay the same resolution from the lobby JSON, which -// gives exactly what the players will see on the load screen. -static void predictLobbyRoll(const nlohmann::json& response) -{ - s_rollPredicted = FALSE; - for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) - { - s_rolledSides[i] = -1; - s_rolledStartPos[i] = -1; - } - - if (TheGameLogic == nullptr || s_mapPathLocal.isEmpty()) - return; - - // The position part needs the map's waypoints, exactly like the game start. - AsciiString lowerMap = s_mapPathLocal; - lowerMap.toLower(); - if (TheMapCache->findMap(lowerMap) == nullptr) - return; - - // The seed the game start will re-seed from (the lobby's RNGSeed). - int seed = 0; - if (!jsonGetIntFlexible(response, "RNGSeed", "rngSeed", seed)) - return; - - GameInfo scratch; - GameSlot scratchSlots[MAX_SLOTS]; - for (Int i = 0; i < MAX_SLOTS; ++i) - scratch.setSlotPointer(i, &scratchSlots[i]); - scratch.setSeed(seed); - scratch.setMap(lowerMap); - - for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) - { - if (!s_slotOccupied[i]) - continue; - - GameSlot slot; - slot.setState((SlotState)s_slotStates[i], s_slotNames[i], i); - slot.setPlayerTemplate(s_slotSides[i] < 0 ? PLAYERTEMPLATE_RANDOM : s_slotSides[i]); - slot.setColor(s_slotColors[i]); - slot.setTeamNumber(s_slotTeams[i]); - slot.setStartPos(s_slotStartPos[i]); - scratch.setSlot(i, slot); - } - - TheGameLogic->rollRandomSlots(&scratch); - - for (Int i = 0; i < MAX_OBSERVER_SLOTS; ++i) - { - if (!s_slotOccupied[i]) - continue; - const GameSlot* slot = scratch.getConstSlot(i); - if (slot == nullptr) - continue; - s_rolledSides[i] = slot->getPlayerTemplate(); - s_rolledStartPos[i] = slot->getStartPos(); - } - s_rollPredicted = TRUE; -} - static void applyLobbyFetch(Bool success, Int statusCode, const AsciiString& body) { if (statusCode == 404) @@ -957,10 +884,6 @@ static void applyLobbyFetch(Bool success, Int statusCode, const AsciiString& bod } } - // The assignment the game start will produce (factions + start positions), computed - // from the same seed and member list the game will use. - predictLobbyRoll(response); - renderLobby(); } catch (const nlohmann::json::exception&) @@ -1307,7 +1230,6 @@ void LobbyObserverShutdown(WindowLayout* layout, void* userData) s_firstLobbyFetchDone = FALSE; s_delayRemaining = 0; s_delayHoldShown = FALSE; - s_rollPredicted = FALSE; s_refetchRequested.store(false); s_gameStartSignal.store(false); s_streamLiveSignal.store(false); @@ -1340,8 +1262,6 @@ void LobbyObserverShutdown(WindowLayout* layout, void* userData) s_slotStates[i] = -1; s_slotStartPos[i] = -1; s_slotUserIds[i] = -1; - s_rolledSides[i] = -1; - s_rolledStartPos[i] = -1; } // Complete the shell's pop/shutdown. The stock setup menu finishes this from its own update diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/SkirmishGameOptionsMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/SkirmishGameOptionsMenu.cpp index d071904490d..3562bdebf3e 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/SkirmishGameOptionsMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/SkirmishGameOptionsMenu.cpp @@ -822,7 +822,26 @@ void updateMapStartSpots( GameInfo *myGame, GameWindow *buttonMapStartPositions[ GameSlot *gs =myGame->getSlot(i); if(onLoadScreen) { - Int startPos = gs->getApparentStartPos(); + // Mirror the guards the non-load-screen branch below has always applied. Without them: + // + // - An empty slot, or one whose position is unresolved, reports -1, and indexing the + // button array with that is no harmless out-of-bounds read. m_buttonMapStartPosition + // is preceded by m_mapPreview in both load screens, so [-1] yields a live, non-NULL + // window that the NULL check below waves through - stamping the player number onto + // the map preview instead of onto a start spot. + // + // - An observer slot gets painted at a position it does not hold. Observers are given a + // start spot by populateRandomStartPosition(), deliberately one that is already taken, + // because that is where their camera opens - so drawing it puts the observer's number + // on some player's real spot. setPlayerTemplate() forcing m_startPos back to -1 for an + // observer is upstream saying the same thing: an observer holds no position. + if (!gs || !gs->isOccupied() || gs->getPlayerTemplate() <= PLAYERTEMPLATE_MIN) + continue; + + const Int startPos = gs->getApparentStartPos(); + if (startPos < 0 || startPos >= MAX_SLOTS || startPos >= mmd.m_numPlayers) + continue; + GameWindow* btn = buttonMapStartPositions[startPos]; if (!btn) continue; 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 8951c0a4a86..8e4dc25af74 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp @@ -236,8 +236,6 @@ static GameWindow *comboBoxStartingCash = NULL; static GameWindow *checkBoxLimitArmies = NULL; #if defined(GENERALS_ONLINE) -// The live-stream controls (Enable Stream checkbox, Delay field, "N observers waiting" label) -// were code-created widgets; they are commands now: /stream, /delay, /observers. /// Validate and store a broadcast delay. Rejects rather than clamps: silently turning "6000" /// into 600 would leave the streamer believing they had a 100-minute buffer. The server copy @@ -1599,12 +1597,6 @@ void InitWOLGameGadgets() checkBoxLimitArmies = TheWindowManager->winGetWindowFromId( parentWOLGameSetup, checkBoxLimitArmiesID ); DEBUG_ASSERTCRASH(windowMap, ("Could not find the GameSpyGameOptionsMenu.wnd:CheckBoxLimitArmies" )); -#if defined(GENERALS_ONLINE) - // The live-stream controls used to be code-created widgets here (Enable Stream checkbox, - // Delay field, "N observers waiting" label). They are slash commands now: /stream, /delay, - // /observers - see handleGameSetupSlashCommands. -#endif - // Limit Armies can ONLY be set in the Host Game window (PopupHostGame.wnd) checkBoxLimitArmies->winEnable( false ); // Ditto use stats diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 92042e6bf16..7bcff9cd2ca 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -7547,6 +7547,14 @@ void InGameUI::drawLiveStatus() AsciiString label; UnsignedInt colour = 0; + // Nothing has come off the wire yet. Since playback now starts on the header alone, this is + // no longer only a pre-playback state: an observer that loads the map faster than the + // streamer sits here briefly with playback running and an empty live edge. The gate holds + // through it (see LiveObserver::updatePlaybackGate), so it is a wait, not a stall, and must + // not be reported as one. Excludes an ended stream, which has its own label. + const Bool nothingReceivedYet = + (TheLiveObserver->getLiveEdge() == 0 && !TheLiveObserver->isStreamEnded()); + if (TheLiveObserver->isDesynced()) { // Not gated: a diverged simulation is a fact about this client, not the match, and @@ -7561,7 +7569,8 @@ void InGameUI::drawLiveStatus() label = "LIVE - CONNECTING..."; colour = 0xFFFFFF00; // yellow } - else if (!TheLiveObserver->hasPlaybackStarted() && !LobbyObserverModeActive()) + else if ((!TheLiveObserver->hasPlaybackStarted() || nothingReceivedYet) + && !LobbyObserverModeActive()) { // Countdown while the join waits in the shell for header + enough body to cover the // delay. Observer-local, so shown regardless of F6. Must be tested before the hold diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp index 3271d522254..2e5302a2d83 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp @@ -41,6 +41,7 @@ #include "Common/GameUtility.h" #include "Common/INI.h" #include "Common/LatchRestore.h" +#include "Common/LiveObserver.h" #include "Common/MapObject.h" #include "Common/MultiplayerSettings.h" #include "Common/OSDisplay.h" @@ -728,6 +729,19 @@ LoadScreen* GameLogic::getLoadScreen(Bool loadingSaveGame) return NEW MultiPlayerLoadScreen; break; case GAME_REPLAY: +#if defined(GENERALS_ONLINE) + // A live observer joins as the match starts and loads alongside the players, so this load + // window is several seconds of real waiting rather than the instant of opening a saved file. + // Show what is being waited for - map, players, factions, colours, start positions - all of + // which the stream's header already carries in TheGameInfo. + // + // MultiPlayerLoadScreen, not GameSpyLoadScreen: the latter's update() drives + // TheNetwork->updateLoadProgress/liteupdate, and there is no network during playback. + // MultiPlayerLoadScreen already has the null-network branch that skirmish uses. + if (TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER) + return NEW MultiPlayerLoadScreen; +#endif + // An ordinary replay opens a finished file and needs none of that. return NEW ShellGameLoadScreen; break; case GAME_INTERNET: @@ -1178,22 +1192,6 @@ static void populateRandomStartPosition(GameInfo* game) } } -// ------------------------------------------------------------------------------------------------ -/** Resolve all random slots the way a game start would, without starting a game. Mirrors the - * sequence in tryStartNewGame() so the result is what the game will actually roll, which is what - * lets the observer lobby preview show the players' real assignment before the match begins. */ -// ------------------------------------------------------------------------------------------------ -void GameLogic::rollRandomSlots(GameInfo* game) -{ - if (!game) - return; - - checkForDuplicateColors(game); - InitRandom(game->getSeed()); - populateRandomSideAndColor(game); - populateRandomStartPosition(game); -} - // ------------------------------------------------------------------------------------------------ /** Update the load screen progress */ // ------------------------------------------------------------------------------------------------ @@ -2412,6 +2410,54 @@ void GameLogic::tryStartNewGame( Bool loadingSaveGame ) Sleep(100); } +#if defined(GENERALS_ONLINE) + // A live observer's load begins on the stream's HEADER, which the streamer queues one logic frame + // before loading its own map - so the observer can finish loading before the match has produced + // frame 1. Handing the screen over at that point shows a black one for as long as the difference + // lasts (~500 ms in practice): the buffering gate correctly refuses to simulate frames whose + // records have not arrived, but a game held at frame 0 has composed no scene, so there is + // nothing to draw over. + // + // So finish the load only once there is a game to load into - the same principle as the network + // game's isProgressComplete() wait above, which is why this sits beside it rather than in the + // gate. The load screen stays up and drawn with its bars full, and the first frame the observer + // is shown is a real one. getLiveEdge() advances on LiveObserver's own network thread, so + // blocking the main loop here does not starve the very data being waited for. + // + // This is cosmetic - correctness is the gate's, and it holds regardless of what happens here - + // hence no wait at all without a load screen to hold up, and a cap rather than a promise. + if (m_loadScreen && TheLiveObserver + && TheRecorder && TheRecorder->getMode() == RECORDERMODETYPE_LIVE_OBSERVER) + { + // Past the cap the gate takes over and the worst case is the black screen this loop exists + // to avoid - never a client wedged on a load screen by a stream that went quiet. + const UnsignedInt observerWaitStartMs = timeGetTime(); + const UnsignedInt observerWaitDeadlineMs = observerWaitStartMs + 60000; + // TheLiveObserver is re-tested every pass, not hoisted: updateLoadProgress() pumps the + // Windows message queue and the window manager, so the session can in principle be torn + // down from under this loop. + while (TheLiveObserver != nullptr + && TheLiveObserver->getLiveEdge() == 0 + && !TheLiveObserver->isStreamEnded() + && timeGetTime() < observerWaitDeadlineMs) + { + // >100 so the bars hold at full instead of restarting. Polled far finer than the loop + // above: this wait is normally a few hundred milliseconds, and every 100 ms spent past + // the first record's arrival is 100 ms of lead handed away for nothing. + updateLoadProgress(101); + Sleep(10); + } + + if (TheLiveObserver != nullptr) + { + liveObserverLog("tryStartNewGame: load complete, live edge %u after waiting %ums%s\n", + TheLiveObserver->getLiveEdge(), + timeGetTime() - observerWaitStartMs, + TheLiveObserver->isStreamEnded() ? " (stream ended)" : ""); + } + } +#endif + // if we're in a load game, don't fade yet if (loadingSaveGame == FALSE && TheTransitionHandler != NULL && m_loadScreen) {