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..225c85dfefe 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"
@@ -1268,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") );
@@ -1390,6 +1394,9 @@ void MultiPlayerLoadScreen::init( GameInfo *game )
const PlayerTemplate* pt = ThePlayerTemplateStore->getNthPlayerTemplate(slot->getPlayerTemplate());
GadgetStaticTextSetText(m_playerSide[netSlot], pt ? pt->getDisplayName() : slot->getApparentPlayerTemplateDisplayName());
#else
+ // 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
@@ -1462,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/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/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/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..07be69d30dd 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;
@@ -219,6 +220,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..c46ec724ca0
--- /dev/null
+++ b/GeneralsMD/Code/GameEngine/Source/Common/LiveObserver.cpp
@@ -0,0 +1,2218 @@
+/*
+** 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. 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 && !nothingToPlay))
+ {
+ 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;
+
+ // 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;
+}
+
+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..461c15269e3 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,38 @@ 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() 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);
+
+ // 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 +1228,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;
@@ -1195,7 +1418,16 @@ 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 (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));
@@ -1206,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();
@@ -1302,18 +1542,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 +1680,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 +1869,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..f17ea87e130
--- /dev/null
+++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LobbyObserverMenu.cpp
@@ -0,0 +1,1338 @@
+/*
+** 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" // PLAYERTEMPLATE_RANDOM / PLAYERTEMPLATE_MIN
+
+#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_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 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)
+ continue;
+ GadgetButtonSetText(s_startButtons[i], UnicodeString::TheEmptyString);
+ if (!s_slotOccupied[i])
+ continue;
+
+ // 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;
+
+ 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;
+ }
+
+ // 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]);
+ }
+
+ 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));
+ }
+}
+
+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;
+ }
+ }
+
+ 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_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;
+ }
+
+ // 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/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 3861888802f..8e4dc25af74 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,53 @@ static GameWindow *checkBoxLimitSuperweapons = NULL;
static GameWindow *comboBoxStartingCash = NULL;
static GameWindow *checkBoxLimitArmies = NULL;
+#if defined(GENERALS_ONLINE)
+
+/// 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 +1413,7 @@ void WOLDisplayGameOptions()
}
DEBUG_ASSERTCRASH( index < itemCount, ("Could not find new starting cash amount %d in list", theGame->getStartingCash().countMoney() ) );
+
}
@@ -1734,7 +1790,7 @@ void DeinitWOLGameGadgets()
windowMap->winSetUserData(NULL);
windowMap = NULL;
}
- checkBoxUseStats = NULL;
+ checkBoxUseStats = NULL;
checkBoxLimitSuperweapons = NULL;
comboBoxStartingCash = NULL;
@@ -1762,6 +1818,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 +1984,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 +2354,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 +2432,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 +2814,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 +3515,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 +3619,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 +3735,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 +4069,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..7bcff9cd2ca 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,141 @@ 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;
+
+ // 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
+ // 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() || 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
+ // 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..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:
@@ -1431,6 +1445,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 +1620,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 +1840,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 +2185,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.
@@ -2371,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)
{
@@ -2780,7 +2867,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 +4086,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")