diff --git a/Core/GameEngine/Include/Common/CRCDebug.h b/Core/GameEngine/Include/Common/CRCDebug.h index b4bda92b2c6..0a4511b33de 100644 --- a/Core/GameEngine/Include/Common/CRCDebug.h +++ b/Core/GameEngine/Include/Common/CRCDebug.h @@ -126,3 +126,4 @@ extern Int NET_CRC_INTERVAL; extern Int REPLAY_CRC_INTERVAL; extern Bool TheDebugIgnoreSyncErrors; +extern Bool TheDebugIgnoreReplaySyncErrors; diff --git a/Core/GameEngine/Source/Common/CommandLine.cpp b/Core/GameEngine/Source/Common/CommandLine.cpp index 0daeb20e9ff..67d39f3656d 100644 --- a/Core/GameEngine/Source/Common/CommandLine.cpp +++ b/Core/GameEngine/Source/Common/CommandLine.cpp @@ -41,6 +41,7 @@ Bool TheDebugIgnoreSyncErrors = FALSE; +Bool TheDebugIgnoreReplaySyncErrors = FALSE; extern Int DX8Wrapper_PreserveFPU; #ifdef DEBUG_CRC @@ -751,6 +752,37 @@ Int parseLoadSave(char *args[], int num) return 1; } +// TheSuperHackers @feature bobtista 08/08/2026 Play a replay visually from the command line. +Int parseLoadReplay(char *args[], int num) +{ + if (num > 1) + { + AsciiString filename = args[1]; + if (!filename.endsWithNoCase(RecorderClass::getReplayExtention())) + { + printf("Invalid replay name \"%s\"\n", filename.str()); + exit(1); + } + + TheWritableGlobalData->m_loadReplayGame = filename; + TheWritableGlobalData->m_shellMapOn = FALSE; + TheWritableGlobalData->m_playIntro = FALSE; + TheWritableGlobalData->m_playSizzle = FALSE; + + return 2; + } + return 1; +} + +// TheSuperHackers @feature bobtista 08/08/2026 Let diagnostic replay playback continue past a CRC +// mismatch without the UI report and pause that normal playback uses. +Int parseIgnoreReplaySyncErrors(char *args[], int) +{ + TheDebugIgnoreReplaySyncErrors = true; + + return 1; +} + //============================================================================= //============================================================================= @@ -1200,6 +1232,8 @@ static CommandLineParam paramsForEngineInit[] = // TheSuperHackers @feature bobtista 22/07/2026 Load a save game file from the command line. { "-loadsave", parseLoadSave }, + { "-loadreplay", parseLoadReplay }, + { "-ignoreReplaySyncErrors", parseIgnoreReplaySyncErrors }, // TheSuperHackers @feature xezon 03/08/2025 Force full viewport for 'Control Bar Pro' Addons like GenTool did it. { "-forcefullviewport", parseFullViewport }, diff --git a/Generals/Code/GameEngine/Include/Common/GlobalData.h b/Generals/Code/GameEngine/Include/Common/GlobalData.h index c00862a980b..6b52265f66e 100644 --- a/Generals/Code/GameEngine/Include/Common/GlobalData.h +++ b/Generals/Code/GameEngine/Include/Common/GlobalData.h @@ -354,6 +354,7 @@ class GlobalData : public SubsystemInterface AsciiString m_initialFile; ///< If this is specified, load a specific map from the command-line AsciiString m_pendingFile; ///< If this is specified, use this map at the next game start AsciiString m_loadSaveGame; ///< If this is specified, load a save game file from the command-line + AsciiString m_loadReplayGame; ///< If this is specified, play a replay file from the command-line std::vector m_simulateReplays; ///< If not empty, simulate this list of replays and exit. Int m_simulateReplayJobs; ///< Maximum number of processes to use for simulation, or SIMULATE_REPLAYS_SEQUENTIAL for sequential simulation diff --git a/Generals/Code/GameEngine/Include/Common/Recorder.h b/Generals/Code/GameEngine/Include/Common/Recorder.h index cb96ac8ccba..a84311c4d11 100644 --- a/Generals/Code/GameEngine/Include/Common/Recorder.h +++ b/Generals/Code/GameEngine/Include/Common/Recorder.h @@ -99,6 +99,7 @@ class RecorderClass : public SubsystemInterface // Methods dealing with playback. void updatePlayback(); ///< The update function for playing back a file. Bool playbackFile(AsciiString filename); ///< Starts playback of the specified file. + void loadQueuedReplay(); ///< Play the replay file requested on startup. Bool replayMatchesGameVersion(AsciiString filename); ///< Returns true if the playback is a valid playback file for this version. static Bool replayMatchesGameVersion(const ReplayHeader& header); ///< Returns true if the playback is a valid playback file for this version. AsciiString getCurrentReplayFilename(); ///< valid during playback only diff --git a/Generals/Code/GameEngine/Source/Common/Recorder.cpp b/Generals/Code/GameEngine/Source/Common/Recorder.cpp index 7a955895827..136ddc1b0df 100644 --- a/Generals/Code/GameEngine/Source/Common/Recorder.cpp +++ b/Generals/Code/GameEngine/Source/Common/Recorder.cpp @@ -32,6 +32,7 @@ #include "Common/GlobalData.h" #include "Common/GameEngine.h" #include "GameClient/ClientInstance.h" +#include "GameClient/MapUtil.h" #include "GameClient/GameWindow.h" #include "GameClient/GameWindowManager.h" #include "GameClient/InGameUI.h" @@ -47,6 +48,7 @@ #include "Common/CRCDebug.h" #include "Common/OptionPreferences.h" #include "Common/version.h" +#include "Lib/PathUtil.h" constexpr const char s_genrep[] = "GENREP"; constexpr const UnsignedInt replayBufferBytes = 8192; @@ -847,8 +849,14 @@ void RecorderClass::writeArgument(GameMessageArgumentDataType type, const GameMe */ Bool RecorderClass::readReplayHeader(ReplayHeader& header) { - AsciiString filepath = getReplayDir(); - filepath.concat(header.filename.str()); + // TheSuperHackers @feature bobtista 08/08/2026 Open explicitly selected replay paths in place + // while preserving Replay directory lookup for menu filenames and existing command lines. + AsciiString filepath = header.filename; + if (!isAbsolutePath(filepath.str())) + { + filepath = getReplayDir(); + filepath.concat(header.filename.str()); + } // TheSuperHackers @performance More buffered data reduces disk overhead and will improve fast forward playback const UnsignedInt buffersize = header.forPlayback ? replayBufferBytes : File::BUFFERSIZE; @@ -998,6 +1006,18 @@ void RecorderClass::handleCRCMessage(UnsignedInt newCRC, Int playerIndex, Bool f // playbackCRC, newCRC, TheGameLogic->getFrame()-m_crcInfo.GetQueueSize()-1, playerIndex)); if (TheGameLogic->getFrame() > 0 && newCRC != playbackCRC && !m_crcInfo.sawCRCMismatch()) { + // TheSuperHackers @feature bobtista 08/08/2026 Diagnostic playback continues past a mismatch + // without the UI report and the pause that normal playback uses. + if (TheDebugIgnoreReplaySyncErrors) + { + const UnsignedInt ignoredFrame = TheGameLogic->getFrame() - m_crcInfo.GetQueueSize() - 1; + DEBUG_LOG(("Replay CRC mismatch ignored\nInGame:%8.8X Replay:%8.8X\nFrame:%d", + playbackCRC, newCRC, ignoredFrame)); + printf("CRC Mismatch in Frame %d (ignored)\n", ignoredFrame); + m_crcInfo.setSawCRCMismatch(); + return; + } + // 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 @@ -1074,6 +1094,46 @@ Bool RecorderClass::replayMatchesGameVersion(const ReplayHeader& header) * Start playback of the file. Return true or false depending on if the file is * a valid replay file or not. */ +// TheSuperHackers @feature bobtista 08/08/2026 Play the replay requested on startup, once the client +// has created the shell layout that the playback returns to when it ends. +void RecorderClass::loadQueuedReplay() +{ + const AsciiString filename = TheGlobalData->m_loadReplayGame; + TheWritableGlobalData->m_loadReplayGame.clear(); + + ReplayHeader header; + header.forPlayback = FALSE; + header.filename = filename; + if (!readReplayHeader(header)) + { + DEBUG_LOG(("Replay '%s' could not be read", filename.str())); + TheGameEngine->setQuitting(TRUE); + return; + } + + // A replay whose map is missing starts a game that cannot load, so reject it here instead + ReplayGameInfo gameInfo; + if (!ParseAsciiStringToGameInfo(&gameInfo, header.gameOptions)) + { + DEBUG_LOG(("Replay '%s' contains invalid game options", filename.str())); + TheGameEngine->setQuitting(TRUE); + return; + } + + if (TheMapCache == nullptr || TheMapCache->findMap(gameInfo.getMap()) == nullptr) + { + DEBUG_LOG(("Replay '%s' requires unavailable map '%s'", filename.str(), gameInfo.getMap().str())); + TheGameEngine->setQuitting(TRUE); + return; + } + + if (!playbackFile(filename)) + { + DEBUG_LOG(("Failed to play replay '%s'", filename.str())); + TheGameEngine->setQuitting(TRUE); + } +} + Bool RecorderClass::playbackFile(AsciiString filename) { if (!m_doingAnalysis) diff --git a/Generals/Code/GameEngine/Source/GameClient/GameClient.cpp b/Generals/Code/GameEngine/Source/GameClient/GameClient.cpp index d556d8b4855..7fc03e4e0ea 100644 --- a/Generals/Code/GameEngine/Source/GameClient/GameClient.cpp +++ b/Generals/Code/GameEngine/Source/GameClient/GameClient.cpp @@ -35,6 +35,7 @@ #include "Common/ActionManager.h" #include "Common/GameEngine.h" #include "Common/GameState.h" +#include "Common/Recorder.h" #include "Common/GameUtility.h" #include "Common/GlobalData.h" #include "Common/PerfTimer.h" @@ -520,6 +521,10 @@ void GameClient::update() { TheGameState->loadQueuedSaveGame(); } + else if (TheGlobalData->m_loadReplayGame.isNotEmpty()) + { + TheRecorder->loadQueuedReplay(); + } } } diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index 92761fd3c33..bbdc79e6183 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -355,6 +355,7 @@ class GlobalData : public SubsystemInterface AsciiString m_initialFile; ///< If this is specified, load a specific map from the command-line AsciiString m_pendingFile; ///< If this is specified, use this map at the next game start AsciiString m_loadSaveGame; ///< If this is specified, load a save game file from the command-line + AsciiString m_loadReplayGame; ///< If this is specified, play a replay file from the command-line std::vector m_simulateReplays; ///< If not empty, simulate this list of replays and exit. Int m_simulateReplayJobs; ///< Maximum number of processes to use for simulation, or SIMULATE_REPLAYS_SEQUENTIAL for sequential simulation diff --git a/GeneralsMD/Code/GameEngine/Include/Common/Recorder.h b/GeneralsMD/Code/GameEngine/Include/Common/Recorder.h index fba73ad5ec3..74e89480957 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/Recorder.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/Recorder.h @@ -99,6 +99,7 @@ class RecorderClass : public SubsystemInterface // Methods dealing with playback. void updatePlayback(); ///< The update function for playing back a file. Bool playbackFile(AsciiString filename); ///< Starts playback of the specified file. + void loadQueuedReplay(); ///< Play the replay file requested on startup. Bool replayMatchesGameVersion(AsciiString filename); ///< Returns true if the playback is a valid playback file for this version. static Bool replayMatchesGameVersion(const ReplayHeader& header); ///< Returns true if the playback is a valid playback file for this version. AsciiString getCurrentReplayFilename(); ///< valid during playback only diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp index 9d71eb45b1b..f02f0466cf4 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp @@ -32,6 +32,7 @@ #include "Common/GlobalData.h" #include "Common/GameEngine.h" #include "GameClient/ClientInstance.h" +#include "GameClient/MapUtil.h" #include "GameClient/GameWindow.h" #include "GameClient/GameWindowManager.h" #include "GameClient/InGameUI.h" @@ -47,6 +48,7 @@ #include "Common/CRCDebug.h" #include "Common/OptionPreferences.h" #include "Common/version.h" +#include "Lib/PathUtil.h" constexpr const char s_genrep[] = "GENREP"; constexpr const UnsignedInt replayBufferBytes = 8192; @@ -849,8 +851,14 @@ void RecorderClass::writeArgument(GameMessageArgumentDataType type, const GameMe */ Bool RecorderClass::readReplayHeader(ReplayHeader& header) { - AsciiString filepath = getReplayDir(); - filepath.concat(header.filename.str()); + // TheSuperHackers @feature bobtista 08/08/2026 Open explicitly selected replay paths in place + // while preserving Replay directory lookup for menu filenames and existing command lines. + AsciiString filepath = header.filename; + if (!isAbsolutePath(filepath.str())) + { + filepath = getReplayDir(); + filepath.concat(header.filename.str()); + } // TheSuperHackers @performance More buffered data reduces disk overhead and will improve fast forward playback const UnsignedInt buffersize = header.forPlayback ? replayBufferBytes : File::BUFFERSIZE; @@ -1000,6 +1008,18 @@ void RecorderClass::handleCRCMessage(UnsignedInt newCRC, Int playerIndex, Bool f // playbackCRC, newCRC, TheGameLogic->getFrame()-m_crcInfo.GetQueueSize()-1, playerIndex)); if (TheGameLogic->getFrame() > 0 && newCRC != playbackCRC && !m_crcInfo.sawCRCMismatch()) { + // TheSuperHackers @feature bobtista 08/08/2026 Diagnostic playback continues past a mismatch + // without the UI report and the pause that normal playback uses. + if (TheDebugIgnoreReplaySyncErrors) + { + const UnsignedInt ignoredFrame = TheGameLogic->getFrame() - m_crcInfo.GetQueueSize() - 1; + DEBUG_LOG(("Replay CRC mismatch ignored\nInGame:%8.8X Replay:%8.8X\nFrame:%d", + playbackCRC, newCRC, ignoredFrame)); + printf("CRC Mismatch in Frame %d (ignored)\n", ignoredFrame); + m_crcInfo.setSawCRCMismatch(); + return; + } + //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 @@ -1077,6 +1097,46 @@ Bool RecorderClass::replayMatchesGameVersion(const ReplayHeader& header) * Start playback of the file. Return true or false depending on if the file is * a valid replay file or not. */ +// TheSuperHackers @feature bobtista 08/08/2026 Play the replay requested on startup, once the client +// has created the shell layout that the playback returns to when it ends. +void RecorderClass::loadQueuedReplay() +{ + const AsciiString filename = TheGlobalData->m_loadReplayGame; + TheWritableGlobalData->m_loadReplayGame.clear(); + + ReplayHeader header; + header.forPlayback = FALSE; + header.filename = filename; + if (!readReplayHeader(header)) + { + DEBUG_LOG(("Replay '%s' could not be read", filename.str())); + TheGameEngine->setQuitting(TRUE); + return; + } + + // A replay whose map is missing starts a game that cannot load, so reject it here instead + ReplayGameInfo gameInfo; + if (!ParseAsciiStringToGameInfo(&gameInfo, header.gameOptions)) + { + DEBUG_LOG(("Replay '%s' contains invalid game options", filename.str())); + TheGameEngine->setQuitting(TRUE); + return; + } + + if (TheMapCache == nullptr || TheMapCache->findMap(gameInfo.getMap()) == nullptr) + { + DEBUG_LOG(("Replay '%s' requires unavailable map '%s'", filename.str(), gameInfo.getMap().str())); + TheGameEngine->setQuitting(TRUE); + return; + } + + if (!playbackFile(filename)) + { + DEBUG_LOG(("Failed to play replay '%s'", filename.str())); + TheGameEngine->setQuitting(TRUE); + } +} + Bool RecorderClass::playbackFile(AsciiString filename) { if (!m_doingAnalysis) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp index af5f407209b..e3d83bb7323 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp @@ -35,6 +35,7 @@ #include "Common/ActionManager.h" #include "Common/GameEngine.h" #include "Common/GameState.h" +#include "Common/Recorder.h" #include "Common/GameUtility.h" #include "Common/GlobalData.h" #include "Common/PerfTimer.h" @@ -541,6 +542,10 @@ void GameClient::update() { TheGameState->loadQueuedSaveGame(); } + else if (TheGlobalData->m_loadReplayGame.isNotEmpty()) + { + TheRecorder->loadQueuedReplay(); + } } }