diff --git a/CREDITS.md b/CREDITS.md index 0fb9954050..d4e1fd97bc 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -49,6 +49,8 @@ This page lists all the individual contributions to the project by their author. - Voxel light source position customization - `UseFixedVoxelLighting` - Warhead activation target health thresholds + - MP saves support for quicksave command and savegame trigger action + - Ported XNA CnCNet Client MP save handling - **Uranusian (Thrifinesma)**: - Mind Control enhancement - Custom warhead splash list diff --git a/Phobos.vcxproj b/Phobos.vcxproj index 47cd65d4a7..2509d78ba7 100644 --- a/Phobos.vcxproj +++ b/Phobos.vcxproj @@ -197,8 +197,10 @@ + + @@ -292,6 +294,7 @@ + diff --git a/YRpp b/YRpp index 1a4e510539..2d19944aa0 160000 --- a/YRpp +++ b/YRpp @@ -1 +1 @@ -Subproject commit 1a4e510539459bc3535b477e2b1a873fc62821f0 +Subproject commit 2d19944aa0846a098826b4cdd2e0ac00f36915b0 diff --git a/docs/AI-Scripting-and-Mapping.md b/docs/AI-Scripting-and-Mapping.md index 942dc35c3b..2c0ab8593b 100644 --- a/docs/AI-Scripting-and-Mapping.md +++ b/docs/AI-Scripting-and-Mapping.md @@ -498,7 +498,12 @@ This category is empty for now. ### `500` Save Game -- Save the current game immediately (singleplayer game only). +- Save the current game immediately. + +```{note} +For this action to work in multiplayer - you need to use a version of [YRpp spawner](https://github.com/CnCNet/yrpp-spawner) with multiplayer saves support. +``` + - These vanilla CSF entries will be used: `TXT_SAVING_GAME`, `TXT_GAME_WAS_SAVED` and `TXT_ERROR_SAVING_GAME`. - The save's description will look like `MapDescName - CSFText`. - For example: `Allied Mission 25: Esther's Money - Money Stolen`. diff --git a/docs/Fixed-or-Improved-Logics.md b/docs/Fixed-or-Improved-Logics.md index 0b32fc9a08..2397954fa2 100644 --- a/docs/Fixed-or-Improved-Logics.md +++ b/docs/Fixed-or-Improved-Logics.md @@ -274,6 +274,19 @@ This page describes all ingame logics that are fixed or improved in Phobos witho - Fixed an issue where some units crashed after the deployment transformation. - Fixed the bug that AlphaImage remained after unit entered tunnel. - Fixed an issue where Ares' `Convert.Deploy` triggers repeatedly when the unit is turning or moving. +- The game now automatically changes save file name from `SAVEGAME.NET` to `SVGM_XXX.NET` (where `XXX` is a number) when saving to prevent occasional overwriting of the save file when using Phobos with XNA CnCNet Client and saving too frequently. + - 1000 save files are supported, from `SVGM_000.NET` to `SVGM_999.NET`. When the limit is reached, the game will overwrite the latest save file. + - The previous `SVGM_XXX.NET` files are cleaned up before first copy if it's a new game, otherwise the highest numbered `SVGM_XXX.NET` file is found and the index is incremented, if possible. + - The game also automatically copies `spawn.ini` to the save folder as `spawnSG.ini` when saving a game. + + +```{note} +The described behavior is a replica of and is compliant with XNA CnCNet Client's multiplayer save game support. +``` + +```{note} +At the moment this is only useful if you use a version of [YRpp Spawner](https://github.com/CnCNet/yrpp-spawner) with multiplayer saves support (along with [XNA CnCNet Client](https://github.com/CnCNet/xna-cncnet-client)). +``` ## Aircraft diff --git a/docs/User-Interface.md b/docs/User-Interface.md index 30b4f2c345..5807427f78 100644 --- a/docs/User-Interface.md +++ b/docs/User-Interface.md @@ -462,7 +462,12 @@ DisplayIncome.Offset=0,0 ; X,Y, pixels relative to default ### `[ ]` Quicksave -- Save the current singleplayer game. +- Saves the current game. + +```{note} +For this command to work in multiplayer - you need to use a version of [YRpp spawner](https://github.com/CnCNet/yrpp-spawner) with multiplayer saves support. +``` + - For localization, add `TXT_QUICKSAVE`, `TXT_QUICKSAVE_DESC`, `TXT_QUICKSAVE_SUFFIX` and `MSG:NotAvailableInMultiplayer` into your `.csf` file. - These vanilla CSF entries will be used: `TXT_SAVING_GAME`, `TXT_GAME_WAS_SAVED` and `TXT_ERROR_SAVING_GAME`. - The save should be looks like `Allied Mission 25: Esther's Money - QuickSaved`. diff --git a/docs/Whats-New.md b/docs/Whats-New.md index 007025be34..a1cbdd831e 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -834,6 +834,8 @@ Fixes / interactions with other extensions: - Fixed an issue where some units crashed after the deployment transformation (by ststl & FlyStar) - Fixed the bug that AlphaImage remained after unit entered tunnel (by NetsuNegi) - Fixed an issue where Ares' `Convert.Deploy` triggers repeatedly when the unit is turning or moving (by CrimRecya) +- Fixed quicksave command and save game trigger action to work with YRpp spawner's multiplayer saves (by Kerbiter) +- Ported XNA CnCNet Client multiplayer save handling to get rid of occasional multiplayer save file overwriting when saving too fast (by Kerbiter) ``` ### 0.3.0.1 diff --git a/src/Commands/QuickSave.cpp b/src/Commands/QuickSave.cpp index e55d3df152..85c6514306 100644 --- a/src/Commands/QuickSave.cpp +++ b/src/Commands/QuickSave.cpp @@ -3,7 +3,9 @@ #include #include #include +#include #include +#include const char* QuickSaveCommandClass::GetName() const { @@ -22,7 +24,7 @@ const wchar_t* QuickSaveCommandClass::GetUICategory() const const wchar_t* QuickSaveCommandClass::GetUIDescription() const { - return GeneralUtils::LoadStringUnlessMissing("TXT_QUICKSAVE_DESC", L"Save the current game (Singleplayer only)."); + return GeneralUtils::LoadStringUnlessMissing("TXT_QUICKSAVE_DESC", L"Save the current game."); } void QuickSaveCommandClass::Execute(WWKey eInput) const @@ -39,15 +41,12 @@ void QuickSaveCommandClass::Execute(WWKey eInput) const if (SessionClass::IsSingleplayer()) { - *reinterpret_cast(0xABCE08) = false; - Phobos::ShouldQuickSave = true; - - if (SessionClass::IsCampaign()) - Phobos::CustomGameSaveDescription = ScenarioClass::Instance->UINameLoaded; - else - Phobos::CustomGameSaveDescription = ScenarioClass::Instance->Name; - Phobos::CustomGameSaveDescription += L" - "; - Phobos::CustomGameSaveDescription += GeneralUtils::LoadStringUnlessMissing("TXT_QUICKSAVE_SUFFIX", L"Quicksaved"); + Phobos::ScheduleGameSave(GeneralUtils::LoadStringUnlessMissing("TXT_QUICKSAVE_SUFFIX", L"Quicksaved")); + } + else if (SpawnerHelper::IsSaveGameEventHooked()) + { + // Relinquish handling of the save game to spawner + EventClass::OutList.Add(EventClass { HouseClass::CurrentPlayer->ArrayIndex, EventType::SaveGame }); } else { diff --git a/src/Ext/TAction/Body.cpp b/src/Ext/TAction/Body.cpp index e23faeba7c..9284bebb66 100644 --- a/src/Ext/TAction/Body.cpp +++ b/src/Ext/TAction/Body.cpp @@ -10,6 +10,7 @@ #include #include +#include #include //Static init @@ -116,18 +117,8 @@ bool TActionExt::PlayAudioAtRandomWP(TActionClass* pThis, HouseClass* pHouse, Ob bool TActionExt::SaveGame(TActionClass* pThis, HouseClass* pHouse, ObjectClass* pObject, TriggerClass* pTrigger, CellStruct const& location) { - if (SessionClass::IsSingleplayer()) - { - *reinterpret_cast(0xABCE08) = false; - Phobos::ShouldQuickSave = true; - - if (SessionClass::IsCampaign()) - Phobos::CustomGameSaveDescription = ScenarioClass::Instance->UINameLoaded; - else - Phobos::CustomGameSaveDescription = ScenarioClass::Instance->Name; - Phobos::CustomGameSaveDescription += L" - "; - Phobos::CustomGameSaveDescription += StringTable::LoadString(pThis->Text); - } + if (SessionClass::IsSingleplayer() || SpawnerHelper::IsSaveGameEventHooked()) + Phobos::ScheduleGameSave(StringTable::LoadString(pThis->Text)); return true; } diff --git a/src/Phobos.INI.cpp b/src/Phobos.INI.cpp index 5b458a3089..32f6a0b953 100644 --- a/src/Phobos.INI.cpp +++ b/src/Phobos.INI.cpp @@ -277,56 +277,3 @@ DEFINE_HOOK(0x52D21F, InitRules_ThingsThatShouldntBeSerailized, 0x6) return 0; } - - -bool Phobos::ShouldQuickSave = false; -std::wstring Phobos::CustomGameSaveDescription {}; - -void Phobos::PassiveSaveGame() -{ - auto PrintMessage = [](const wchar_t* pMessage) - { - MessageListClass::Instance.PrintMessage( - pMessage, - RulesClass::Instance->MessageDelay, - HouseClass::CurrentPlayer->ColorSchemeIndex, - true - ); - }; - - PrintMessage(StringTable::LoadString(GameStrings::TXT_SAVING_GAME)); - char fName[0x80]; - - SYSTEMTIME time; - GetLocalTime(&time); - - _snprintf_s(fName, 0x7F, "Map.%04u%02u%02u-%02u%02u%02u-%05u.sav", - time.wYear, time.wMonth, time.wDay, time.wHour, time.wMinute, time.wSecond, time.wMilliseconds); - - if (ScenarioClass::SaveGame(fName, Phobos::CustomGameSaveDescription.c_str())) - PrintMessage(StringTable::LoadString(GameStrings::TXT_GAME_WAS_SAVED)); - else - PrintMessage(StringTable::LoadString(GameStrings::TXT_ERROR_SAVING_GAME)); -} - -DEFINE_HOOK(0x55DBCD, MainLoop_SaveGame, 0x6) -{ - // This happens right before LogicClass::Update() - enum { SkipSave = 0x55DC99, InitialSave = 0x55DBE6 }; - - bool& scenario_saved = *reinterpret_cast(0xABCE08); - if (SessionClass::IsSingleplayer() && !scenario_saved) - { - scenario_saved = true; - if (Phobos::ShouldQuickSave) - { - Phobos::PassiveSaveGame(); - Phobos::ShouldQuickSave = false; - Phobos::CustomGameSaveDescription.clear(); - } - else if (Phobos::Config::SaveGameOnScenarioStart && SessionClass::IsCampaign()) - return InitialSave; - } - - return SkipSave; -} diff --git a/src/Phobos.Save.cpp b/src/Phobos.Save.cpp new file mode 100644 index 0000000000..4b315ec688 --- /dev/null +++ b/src/Phobos.Save.cpp @@ -0,0 +1,232 @@ +#include "Phobos.h" + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +bool Phobos::ShouldSave = false; +std::wstring Phobos::CustomGameSaveDescription {}; + +void Phobos::ScheduleGameSave(const std::wstring& description) +{ + ScenarioClass::WasGameSaved = false; + Phobos::ShouldSave = true; + + if (SessionClass::IsCampaign()) + Phobos::CustomGameSaveDescription = ScenarioClass::Instance->UINameLoaded; + else + Phobos::CustomGameSaveDescription = ScenarioClass::Instance->Name; + Phobos::CustomGameSaveDescription += L" - "; + Phobos::CustomGameSaveDescription += description; +} + +void Phobos::PassiveSaveGame() +{ + auto PrintMessage = [](const wchar_t* pMessage) + { + MessageListClass::Instance.PrintMessage( + pMessage, + RulesClass::Instance->MessageDelay, + HouseClass::CurrentPlayer->ColorSchemeIndex, + /* bSilent: */ true + ); + + // Force a redraw so that our message gets printed. + if (Game::SpecialDialog == 0) + { + MapClass::Instance.MarkNeedsRedraw(2); + MapClass::Instance.Render(); + } + }; + + PrintMessage(StringTable::LoadString(GameStrings::TXT_SAVING_GAME)); + char fName[0x80]; + + if (SessionClass::IsSingleplayer()) + { + SYSTEMTIME time; + GetLocalTime(&time); + + _snprintf_s(fName, sizeof(fName), "Map.%04u%02u%02u-%02u%02u%02u-%05u.sav", + time.wYear, time.wMonth, time.wDay, time.wHour, time.wMinute, time.wSecond, time.wMilliseconds); + } + else if (SessionClass::IsMultiplayer()) + { + // Support for this is in the YRpp Spawner, be sure to read the respective comments + + _snprintf_s(fName, sizeof(fName), GameStrings::SAVEGAME_NET); + } + + if (ScenarioClass::SaveGame(fName, Phobos::CustomGameSaveDescription.c_str())) + PrintMessage(StringTable::LoadString(GameStrings::TXT_GAME_WAS_SAVED)); + else + PrintMessage(StringTable::LoadString(GameStrings::TXT_ERROR_SAVING_GAME)); + + Phobos::ShouldSave = false; + Phobos::CustomGameSaveDescription.clear(); +} + +#define SVGM_XXX_NET "SVGM_XXX.NET" +#define SVGM_FORMAT "SVGM_%03d.NET" +#define SVGM_MAX 999 + +namespace SaveGameTemp +{ + int NextSaveIndex = 0; + bool IsSavingMPSave = false; +} + +// If we load the same game multiple times, we have to NOT reset the save index, +// so we can continue saving to the next free index. Consistent with XNA CnCNet Client. +DEFINE_HOOK(0x67E6DA, LoadGame_AfterInit_DetermineNextMPSaveIndex, 0x6) +{ + if (SessionClass::IsSingleplayer()) + return 0; + + GET(const char* const, fileName, ESI); + + namespace fs = std::filesystem; + + try + { + fs::path savePath(fileName); + fs::path saveDir = savePath.parent_path(); + + // Check all possible save files starting from the highest index down to 0 + for (int i = SVGM_MAX; i >= 0; --i) + { + char buf[sizeof(SVGM_XXX_NET)]; + std::snprintf(buf, sizeof(buf), SVGM_FORMAT, i); + fs::path svgmPath = saveDir / buf; + + if (fs::exists(svgmPath)) + { + Debug::Log("Found existing MP save file: %s\n", svgmPath.string().c_str()); + SaveGameTemp::NextSaveIndex = std::min(i + 1, SVGM_MAX); // Next index to use + break; + } + } + + Debug::Log("Determined latest MP save index: %d\n", SaveGameTemp::NextSaveIndex); + } + catch (const std::exception& e) + { + Debug::Log("Failed to determine next save index: %s\n", e.what()); + } + + return 0; +} + +// Existing XNA CNCNet Client can't handle renaming savegames when they +// are being saved too fast, which may happen when quicksaving f.ex., hence +// we do this here. Hooked at low level saving function for better compatibility +// with other DLLs that may save MP games, like the spawner itself. +// - Kerbiter +DEFINE_HOOK(0x67CEF0, ScenarioClass_SaveGame_AdjustMPSaveFileName, 0x6) +{ + GET(const char* const, fileName, ECX); + + // SAVEGAME.NET -> SVGM_XXX.NET + if (_strcmpi(fileName, GameStrings::SAVEGAME_NET) == 0 || _strcmpi(fileName, "SAVEGAME.NET") == 0) + { + static char newFileName[sizeof(SVGM_XXX_NET)]; + _snprintf_s(newFileName, sizeof(newFileName), SVGM_FORMAT, SaveGameTemp::NextSaveIndex); + + R->ECX(newFileName); + + Debug::Log("Changed multiplayer save file name from %s to %s\n", fileName, newFileName); + SaveGameTemp::IsSavingMPSave = true; // Set this so that we cang increment the save index later + } + + return 0; +} + +// This hook is very strategically placed so that it is called +// after spawner changes the directory, so we can also copy the +// spawn.ini to [savegame dir]/spawnSG.ini and cleanup old files. +// This also replicates XNA CNCNet Client behavior. +// - Kerbiter +DEFINE_HOOK(0x67CF16, ScenarioClass_SaveGame_CopySpawnIni, 0x5) +{ + // We only want to do this when saving a multiplayer game the first time + if (!SaveGameTemp::IsSavingMPSave || SaveGameTemp::NextSaveIndex != 0) // Not incremented yet so check against 0 + return 0; + + GET(const char* const, fileName, EDI); + + namespace fs = std::filesystem; + + try + { + fs::path spawnIni = "spawn.ini"; + + // Parse the save file path and replace filename with spawnSG.ini + fs::path savePath(fileName); + fs::path saveDir = savePath.parent_path(); + + if (fs::exists(spawnIni)) + fs::copy_file(spawnIni,saveDir / "spawnSG.ini", fs::copy_options::overwrite_existing); + + // Clean up old network save files + for (int i = 0; i <= SVGM_MAX; ++i) + { + char buf[sizeof(SVGM_XXX_NET)]; + std::snprintf(buf, sizeof(buf), SVGM_FORMAT, i); + fs::path svgmPath = saveDir / buf; + + if (fs::exists(svgmPath)) + fs::remove(svgmPath); + } + Debug::Log("Copied spawn.ini to %s/spawnSG.ini and cleaned up previous network saves\n", saveDir.string().c_str()); + } + catch (const std::exception& e) + { + Debug::Log("Failed to copy spawn.ini and cleanup previous network saves: %s\n", e.what()); + } + + return 0; +} + +// Only increment after it's confirmed that the file is created to mimic +// the behavior that XNA CNCNet Client has and expects. +DEFINE_HOOK(0x67CF64, ScenarioClass_SaveGame_IncrementMPSaveIndex, 0x7) +{ + if (SaveGameTemp::IsSavingMPSave) + { + // consistent with XNA CNCNet Client. don't ask me - Kerbiter + SaveGameTemp::NextSaveIndex = std::min(SaveGameTemp::NextSaveIndex + 1, SVGM_MAX); + SaveGameTemp::IsSavingMPSave = false; // Reset this so that we don't increment again + } + + return 0; +} + +DEFINE_HOOK(0x55DBCD, MainLoop_SaveGame, 0x6) +{ + // This happens right before LogicClass::Update() + enum { SkipSave = 0x55DC99, InitialSave = 0x55DBE6 }; + + if (!ScenarioClass::WasGameSaved) + { + ScenarioClass::WasGameSaved = true; + if (Phobos::ShouldSave) + Phobos::PassiveSaveGame(); + else if (Phobos::Config::SaveGameOnScenarioStart && SessionClass::IsCampaign()) + return InitialSave; + } + + return SkipSave; +} + +#undef SVGM_MAX +#undef SVGM_FORMAT +#undef SVGM_XXX_NET diff --git a/src/Phobos.cpp b/src/Phobos.cpp index 577a8d4501..785fdc889b 100644 --- a/src/Phobos.cpp +++ b/src/Phobos.cpp @@ -269,11 +269,6 @@ void Phobos::ApplyOptimizations() if (Phobos::Optimizations::DisableRadDamageOnBuildings) Patch::Apply_RAW(0x43FB23, { 0x53, 0x55, 0x56, 0x8B, 0xF1 }); - if (SessionClass::IsMultiplayer()) - { - // Disable MainLoop_SaveGame - Patch::Apply_LJMP(0x55DBCD, 0x55DC99); - } else { // Disable Random2Class_Random_SyncLog diff --git a/src/Phobos.h b/src/Phobos.h index fa1ac7d1d1..e30ca6b066 100644 --- a/src/Phobos.h +++ b/src/Phobos.h @@ -32,8 +32,9 @@ class Phobos static const wchar_t* VersionDescription; static bool DisplayDamageNumbers; static bool IsLoadingSaveGame; - static bool ShouldQuickSave; + static bool ShouldSave; static std::wstring CustomGameSaveDescription; + static void ScheduleGameSave(const std::wstring& description); static void PassiveSaveGame(); #ifdef DEBUG static bool DetachFromDebugger(); diff --git a/src/Utilities/SpawnerHelper.cpp b/src/Utilities/SpawnerHelper.cpp new file mode 100644 index 0000000000..e84808c8a8 --- /dev/null +++ b/src/Utilities/SpawnerHelper.cpp @@ -0,0 +1,9 @@ +#include "SpawnerHelper.h" + +#include +#include + +bool SpawnerHelper::IsSaveGameEventHooked() +{ + return SaveGameHookStart == LJMP_OPCODE; +} diff --git a/src/Utilities/SpawnerHelper.h b/src/Utilities/SpawnerHelper.h new file mode 100644 index 0000000000..7c0f04584a --- /dev/null +++ b/src/Utilities/SpawnerHelper.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include + +class SpawnerHelper +{ +private: + DEFINE_REFERENCE(const unsigned char, SaveGameHookStart, 0x4C7A14u); + +public: + // Spawner hooks 0x4C7A14 and places an LJMP there. We check that memory address on whether it is a valid LJMP opcode + // and assume (unreliable, but I am open for better ideas) that if it is, the save game event hook is active. + // + // This doesn't account for Spawner::Active, so in a case where the spawner is loaded but not active this will fail, + // but oh well I am not engeneering a complicated solution just to fix that niche case which wouldn't happen 99% of the time. + // + // To read more about this mess and possibly engineer a better solution, look up the comments mentioning 0x4C7A14 in spawner. + // - Kerbiter + static bool IsSaveGameEventHooked(); +};