From ac72c91da579456821edfdb58604d58566dd288c Mon Sep 17 00:00:00 2001 From: Trsdy <914137150@qq.com> Date: Sat, 21 Feb 2026 17:17:24 +0800 Subject: [PATCH 1/4] initial commit --- src/Misc/SavedGamesInSubdir.cpp | 155 ++++++++++++++++++++++++++++++++ src/Spawner/Spawner.Config.cpp | 1 + src/Spawner/Spawner.Config.h | 2 + src/Spawner/Spawner.cpp | 20 ++++- 4 files changed, 175 insertions(+), 3 deletions(-) diff --git a/src/Misc/SavedGamesInSubdir.cpp b/src/Misc/SavedGamesInSubdir.cpp index 3307cc96..2df7fc78 100644 --- a/src/Misc/SavedGamesInSubdir.cpp +++ b/src/Misc/SavedGamesInSubdir.cpp @@ -22,7 +22,11 @@ #include #include +#include +#include + #include +#include namespace SavedGames { @@ -162,3 +166,154 @@ DEFINE_HOOK(0x67FD26, LoadOptionsClass_ReadSaveInfo_SGInSubdir, 0x5) return 0; } + + +//issue #18 : Save game filter for 3rd party campaigns +namespace SavedGames +{ + struct CustomMissionID + { + static constexpr const wchar_t* SaveName = L"CustomMissionID"; + + int Number; + + CustomMissionID() : Number { Spawner::GetConfig()->CustomMissionID } { } + + CustomMissionID(int num) : Number { num } { } + + operator int() const { return Number; } + }; + + + template + bool AppendToStorage(IStorage* pStorage) + { + IStreamPtr pStream = nullptr; + bool ret = false; + HRESULT hr = pStorage->CreateStream( + T::SaveName, + STGM_WRITE | STGM_CREATE | STGM_SHARE_EXCLUSIVE, + 0, + 0, + &pStream + ); + + if (SUCCEEDED(hr) && pStream != nullptr) + { + T info {}; + ULONG written = 0; + hr = pStream->Write(&info, sizeof(info), &written); + ret = SUCCEEDED(hr) && written == sizeof(info); + } + + return ret; + } + + + template + std::optional ReadFromStorage(IStorage* pStorage) + { + IStreamPtr pStream = nullptr; + bool hasValue = false; + HRESULT hr = pStorage->OpenStream( + T::SaveName, + NULL, + STGM_READ | STGM_SHARE_EXCLUSIVE, + 0, + &pStream + ); + + T info; + + if (SUCCEEDED(hr) && pStream != nullptr) + { + ULONG read = 0; + hr = pStream->Read(&info, sizeof(info), &read); + hasValue = SUCCEEDED(hr) && read == sizeof(info); + } + + return hasValue ? std::make_optional(info) : std::nullopt; + } + +} + +DEFINE_HOOK(0x559921, LoadOptionsClass_FillList_FilterFiles, 0x6) +{ + GET(FileEntryClass*, pEntry, EBP); + enum { NullThisEntry = 0x559959 }; + /* + // there was a qsort later and filters out these but we could have just removed them right here + if (pEntry->IsWrongVersion || !pEntry->IsValid) + { + GameDelete(pEntry); + return NullThisEntry; + }; + */ + OLECHAR wNameBuffer[0x100] {}; + SavedGames::FormatPath(Main::readBuffer, pEntry->Filename.data()); + MultiByteToWideChar(CP_UTF8, 0, Main::readBuffer, -1, wNameBuffer, std::size(wNameBuffer)); + IStoragePtr pStorage = nullptr; + bool shouldDelete = false; + if (SUCCEEDED(StgOpenStorage(wNameBuffer, NULL, + STGM_READWRITE | STGM_SHARE_EXCLUSIVE, + 0, 0, &pStorage) + )) + { + auto id = SavedGames::ReadFromStorage(pStorage); + + if (Spawner::GetConfig()->CustomMissionID != id.value_or(0)) + shouldDelete = true; + } + + if (shouldDelete) + { + GameDelete(pEntry); + return NullThisEntry; + } + + return 0; +} + +// Write : A la fin +DEFINE_HOOK(0x67D2E3, SaveGame_AdditionalInfoForClient, 0x6) +{ + GET_STACK(IStorage*, pStorage, STACK_OFFSET(0x4A0, -0x490)); + using namespace SavedGames; + + if (pStorage) + { + if (SessionClass::IsCampaign() && Spawner::GetConfig()->CustomMissionID) + AppendToStorage(pStorage); + } + + return 0; +} + +// Read : Au debut +DEFINE_HOOK(0x67E4DC, LoadGame_AdditionalInfoForClient, 0x7) +{ + LEA_STACK(const wchar_t*, filename, STACK_OFFSET(0x518, -0x4F4)); + IStoragePtr pStorage = nullptr; + using namespace SavedGames; + + if (SUCCEEDED(StgOpenStorage(filename, NULL, + STGM_READWRITE | STGM_SHARE_EXCLUSIVE, + 0, 0, &pStorage) + )) + { + if (auto id = ReadFromStorage(pStorage)) + { + int num = id->Number; + Debug::Log("[Spawner] sav file CustomMissionID = %d\n", num); + Spawner::GetConfig()->CustomMissionID = num; + ScenarioClass::Instance->EndOfGame = true; + } + else + { + Spawner::GetConfig()->CustomMissionID = 0; + } + } + + return 0; +} + diff --git a/src/Spawner/Spawner.Config.cpp b/src/Spawner/Spawner.Config.cpp index 430d242f..8d8e4391 100644 --- a/src/Spawner/Spawner.Config.cpp +++ b/src/Spawner/Spawner.Config.cpp @@ -57,6 +57,7 @@ void SpawnerConfig::LoadFromINIFile(CCINIClass* pINI) LoadSaveGame = pINI->ReadBool(pSettingsSection, "LoadSaveGame", LoadSaveGame); /* SavedGameDir */ pINI->ReadString(pSettingsSection, "SavedGameDir", SavedGameDir, SavedGameDir, sizeof(SavedGameDir)); /* SaveGameName */ pINI->ReadString(pSettingsSection, "SaveGameName", SaveGameName, SaveGameName, sizeof(SaveGameName)); + CustomMissionID = pINI->ReadInteger(pSettingsSection, "CustomMissionID", 0); AutoSaveCount = pINI->ReadInteger(pSettingsSection, "AutoSaveCount", AutoSaveCount); AutoSaveInterval = pINI->ReadInteger(pSettingsSection, "AutoSaveInterval", AutoSaveInterval); NextAutoSaveNumber = pINI->ReadInteger(pSettingsSection, "NextAutoSaveNumber", NextAutoSaveNumber); diff --git a/src/Spawner/Spawner.Config.h b/src/Spawner/Spawner.Config.h index a390baaf..6ed0bec1 100644 --- a/src/Spawner/Spawner.Config.h +++ b/src/Spawner/Spawner.Config.h @@ -99,6 +99,7 @@ class SpawnerConfig bool LoadSaveGame; char SavedGameDir[MAX_PATH]; // Nested paths are also supported, e.g. "Saved Games\\Yuri's Revenge" char SaveGameName[60]; + int CustomMissionID; int AutoSaveCount; int AutoSaveInterval; int NextAutoSaveNumber; @@ -172,6 +173,7 @@ class SpawnerConfig , LoadSaveGame { false } , SavedGameDir { "Saved Games" } , SaveGameName { "" } + , CustomMissionID { 0 } , AutoSaveCount { 5 } , AutoSaveInterval { 7200 } , NextAutoSaveNumber { 0 } diff --git a/src/Spawner/Spawner.cpp b/src/Spawner/Spawner.cpp index afe4ada4..a0145b18 100644 --- a/src/Spawner/Spawner.cpp +++ b/src/Spawner/Spawner.cpp @@ -24,6 +24,7 @@ #include "ProtocolZero.LatencyLevel.h" #include #include +#include #include #include @@ -303,9 +304,22 @@ bool Spawner::StartScenario(const char* pScenarioName) if (SessionClass::IsCampaign()) { pGameModeOptions->Crates = true; - return Config->LoadSaveGame - ? Spawner::LoadSavedGame(Config->SaveGameName) - : ScenarioClass::StartScenario(pScenarioName, 1, 0); + + if (Config->LoadSaveGame) + return Spawner::LoadSavedGame(Config->SaveGameName); + + // Rename MISSIONMD.INI to this + // because Ares has LoadScreenText.Color and Phobos has Starkku's PR #1145 + + if (Spawner::Config->ReadMissionSection) // before parsing + Patch::Apply_RAW(0x839724, "Spawn.ini"); + + bool result = ScenarioClass::StartScenario(pScenarioName, 1, 0); + + if (Spawner::Config->CustomMissionID != 0) // after parsing + ScenarioClass::Instance->EndOfGame = true; + + return result; } else if (SessionClass::IsSkirmish()) { From 94497c73512b6e46d7c1ff340e8bb760e3ca1865 Mon Sep 17 00:00:00 2001 From: Trsdy <914137150@qq.com> Date: Sat, 21 Feb 2026 17:21:26 +0800 Subject: [PATCH 2/4] Fixes to spawn.ini mission data parsing --- src/Spawner/Spawner.Config.cpp | 1 + src/Spawner/Spawner.Config.h | 2 ++ src/Spawner/Spawner.Hook.cpp | 27 +++++++++++++++++++++++++++ src/Spawner/Spawner.cpp | 6 +++--- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Spawner/Spawner.Config.cpp b/src/Spawner/Spawner.Config.cpp index 8d8e4391..f7297e85 100644 --- a/src/Spawner/Spawner.Config.cpp +++ b/src/Spawner/Spawner.Config.cpp @@ -71,6 +71,7 @@ void SpawnerConfig::LoadFromINIFile(CCINIClass* pINI) WOLGameID = pINI->ReadInteger(pSettingsSection, "GameID", WOLGameID); /* ScenarioName */ pINI->ReadString(pSettingsSection, "Scenario", ScenarioName, ScenarioName, sizeof(ScenarioName)); /* MapHash */ pINI->ReadString(pSettingsSection, "MapHash", MapHash, MapHash, sizeof(MapHash)); + ReadMissionSection = pINI->ReadBool(pSettingsSection, "ReadMissionSection", ReadMissionSection); if (INIClassExt::ReadString_WithoutAresHook(pINI, pSettingsSection, "UIMapName", "", Main::readBuffer, sizeof(Main::readBuffer)) > 0) MultiByteToWideChar(CP_UTF8, 0, Main::readBuffer, strlen(Main::readBuffer), UIMapName, std::size(UIMapName)); diff --git a/src/Spawner/Spawner.Config.h b/src/Spawner/Spawner.Config.h index 6ed0bec1..44c7248b 100644 --- a/src/Spawner/Spawner.Config.h +++ b/src/Spawner/Spawner.Config.h @@ -113,6 +113,7 @@ class SpawnerConfig char ScenarioName[260]; char MapHash[0xff]; wchar_t UIMapName[45]; + bool ReadMissionSection; // Network Options int Protocol; @@ -187,6 +188,7 @@ class SpawnerConfig , ScenarioName { "spawnmap.ini" } , MapHash { "" } , UIMapName { L"" } + , ReadMissionSection { false } // Network Options , Protocol { 2 } diff --git a/src/Spawner/Spawner.Hook.cpp b/src/Spawner/Spawner.Hook.cpp index 79113992..0659dcad 100644 --- a/src/Spawner/Spawner.Hook.cpp +++ b/src/Spawner/Spawner.Hook.cpp @@ -26,6 +26,7 @@ #include #include #include +#include DEFINE_HOOK(0x6BD7C5, WinMain_SpawnerInit, 0x6) { @@ -264,3 +265,29 @@ DEFINE_HOOK(0x686A9E, ReadScenario_InitSomeThings_SpecialHouseIsAlly, 0x6) return 0x686AC6; } + +DEFINE_HOOK(0x686D46, ReadScenarioINI_MissionININame, 0x5) +{ + LEA_STACK(CCFileClass*, pFile, STACK_OFFSET(0x174, -0xF0)); + + if (Spawner::GetConfig()->ReadMissionSection) + { + pFile->SetFileName("SPAWN.INI"); + return 0x686D57; + } + + return 0; +} + +DEFINE_HOOK(0x65F57F, BriefingDialog_MissionININame, 0x6) +{ + LEA_STACK(CCFileClass*, pFile, STACK_OFFSET(0x1D4, -0x16C)); + + if (Spawner::GetConfig()->ReadMissionSection) + { + pFile->SetFileName("SPAWN.INI"); + return 0x65F58F; + } + + return 0; +} diff --git a/src/Spawner/Spawner.cpp b/src/Spawner/Spawner.cpp index a0145b18..3577f5d4 100644 --- a/src/Spawner/Spawner.cpp +++ b/src/Spawner/Spawner.cpp @@ -310,9 +310,9 @@ bool Spawner::StartScenario(const char* pScenarioName) // Rename MISSIONMD.INI to this // because Ares has LoadScreenText.Color and Phobos has Starkku's PR #1145 - - if (Spawner::Config->ReadMissionSection) // before parsing - Patch::Apply_RAW(0x839724, "Spawn.ini"); + // 2025-05-28: Moved to a hook in Spawner.Hook.cpp - Starkku + // if (Spawner::Config->ReadMissionSection) // before parsing + // Patch::Apply_RAW(0x839724, "Spawn.ini"); bool result = ScenarioClass::StartScenario(pScenarioName, 1, 0); From c95cf6edc31659262d7235b4add6b775bb3c10ba Mon Sep 17 00:00:00 2001 From: Trsdy <914137150@qq.com> Date: Sat, 21 Feb 2026 17:22:00 +0800 Subject: [PATCH 3/4] Fix issue with save game filenames containing paths --- src/Misc/SavedGamesInSubdir.cpp | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/Misc/SavedGamesInSubdir.cpp b/src/Misc/SavedGamesInSubdir.cpp index 2df7fc78..1d8449c6 100644 --- a/src/Misc/SavedGamesInSubdir.cpp +++ b/src/Misc/SavedGamesInSubdir.cpp @@ -317,3 +317,29 @@ DEFINE_HOOK(0x67E4DC, LoadGame_AdditionalInfoForClient, 0x7) return 0; } +// Custom missions especially can contain paths in scenario filenames which cause +// the initial save game to fail, remove the paths before filename and make the +// filename uppercase to match with usual savegame names. +DEFINE_HOOK(0x55DC85, MainLoop_SaveGame_SanitizeFilename, 0x7) +{ + LEA_STACK(char*, pFilename, STACK_OFFSET(0x1C4, -0x178)); + LEA_STACK(const wchar_t*, pDescription, STACK_OFFSET(0x1C4, -0x70)); + + char* slash1 = strrchr(pFilename, '/'); + char* slash2 = strrchr(pFilename, '\\'); + char* lastSlash = (slash1 > slash2) ? slash1 : slash2; + + if (lastSlash != NULL) + { + pFilename = lastSlash + 1; + *lastSlash = '\0'; + } + + for (char* p = pFilename; *p; ++p) + *p = (char)toupper((unsigned char)*p); + + R->ECX(pFilename); + R->EDX(pDescription); + + return 0x55DC90; +} From 4b3f484f623aa613950191f2be57315a87d69d68 Mon Sep 17 00:00:00 2001 From: Trsdy <914137150@qq.com> Date: Tue, 24 Feb 2026 12:49:06 +0800 Subject: [PATCH 4/4] minor cleanup --- src/Misc/SavedGamesInSubdir.cpp | 16 ++++++++-------- src/Spawner/Spawner.cpp | 1 - 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Misc/SavedGamesInSubdir.cpp b/src/Misc/SavedGamesInSubdir.cpp index 1d8449c6..6a9ce5bf 100644 --- a/src/Misc/SavedGamesInSubdir.cpp +++ b/src/Misc/SavedGamesInSubdir.cpp @@ -185,8 +185,8 @@ namespace SavedGames }; - template - bool AppendToStorage(IStorage* pStorage) + template requires std::is_trivially_copyable_v + bool WriteToStorage(IStorage* pStorage) { IStreamPtr pStream = nullptr; bool ret = false; @@ -210,7 +210,7 @@ namespace SavedGames } - template + template requires std::is_trivially_copyable_v std::optional ReadFromStorage(IStorage* pStorage) { IStreamPtr pStream = nullptr; @@ -223,7 +223,7 @@ namespace SavedGames &pStream ); - T info; + T info {}; if (SUCCEEDED(hr) && pStream != nullptr) { @@ -280,10 +280,10 @@ DEFINE_HOOK(0x67D2E3, SaveGame_AdditionalInfoForClient, 0x6) GET_STACK(IStorage*, pStorage, STACK_OFFSET(0x4A0, -0x490)); using namespace SavedGames; - if (pStorage) + if (pStorage && SessionClass::IsCampaign()) { - if (SessionClass::IsCampaign() && Spawner::GetConfig()->CustomMissionID) - AppendToStorage(pStorage); + if (Spawner::GetConfig()->CustomMissionID) + WriteToStorage(pStorage); } return 0; @@ -304,7 +304,7 @@ DEFINE_HOOK(0x67E4DC, LoadGame_AdditionalInfoForClient, 0x7) if (auto id = ReadFromStorage(pStorage)) { int num = id->Number; - Debug::Log("[Spawner] sav file CustomMissionID = %d\n", num); + Debug::Log("sav file CustomMissionID = %d\n", num); Spawner::GetConfig()->CustomMissionID = num; ScenarioClass::Instance->EndOfGame = true; } diff --git a/src/Spawner/Spawner.cpp b/src/Spawner/Spawner.cpp index 3577f5d4..2cc048db 100644 --- a/src/Spawner/Spawner.cpp +++ b/src/Spawner/Spawner.cpp @@ -24,7 +24,6 @@ #include "ProtocolZero.LatencyLevel.h" #include #include -#include #include #include