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