From 6d5d611a98945b249ddfe963d737ce43840df13c Mon Sep 17 00:00:00 2001 From: Metadorius Date: Tue, 29 Jul 2025 16:50:52 +0300 Subject: [PATCH 01/10] Support YRpp spawner's MP saves --- Phobos.vcxproj | 2 ++ YRpp | 2 +- src/Commands/QuickSave.cpp | 12 ++++++++++-- src/Ext/TAction/Body.cpp | 5 +++-- src/Phobos.INI.cpp | 32 ++++++++++++++++++++++++-------- src/Phobos.h | 2 +- src/Utilities/SpawnerHelper.cpp | 6 ++++++ src/Utilities/SpawnerHelper.h | 22 ++++++++++++++++++++++ 8 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 src/Utilities/SpawnerHelper.cpp create mode 100644 src/Utilities/SpawnerHelper.h diff --git a/Phobos.vcxproj b/Phobos.vcxproj index 0f18342d27..7dd3386ca3 100644 --- a/Phobos.vcxproj +++ b/Phobos.vcxproj @@ -200,6 +200,7 @@ + @@ -293,6 +294,7 @@ + diff --git a/YRpp b/YRpp index a8c3f616b8..0c6a17b116 160000 --- a/YRpp +++ b/YRpp @@ -1 +1 @@ -Subproject commit a8c3f616b8b421bd8b806cf90e560ec59355fd05 +Subproject commit 0c6a17b11687716034615a87424370e700c7b2a2 diff --git a/src/Commands/QuickSave.cpp b/src/Commands/QuickSave.cpp index e55d3df152..08fa6039aa 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 @@ -40,7 +42,7 @@ void QuickSaveCommandClass::Execute(WWKey eInput) const if (SessionClass::IsSingleplayer()) { *reinterpret_cast(0xABCE08) = false; - Phobos::ShouldQuickSave = true; + Phobos::ShouldSave = true; if (SessionClass::IsCampaign()) Phobos::CustomGameSaveDescription = ScenarioClass::Instance->UINameLoaded; @@ -49,6 +51,12 @@ void QuickSaveCommandClass::Execute(WWKey eInput) const Phobos::CustomGameSaveDescription += L" - "; Phobos::CustomGameSaveDescription += GeneralUtils::LoadStringUnlessMissing("TXT_QUICKSAVE_SUFFIX", L"Quicksaved"); } + else if (SpawnerHelper::SaveGameEventHooked()) + { + // Relinquish handling of the save game to spawner + EventClass event { HouseClass::CurrentPlayer->ArrayIndex, EventType::SaveGame }; + EventClass::AddEvent(event); + } else { PrintMessage(GeneralUtils::LoadStringUnlessMissing("MSG:NotAvailableInMultiplayer", L"QuickSave is not available in multiplayer")); diff --git a/src/Ext/TAction/Body.cpp b/src/Ext/TAction/Body.cpp index 7fd0362b84..7c79034e17 100644 --- a/src/Ext/TAction/Body.cpp +++ b/src/Ext/TAction/Body.cpp @@ -10,6 +10,7 @@ #include #include +#include #include //Static init @@ -116,10 +117,10 @@ 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()) + if (SessionClass::IsSingleplayer() || SpawnerHelper::SaveGameEventHooked()) { *reinterpret_cast(0xABCE08) = false; - Phobos::ShouldQuickSave = true; + Phobos::ShouldSave = true; if (SessionClass::IsCampaign()) Phobos::CustomGameSaveDescription = ScenarioClass::Instance->UINameLoaded; diff --git a/src/Phobos.INI.cpp b/src/Phobos.INI.cpp index 5b458a3089..8c1e63096b 100644 --- a/src/Phobos.INI.cpp +++ b/src/Phobos.INI.cpp @@ -279,7 +279,7 @@ DEFINE_HOOK(0x52D21F, InitRules_ThingsThatShouldntBeSerailized, 0x6) } -bool Phobos::ShouldQuickSave = false; +bool Phobos::ShouldSave = false; std::wstring Phobos::CustomGameSaveDescription {}; void Phobos::PassiveSaveGame() @@ -290,18 +290,34 @@ void Phobos::PassiveSaveGame() pMessage, RulesClass::Instance->MessageDelay, HouseClass::CurrentPlayer->ColorSchemeIndex, - true + /* 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]; - SYSTEMTIME time; - GetLocalTime(&time); + 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, 0x7F, "Map.%04u%02u%02u-%02u%02u%02u-%05u.sav", - time.wYear, time.wMonth, time.wDay, time.wHour, time.wMinute, time.wSecond, time.wMilliseconds); + _snprintf_s(fName, sizeof(fName), GameStrings::SAVEGAME_NET); + } if (ScenarioClass::SaveGame(fName, Phobos::CustomGameSaveDescription.c_str())) PrintMessage(StringTable::LoadString(GameStrings::TXT_GAME_WAS_SAVED)); @@ -318,10 +334,10 @@ DEFINE_HOOK(0x55DBCD, MainLoop_SaveGame, 0x6) if (SessionClass::IsSingleplayer() && !scenario_saved) { scenario_saved = true; - if (Phobos::ShouldQuickSave) + if (Phobos::ShouldSave) { Phobos::PassiveSaveGame(); - Phobos::ShouldQuickSave = false; + Phobos::ShouldSave = false; Phobos::CustomGameSaveDescription.clear(); } else if (Phobos::Config::SaveGameOnScenarioStart && SessionClass::IsCampaign()) diff --git a/src/Phobos.h b/src/Phobos.h index fa1ac7d1d1..c0f27c2347 100644 --- a/src/Phobos.h +++ b/src/Phobos.h @@ -32,7 +32,7 @@ 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 PassiveSaveGame(); #ifdef DEBUG diff --git a/src/Utilities/SpawnerHelper.cpp b/src/Utilities/SpawnerHelper.cpp new file mode 100644 index 0000000000..a5933284ed --- /dev/null +++ b/src/Utilities/SpawnerHelper.cpp @@ -0,0 +1,6 @@ +#include "SpawnerHelper.h" + +bool SpawnerHelper::SaveGameEventHooked() +{ + return SaveGameHookStart == 0xE9; +} diff --git a/src/Utilities/SpawnerHelper.h b/src/Utilities/SpawnerHelper.h new file mode 100644 index 0000000000..7d0676737e --- /dev/null +++ b/src/Utilities/SpawnerHelper.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include + +class SpawnerHelper +{ +private: + DEFINE_REFERENCE(const 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 SaveGameEventHooked(); +}; From ca0009f619502947a7124d0997008596b2719859 Mon Sep 17 00:00:00 2001 From: Metadorius Date: Tue, 29 Jul 2025 17:23:26 +0300 Subject: [PATCH 02/10] Add docs/changelog/credits --- CREDITS.md | 1 + docs/User-Interface.md | 7 ++++++- docs/Whats-New.md | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CREDITS.md b/CREDITS.md index c7d8c52a4d..b65c3e3f23 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -49,6 +49,7 @@ 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 - **Uranusian (Thrifinesma)**: - Mind Control enhancement - Custom warhead splash list 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 6894cd58a7..adce332447 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -834,6 +834,7 @@ 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) ``` ### 0.3.0.1 From 3f32eaa504f922d491165bf83f368702d27961e0 Mon Sep 17 00:00:00 2001 From: Metadorius Date: Wed, 30 Jul 2025 20:07:17 +0300 Subject: [PATCH 03/10] Fix missed doc changes --- docs/AI-Scripting-and-Mapping.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/AI-Scripting-and-Mapping.md b/docs/AI-Scripting-and-Mapping.md index 105daf1033..ac07478349 100644 --- a/docs/AI-Scripting-and-Mapping.md +++ b/docs/AI-Scripting-and-Mapping.md @@ -484,7 +484,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`. From d47ae94acbd6fa5f1282af15c67f48839035d511 Mon Sep 17 00:00:00 2001 From: Metadorius Date: Wed, 30 Jul 2025 20:07:40 +0300 Subject: [PATCH 04/10] Add temporary logging and fix the name a bit --- src/Commands/QuickSave.cpp | 2 +- src/Ext/TAction/Body.cpp | 2 +- src/Utilities/SpawnerHelper.cpp | 12 ++++++++++-- src/Utilities/SpawnerHelper.h | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Commands/QuickSave.cpp b/src/Commands/QuickSave.cpp index 08fa6039aa..720c75d2f6 100644 --- a/src/Commands/QuickSave.cpp +++ b/src/Commands/QuickSave.cpp @@ -51,7 +51,7 @@ void QuickSaveCommandClass::Execute(WWKey eInput) const Phobos::CustomGameSaveDescription += L" - "; Phobos::CustomGameSaveDescription += GeneralUtils::LoadStringUnlessMissing("TXT_QUICKSAVE_SUFFIX", L"Quicksaved"); } - else if (SpawnerHelper::SaveGameEventHooked()) + else if (SpawnerHelper::IsSaveGameEventHooked()) { // Relinquish handling of the save game to spawner EventClass event { HouseClass::CurrentPlayer->ArrayIndex, EventType::SaveGame }; diff --git a/src/Ext/TAction/Body.cpp b/src/Ext/TAction/Body.cpp index 7c79034e17..77ea3fd31a 100644 --- a/src/Ext/TAction/Body.cpp +++ b/src/Ext/TAction/Body.cpp @@ -117,7 +117,7 @@ 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() || SpawnerHelper::SaveGameEventHooked()) + if (SessionClass::IsSingleplayer() || SpawnerHelper::IsSaveGameEventHooked()) { *reinterpret_cast(0xABCE08) = false; Phobos::ShouldSave = true; diff --git a/src/Utilities/SpawnerHelper.cpp b/src/Utilities/SpawnerHelper.cpp index a5933284ed..15780e3b84 100644 --- a/src/Utilities/SpawnerHelper.cpp +++ b/src/Utilities/SpawnerHelper.cpp @@ -1,6 +1,14 @@ #include "SpawnerHelper.h" +#include -bool SpawnerHelper::SaveGameEventHooked() +bool SpawnerHelper::IsSaveGameEventHooked() { - return SaveGameHookStart == 0xE9; + bool isHooked = SaveGameHookStart == 0xE9; + + if (isHooked) + Debug::Log("IsSaveGameEventHooked: Spawner save game event hook is active.\n"); + else + Debug::Log("IsSaveGameEventHooked: Spawner save game event hook is not active.\n"); + + return isHooked; } diff --git a/src/Utilities/SpawnerHelper.h b/src/Utilities/SpawnerHelper.h index 7d0676737e..83d90b48cc 100644 --- a/src/Utilities/SpawnerHelper.h +++ b/src/Utilities/SpawnerHelper.h @@ -18,5 +18,5 @@ class SpawnerHelper // // To read more about this mess and possibly engineer a better solution, look up the comments mentioning 0x4C7A14 in spawner. // - Kerbiter - static bool SaveGameEventHooked(); + static bool IsSaveGameEventHooked(); }; From d92f6223351acb307ab66396a6c8c9e038901e10 Mon Sep 17 00:00:00 2001 From: Metadorius Date: Thu, 31 Jul 2025 00:50:18 +0300 Subject: [PATCH 05/10] Fix the check not working and remove the logging --- src/Utilities/SpawnerHelper.cpp | 9 +-------- src/Utilities/SpawnerHelper.h | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/Utilities/SpawnerHelper.cpp b/src/Utilities/SpawnerHelper.cpp index 15780e3b84..e79448aff0 100644 --- a/src/Utilities/SpawnerHelper.cpp +++ b/src/Utilities/SpawnerHelper.cpp @@ -3,12 +3,5 @@ bool SpawnerHelper::IsSaveGameEventHooked() { - bool isHooked = SaveGameHookStart == 0xE9; - - if (isHooked) - Debug::Log("IsSaveGameEventHooked: Spawner save game event hook is active.\n"); - else - Debug::Log("IsSaveGameEventHooked: Spawner save game event hook is not active.\n"); - - return isHooked; + return SaveGameHookStart == 0xE9; } diff --git a/src/Utilities/SpawnerHelper.h b/src/Utilities/SpawnerHelper.h index 83d90b48cc..7c0f04584a 100644 --- a/src/Utilities/SpawnerHelper.h +++ b/src/Utilities/SpawnerHelper.h @@ -7,7 +7,7 @@ class SpawnerHelper { private: - DEFINE_REFERENCE(const char, SaveGameHookStart, 0x4C7A14u); + 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 From 3d9a54baffebc05cc56c6bedca48231ae185b6b8 Mon Sep 17 00:00:00 2001 From: Metadorius Date: Fri, 1 Aug 2025 00:16:21 +0300 Subject: [PATCH 06/10] Address feedback and don't use deprecated function --- src/Commands/QuickSave.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Commands/QuickSave.cpp b/src/Commands/QuickSave.cpp index 720c75d2f6..489e6a32ce 100644 --- a/src/Commands/QuickSave.cpp +++ b/src/Commands/QuickSave.cpp @@ -54,8 +54,7 @@ void QuickSaveCommandClass::Execute(WWKey eInput) const else if (SpawnerHelper::IsSaveGameEventHooked()) { // Relinquish handling of the save game to spawner - EventClass event { HouseClass::CurrentPlayer->ArrayIndex, EventType::SaveGame }; - EventClass::AddEvent(event); + EventClass::OutList.Add(EventClass { HouseClass::CurrentPlayer->ArrayIndex, EventType::SaveGame }); } else { From d0358ad45dbd8b51ebe14973069cc3875f8aab47 Mon Sep 17 00:00:00 2001 From: Metadorius Date: Sat, 2 Aug 2025 00:22:31 +0300 Subject: [PATCH 07/10] GO AWAY, BEGONE --- src/Phobos.INI.cpp | 2 +- src/Phobos.cpp | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Phobos.INI.cpp b/src/Phobos.INI.cpp index 8c1e63096b..85c01ba9c1 100644 --- a/src/Phobos.INI.cpp +++ b/src/Phobos.INI.cpp @@ -331,7 +331,7 @@ DEFINE_HOOK(0x55DBCD, MainLoop_SaveGame, 0x6) enum { SkipSave = 0x55DC99, InitialSave = 0x55DBE6 }; bool& scenario_saved = *reinterpret_cast(0xABCE08); - if (SessionClass::IsSingleplayer() && !scenario_saved) + if (!scenario_saved) { scenario_saved = true; if (Phobos::ShouldSave) 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 From dcace53b0a954552592ade111d30b7afb5f3cc79 Mon Sep 17 00:00:00 2001 From: Metadorius Date: Mon, 4 Aug 2025 01:54:17 +0300 Subject: [PATCH 08/10] Add SAVEGAME.NET -> SVGM_XXX.NET rename hooks --- CREDITS.md | 1 + docs/Fixed-or-Improved-Logics.md | 5 ++++ docs/Whats-New.md | 2 ++ src/Phobos.INI.cpp | 41 +++++++++++++++++++++++++++++++- 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CREDITS.md b/CREDITS.md index fb59d3beb6..e6abf6e947 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -50,6 +50,7 @@ This page lists all the individual contributions to the project by their author. - `UseFixedVoxelLighting` - Warhead activation target health thresholds - MP saves support for quicksave command and savegame trigger action + - `SAVEGAME.NET` -> `SVGM_XXX.NET` rename - **Uranusian (Thrifinesma)**: - Mind Control enhancement - Custom warhead splash list diff --git a/docs/Fixed-or-Improved-Logics.md b/docs/Fixed-or-Improved-Logics.md index 1da0df8e64..6a860a80ac 100644 --- a/docs/Fixed-or-Improved-Logics.md +++ b/docs/Fixed-or-Improved-Logics.md @@ -274,6 +274,11 @@ 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; this naming scheme compliant with XNA CnCNet Client) when saving to prevent occasional overwriting of the save file when using Phobos with XNA CnCNet Client and spamming game save. + +```{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. +``` ## Aircraft diff --git a/docs/Whats-New.md b/docs/Whats-New.md index f5954c9595..41ba8f79e7 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -835,6 +835,8 @@ Fixes / interactions with other extensions: - 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) +- The game now automatically renames `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 spamming game save (by Kerbiter) + ``` ### 0.3.0.1 diff --git a/src/Phobos.INI.cpp b/src/Phobos.INI.cpp index 85c01ba9c1..55da71bc1b 100644 --- a/src/Phobos.INI.cpp +++ b/src/Phobos.INI.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include "Misc/BlittersFix.h" @@ -278,7 +279,6 @@ DEFINE_HOOK(0x52D21F, InitRules_ThingsThatShouldntBeSerailized, 0x6) return 0; } - bool Phobos::ShouldSave = false; std::wstring Phobos::CustomGameSaveDescription {}; @@ -325,6 +325,45 @@ void Phobos::PassiveSaveGame() PrintMessage(StringTable::LoadString(GameStrings::TXT_ERROR_SAVING_GAME)); } +namespace SaveGameTemp +{ + int NextSaveIndex = 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_%03d.NET", SaveGameTemp::NextSaveIndex); + + R->ECX(newFileName); + + Debug::Log("Renamed multiplayer save file from %s to %s\n", fileName, newFileName); + } + + 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) +{ + // consistent with XNA CNCNet Client. don't ask me - Kerbiter + if (SaveGameTemp::NextSaveIndex + 1 < 1000) + SaveGameTemp::NextSaveIndex++; + + return 0; +} + DEFINE_HOOK(0x55DBCD, MainLoop_SaveGame, 0x6) { // This happens right before LogicClass::Update() From 358d820f8355807a9444ee3607a9b6baa05a5209 Mon Sep 17 00:00:00 2001 From: Metadorius Date: Wed, 6 Aug 2025 00:32:07 +0300 Subject: [PATCH 09/10] Port more of XNA client MP save routine and cleanup the code --- CREDITS.md | 2 +- Phobos.vcxproj | 1 + YRpp | 2 +- docs/Fixed-or-Improved-Logics.md | 12 +- docs/Whats-New.md | 3 +- src/Commands/QuickSave.cpp | 10 +- src/Ext/TAction/Body.cpp | 12 +- src/Phobos.INI.cpp | 108 -------------- src/Phobos.Save.cpp | 232 +++++++++++++++++++++++++++++++ src/Phobos.h | 1 + 10 files changed, 249 insertions(+), 134 deletions(-) create mode 100644 src/Phobos.Save.cpp diff --git a/CREDITS.md b/CREDITS.md index 8766a8f131..d4e1fd97bc 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -50,7 +50,7 @@ This page lists all the individual contributions to the project by their author. - `UseFixedVoxelLighting` - Warhead activation target health thresholds - MP saves support for quicksave command and savegame trigger action - - `SAVEGAME.NET` -> `SVGM_XXX.NET` rename + - 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 093849fa54..2509d78ba7 100644 --- a/Phobos.vcxproj +++ b/Phobos.vcxproj @@ -197,6 +197,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/Fixed-or-Improved-Logics.md b/docs/Fixed-or-Improved-Logics.md index cbe62db2fb..2397954fa2 100644 --- a/docs/Fixed-or-Improved-Logics.md +++ b/docs/Fixed-or-Improved-Logics.md @@ -274,10 +274,18 @@ 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; this naming scheme compliant with XNA CnCNet Client) when saving to prevent occasional overwriting of the save file when using Phobos with XNA CnCNet Client and spamming game save. +- 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. +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/Whats-New.md b/docs/Whats-New.md index 79f9b95295..a1cbdd831e 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -835,8 +835,7 @@ Fixes / interactions with other extensions: - 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) -- The game now automatically renames `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 spamming game save (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 489e6a32ce..85c6514306 100644 --- a/src/Commands/QuickSave.cpp +++ b/src/Commands/QuickSave.cpp @@ -41,15 +41,7 @@ void QuickSaveCommandClass::Execute(WWKey eInput) const if (SessionClass::IsSingleplayer()) { - *reinterpret_cast(0xABCE08) = false; - Phobos::ShouldSave = 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()) { diff --git a/src/Ext/TAction/Body.cpp b/src/Ext/TAction/Body.cpp index 14d50e7268..9284bebb66 100644 --- a/src/Ext/TAction/Body.cpp +++ b/src/Ext/TAction/Body.cpp @@ -118,17 +118,7 @@ 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() || SpawnerHelper::IsSaveGameEventHooked()) - { - *reinterpret_cast(0xABCE08) = false; - Phobos::ShouldSave = true; - - if (SessionClass::IsCampaign()) - Phobos::CustomGameSaveDescription = ScenarioClass::Instance->UINameLoaded; - else - Phobos::CustomGameSaveDescription = ScenarioClass::Instance->Name; - Phobos::CustomGameSaveDescription += L" - "; - Phobos::CustomGameSaveDescription += StringTable::LoadString(pThis->Text); - } + Phobos::ScheduleGameSave(StringTable::LoadString(pThis->Text)); return true; } diff --git a/src/Phobos.INI.cpp b/src/Phobos.INI.cpp index 55da71bc1b..32f6a0b953 100644 --- a/src/Phobos.INI.cpp +++ b/src/Phobos.INI.cpp @@ -11,7 +11,6 @@ #include #include #include -#include #include "Misc/BlittersFix.h" @@ -278,110 +277,3 @@ DEFINE_HOOK(0x52D21F, InitRules_ThingsThatShouldntBeSerailized, 0x6) return 0; } - -bool Phobos::ShouldSave = false; -std::wstring Phobos::CustomGameSaveDescription {}; - -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)); -} - -namespace SaveGameTemp -{ - int NextSaveIndex = 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_%03d.NET", SaveGameTemp::NextSaveIndex); - - R->ECX(newFileName); - - Debug::Log("Renamed multiplayer save file from %s to %s\n", fileName, newFileName); - } - - 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) -{ - // consistent with XNA CNCNet Client. don't ask me - Kerbiter - if (SaveGameTemp::NextSaveIndex + 1 < 1000) - SaveGameTemp::NextSaveIndex++; - - return 0; -} - -DEFINE_HOOK(0x55DBCD, MainLoop_SaveGame, 0x6) -{ - // This happens right before LogicClass::Update() - enum { SkipSave = 0x55DC99, InitialSave = 0x55DBE6 }; - - bool& scenario_saved = *reinterpret_cast(0xABCE08); - if (!scenario_saved) - { - scenario_saved = true; - if (Phobos::ShouldSave) - { - Phobos::PassiveSaveGame(); - Phobos::ShouldSave = 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.h b/src/Phobos.h index c0f27c2347..e30ca6b066 100644 --- a/src/Phobos.h +++ b/src/Phobos.h @@ -34,6 +34,7 @@ class Phobos static bool IsLoadingSaveGame; static bool ShouldSave; static std::wstring CustomGameSaveDescription; + static void ScheduleGameSave(const std::wstring& description); static void PassiveSaveGame(); #ifdef DEBUG static bool DetachFromDebugger(); From 5c6514ea32dca33cd1aaacf4b5bfe8e0f0340104 Mon Sep 17 00:00:00 2001 From: Metadorius Date: Mon, 11 Aug 2025 23:19:41 +0300 Subject: [PATCH 10/10] Magic number -> `LJMP_OPCODE` --- src/Utilities/SpawnerHelper.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Utilities/SpawnerHelper.cpp b/src/Utilities/SpawnerHelper.cpp index e79448aff0..e84808c8a8 100644 --- a/src/Utilities/SpawnerHelper.cpp +++ b/src/Utilities/SpawnerHelper.cpp @@ -1,7 +1,9 @@ #include "SpawnerHelper.h" + #include +#include bool SpawnerHelper::IsSaveGameEventHooked() { - return SaveGameHookStart == 0xE9; + return SaveGameHookStart == LJMP_OPCODE; }