From c89d8fc3b07c3149bdaf06e591e67989f04ecc48 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Thu, 13 Aug 2026 20:24:12 -0400 Subject: [PATCH] feat(cli): Add -saveatframe and -saveto options --- Core/GameEngine/Source/Common/CommandLine.cpp | 22 +++++++++++++ .../GameEngine/Include/Common/GlobalData.h | 2 ++ .../GameEngine/Source/Common/GlobalData.cpp | 2 ++ .../Source/GameLogic/System/GameLogic.cpp | 32 +++++++++++++++++++ .../GameEngine/Include/Common/GlobalData.h | 2 ++ .../GameEngine/Source/Common/GlobalData.cpp | 2 ++ .../Source/GameLogic/System/GameLogic.cpp | 32 +++++++++++++++++++ 7 files changed, 94 insertions(+) diff --git a/Core/GameEngine/Source/Common/CommandLine.cpp b/Core/GameEngine/Source/Common/CommandLine.cpp index 772830f0f67..99ecabc51ff 100644 --- a/Core/GameEngine/Source/Common/CommandLine.cpp +++ b/Core/GameEngine/Source/Common/CommandLine.cpp @@ -679,6 +679,26 @@ Int parseDisplayDebug(char *args[], int) return 1; } +// TheSuperHackers @feature bobtista 14/08/2026 Write a save at a chosen logic frame so a save +// and load round trip can be run without a person at the keyboard. +Int parseSaveAtFrame(char *args[], int num) +{ + if (num > 1) + { + TheWritableGlobalData->m_saveAtFrame = atoi(args[1]); + } + return 2; +} + +Int parseSaveTo(char *args[], int num) +{ + if (num > 1) + { + TheWritableGlobalData->m_saveToFile = args[1]; + } + return 2; +} + Int parseFile(char *args[], int num) { if (num > 1) @@ -1254,6 +1274,8 @@ static CommandLineParam paramsForEngineInit[] = { "-munkee", parseMunkee }, { "-displayDebug", parseDisplayDebug }, { "-file", parseFile }, + { "-saveatframe", parseSaveAtFrame }, + { "-saveto", parseSaveTo }, // { "-preload", parsePreload }, diff --git a/Generals/Code/GameEngine/Include/Common/GlobalData.h b/Generals/Code/GameEngine/Include/Common/GlobalData.h index e631654250d..22c6adf2eca 100644 --- a/Generals/Code/GameEngine/Include/Common/GlobalData.h +++ b/Generals/Code/GameEngine/Include/Common/GlobalData.h @@ -349,6 +349,8 @@ class GlobalData : public SubsystemInterface Bool m_enforceMaxCameraHeight; ///< Enforce max camera height while scrolling? Bool m_buildMapCache; AsciiString m_initialFile; ///< If this is specified, load a specific map from the command-line + Int m_saveAtFrame; ///< If greater than zero, write a save when this logic frame is reached + AsciiString m_saveToFile; ///< Filename used by m_saveAtFrame AsciiString m_pendingFile; ///< If this is specified, use this map at the next game start std::vector m_simulateReplays; ///< If not empty, simulate this list of replays and exit. diff --git a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp index f7720c351a2..2c5a6f39f5f 100644 --- a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp @@ -985,6 +985,8 @@ GlobalData::GlobalData() m_buildMapCache = FALSE; m_initialFile.clear(); + m_saveAtFrame = 0; + m_saveToFile.clear(); m_pendingFile.clear(); m_simulateReplays.clear(); diff --git a/Generals/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp b/Generals/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp index 857c63995b4..8afe301f73e 100644 --- a/Generals/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp +++ b/Generals/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp @@ -3201,6 +3201,38 @@ void GameLogic::update() PROFILER_PLOT("LogicFrame", static_cast(now)); + // TheSuperHackers @feature bobtista 14/08/2026 Write a save at the requested logic frame and quit. + // This sits ahead of everything the frame does, because the engine's own save runs from + // TheGameClient->UPDATE(), which precedes TheGameLogic->UPDATE(). Saving further down would + // capture a mid-frame state that no player save can produce, and reloading it would re-run the + // part of the frame that had already executed. + if (TheGlobalData->m_saveAtFrame > 0 && (Int)m_frame >= TheGlobalData->m_saveAtFrame && getGameMode() != GAME_SHELL) + { + // TheSuperHackers @bugfix bobtista 15/08/2026 Only write a save the game itself would let the + // player write. The Save button is disabled whenever input is disabled. Wait for the first + // frame that allows it instead, which is why the frame test above is >= rather than ==. + if (TheInGameUI != nullptr && TheInGameUI->getInputEnabled() == FALSE) + { + if ((Int)m_frame == TheGlobalData->m_saveAtFrame) + { + DEBUG_LOG(("Command line save deferred at frame %d: input is disabled, waiting for a frame that allows saving", m_frame)); + } + } + else + { + AsciiString saveName = TheGlobalData->m_saveToFile; + if (saveName.isEmpty()) + { + saveName = "commandline.sav"; + } + MAYBE_UNUSED const SaveCode saveResult = TheGameState->saveGame(saveName, UnicodeString(L"Command line save"), SAVE_FILE_TYPE_NORMAL); + (void)saveResult; + DEBUG_LOG(("Command line save to '%s' at frame %d returned %d", saveName.str(), m_frame, (Int)saveResult)); + TheWritableGlobalData->m_saveAtFrame = 0; + TheGameEngine->setQuitting(TRUE); + } + } + // update (execute) scripts { TheScriptEngine->UPDATE(); diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index 7f484111672..7e565834be4 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -350,6 +350,8 @@ class GlobalData : public SubsystemInterface Bool m_enforceMaxCameraHeight; ///< Enforce max camera height while scrolling? Bool m_buildMapCache; AsciiString m_initialFile; ///< If this is specified, load a specific map from the command-line + Int m_saveAtFrame; ///< If greater than zero, write a save when this logic frame is reached + AsciiString m_saveToFile; ///< Filename used by m_saveAtFrame AsciiString m_pendingFile; ///< If this is specified, use this map at the next game start std::vector m_simulateReplays; ///< If not empty, simulate this list of replays and exit. diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index e862cd149d5..15d74748c85 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -992,6 +992,8 @@ GlobalData::GlobalData() m_buildMapCache = FALSE; m_initialFile.clear(); + m_saveAtFrame = 0; + m_saveToFile.clear(); m_pendingFile.clear(); m_simulateReplays.clear(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp index 53d28a95aa0..3fdde8383b6 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp @@ -3740,6 +3740,38 @@ void GameLogic::update() PROFILER_PLOT("LogicFrame", static_cast(now)); + // TheSuperHackers @feature bobtista 14/08/2026 Write a save at the requested logic frame and quit. + // This sits ahead of everything the frame does, because the engine's own save runs from + // TheGameClient->UPDATE(), which precedes TheGameLogic->UPDATE(). Saving further down would + // capture a mid-frame state that no player save can produce, and reloading it would re-run the + // part of the frame that had already executed. + if (TheGlobalData->m_saveAtFrame > 0 && (Int)m_frame >= TheGlobalData->m_saveAtFrame && getGameMode() != GAME_SHELL) + { + // TheSuperHackers @bugfix bobtista 15/08/2026 Only write a save the game itself would let the + // player write. The Save button is disabled whenever input is disabled. Wait for the first + // frame that allows it instead, which is why the frame test above is >= rather than ==. + if (TheInGameUI != nullptr && TheInGameUI->getInputEnabled() == FALSE) + { + if ((Int)m_frame == TheGlobalData->m_saveAtFrame) + { + DEBUG_LOG(("Command line save deferred at frame %d: input is disabled, waiting for a frame that allows saving", m_frame)); + } + } + else + { + AsciiString saveName = TheGlobalData->m_saveToFile; + if (saveName.isEmpty()) + { + saveName = "commandline.sav"; + } + MAYBE_UNUSED const SaveCode saveResult = TheGameState->saveGame(saveName, UnicodeString(L"Command line save"), SAVE_FILE_TYPE_NORMAL); + (void)saveResult; + DEBUG_LOG(("Command line save to '%s' at frame %d returned %d", saveName.str(), m_frame, (Int)saveResult)); + TheWritableGlobalData->m_saveAtFrame = 0; + TheGameEngine->setQuitting(TRUE); + } + } + // update (execute) scripts { TheScriptEngine->UPDATE();