diff --git a/Core/GameEngine/CMakeLists.txt b/Core/GameEngine/CMakeLists.txt
index 0f36ff63383..0e030311e34 100644
--- a/Core/GameEngine/CMakeLists.txt
+++ b/Core/GameEngine/CMakeLists.txt
@@ -217,6 +217,7 @@ set(GAMEENGINE_SRC
Include/GameClient/ProcessAnimateWindow.h
Include/GameClient/RadiusDecal.h
Include/GameClient/RayEffect.h
+ Include/GameClient/SaveLoadFeedback.h
Include/GameClient/SelectionInfo.h
Include/GameClient/SelectionXlat.h
# Include/GameClient/Shadow.h
@@ -795,6 +796,7 @@ set(GAMEENGINE_SRC
# Source/GameClient/GUI/GUICallbacks/Menus/WOLWelcomeMenu.cpp
Source/GameClient/GUI/GUICallbacks/MessageBox.cpp
Source/GameClient/GUI/GUICallbacks/ReplayControls.cpp
+ Source/GameClient/GUI/GUICallbacks/SaveLoadFeedback.cpp
Source/GameClient/GUI/HeaderTemplate.cpp
Source/GameClient/GUI/IMEManager.cpp
Source/GameClient/GUI/LoadScreen.cpp
diff --git a/Core/GameEngine/Include/Common/FileSystem.h b/Core/GameEngine/Include/Common/FileSystem.h
index 2aaa30a61f1..be0fd1c99c2 100644
--- a/Core/GameEngine/Include/Common/FileSystem.h
+++ b/Core/GameEngine/Include/Common/FileSystem.h
@@ -157,6 +157,7 @@ class FileSystem : public SubsystemInterface
Bool createDirectory(AsciiString directory); ///< create a directory of the given name.
+ static Bool isAbsolutePath(const AsciiString& path); ///< determines if a path is absolute on the current platform.
static AsciiString normalizePath(const AsciiString& path); ///< normalizes a file path. The path can refer to a directory. File path must be absolute, but does not need to exist. Returns an empty string on failure.
static Bool isPathInDirectory(const AsciiString& testPath, const AsciiString& basePath); ///< determines if a file path is within a base path. Both paths must be absolute, but do not need to exist.
diff --git a/Core/GameEngine/Include/GameClient/SaveLoadFeedback.h b/Core/GameEngine/Include/GameClient/SaveLoadFeedback.h
new file mode 100644
index 00000000000..df90a7a0cce
--- /dev/null
+++ b/Core/GameEngine/Include/GameClient/SaveLoadFeedback.h
@@ -0,0 +1,24 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** 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/GameState.h"
+
+void presentSaveResult( const SaveResult &result );
+void presentLoadResult( SaveCode result, const AsciiString &filename );
diff --git a/Core/GameEngine/Source/Common/CommandLine.cpp b/Core/GameEngine/Source/Common/CommandLine.cpp
index 772830f0f67..676d5546b91 100644
--- a/Core/GameEngine/Source/Common/CommandLine.cpp
+++ b/Core/GameEngine/Source/Common/CommandLine.cpp
@@ -719,6 +719,47 @@ Int parseVTune ( char *args[], int num )
#endif // defined(RTS_DEBUG)
+// TheSuperHackers @feature bobtista 22/07/2026 Load a save game file from the command line.
+Int parseLoadSave(char *args[], int num)
+{
+ if (num > 1)
+ {
+ AsciiString filename = args[1];
+ if (!filename.endsWithNoCase(".sav"))
+ {
+ printf("Invalid save game name \"%s\"\n", filename.str());
+ exit(1);
+ }
+
+ TheWritableGlobalData->m_loadSaveGame = filename;
+ TheWritableGlobalData->m_playIntro = FALSE;
+ TheWritableGlobalData->m_playSizzle = FALSE;
+ return 2;
+ }
+ return 1;
+}
+
+// TheSuperHackers @feature bobtista 08/08/2026 Load 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_playIntro = FALSE;
+ TheWritableGlobalData->m_playSizzle = FALSE;
+ return 2;
+ }
+
+ return 1;
+}
+
//=============================================================================
//=============================================================================
@@ -1159,6 +1200,10 @@ static CommandLineParam paramsForEngineInit[] =
{ "-noshaders", parseNoShaders },
{ "-quickstart", parseQuickStart },
{ "-useWaveEditor", parseUseWaveEditor },
+ { "-loadsave", parseLoadSave },
+ { "-loadreplay", parseLoadReplay },
+ // TheSuperHackers @feature bobtista 08/08/2026 Allow diagnostic replay playback to continue after CRC mismatches.
+ { "-ignoreReplaySyncErrors", parseSync },
// TheSuperHackers @feature xezon 03/08/2025 Force full viewport for 'Control Bar Pro' Addons like GenTool did it.
{ "-forcefullviewport", parseFullViewport },
diff --git a/Core/GameEngine/Source/Common/System/FileSystem.cpp b/Core/GameEngine/Source/Common/System/FileSystem.cpp
index b8e4c4695b6..a3b8ee14865 100644
--- a/Core/GameEngine/Source/Common/System/FileSystem.cpp
+++ b/Core/GameEngine/Source/Common/System/FileSystem.cpp
@@ -331,6 +331,31 @@ Bool FileSystem::createDirectory(AsciiString directory)
return FALSE;
}
+//============================================================================
+// FileSystem::isAbsolutePath
+//============================================================================
+// TheSuperHackers @feature bobtista 08/08/2026 Identify absolute paths so callers can distinguish
+// explicitly selected files from names relative to their managed directories.
+Bool FileSystem::isAbsolutePath(const AsciiString& path)
+{
+ const Char *value = path.str();
+ if (value == nullptr || value[0] == 0)
+ {
+ return FALSE;
+ }
+
+#ifdef _WIN32
+ const Bool hasDriveLetter = (value[0] >= 'A' && value[0] <= 'Z') ||
+ (value[0] >= 'a' && value[0] <= 'z');
+ const Bool hasDriveRoot = hasDriveLetter && value[1] == ':' &&
+ (value[2] == '\\' || value[2] == '/');
+ const Bool hasCurrentDriveRoot = value[0] == '\\' || value[0] == '/';
+ return hasDriveRoot || hasCurrentDriveRoot;
+#else
+ return value[0] == '/';
+#endif
+}
+
//============================================================================
// FileSystem::normalizePath
//============================================================================
diff --git a/Core/GameEngine/Source/GameClient/GUI/GUICallbacks/SaveLoadFeedback.cpp b/Core/GameEngine/Source/GameClient/GUI/GUICallbacks/SaveLoadFeedback.cpp
new file mode 100644
index 00000000000..4979ba629be
--- /dev/null
+++ b/Core/GameEngine/Source/GameClient/GUI/GUICallbacks/SaveLoadFeedback.cpp
@@ -0,0 +1,68 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** 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 "GameClient/GameText.h"
+#include "GameClient/InGameUI.h"
+#include "GameClient/MessageBox.h"
+#include "GameClient/SaveLoadFeedback.h"
+
+static UnicodeString getUnicodeSavePath( const AsciiString &filename )
+{
+ UnicodeString path;
+ path.translate( TheGameState->getFilePathInSaveDirectory(filename) );
+ return path;
+}
+
+void presentSaveResult( const SaveResult &result )
+{
+ switch( result.saveCode )
+ {
+ case SC_OK:
+ {
+ TheInGameUI->message( TheGameText->fetch("GUI:GameSaveComplete") );
+ break;
+ }
+ case SC_UNABLE_TO_OPEN_FILE:
+ {
+ TheInGameUI->message( "GUI:Error" );
+ break;
+ }
+ case SC_ERROR:
+ {
+ UnicodeString msg;
+ msg.format( TheGameText->fetch("GUI:ErrorSavingGame"), getUnicodeSavePath(result.filename).str() );
+ MessageBoxOk( TheGameText->fetch("GUI:Error"), msg, nullptr );
+ break;
+ }
+ default:
+ {
+ break;
+ }
+ }
+}
+
+void presentLoadResult( SaveCode result, const AsciiString &filename )
+{
+ if( result == SC_INVALID_DATA )
+ {
+ UnicodeString msg;
+ msg.format( TheGameText->fetch("GUI:ErrorLoadingGame"), getUnicodeSavePath(filename).str() );
+ MessageBoxOk( TheGameText->fetch("GUI:Error"), msg, nullptr );
+ }
+}
diff --git a/Generals/Code/GameEngine/Include/Common/GameState.h b/Generals/Code/GameEngine/Include/Common/GameState.h
index 20e8910174f..cbfcb58e396 100644
--- a/Generals/Code/GameEngine/Include/Common/GameState.h
+++ b/Generals/Code/GameEngine/Include/Common/GameState.h
@@ -132,6 +132,15 @@ enum SaveCode CPP_11(: Int)
SC_ERROR,
};
+struct SaveResult
+{
+ explicit SaveResult( SaveCode code ) : saveCode(code) { }
+ SaveResult( SaveCode code, const AsciiString &file ) : saveCode(code), filename(file) { }
+
+ SaveCode saveCode;
+ AsciiString filename; ///< the file that was written, empty when no filename could be found
+};
+
enum SnapshotType CPP_11(: Int) {
SNAPSHOT_SAVELOAD,
SNAPSHOT_DEEPCRC_LOGICONLY,
@@ -156,11 +165,11 @@ class GameState : public SubsystemInterface,
virtual void update() override { }
// save game methods
- SaveCode saveGame( AsciiString filename,
+ SaveResult saveGame( AsciiString filename,
UnicodeString desc,
SaveFileType saveType,
- SnapshotType which = SNAPSHOT_SAVELOAD ); ///< save a game
- SaveCode missionSave(); ///< do a in between mission save
+ SnapshotType which = SNAPSHOT_SAVELOAD ); ///< save a game
+ SaveResult missionSave(); ///< do a in between mission save
SaveCode loadGame( AvailableGameInfo gameInfo ); ///< load a save file
SaveGameInfo *getSaveGameInfo() { return &m_gameInfo; }
@@ -170,7 +179,7 @@ class GameState : public SubsystemInterface,
// manipulating files
Bool doesSaveGameExist( AsciiString filename ); ///< does the save file exist
void populateSaveGameListbox( GameWindow *listbox, SaveLoadLayoutType layoutType ); ///< populate listbox with available save games
- void getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *saveGameInfo ); ///< get save game info from file
+ void getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *saveGameInfo ); ///< get save game info from a Save-directory name or absolute path
void friend_xferSaveDataForCRC( Xfer *xfer, SnapshotType which ); ///< This should only be called to DeepCRC sanity checking
@@ -181,6 +190,7 @@ class GameState : public SubsystemInterface,
AsciiString getSaveDirectory() const;
AsciiString getFilePathInSaveDirectory(const AsciiString& leaf) const;
+ AsciiString getSaveGamePathForRead(const AsciiString& filenameOrPath) const;
Bool isInSaveDirectory(const AsciiString& path) const;
AsciiString realMapPathToPortableMapPath(const AsciiString& in) const;
diff --git a/Generals/Code/GameEngine/Include/Common/GlobalData.h b/Generals/Code/GameEngine/Include/Common/GlobalData.h
index e631654250d..2031f14adf0 100644
--- a/Generals/Code/GameEngine/Include/Common/GlobalData.h
+++ b/Generals/Code/GameEngine/Include/Common/GlobalData.h
@@ -350,6 +350,8 @@ class GlobalData : public SubsystemInterface
Bool m_buildMapCache;
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, load 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/Source/Common/GameEngine.cpp b/Generals/Code/GameEngine/Source/Common/GameEngine.cpp
index 4631cc28133..b40c40d68d4 100644
--- a/Generals/Code/GameEngine/Source/Common/GameEngine.cpp
+++ b/Generals/Code/GameEngine/Source/Common/GameEngine.cpp
@@ -723,6 +723,40 @@ Bool GameEngine::canUpdateRegularGameLogic(UnsignedInt logicTimeQueryFlags)
/// -----------------------------------------------------------------------------------------------
DECLARE_PERF_TIMER(GameEngine_update)
+// TheSuperHackers @feature bobtista 08/08/2026 Validate command-line replays before starting visual playback.
+static Bool validateCommandLineReplay(const AsciiString& filename)
+{
+ RecorderClass::ReplayHeader header;
+ header.forPlayback = FALSE;
+ header.filename = filename;
+
+ if (TheRecorder == nullptr || !TheRecorder->readReplayHeader(header))
+ {
+ DEBUG_LOG(("Failed to read replay '%s'", filename.str()));
+ return FALSE;
+ }
+
+ ReplayGameInfo gameInfo;
+ if (!ParseAsciiStringToGameInfo(&gameInfo, header.gameOptions))
+ {
+ DEBUG_LOG(("Replay '%s' contains invalid game options", filename.str()));
+ return FALSE;
+ }
+
+ if (TheMapCache == nullptr || TheMapCache->findMap(gameInfo.getMap()) == nullptr)
+ {
+ DEBUG_LOG(("Replay '%s' requires unavailable map '%s'", filename.str(), gameInfo.getMap().str()));
+ return FALSE;
+ }
+
+ if (!RecorderClass::replayMatchesGameVersion(header))
+ {
+ DEBUG_LOG(("Replay '%s' was created by a different game version; command-line playback will continue", filename.str()));
+ }
+
+ return TRUE;
+}
+
/** -----------------------------------------------------------------------------------------------
* Update the game engine by updating the GameClient and GameLogic singletons.
*/
@@ -748,6 +782,71 @@ void GameEngine::update()
}
}
+ // TheSuperHackers @feature bobtista 22/07/2026 Defer command-line save loading until the client has created the shell
+ // layout that normal game sessions return to.
+ if (TheGlobalData->m_loadSaveGame.isNotEmpty())
+ {
+ AvailableGameInfo gameInfo;
+ gameInfo.filename = TheGlobalData->m_loadSaveGame;
+ gameInfo.next = nullptr;
+ gameInfo.prev = nullptr;
+
+ AsciiString filename = gameInfo.filename;
+ Bool loadSucceeded = FALSE;
+ try
+ {
+ TheGameState->getSaveGameInfoFromFile(gameInfo.filename, &gameInfo.saveGameInfo);
+ TheGameLogic->prepareNewGame(GAME_SINGLE_PLAYER, DIFFICULTY_NORMAL, 0);
+ loadSucceeded = TheGameState->loadGame(gameInfo) == SC_OK;
+ }
+ catch (...)
+ {
+ DEBUG_LOG(("Failed to read save game '%s'", filename.str()));
+ }
+
+ TheWritableGlobalData->m_loadSaveGame.clear();
+ if (loadSucceeded)
+ {
+ if (TheShell)
+ {
+ TheShell->hideShell();
+ }
+ }
+ else
+ {
+ if (TheGameLogic->isInGame())
+ {
+ TheGameLogic->clearGameData(FALSE);
+ }
+ TheGameEngine->reset();
+ DEBUG_LOG(("Failed to load save game '%s'", filename.str()));
+ printf("Failed to load save game '%s'\n", filename.str());
+ m_quitting = TRUE;
+ }
+ }
+
+ // TheSuperHackers @feature bobtista 08/08/2026 Defer command-line replay loading until the client has initialized the
+ // UI state used by normal visual replay playback.
+ if (TheGlobalData->m_loadReplayGame.isNotEmpty())
+ {
+ AsciiString replayGame = TheGlobalData->m_loadReplayGame;
+ TheWritableGlobalData->m_loadReplayGame.clear();
+
+ if (validateCommandLineReplay(replayGame) && TheRecorder->playbackFile(replayGame))
+ {
+ if (TheShell)
+ {
+ TheShell->hideShell();
+ }
+ }
+ else
+ {
+ DEBUG_LOG(("Failed to load replay '%s'", replayGame.str()));
+ printf("Failed to load replay '%s'\n", replayGame.str());
+ m_quitting = TRUE;
+ }
+ }
+
// TheSuperHackers @info Ignores frozen time because the script engine needs updating in the logic update regardless.
if (canUpdateGameLogic(FramePacer::IgnoreFrozenTime))
{
@@ -774,6 +873,35 @@ void GameEngine::execute()
DWORD startTime = timeGetTime() / 1000;
#endif
+ // TheSuperHackers @feature bobtista 22/07/2026 Load a save game directly from the command line.
+ if (TheGlobalData->m_loadSaveGame.isNotEmpty())
+ {
+ AvailableGameInfo gameInfo;
+ gameInfo.filename = TheGlobalData->m_loadSaveGame;
+ gameInfo.next = nullptr;
+ gameInfo.prev = nullptr;
+
+ AsciiString fullPath = TheGameState->getFilePathInSaveDirectory(gameInfo.filename);
+ TheGameState->getSaveGameInfoFromFile(fullPath, &gameInfo.saveGameInfo);
+ TheGameLogic->prepareNewGame(GAME_SINGLE_PLAYER, DIFFICULTY_NORMAL, 0);
+
+ AsciiString filename = gameInfo.filename;
+ SaveCode result = TheGameState->loadGame(gameInfo);
+ TheWritableGlobalData->m_loadSaveGame.clear();
+ if (result == SC_OK)
+ {
+ if (TheShell)
+ {
+ TheShell->hideShell();
+ }
+ }
+ else
+ {
+ DEBUG_LOG(("Failed to load save game '%s'", filename.str()));
+ m_quitting = TRUE;
+ }
+ }
+
// pretty basic for now
while( !m_quitting )
{
diff --git a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp
index f7720c351a2..b983e14f441 100644
--- a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp
+++ b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp
@@ -986,6 +986,8 @@ GlobalData::GlobalData()
m_buildMapCache = FALSE;
m_initialFile.clear();
m_pendingFile.clear();
+ m_loadSaveGame.clear();
+ m_loadReplayGame.clear();
m_simulateReplays.clear();
m_simulateReplayJobs = SIMULATE_REPLAYS_SEQUENTIAL;
diff --git a/Generals/Code/GameEngine/Source/Common/Recorder.cpp b/Generals/Code/GameEngine/Source/Common/Recorder.cpp
index 7a955895827..e9fab9877fa 100644
--- a/Generals/Code/GameEngine/Source/Common/Recorder.cpp
+++ b/Generals/Code/GameEngine/Source/Common/Recorder.cpp
@@ -847,8 +847,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 (!FileSystem::isAbsolutePath(filepath))
+ {
+ 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 +1004,17 @@ 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 Allow explicit diagnostic replay playback to continue without UI reporting or pausing.
+ if (TheDebugIgnoreSyncErrors)
+ {
+ const UnsignedInt mismatchFrame = TheGameLogic->getFrame() - m_crcInfo.GetQueueSize() - 1;
+ DEBUG_LOG(("Replay CRC mismatch ignored at frame %d\nInGame:%8.8X Replay:%8.8X",
+ mismatchFrame, playbackCRC, newCRC));
+ printf("CRC Mismatch in Frame %d (ignored)\n", mismatchFrame);
+ 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
diff --git a/Generals/Code/GameEngine/Source/Common/System/SaveGame/GameState.cpp b/Generals/Code/GameEngine/Source/Common/System/SaveGame/GameState.cpp
index 7390ff5ed85..8942334cf6a 100644
--- a/Generals/Code/GameEngine/Source/Common/System/SaveGame/GameState.cpp
+++ b/Generals/Code/GameEngine/Source/Common/System/SaveGame/GameState.cpp
@@ -48,7 +48,6 @@
#include "GameClient/GameClient.h"
#include "GameClient/GameText.h"
#include "GameClient/MapUtil.h"
-#include "GameClient/MessageBox.h"
#include "GameClient/InGameUI.h"
#include "GameClient/ParticleSys.h"
#include "GameClient/TerrainVisual.h"
@@ -73,6 +72,13 @@ static const Int MAX_SAVE_FILE_NUMBER = 99999999;
#define GAME_STATE_BLOCK_STRING "CHUNK_GameState" // block of save game data with game info data
#define CAMPAIGN_BLOCK_STRING "CHUNK_Campaign" // block of game data that has campaign info
+static Bool isHeadlessOmittedBlock( const AsciiString &blockName )
+{
+ return blockName.compareNoCase( "CHUNK_ParticleSystem" ) == 0 ||
+ blockName.compareNoCase( "CHUNK_TerrainVisual" ) == 0 ||
+ blockName.compareNoCase( "CHUNK_GhostObject" ) == 0;
+}
+
// ------------------------------------------------------------------------------------------------
// ------------------------------------------------------------------------------------------------
SaveGameInfo::SaveGameInfo()
@@ -532,8 +538,8 @@ AsciiString GameState::findNextSaveFilename( UnicodeString desc )
/** Save the current state of the engine in a save file
* NOTE: filename is a *filename only* */
// ------------------------------------------------------------------------------------------------
-SaveCode GameState::saveGame( AsciiString filename, UnicodeString desc,
- SaveFileType saveType, SnapshotType which )
+SaveResult GameState::saveGame( AsciiString filename, UnicodeString desc,
+ SaveFileType saveType, SnapshotType which )
{
// if there is no filename, this is a new file being created, find an appropriate filename
@@ -543,7 +549,7 @@ SaveCode GameState::saveGame( AsciiString filename, UnicodeString desc,
{
DEBUG_CRASH(( "GameState::saveGame - Unable to find valid filename for save game" ));
- return SC_NO_FILE_AVAILABLE;
+ return SaveResult( SC_NO_FILE_AVAILABLE );
}
@@ -561,10 +567,8 @@ SaveCode GameState::saveGame( AsciiString filename, UnicodeString desc,
try {
xferSave.open( filepath );
} catch(...) {
- // print error message to the user
- TheInGameUI->message( "GUI:Error" );
DEBUG_LOG(( "Error opening file '%s'", filepath.str() ));
- return SC_ERROR;
+ return SaveResult( SC_UNABLE_TO_OPEN_FILE, filename );
}
// save our save file type
@@ -592,35 +596,23 @@ SaveCode GameState::saveGame( AsciiString filename, UnicodeString desc,
catch( ... )
{
- UnicodeString ufilepath;
- ufilepath.translate(filepath);
-
- UnicodeString msg;
- msg.format( TheGameText->fetch("GUI:ErrorSavingGame"), ufilepath.str() );
-
- MessageBoxOk(TheGameText->fetch("GUI:Error"), msg, nullptr);
-
// close the file and get out of here
xferSave.close();
- return SC_ERROR;
+ return SaveResult( SC_ERROR, filename );
}
// close the file
xferSave.close();
- // print message to the user for game successfully saved
- UnicodeString msg = TheGameText->fetch( "GUI:GameSaveComplete" );
- TheInGameUI->message( msg );
-
- return SC_OK;
+ return SaveResult( SC_OK, filename );
}
// ------------------------------------------------------------------------------------------------
/** A mission save */
// ------------------------------------------------------------------------------------------------
-SaveCode GameState::missionSave()
+SaveResult GameState::missionSave()
{
// get campaign
@@ -664,8 +656,9 @@ SaveCode GameState::loadGame( AvailableGameInfo gameInfo )
//
TheGameStateMap->clearScratchPadMaps();
- // construct path to file
- AsciiString filepath = getFilePathInSaveDirectory(gameInfo.filename);
+ // Relative names come from the save menu. Absolute paths can come from
+ // command-line file handlers and are opened in place.
+ AsciiString filepath = getSaveGamePathForRead(gameInfo.filename);
// open the save file
XferLoad xferLoad;
@@ -717,15 +710,6 @@ SaveCode GameState::loadGame( AvailableGameInfo gameInfo )
TheGameLogic->clearGameData( FALSE );
TheGameEngine->reset();
- // print error message to the user
- UnicodeString ufilepath;
- ufilepath.translate(filepath);
-
- UnicodeString msg;
- msg.format( TheGameText->fetch("GUI:ErrorLoadingGame"), ufilepath.str() );
-
- MessageBoxOk(TheGameText->fetch("GUI:Error"), msg, nullptr);
-
return SC_INVALID_DATA; // you can't use a naked "throw" outside of a catch statement!
}
@@ -773,6 +757,19 @@ AsciiString GameState::getFilePathInSaveDirectory(const AsciiString& leaf) const
return tmp;
}
+//-------------------------------------------------------------------------------------------------
+// TheSuperHackers @feature bobtista 08/08/2026 Open command-line save paths in place while
+// preserving the managed Save directory for relative menu filenames and save writes.
+AsciiString GameState::getSaveGamePathForRead(const AsciiString& filenameOrPath) const
+{
+ if (FileSystem::isAbsolutePath(filenameOrPath))
+ {
+ return filenameOrPath;
+ }
+
+ return getFilePathInSaveDirectory(filenameOrPath);
+}
+
//-------------------------------------------------------------------------------------------------
Bool GameState::isInSaveDirectory(const AsciiString& path) const
{
@@ -930,8 +927,7 @@ AsciiString GameState::portableMapPathToRealMapPath(const AsciiString& in) const
Bool GameState::doesSaveGameExist( AsciiString filename )
{
- // construct full path to file
- AsciiString filepath = getFilePathInSaveDirectory(filename);
+ AsciiString filepath = getSaveGamePathForRead(filename);
// open file
XferLoad xfer;
@@ -976,6 +972,8 @@ void GameState::getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *sav
}
+ filename = getSaveGamePathForRead(filename);
+
// open file for partial loading
XferLoad xferLoad;
xferLoad.open( filename );
@@ -1351,6 +1349,10 @@ void GameState::xferSaveData( Xfer *xfer, SnapshotType which )
blockName = blockInfo->blockName;
DEBUG_LOG(("Looking at block '%s'", blockName.str()));
+ if( TheGlobalData->m_headless && isHeadlessOmittedBlock( blockName ) )
+ {
+ continue;
+ }
//
// for mission save files, we only save the game state block and campaign manager
@@ -1448,8 +1450,15 @@ void GameState::xferSaveData( Xfer *xfer, SnapshotType which )
// read block start
blockSize = xfer->beginBlock();
- // parse this data
- xfer->xferSnapshot( blockInfo->snapshot );
+ if( TheGlobalData->m_headless && isHeadlessOmittedBlock( token ) )
+ {
+ xfer->skip( blockSize );
+ }
+ else
+ {
+ // parse this data
+ xfer->xferSnapshot( blockInfo->snapshot );
+ }
// read block end
xfer->endBlock();
diff --git a/Generals/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupSaveLoad.cpp b/Generals/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupSaveLoad.cpp
index 6dbbacb9c0c..ff6d7b7e7d9 100644
--- a/Generals/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupSaveLoad.cpp
+++ b/Generals/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupSaveLoad.cpp
@@ -56,6 +56,7 @@
#include "GameClient/GameText.h"
#include "GameClient/GameWindowManager.h"
#include "GameClient/GUICallbacks.h"
+#include "GameClient/SaveLoadFeedback.h"
#include "GameClient/Shell.h"
#include "GameLogic/GameLogic.h"
#include "GameClient/GameWindowTransitions.h"
@@ -404,7 +405,10 @@ static void doLoadGame()
// loose these allocated user data pointers attached as listbox item data when the
// engine resets
//
- if (TheGameState->loadGame( *selectedGameInfo ) != SC_OK)
+ const AsciiString filename = selectedGameInfo->filename;
+ const SaveCode result = TheGameState->loadGame( *selectedGameInfo );
+ presentLoadResult( result, filename );
+ if (result != SC_OK)
{
if (TheGameLogic->isInGame())
TheGameLogic->clearGameData( FALSE );
@@ -771,7 +775,8 @@ WindowMsgHandledType SaveLoadMenuSystem( GameWindow *window, UnsignedInt msg,
// save the game
AsciiString filename;
filename = selectedGameInfo->filename;
- TheGameState->saveGame( filename, selectedGameInfo->saveGameInfo.description, fileType );
+ presentSaveResult( TheGameState->saveGame( filename,
+ selectedGameInfo->saveGameInfo.description, fileType ) );
/*
// set the description text entry field to default value
@@ -835,7 +840,7 @@ WindowMsgHandledType SaveLoadMenuSystem( GameWindow *window, UnsignedInt msg,
AsciiString filename;
if( selectedGameInfo )
filename = selectedGameInfo->filename;
- TheGameState->saveGame( filename, desc, fileType );
+ presentSaveResult( TheGameState->saveGame( filename, desc, fileType ) );
}
else if( controlID == buttonSaveDescCancel )
diff --git a/Generals/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/ScoreScreen.cpp b/Generals/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/ScoreScreen.cpp
index 8eda61ca615..0202b74a41e 100644
--- a/Generals/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/ScoreScreen.cpp
+++ b/Generals/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/ScoreScreen.cpp
@@ -74,6 +74,7 @@
#include "GameLogic/VictoryConditions.h"
#include "GameClient/Display.h"
#include "GameClient/GUICallbacks.h"
+#include "GameClient/SaveLoadFeedback.h"
#include "GameClient/WindowLayout.h"
#include "GameClient/GameWindowManager.h"
#include "GameClient/Gadget.h"
@@ -767,7 +768,7 @@ void finishSinglePlayerInit()
GadgetButtonSetText(buttonContinue, TheGameText->fetch("GUI:SaveAndContinue"));
// auto save game
- TheGameState->missionSave();
+ presentSaveResult( TheGameState->missionSave() );
if(staticTextGameSaved)
staticTextGameSaved->winHide(FALSE);
}
diff --git a/Generals/Code/GameEngine/Source/GameClient/GUI/Shell/Shell.cpp b/Generals/Code/GameEngine/Source/GameClient/GUI/Shell/Shell.cpp
index 909e27a0e21..bb2093df288 100644
--- a/Generals/Code/GameEngine/Source/GameClient/GUI/Shell/Shell.cpp
+++ b/Generals/Code/GameEngine/Source/GameClient/GUI/Shell/Shell.cpp
@@ -465,10 +465,13 @@ void Shell::showShell( Bool runInit )
{
DEBUG_LOG(("Shell:showShell() - %s (%s)", TheGlobalData->m_initialFile.str(), (top())?top()->getFilename().str():"no top screen"));
- if(!TheGlobalData->m_initialFile.isEmpty() || !TheGlobalData->m_simulateReplays.empty())
+ if(!TheGlobalData->m_initialFile.isEmpty() || !TheGlobalData->m_simulateReplays.empty() ||
+ TheGlobalData->m_loadSaveGame.isNotEmpty())
{
return;
}
+ const Bool isCommandLineLoadPending = TheGlobalData->m_loadSaveGame.isNotEmpty() ||
+ TheGlobalData->m_loadReplayGame.isNotEmpty();
// runInit is used if we want show shell to run
if(runInit)
@@ -513,17 +516,21 @@ void Shell::showShell( Bool runInit )
// }
- if (!TheGlobalData->m_shellMapOn && m_screenCount == 0)
- //else
+ if ((!TheGlobalData->m_shellMapOn || isCommandLineLoadPending) && m_screenCount == 0)
+ {
push( "Menus/MainMenu.wnd" );
+ }
m_isShellActive = TRUE;
}
void Shell::showShellMap(Bool useShellMap )
{
// we don't want any of this to show if we're loading straight into a file
- if (TheGlobalData->m_initialFile.isNotEmpty() || !TheGameLogic || !TheGlobalData->m_simulateReplays.empty())
+ if (TheGlobalData->m_initialFile.isNotEmpty() || !TheGameLogic || !TheGlobalData->m_simulateReplays.empty() ||
+ TheGlobalData->m_loadSaveGame.isNotEmpty() || TheGlobalData->m_loadReplayGame.isNotEmpty())
+ {
return;
+ }
if(useShellMap && TheGlobalData->m_shellMapOn)
{
// we're already in a shell game, return
diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GameState.h b/GeneralsMD/Code/GameEngine/Include/Common/GameState.h
index 16bc991b69a..e8f93386a5d 100644
--- a/GeneralsMD/Code/GameEngine/Include/Common/GameState.h
+++ b/GeneralsMD/Code/GameEngine/Include/Common/GameState.h
@@ -132,6 +132,15 @@ enum SaveCode CPP_11(: Int)
SC_ERROR,
};
+struct SaveResult
+{
+ explicit SaveResult( SaveCode code ) : saveCode(code) { }
+ SaveResult( SaveCode code, const AsciiString &file ) : saveCode(code), filename(file) { }
+
+ SaveCode saveCode;
+ AsciiString filename; ///< the file that was written, empty when no filename could be found
+};
+
enum SnapshotType CPP_11(: Int) {
SNAPSHOT_SAVELOAD,
SNAPSHOT_DEEPCRC_LOGICONLY,
@@ -156,11 +165,11 @@ class GameState : public SubsystemInterface,
virtual void update() override { }
// save game methods
- SaveCode saveGame( AsciiString filename,
+ SaveResult saveGame( AsciiString filename,
UnicodeString desc,
SaveFileType saveType,
- SnapshotType which = SNAPSHOT_SAVELOAD ); ///< save a game
- SaveCode missionSave(); ///< do a in between mission save
+ SnapshotType which = SNAPSHOT_SAVELOAD ); ///< save a game
+ SaveResult missionSave(); ///< do a in between mission save
SaveCode loadGame( AvailableGameInfo gameInfo ); ///< load a save file
SaveGameInfo *getSaveGameInfo() { return &m_gameInfo; }
@@ -170,7 +179,7 @@ class GameState : public SubsystemInterface,
// manipulating files
Bool doesSaveGameExist( AsciiString filename ); ///< does the save file exist
void populateSaveGameListbox( GameWindow *listbox, SaveLoadLayoutType layoutType ); ///< populate listbox with available save games
- void getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *saveGameInfo ); ///< get save game info from file
+ void getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *saveGameInfo ); ///< get save game info from a Save-directory name or absolute path
void friend_xferSaveDataForCRC( Xfer *xfer, SnapshotType which ); ///< This should only be called to DeepCRC sanity checking
@@ -181,6 +190,7 @@ class GameState : public SubsystemInterface,
AsciiString getSaveDirectory() const;
AsciiString getFilePathInSaveDirectory(const AsciiString& leaf) const;
+ AsciiString getSaveGamePathForRead(const AsciiString& filenameOrPath) const;
Bool isInSaveDirectory(const AsciiString& path) const;
AsciiString realMapPathToPortableMapPath(const AsciiString& in) const;
diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h
index 7f484111672..a1df63ea29d 100644
--- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h
+++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h
@@ -351,6 +351,8 @@ class GlobalData : public SubsystemInterface
Bool m_buildMapCache;
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, load 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/Source/Common/GameEngine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp
index 32b93d3dba7..e0973c7824b 100644
--- a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp
@@ -890,6 +890,40 @@ Bool GameEngine::canUpdateRegularGameLogic(UnsignedInt logicTimeQueryFlags)
/// -----------------------------------------------------------------------------------------------
DECLARE_PERF_TIMER(GameEngine_update)
+// TheSuperHackers @feature bobtista 08/08/2026 Validate command-line replays before starting visual playback.
+static Bool validateCommandLineReplay(const AsciiString& filename)
+{
+ RecorderClass::ReplayHeader header;
+ header.forPlayback = FALSE;
+ header.filename = filename;
+
+ if (TheRecorder == nullptr || !TheRecorder->readReplayHeader(header))
+ {
+ DEBUG_LOG(("Failed to read replay '%s'", filename.str()));
+ return FALSE;
+ }
+
+ ReplayGameInfo gameInfo;
+ if (!ParseAsciiStringToGameInfo(&gameInfo, header.gameOptions))
+ {
+ DEBUG_LOG(("Replay '%s' contains invalid game options", filename.str()));
+ return FALSE;
+ }
+
+ if (TheMapCache == nullptr || TheMapCache->findMap(gameInfo.getMap()) == nullptr)
+ {
+ DEBUG_LOG(("Replay '%s' requires unavailable map '%s'", filename.str(), gameInfo.getMap().str()));
+ return FALSE;
+ }
+
+ if (!RecorderClass::replayMatchesGameVersion(header))
+ {
+ DEBUG_LOG(("Replay '%s' was created by a different game version; command-line playback will continue", filename.str()));
+ }
+
+ return TRUE;
+}
+
/** -----------------------------------------------------------------------------------------------
* Update the game engine by updating the GameClient and GameLogic singletons.
*/
@@ -915,6 +949,71 @@ void GameEngine::update()
}
}
+ // TheSuperHackers @feature bobtista 22/07/2026 Defer command-line save loading until the client has created the shell
+ // layout that normal game sessions return to.
+ if (TheGlobalData->m_loadSaveGame.isNotEmpty())
+ {
+ AvailableGameInfo gameInfo;
+ gameInfo.filename = TheGlobalData->m_loadSaveGame;
+ gameInfo.next = nullptr;
+ gameInfo.prev = nullptr;
+
+ AsciiString filename = gameInfo.filename;
+ Bool loadSucceeded = FALSE;
+ try
+ {
+ TheGameState->getSaveGameInfoFromFile(gameInfo.filename, &gameInfo.saveGameInfo);
+ TheGameLogic->prepareNewGame(GAME_SINGLE_PLAYER, DIFFICULTY_NORMAL, 0);
+ loadSucceeded = TheGameState->loadGame(gameInfo) == SC_OK;
+ }
+ catch (...)
+ {
+ DEBUG_LOG(("Failed to read save game '%s'", filename.str()));
+ }
+
+ TheWritableGlobalData->m_loadSaveGame.clear();
+ if (loadSucceeded)
+ {
+ if (TheShell)
+ {
+ TheShell->hideShell();
+ }
+ }
+ else
+ {
+ if (TheGameLogic->isInGame())
+ {
+ TheGameLogic->clearGameData(FALSE);
+ }
+ TheGameEngine->reset();
+ DEBUG_LOG(("Failed to load save game '%s'", filename.str()));
+ printf("Failed to load save game '%s'\n", filename.str());
+ m_quitting = TRUE;
+ }
+ }
+
+ // TheSuperHackers @feature bobtista 08/08/2026 Defer command-line replay loading until the client has initialized the
+ // UI state used by normal visual replay playback.
+ if (TheGlobalData->m_loadReplayGame.isNotEmpty())
+ {
+ AsciiString replayGame = TheGlobalData->m_loadReplayGame;
+ TheWritableGlobalData->m_loadReplayGame.clear();
+
+ if (validateCommandLineReplay(replayGame) && TheRecorder->playbackFile(replayGame))
+ {
+ if (TheShell)
+ {
+ TheShell->hideShell();
+ }
+ }
+ else
+ {
+ DEBUG_LOG(("Failed to load replay '%s'", replayGame.str()));
+ printf("Failed to load replay '%s'\n", replayGame.str());
+ m_quitting = TRUE;
+ }
+ }
+
// 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 e862cd149d5..09a9a09e899 100644
--- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp
@@ -993,6 +993,8 @@ GlobalData::GlobalData()
m_buildMapCache = FALSE;
m_initialFile.clear();
m_pendingFile.clear();
+ m_loadSaveGame.clear();
+ m_loadReplayGame.clear();
m_simulateReplays.clear();
m_simulateReplayJobs = SIMULATE_REPLAYS_SEQUENTIAL;
diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp
index 9d71eb45b1b..1b581a247e0 100644
--- a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp
@@ -849,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 (!FileSystem::isAbsolutePath(filepath))
+ {
+ 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 +1006,17 @@ 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 Allow explicit diagnostic replay playback to continue without UI reporting or pausing.
+ if (TheDebugIgnoreSyncErrors)
+ {
+ const UnsignedInt mismatchFrame = TheGameLogic->getFrame() - m_crcInfo.GetQueueSize() - 1;
+ DEBUG_LOG(("Replay CRC mismatch ignored at frame %d\nInGame:%8.8X Replay:%8.8X",
+ mismatchFrame, playbackCRC, newCRC));
+ printf("CRC Mismatch in Frame %d (ignored)\n", mismatchFrame);
+ 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
diff --git a/GeneralsMD/Code/GameEngine/Source/Common/System/SaveGame/GameState.cpp b/GeneralsMD/Code/GameEngine/Source/Common/System/SaveGame/GameState.cpp
index 04cc701b5e1..88513a979f2 100644
--- a/GeneralsMD/Code/GameEngine/Source/Common/System/SaveGame/GameState.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/Common/System/SaveGame/GameState.cpp
@@ -48,7 +48,6 @@
#include "GameClient/GameClient.h"
#include "GameClient/GameText.h"
#include "GameClient/MapUtil.h"
-#include "GameClient/MessageBox.h"
#include "GameClient/InGameUI.h"
#include "GameClient/ParticleSys.h"
#include "GameClient/TerrainVisual.h"
@@ -73,6 +72,13 @@ static const Int MAX_SAVE_FILE_NUMBER = 99999999;
#define GAME_STATE_BLOCK_STRING "CHUNK_GameState" // block of save game data with game info data
#define CAMPAIGN_BLOCK_STRING "CHUNK_Campaign" // block of game data that has campaign info
+static Bool isHeadlessOmittedBlock( const AsciiString &blockName )
+{
+ return blockName.compareNoCase( "CHUNK_ParticleSystem" ) == 0 ||
+ blockName.compareNoCase( "CHUNK_TerrainVisual" ) == 0 ||
+ blockName.compareNoCase( "CHUNK_GhostObject" ) == 0;
+}
+
// ------------------------------------------------------------------------------------------------
// ------------------------------------------------------------------------------------------------
SaveGameInfo::SaveGameInfo()
@@ -532,8 +538,8 @@ AsciiString GameState::findNextSaveFilename( UnicodeString desc )
/** Save the current state of the engine in a save file
* NOTE: filename is a *filename only* */
// ------------------------------------------------------------------------------------------------
-SaveCode GameState::saveGame( AsciiString filename, UnicodeString desc,
- SaveFileType saveType, SnapshotType which )
+SaveResult GameState::saveGame( AsciiString filename, UnicodeString desc,
+ SaveFileType saveType, SnapshotType which )
{
// if there is no filename, this is a new file being created, find an appropriate filename
@@ -543,7 +549,7 @@ SaveCode GameState::saveGame( AsciiString filename, UnicodeString desc,
{
DEBUG_CRASH(( "GameState::saveGame - Unable to find valid filename for save game" ));
- return SC_NO_FILE_AVAILABLE;
+ return SaveResult( SC_NO_FILE_AVAILABLE );
}
@@ -561,10 +567,8 @@ SaveCode GameState::saveGame( AsciiString filename, UnicodeString desc,
try {
xferSave.open( filepath );
} catch(...) {
- // print error message to the user
- TheInGameUI->message( "GUI:Error" );
DEBUG_LOG(( "Error opening file '%s'", filepath.str() ));
- return SC_ERROR;
+ return SaveResult( SC_UNABLE_TO_OPEN_FILE, filename );
}
// save our save file type
@@ -592,35 +596,23 @@ SaveCode GameState::saveGame( AsciiString filename, UnicodeString desc,
catch( ... )
{
- UnicodeString ufilepath;
- ufilepath.translate(filepath);
-
- UnicodeString msg;
- msg.format( TheGameText->fetch("GUI:ErrorSavingGame"), ufilepath.str() );
-
- MessageBoxOk(TheGameText->fetch("GUI:Error"), msg, nullptr);
-
// close the file and get out of here
xferSave.close();
- return SC_ERROR;
+ return SaveResult( SC_ERROR, filename );
}
// close the file
xferSave.close();
- // print message to the user for game successfully saved
- UnicodeString msg = TheGameText->fetch( "GUI:GameSaveComplete" );
- TheInGameUI->message( msg );
-
- return SC_OK;
+ return SaveResult( SC_OK, filename );
}
// ------------------------------------------------------------------------------------------------
/** A mission save */
// ------------------------------------------------------------------------------------------------
-SaveCode GameState::missionSave()
+SaveResult GameState::missionSave()
{
// get campaign
@@ -664,8 +656,9 @@ SaveCode GameState::loadGame( AvailableGameInfo gameInfo )
//
TheGameStateMap->clearScratchPadMaps();
- // construct path to file
- AsciiString filepath = getFilePathInSaveDirectory(gameInfo.filename);
+ // Relative names come from the save menu. Absolute paths can come from
+ // command-line file handlers and are opened in place.
+ AsciiString filepath = getSaveGamePathForRead(gameInfo.filename);
// open the save file
XferLoad xferLoad;
@@ -717,15 +710,6 @@ SaveCode GameState::loadGame( AvailableGameInfo gameInfo )
TheGameLogic->clearGameData( FALSE );
TheGameEngine->reset();
- // print error message to the user
- UnicodeString ufilepath;
- ufilepath.translate(filepath);
-
- UnicodeString msg;
- msg.format( TheGameText->fetch("GUI:ErrorLoadingGame"), ufilepath.str() );
-
- MessageBoxOk(TheGameText->fetch("GUI:Error"), msg, nullptr);
-
return SC_INVALID_DATA; // you can't use a naked "throw" outside of a catch statement!
}
@@ -773,6 +757,19 @@ AsciiString GameState::getFilePathInSaveDirectory(const AsciiString& leaf) const
return tmp;
}
+//-------------------------------------------------------------------------------------------------
+// TheSuperHackers @feature bobtista 08/08/2026 Open command-line save paths in place while
+// preserving the managed Save directory for relative menu filenames and save writes.
+AsciiString GameState::getSaveGamePathForRead(const AsciiString& filenameOrPath) const
+{
+ if (FileSystem::isAbsolutePath(filenameOrPath))
+ {
+ return filenameOrPath;
+ }
+
+ return getFilePathInSaveDirectory(filenameOrPath);
+}
+
//-------------------------------------------------------------------------------------------------
Bool GameState::isInSaveDirectory(const AsciiString& path) const
{
@@ -930,8 +927,7 @@ AsciiString GameState::portableMapPathToRealMapPath(const AsciiString& in) const
Bool GameState::doesSaveGameExist( AsciiString filename )
{
- // construct full path to file
- AsciiString filepath = getFilePathInSaveDirectory(filename);
+ AsciiString filepath = getSaveGamePathForRead(filename);
// open file
XferLoad xfer;
@@ -976,6 +972,8 @@ void GameState::getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *sav
}
+ filename = getSaveGamePathForRead(filename);
+
// open file for partial loading
XferLoad xferLoad;
xferLoad.open( filename );
@@ -1351,6 +1349,10 @@ void GameState::xferSaveData( Xfer *xfer, SnapshotType which )
blockName = blockInfo->blockName;
DEBUG_LOG(("Looking at block '%s'", blockName.str()));
+ if( TheGlobalData->m_headless && isHeadlessOmittedBlock( blockName ) )
+ {
+ continue;
+ }
//
// for mission save files, we only save the game state block and campaign manager
@@ -1448,8 +1450,15 @@ void GameState::xferSaveData( Xfer *xfer, SnapshotType which )
// read block start
blockSize = xfer->beginBlock();
- // parse this data
- xfer->xferSnapshot( blockInfo->snapshot );
+ if( TheGlobalData->m_headless && isHeadlessOmittedBlock( token ) )
+ {
+ xfer->skip( blockSize );
+ }
+ else
+ {
+ // parse this data
+ xfer->xferSnapshot( blockInfo->snapshot );
+ }
// read block end
xfer->endBlock();
diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupSaveLoad.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupSaveLoad.cpp
index 45cb58fd4b9..0831837b275 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupSaveLoad.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupSaveLoad.cpp
@@ -56,6 +56,7 @@
#include "GameClient/GameText.h"
#include "GameClient/GameWindowManager.h"
#include "GameClient/GUICallbacks.h"
+#include "GameClient/SaveLoadFeedback.h"
#include "GameClient/Shell.h"
#include "GameLogic/GameLogic.h"
#include "GameClient/GameWindowTransitions.h"
@@ -414,7 +415,10 @@ static void doLoadGame()
// loose these allocated user data pointers attached as listbox item data when the
// engine resets
//
- if (TheGameState->loadGame( *selectedGameInfo ) != SC_OK)
+ const AsciiString filename = selectedGameInfo->filename;
+ const SaveCode result = TheGameState->loadGame( *selectedGameInfo );
+ presentLoadResult( result, filename );
+ if (result != SC_OK)
{
if (TheGameLogic->isInGame())
TheGameLogic->clearGameData( FALSE );
@@ -787,7 +791,8 @@ WindowMsgHandledType SaveLoadMenuSystem( GameWindow *window, UnsignedInt msg,
// save the game
AsciiString filename;
filename = selectedGameInfo->filename;
- TheGameState->saveGame( filename, selectedGameInfo->saveGameInfo.description, fileType );
+ presentSaveResult( TheGameState->saveGame( filename,
+ selectedGameInfo->saveGameInfo.description, fileType ) );
/*
// set the description text entry field to default value
@@ -851,7 +856,7 @@ WindowMsgHandledType SaveLoadMenuSystem( GameWindow *window, UnsignedInt msg,
AsciiString filename;
if( selectedGameInfo )
filename = selectedGameInfo->filename;
- TheGameState->saveGame( filename, desc, fileType );
+ presentSaveResult( TheGameState->saveGame( filename, desc, fileType ) );
}
else if( controlID == buttonSaveDescCancel )
diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/ScoreScreen.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/ScoreScreen.cpp
index 3f37cfab57f..2b49f943ff1 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/ScoreScreen.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/ScoreScreen.cpp
@@ -77,6 +77,7 @@
#include "GameLogic/VictoryConditions.h"
#include "GameClient/Display.h"
#include "GameClient/GUICallbacks.h"
+#include "GameClient/SaveLoadFeedback.h"
#include "GameClient/WindowLayout.h"
#include "GameClient/GameWindowManager.h"
#include "GameClient/Gadget.h"
@@ -928,7 +929,7 @@ void finishSinglePlayerInit()
GadgetButtonSetText(buttonContinue, TheGameText->fetch("GUI:SaveAndContinue"));
// auto save game
- TheGameState->missionSave();
+ presentSaveResult( TheGameState->missionSave() );
if(staticTextGameSaved)
staticTextGameSaved->winHide(FALSE);
}
diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/Shell/Shell.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/Shell/Shell.cpp
index 1b6278d02db..a11aac12ffb 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/Shell/Shell.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/Shell/Shell.cpp
@@ -469,6 +469,8 @@ void Shell::showShell( Bool runInit )
{
return;
}
+ const Bool isCommandLineLoadPending = TheGlobalData->m_loadSaveGame.isNotEmpty() ||
+ TheGlobalData->m_loadReplayGame.isNotEmpty();
// runInit is used if we want show shell to run
if(runInit)
@@ -513,12 +515,11 @@ void Shell::showShell( Bool runInit )
// }
- if (!TheGlobalData->m_shellMapOn && m_screenCount == 0)
+ if ((!TheGlobalData->m_shellMapOn || isCommandLineLoadPending) && m_screenCount == 0)
{
#ifdef RTS_PROFILE_LEGACY
Profile::StopRange("init");
#endif
- //else
push( "Menus/MainMenu.wnd" );
}
m_isShellActive = TRUE;
@@ -527,8 +528,11 @@ void Shell::showShell( Bool runInit )
void Shell::showShellMap(Bool useShellMap )
{
// we don't want any of this to show if we're loading straight into a file
- if (TheGlobalData->m_initialFile.isNotEmpty() || !TheGameLogic || !TheGlobalData->m_simulateReplays.empty())
+ if (TheGlobalData->m_initialFile.isNotEmpty() || !TheGameLogic || !TheGlobalData->m_simulateReplays.empty() ||
+ TheGlobalData->m_loadSaveGame.isNotEmpty() || TheGlobalData->m_loadReplayGame.isNotEmpty())
+ {
return;
+ }
if(useShellMap && TheGlobalData->m_shellMapOn)
{
// we're already in a shell game, return