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();
+};