From a2d88824bd8cd09b1b0779dc3fa53a42597262ec Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Fri, 2 Oct 2026 22:29:53 -0300 Subject: [PATCH 1/9] fix(fs): prioritize mod archives over stock loose files Retail Zero Hour installs contain loose files in Data/Scripts/ (such as SkirmishScripts.scb) which historically shadowed custom scripts inside mod archives (like Shockwave's !Shw_scripts.big). On Windows, the original Shockwave launcher bypassed this by renaming stock .scb files to .bcs. On Linux and macOS manual installs, this step was missing, causing Shockwave Skirmish AI to become completely inactive for new generals. Add ArchiveFileSystem::hasModArchiveOverride to detect when an archive file originates from a mod archive (!*.big or custom scripts), and prioritize archive lookups in FileSystem::openFile and doesFileExist over stock loose files. --- .../Include/Common/ArchiveFileSystem.h | 3 + .../Common/System/ArchiveFileSystem.cpp | 56 +++++++++++++++++++ .../Source/Common/System/FileSystem.cpp | 31 ++++++++++ docs/WORKLOG/2026-10-DIARY.md | 8 +++ 4 files changed, 98 insertions(+) diff --git a/Core/GameEngine/Include/Common/ArchiveFileSystem.h b/Core/GameEngine/Include/Common/ArchiveFileSystem.h index 4eb6371f1e3..036fc675243 100644 --- a/Core/GameEngine/Include/Common/ArchiveFileSystem.h +++ b/Core/GameEngine/Include/Common/ArchiveFileSystem.h @@ -145,6 +145,9 @@ class ArchiveFileSystem : public SubsystemInterface void loadMods(); + // GeneralsX @bugfix felipebraz 02/10/2026 Check if a file is provided by an overriding mod archive (!*.big or script files) + Bool hasModArchiveOverride(const Char *filename) const; + ArchivedDirectoryInfo* friend_getArchivedDirectoryInfo(const Char* directory); protected: diff --git a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp index 69a91ac21b9..a1954905585 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp @@ -345,3 +345,59 @@ void ArchiveFileSystem::getFileListInDirectory(const AsciiString& currentDirecto it++; } } + +// GeneralsX @bugfix felipebraz 02/10/2026 Allow mod archives to take precedence over loose stock files. +// Retail Zero Hour shipped stock loose files (e.g. Data/Scripts/SkirmishScripts.scb) that shadow mod archives +// (such as Shockwave's !Shw_scripts.big) unless the launcher explicitly renamed them on disk. +Bool ArchiveFileSystem::hasModArchiveOverride(const Char *filename) const +{ + if (filename == nullptr || filename[0] == '\0') + { + return FALSE; + } + + ArchiveFile *archive = getArchiveFile(AsciiString(filename)); + if (archive == nullptr) + { + return FALSE; + } + + // 1. Script files: SkirmishScripts.scb, MultiplayerScripts.scb, Scripts.ini. + // Vanilla Zero Hour never packaged these into any .big archive; they only shipped as loose files. + // If any archive in the archive file system contains them, it is guaranteed to be a mod. + AsciiString fname = filename; + if (fname.endsWithNoCase("SkirmishScripts.scb") || + fname.endsWithNoCase("MultiplayerScripts.scb") || + fname.endsWithNoCase("Scripts.ini")) + { + return TRUE; + } + + // 2. Mod archives starting with '!' (or '!!', '@', etc.) - the universal SAGE mod convention. + AsciiString archiveName = archive->getName(); + const char* raw = archiveName.str(); + const char* lastSlash = strrchr(raw, '/'); + const char* lastBackslash = strrchr(raw, '\\'); + const char* split = lastSlash; + if (split == nullptr || (lastBackslash != nullptr && lastBackslash > split)) + { + split = lastBackslash; + } + const char* baseName = (split != nullptr) ? (split + 1) : raw; + if (baseName[0] == '!' || baseName[0] == '@') + { + return TRUE; + } + + // 3. Archives loaded explicitly via -mod command line parameter. + if (TheGlobalData && TheGlobalData->m_modDir.isNotEmpty()) + { + if (strstr(archiveName.str(), TheGlobalData->m_modDir.str()) != nullptr) + { + return TRUE; + } + } + + return FALSE; +} + diff --git a/Core/GameEngine/Source/Common/System/FileSystem.cpp b/Core/GameEngine/Source/Common/System/FileSystem.cpp index 0907722dcf1..c80cba77613 100644 --- a/Core/GameEngine/Source/Common/System/FileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/FileSystem.cpp @@ -177,6 +177,21 @@ File* FileSystem::openFile( const Char *filename, Int access, size_t bufferSize USE_PERF_TIMER(FileSystem) File *file = nullptr; + // GeneralsX @bugfix felipebraz 02/10/2026 Allow mod archives (e.g. !*.big, custom SkirmishScripts) + // to override stock loose files that otherwise shadow mod contents. + const Bool hasModOverride = (TheArchiveFileSystem != nullptr) && + !(access & File::WRITE) && + TheArchiveFileSystem->hasModArchiveOverride(filename); + + if (hasModOverride) + { + file = TheArchiveFileSystem->openFile( filename, 0, instance ); + if (file != nullptr) + { + return file; + } + } + if ( TheLocalFileSystem != nullptr ) { if (instance != 0) @@ -242,6 +257,22 @@ Bool FileSystem::doesFileExist(const Char *filename, FileInstance instance) cons } #endif + // GeneralsX @bugfix felipebraz 02/10/2026 Prioritize mod archive existence if an override is active. + if (TheArchiveFileSystem != nullptr && TheArchiveFileSystem->hasModArchiveOverride(filename)) + { + if (TheArchiveFileSystem->doesFileExist(filename, instance)) + { +#if ENABLE_FILESYSTEM_EXISTENCE_CACHE + { + FastCriticalSectionClass::LockClass lock(m_fileExistMutex); + FileExistMap::mapped_type& value = m_fileExist[filename]; + value.instanceExists = max(value.instanceExists, instance); + } +#endif + return TRUE; + } + } + if (TheLocalFileSystem->doesFileExist(filename)) { if (instance == 0) diff --git a/docs/WORKLOG/2026-10-DIARY.md b/docs/WORKLOG/2026-10-DIARY.md index 86eedab604d..694177366ab 100644 --- a/docs/WORKLOG/2026-10-DIARY.md +++ b/docs/WORKLOG/2026-10-DIARY.md @@ -7,3 +7,11 @@ ### Use shared SELinux labels for PR #344 Docker mounts - Changed the project and vcpkg bind mounts from private `:Z` to shared `:z` in the Generals, Zero Hour, and configure Linux Docker scripts so overlapping containers do not revoke each other's directory access. + +## 02/10/2026 + +### Fix mod archive precedence over stock loose files (#335) +- Diagnosed issue #335 where Shockwave mod Skirmish AI was completely inactive when assigned new generals (Armor, Salvage, Special Weapons). +- Discovered that retail Zero Hour installs ship stock loose files in `Data/Scripts/` (e.g. `SkirmishScripts.scb`) that shadowed custom scripts packaged inside mod archives (`!Shw_scripts.big`), because `TheLocalFileSystem` always prioritized loose files over archives. On Windows, the original `ShockWaveLauncher.exe` bypassed this by renaming stock `.scb` files to `.bcs`, which is absent on Linux/Flatpak/macOS manual installations. +- Implemented `ArchiveFileSystem::hasModArchiveOverride` and updated `FileSystem::openFile` / `doesFileExist` to prioritize mod archives (`!*.big`, `-mod`, and custom script archives) over stock loose files, ensuring full mod compatibility without manual file manipulation. + From df05f3061c9bfcbd723ef9604b27d82494ee63da Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Fri, 2 Oct 2026 22:32:41 -0300 Subject: [PATCH 2/9] docs(howto): add redirection stub for Shockwave mod wiki guide --- docs/HOWTO/README.md | 2 ++ docs/HOWTO/SHOCKWAVE_MOD.md | 5 +++++ 2 files changed, 7 insertions(+) create mode 100644 docs/HOWTO/SHOCKWAVE_MOD.md diff --git a/docs/HOWTO/README.md b/docs/HOWTO/README.md index acad896bd9c..4cbd2d8a465 100644 --- a/docs/HOWTO/README.md +++ b/docs/HOWTO/README.md @@ -14,3 +14,5 @@ > - [macOS Crash Diagnostics](https://github.com/fbraz3/GeneralsX/wiki/How-to-Capture-&-Report-macOS-Crashes) > - [Linux Crash Diagnostics](https://github.com/fbraz3/GeneralsX/wiki/How-to-Capture-&-Report-Linux-Crashes) > - [How to Install & Play Custom Missions](https://github.com/fbraz3/GeneralsX/wiki/How-to-Install-&-Play-Custom-Missions) +> - [How to Install & Play Shockwave Mod](https://github.com/fbraz3/GeneralsX/wiki/How-to-Install-&-Play-Shockwave-Mod) + diff --git a/docs/HOWTO/SHOCKWAVE_MOD.md b/docs/HOWTO/SHOCKWAVE_MOD.md new file mode 100644 index 00000000000..097de2c9961 --- /dev/null +++ b/docs/HOWTO/SHOCKWAVE_MOD.md @@ -0,0 +1,5 @@ +# Shockwave Mod Guide + +> [!NOTE] +> This article is part of the project wiki: +> 👉 **[How to Install & Play Shockwave Mod](https://github.com/fbraz3/GeneralsX/wiki/How-to-Install-&-Play-Shockwave-Mod)** From 388ecdf556966bed7fe742295dc1ca4c72fbdfd9 Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Fri, 2 Oct 2026 22:49:16 -0300 Subject: [PATCH 3/9] fix(fs): address review feedback on mod archive precedence and instance routing --- .../Include/Common/ArchiveFileSystem.h | 1 + .../Common/System/ArchiveFileSystem.cpp | 44 ++++++++++++++++--- .../Source/Common/System/FileSystem.cpp | 36 +++++++++++---- docs/WORKLOG/2026-10-DIARY.md | 12 ++--- 4 files changed, 72 insertions(+), 21 deletions(-) diff --git a/Core/GameEngine/Include/Common/ArchiveFileSystem.h b/Core/GameEngine/Include/Common/ArchiveFileSystem.h index 036fc675243..88715d0f62b 100644 --- a/Core/GameEngine/Include/Common/ArchiveFileSystem.h +++ b/Core/GameEngine/Include/Common/ArchiveFileSystem.h @@ -147,6 +147,7 @@ class ArchiveFileSystem : public SubsystemInterface // GeneralsX @bugfix felipebraz 02/10/2026 Check if a file is provided by an overriding mod archive (!*.big or script files) Bool hasModArchiveOverride(const Char *filename) const; + FileInstance getFileCount(const Char *filename) const; ArchivedDirectoryInfo* friend_getArchivedDirectoryInfo(const Char* directory); diff --git a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp index a1954905585..c6fc998d7a9 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp @@ -365,10 +365,18 @@ Bool ArchiveFileSystem::hasModArchiveOverride(const Char *filename) const // 1. Script files: SkirmishScripts.scb, MultiplayerScripts.scb, Scripts.ini. // Vanilla Zero Hour never packaged these into any .big archive; they only shipped as loose files. // If any archive in the archive file system contains them, it is guaranteed to be a mod. - AsciiString fname = filename; - if (fname.endsWithNoCase("SkirmishScripts.scb") || - fname.endsWithNoCase("MultiplayerScripts.scb") || - fname.endsWithNoCase("Scripts.ini")) + const char* fnRaw = filename; + const char* fnLastSlash = strrchr(fnRaw, '/'); + const char* fnLastBackslash = strrchr(fnRaw, '\\'); + const char* fnSplit = fnLastSlash; + if (fnSplit == nullptr || (fnLastBackslash != nullptr && fnLastBackslash > fnSplit)) + { + fnSplit = fnLastBackslash; + } + const char* fnBase = (fnSplit != nullptr) ? (fnSplit + 1) : fnRaw; + if (stricmp(fnBase, "SkirmishScripts.scb") == 0 || + stricmp(fnBase, "MultiplayerScripts.scb") == 0 || + stricmp(fnBase, "Scripts.ini") == 0) { return TRUE; } @@ -389,10 +397,15 @@ Bool ArchiveFileSystem::hasModArchiveOverride(const Char *filename) const return TRUE; } - // 3. Archives loaded explicitly via -mod command line parameter. - if (TheGlobalData && TheGlobalData->m_modDir.isNotEmpty()) + // 3. Archives loaded explicitly via -mod command line parameter (directory or direct .big). + if (TheGlobalData) { - if (strstr(archiveName.str(), TheGlobalData->m_modDir.str()) != nullptr) + if (TheGlobalData->m_modDir.isNotEmpty() && strstr(archiveName.str(), TheGlobalData->m_modDir.str()) != nullptr) + { + return TRUE; + } + if (TheGlobalData->m_modBIG.isNotEmpty() && + (archiveName.compareNoCase(TheGlobalData->m_modBIG) == 0 || strstr(archiveName.str(), TheGlobalData->m_modBIG.str()) != nullptr)) { return TRUE; } @@ -401,3 +414,20 @@ Bool ArchiveFileSystem::hasModArchiveOverride(const Char *filename) const return FALSE; } +FileInstance ArchiveFileSystem::getFileCount(const Char *filename) const +{ + if (filename == nullptr) + { + return 0; + } + + ArchivedDirectoryInfoResult result = const_cast(this)->getArchivedDirectoryInfo(filename); + if (!result.valid()) + { + return 0; + } + + return static_cast(result.dirInfo->m_files.count(result.lastToken)); +} + + diff --git a/Core/GameEngine/Source/Common/System/FileSystem.cpp b/Core/GameEngine/Source/Common/System/FileSystem.cpp index c80cba77613..6f7dfdedb1c 100644 --- a/Core/GameEngine/Source/Common/System/FileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/FileSystem.cpp @@ -185,11 +185,12 @@ File* FileSystem::openFile( const Char *filename, Int access, size_t bufferSize if (hasModOverride) { - file = TheArchiveFileSystem->openFile( filename, 0, instance ); - if (file != nullptr) + FileInstance archiveCount = TheArchiveFileSystem->getFileCount(filename); + if (instance < archiveCount) { - return file; + return TheArchiveFileSystem->openFile( filename, access, instance ); } + instance -= archiveCount; } if ( TheLocalFileSystem != nullptr ) @@ -225,7 +226,7 @@ File* FileSystem::openFile( const Char *filename, Int access, size_t bufferSize } } - if ( (TheArchiveFileSystem != nullptr) && (file == nullptr) ) + if ( !hasModOverride && (TheArchiveFileSystem != nullptr) && (file == nullptr) ) { // TheSuperHackers @todo Pass 'access' here? file = TheArchiveFileSystem->openFile( filename, 0, instance ); @@ -258,9 +259,13 @@ Bool FileSystem::doesFileExist(const Char *filename, FileInstance instance) cons #endif // GeneralsX @bugfix felipebraz 02/10/2026 Prioritize mod archive existence if an override is active. - if (TheArchiveFileSystem != nullptr && TheArchiveFileSystem->hasModArchiveOverride(filename)) + const Bool hasModOverride = (TheArchiveFileSystem != nullptr) && + TheArchiveFileSystem->hasModArchiveOverride(filename); + + if (hasModOverride) { - if (TheArchiveFileSystem->doesFileExist(filename, instance)) + FileInstance archiveCount = TheArchiveFileSystem->getFileCount(filename); + if (instance < archiveCount) { #if ENABLE_FILESYSTEM_EXISTENCE_CACHE { @@ -271,6 +276,7 @@ Bool FileSystem::doesFileExist(const Char *filename, FileInstance instance) cons #endif return TRUE; } + instance -= archiveCount; } if (TheLocalFileSystem->doesFileExist(filename)) @@ -289,7 +295,7 @@ Bool FileSystem::doesFileExist(const Char *filename, FileInstance instance) cons --instance; } - if (TheArchiveFileSystem->doesFileExist(filename, instance)) + if (!hasModOverride && TheArchiveFileSystem->doesFileExist(filename, instance)) { #if ENABLE_FILESYSTEM_EXISTENCE_CACHE { @@ -335,6 +341,20 @@ Bool FileSystem::getFileInfo(const AsciiString& filename, FileInfo *fileInfo, Fi } memset(fileInfo, 0, sizeof(*fileInfo)); + // GeneralsX @bugfix felipebraz 02/10/2026 Prioritize mod archive metadata if an override is active. + const Bool hasModOverride = (TheArchiveFileSystem != nullptr) && + TheArchiveFileSystem->hasModArchiveOverride(filename.str()); + + if (hasModOverride) + { + FileInstance archiveCount = TheArchiveFileSystem->getFileCount(filename.str()); + if (instance < archiveCount) + { + return TheArchiveFileSystem->getFileInfo(filename, fileInfo, instance); + } + instance -= archiveCount; + } + if (TheLocalFileSystem->getFileInfo(filename, fileInfo)) { if (instance == 0) { return TRUE; @@ -343,7 +363,7 @@ Bool FileSystem::getFileInfo(const AsciiString& filename, FileInfo *fileInfo, Fi --instance; } - if (TheArchiveFileSystem->getFileInfo(filename, fileInfo, instance)) { + if (!hasModOverride && TheArchiveFileSystem->getFileInfo(filename, fileInfo, instance)) { return TRUE; } diff --git a/docs/WORKLOG/2026-10-DIARY.md b/docs/WORKLOG/2026-10-DIARY.md index 694177366ab..46b14b8572e 100644 --- a/docs/WORKLOG/2026-10-DIARY.md +++ b/docs/WORKLOG/2026-10-DIARY.md @@ -3,15 +3,15 @@ > [!NOTE] > **AI-Generated Content Disclosure**: This worklog is automatically generated and maintained by AI coding agents to document daily progress, debugging sessions, and technical decisions. -## 01/10/2026 - -### Use shared SELinux labels for PR #344 Docker mounts -- Changed the project and vcpkg bind mounts from private `:Z` to shared `:z` in the Generals, Zero Hour, and configure Linux Docker scripts so overlapping containers do not revoke each other's directory access. - ## 02/10/2026 - + ### Fix mod archive precedence over stock loose files (#335) - Diagnosed issue #335 where Shockwave mod Skirmish AI was completely inactive when assigned new generals (Armor, Salvage, Special Weapons). - Discovered that retail Zero Hour installs ship stock loose files in `Data/Scripts/` (e.g. `SkirmishScripts.scb`) that shadowed custom scripts packaged inside mod archives (`!Shw_scripts.big`), because `TheLocalFileSystem` always prioritized loose files over archives. On Windows, the original `ShockWaveLauncher.exe` bypassed this by renaming stock `.scb` files to `.bcs`, which is absent on Linux/Flatpak/macOS manual installations. - Implemented `ArchiveFileSystem::hasModArchiveOverride` and updated `FileSystem::openFile` / `doesFileExist` to prioritize mod archives (`!*.big`, `-mod`, and custom script archives) over stock loose files, ensuring full mod compatibility without manual file manipulation. +## 01/10/2026 + +### Use shared SELinux labels for PR #344 Docker mounts +- Changed the project and vcpkg bind mounts from private `:Z` to shared `:z` in the Generals, Zero Hour, and configure Linux Docker scripts so overlapping containers do not revoke each other's directory access. + From dd891e81ca56f1233cf22144bf68772031d46447 Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Fri, 2 Oct 2026 23:41:01 -0300 Subject: [PATCH 4/9] fix(fs): separate mod vs stock archive lookup and preserve existence cache index --- .../Include/Common/ArchiveFileSystem.h | 15 +- .../Common/System/ArchiveFileSystem.cpp | 138 +++++++++++++----- .../Source/Common/System/FileSystem.cpp | 63 ++++---- docs/WORKLOG/2026-10-DIARY.md | 1 + 4 files changed, 149 insertions(+), 68 deletions(-) diff --git a/Core/GameEngine/Include/Common/ArchiveFileSystem.h b/Core/GameEngine/Include/Common/ArchiveFileSystem.h index 88715d0f62b..46bb90168ac 100644 --- a/Core/GameEngine/Include/Common/ArchiveFileSystem.h +++ b/Core/GameEngine/Include/Common/ArchiveFileSystem.h @@ -123,6 +123,13 @@ class ArchivedFileInfo class ArchiveFileSystem : public SubsystemInterface { public: + enum ArchiveFilter + { + ARCHIVE_FILTER_ALL = 0, + ARCHIVE_FILTER_MOD_ONLY, + ARCHIVE_FILTER_NON_MOD_ONLY + }; + ArchiveFileSystem(); virtual ~ArchiveFileSystem() override; @@ -135,11 +142,14 @@ class ArchiveFileSystem : public SubsystemInterface // File operations virtual File* openFile( const Char *filename, Int access = 0, FileInstance instance = 0); ///< Search Archive files for specified file name and open it if found + File* openFile( const Char *filename, Int access, FileInstance instance, ArchiveFilter filter ); virtual void closeAllFiles() = 0; ///< Close all files associated with Archive files virtual Bool doesFileExist(const Char *filename, FileInstance instance = 0) const; ///< return true if that file exists in an archive file somewhere. + Bool doesFileExist(const Char *filename, FileInstance instance, ArchiveFilter filter) const; void getFileListInDirectory(const AsciiString& currentDirectory, const AsciiString& originalDirectory, const AsciiString& searchName, FilenameList &filenameList, Bool searchSubdirectories) const; ///< search the given directory for files matching the searchName (egs. *.ini, *.rep). Possibly search subdirectories. Scans each Archive file. Bool getFileInfo(const AsciiString& filename, FileInfo *fileInfo, FileInstance instance = 0) const; ///< see FileSystem.h + Bool getFileInfo(const AsciiString& filename, FileInfo *fileInfo, FileInstance instance, ArchiveFilter filter) const; virtual Bool loadBigFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite = FALSE) = 0; @@ -147,7 +157,8 @@ class ArchiveFileSystem : public SubsystemInterface // GeneralsX @bugfix felipebraz 02/10/2026 Check if a file is provided by an overriding mod archive (!*.big or script files) Bool hasModArchiveOverride(const Char *filename) const; - FileInstance getFileCount(const Char *filename) const; + FileInstance getFileCount(const Char *filename, ArchiveFilter filter = ARCHIVE_FILTER_ALL) const; + Bool isModArchive(ArchiveFile *archive, const Char *filename) const; ArchivedDirectoryInfo* friend_getArchivedDirectoryInfo(const Char* directory); @@ -161,7 +172,7 @@ class ArchiveFileSystem : public SubsystemInterface AsciiString lastToken; ///< Synonymous for file name if the search directory was a file path }; - ArchiveFile* getArchiveFile(const AsciiString& filename, FileInstance instance = 0) const; + ArchiveFile* getArchiveFile(const AsciiString& filename, FileInstance instance = 0, ArchiveFilter filter = ARCHIVE_FILTER_ALL) const; ArchivedDirectoryInfoResult getArchivedDirectoryInfo(const Char* directory); virtual void loadIntoDirectoryTree(ArchiveFile *archiveFile, Bool overwrite = FALSE); ///< load the archive file's header information and apply it to the global archive directory tree. diff --git a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp index c6fc998d7a9..2ff75c7fc4b 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp @@ -245,14 +245,17 @@ void ArchiveFileSystem::loadMods() Bool ArchiveFileSystem::doesFileExist(const Char *filename, FileInstance instance) const { - ArchivedDirectoryInfoResult result = const_cast(this)->getArchivedDirectoryInfo(filename); + return doesFileExist(filename, instance, ARCHIVE_FILTER_ALL); +} - if (!result.valid()) +Bool ArchiveFileSystem::doesFileExist(const Char *filename, FileInstance instance, ArchiveFilter filter) const +{ + if (filename == nullptr || filename[0] == '\0') + { return false; + } - stl::const_range range = stl::get_range(result.dirInfo->m_files, result.lastToken, instance); - - return range.valid(); + return getArchiveFile(AsciiString(filename), instance, filter) != nullptr; } ArchivedDirectoryInfo* ArchiveFileSystem::friend_getArchivedDirectoryInfo(const Char* directory) @@ -296,7 +299,17 @@ ArchiveFileSystem::ArchivedDirectoryInfoResult ArchiveFileSystem::getArchivedDir File * ArchiveFileSystem::openFile(const Char *filename, Int access, FileInstance instance) { - ArchiveFile* archive = getArchiveFile(filename, instance); + return openFile(filename, access, instance, ARCHIVE_FILTER_ALL); +} + +File * ArchiveFileSystem::openFile(const Char *filename, Int access, FileInstance instance, ArchiveFilter filter) +{ + if (filename == nullptr || filename[0] == '\0') + { + return nullptr; + } + + ArchiveFile* archive = getArchiveFile(AsciiString(filename), instance, filter); if (archive == nullptr) return nullptr; @@ -306,15 +319,16 @@ File * ArchiveFileSystem::openFile(const Char *filename, Int access, FileInstanc Bool ArchiveFileSystem::getFileInfo(const AsciiString& filename, FileInfo *fileInfo, FileInstance instance) const { - if (fileInfo == nullptr) { - return FALSE; - } + return getFileInfo(filename, fileInfo, instance, ARCHIVE_FILTER_ALL); +} - if (filename.isEmpty()) { +Bool ArchiveFileSystem::getFileInfo(const AsciiString& filename, FileInfo *fileInfo, FileInstance instance, ArchiveFilter filter) const +{ + if (fileInfo == nullptr || filename.isEmpty()) { return FALSE; } - ArchiveFile* archive = getArchiveFile(filename, instance); + ArchiveFile* archive = getArchiveFile(filename, instance, filter); if (archive == nullptr) return FALSE; @@ -322,19 +336,37 @@ Bool ArchiveFileSystem::getFileInfo(const AsciiString& filename, FileInfo *fileI return archive->getFileInfo(filename, fileInfo); } -ArchiveFile* ArchiveFileSystem::getArchiveFile(const AsciiString& filename, FileInstance instance) const +ArchiveFile* ArchiveFileSystem::getArchiveFile(const AsciiString& filename, FileInstance instance, ArchiveFilter filter) const { ArchivedDirectoryInfoResult result = const_cast(this)->getArchivedDirectoryInfo(filename.str()); if (!result.valid()) return nullptr; - stl::const_range range = stl::get_range(result.dirInfo->m_files, result.lastToken, instance); + std::pair range = + result.dirInfo->m_files.equal_range(result.lastToken); - if (!range.valid()) - return nullptr; - - return range.get()->second; + FileInstance currentInstance = 0; + for (ArchivedFileLocationMap::const_iterator it = range.first; it != range.second; ++it) + { + ArchiveFile* archive = it->second; + if (filter == ARCHIVE_FILTER_MOD_ONLY && !isModArchive(archive, filename.str())) + { + continue; + } + if (filter == ARCHIVE_FILTER_NON_MOD_ONLY && isModArchive(archive, filename.str())) + { + continue; + } + + if (currentInstance == instance) + { + return archive; + } + ++currentInstance; + } + + return nullptr; } void ArchiveFileSystem::getFileListInDirectory(const AsciiString& currentDirectory, const AsciiString& originalDirectory, const AsciiString& searchName, FilenameList &filenameList, Bool searchSubdirectories) const @@ -349,14 +381,8 @@ void ArchiveFileSystem::getFileListInDirectory(const AsciiString& currentDirecto // GeneralsX @bugfix felipebraz 02/10/2026 Allow mod archives to take precedence over loose stock files. // Retail Zero Hour shipped stock loose files (e.g. Data/Scripts/SkirmishScripts.scb) that shadow mod archives // (such as Shockwave's !Shw_scripts.big) unless the launcher explicitly renamed them on disk. -Bool ArchiveFileSystem::hasModArchiveOverride(const Char *filename) const +Bool ArchiveFileSystem::isModArchive(ArchiveFile *archive, const Char *filename) const { - if (filename == nullptr || filename[0] == '\0') - { - return FALSE; - } - - ArchiveFile *archive = getArchiveFile(AsciiString(filename)); if (archive == nullptr) { return FALSE; @@ -365,20 +391,23 @@ Bool ArchiveFileSystem::hasModArchiveOverride(const Char *filename) const // 1. Script files: SkirmishScripts.scb, MultiplayerScripts.scb, Scripts.ini. // Vanilla Zero Hour never packaged these into any .big archive; they only shipped as loose files. // If any archive in the archive file system contains them, it is guaranteed to be a mod. - const char* fnRaw = filename; - const char* fnLastSlash = strrchr(fnRaw, '/'); - const char* fnLastBackslash = strrchr(fnRaw, '\\'); - const char* fnSplit = fnLastSlash; - if (fnSplit == nullptr || (fnLastBackslash != nullptr && fnLastBackslash > fnSplit)) - { - fnSplit = fnLastBackslash; - } - const char* fnBase = (fnSplit != nullptr) ? (fnSplit + 1) : fnRaw; - if (stricmp(fnBase, "SkirmishScripts.scb") == 0 || - stricmp(fnBase, "MultiplayerScripts.scb") == 0 || - stricmp(fnBase, "Scripts.ini") == 0) + if (filename != nullptr) { - return TRUE; + const char* fnRaw = filename; + const char* fnLastSlash = strrchr(fnRaw, '/'); + const char* fnLastBackslash = strrchr(fnRaw, '\\'); + const char* fnSplit = fnLastSlash; + if (fnSplit == nullptr || (fnLastBackslash != nullptr && fnLastBackslash > fnSplit)) + { + fnSplit = fnLastBackslash; + } + const char* fnBase = (fnSplit != nullptr) ? (fnSplit + 1) : fnRaw; + if (stricmp(fnBase, "SkirmishScripts.scb") == 0 || + stricmp(fnBase, "MultiplayerScripts.scb") == 0 || + stricmp(fnBase, "Scripts.ini") == 0) + { + return TRUE; + } } // 2. Mod archives starting with '!' (or '!!', '@', etc.) - the universal SAGE mod convention. @@ -414,9 +443,14 @@ Bool ArchiveFileSystem::hasModArchiveOverride(const Char *filename) const return FALSE; } -FileInstance ArchiveFileSystem::getFileCount(const Char *filename) const +Bool ArchiveFileSystem::hasModArchiveOverride(const Char *filename) const { - if (filename == nullptr) + return getFileCount(filename, ARCHIVE_FILTER_MOD_ONLY) > 0; +} + +FileInstance ArchiveFileSystem::getFileCount(const Char *filename, ArchiveFilter filter) const +{ + if (filename == nullptr || filename[0] == '\0') { return 0; } @@ -427,7 +461,31 @@ FileInstance ArchiveFileSystem::getFileCount(const Char *filename) const return 0; } - return static_cast(result.dirInfo->m_files.count(result.lastToken)); + if (filter == ARCHIVE_FILTER_ALL) + { + return static_cast(result.dirInfo->m_files.count(result.lastToken)); + } + + std::pair range = + result.dirInfo->m_files.equal_range(result.lastToken); + + FileInstance count = 0; + for (ArchivedFileLocationMap::const_iterator it = range.first; it != range.second; ++it) + { + ArchiveFile* archive = it->second; + if (filter == ARCHIVE_FILTER_MOD_ONLY && !isModArchive(archive, filename)) + { + continue; + } + if (filter == ARCHIVE_FILTER_NON_MOD_ONLY && isModArchive(archive, filename)) + { + continue; + } + ++count; + } + + return count; } + diff --git a/Core/GameEngine/Source/Common/System/FileSystem.cpp b/Core/GameEngine/Source/Common/System/FileSystem.cpp index 6f7dfdedb1c..73a6296798b 100644 --- a/Core/GameEngine/Source/Common/System/FileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/FileSystem.cpp @@ -183,23 +183,25 @@ File* FileSystem::openFile( const Char *filename, Int access, size_t bufferSize !(access & File::WRITE) && TheArchiveFileSystem->hasModArchiveOverride(filename); + FileInstance remainingInstance = instance; + if (hasModOverride) { - FileInstance archiveCount = TheArchiveFileSystem->getFileCount(filename); - if (instance < archiveCount) + FileInstance modArchiveCount = TheArchiveFileSystem->getFileCount(filename, ArchiveFileSystem::ARCHIVE_FILTER_MOD_ONLY); + if (remainingInstance < modArchiveCount) { - return TheArchiveFileSystem->openFile( filename, access, instance ); + return TheArchiveFileSystem->openFile( filename, access, remainingInstance, ArchiveFileSystem::ARCHIVE_FILTER_MOD_ONLY ); } - instance -= archiveCount; + remainingInstance -= modArchiveCount; } if ( TheLocalFileSystem != nullptr ) { - if (instance != 0) + if (remainingInstance != 0) { if (TheLocalFileSystem->doesFileExist(filename)) { - --instance; + --remainingInstance; } } else @@ -226,10 +228,11 @@ File* FileSystem::openFile( const Char *filename, Int access, size_t bufferSize } } - if ( !hasModOverride && (TheArchiveFileSystem != nullptr) && (file == nullptr) ) + if ( (TheArchiveFileSystem != nullptr) && (file == nullptr) ) { - // TheSuperHackers @todo Pass 'access' here? - file = TheArchiveFileSystem->openFile( filename, 0, instance ); + // Non-mod (ordinary) archives fallback if mod overrides were active, or all archives if not. + ArchiveFileSystem::ArchiveFilter filter = hasModOverride ? ArchiveFileSystem::ARCHIVE_FILTER_NON_MOD_ONLY : ArchiveFileSystem::ARCHIVE_FILTER_ALL; + file = TheArchiveFileSystem->openFile( filename, 0, remainingInstance, filter ); } return file; @@ -258,50 +261,55 @@ Bool FileSystem::doesFileExist(const Char *filename, FileInstance instance) cons } #endif + const FileInstance requestedInstance = instance; + FileInstance remainingInstance = instance; + // GeneralsX @bugfix felipebraz 02/10/2026 Prioritize mod archive existence if an override is active. const Bool hasModOverride = (TheArchiveFileSystem != nullptr) && TheArchiveFileSystem->hasModArchiveOverride(filename); if (hasModOverride) { - FileInstance archiveCount = TheArchiveFileSystem->getFileCount(filename); - if (instance < archiveCount) + FileInstance modArchiveCount = TheArchiveFileSystem->getFileCount(filename, ArchiveFileSystem::ARCHIVE_FILTER_MOD_ONLY); + if (remainingInstance < modArchiveCount) { #if ENABLE_FILESYSTEM_EXISTENCE_CACHE { FastCriticalSectionClass::LockClass lock(m_fileExistMutex); FileExistMap::mapped_type& value = m_fileExist[filename]; - value.instanceExists = max(value.instanceExists, instance); + value.instanceExists = max(value.instanceExists, requestedInstance); } #endif return TRUE; } - instance -= archiveCount; + remainingInstance -= modArchiveCount; } if (TheLocalFileSystem->doesFileExist(filename)) { - if (instance == 0) + if (remainingInstance == 0) { #if ENABLE_FILESYSTEM_EXISTENCE_CACHE { FastCriticalSectionClass::LockClass lock(m_fileExistMutex); - m_fileExist[filename]; + FileExistMap::mapped_type& value = m_fileExist[filename]; + value.instanceExists = max(value.instanceExists, requestedInstance); } #endif return TRUE; } - --instance; + --remainingInstance; } - if (!hasModOverride && TheArchiveFileSystem->doesFileExist(filename, instance)) + ArchiveFileSystem::ArchiveFilter filter = hasModOverride ? ArchiveFileSystem::ARCHIVE_FILTER_NON_MOD_ONLY : ArchiveFileSystem::ARCHIVE_FILTER_ALL; + if (TheArchiveFileSystem->doesFileExist(filename, remainingInstance, filter)) { #if ENABLE_FILESYSTEM_EXISTENCE_CACHE { FastCriticalSectionClass::LockClass lock(m_fileExistMutex); FileExistMap::mapped_type& value = m_fileExist[filename]; - value.instanceExists = max(value.instanceExists, instance); + value.instanceExists = max(value.instanceExists, requestedInstance); } #endif return TRUE; @@ -311,7 +319,7 @@ Bool FileSystem::doesFileExist(const Char *filename, FileInstance instance) cons { FastCriticalSectionClass::LockClass lock(m_fileExistMutex); FileExistMap::mapped_type& value = m_fileExist[filename]; - value.instanceDoesNotExist = min(value.instanceDoesNotExist, instance); + value.instanceDoesNotExist = min(value.instanceDoesNotExist, requestedInstance); } #endif return FALSE; @@ -341,29 +349,32 @@ Bool FileSystem::getFileInfo(const AsciiString& filename, FileInfo *fileInfo, Fi } memset(fileInfo, 0, sizeof(*fileInfo)); + FileInstance remainingInstance = instance; + // GeneralsX @bugfix felipebraz 02/10/2026 Prioritize mod archive metadata if an override is active. const Bool hasModOverride = (TheArchiveFileSystem != nullptr) && TheArchiveFileSystem->hasModArchiveOverride(filename.str()); if (hasModOverride) { - FileInstance archiveCount = TheArchiveFileSystem->getFileCount(filename.str()); - if (instance < archiveCount) + FileInstance modArchiveCount = TheArchiveFileSystem->getFileCount(filename.str(), ArchiveFileSystem::ARCHIVE_FILTER_MOD_ONLY); + if (remainingInstance < modArchiveCount) { - return TheArchiveFileSystem->getFileInfo(filename, fileInfo, instance); + return TheArchiveFileSystem->getFileInfo(filename, fileInfo, remainingInstance, ArchiveFileSystem::ARCHIVE_FILTER_MOD_ONLY); } - instance -= archiveCount; + remainingInstance -= modArchiveCount; } if (TheLocalFileSystem->getFileInfo(filename, fileInfo)) { - if (instance == 0) { + if (remainingInstance == 0) { return TRUE; } - --instance; + --remainingInstance; } - if (!hasModOverride && TheArchiveFileSystem->getFileInfo(filename, fileInfo, instance)) { + ArchiveFileSystem::ArchiveFilter filter = hasModOverride ? ArchiveFileSystem::ARCHIVE_FILTER_NON_MOD_ONLY : ArchiveFileSystem::ARCHIVE_FILTER_ALL; + if (TheArchiveFileSystem->getFileInfo(filename, fileInfo, remainingInstance, filter)) { return TRUE; } diff --git a/docs/WORKLOG/2026-10-DIARY.md b/docs/WORKLOG/2026-10-DIARY.md index 46b14b8572e..53b61ff0747 100644 --- a/docs/WORKLOG/2026-10-DIARY.md +++ b/docs/WORKLOG/2026-10-DIARY.md @@ -9,6 +9,7 @@ - Diagnosed issue #335 where Shockwave mod Skirmish AI was completely inactive when assigned new generals (Armor, Salvage, Special Weapons). - Discovered that retail Zero Hour installs ship stock loose files in `Data/Scripts/` (e.g. `SkirmishScripts.scb`) that shadowed custom scripts packaged inside mod archives (`!Shw_scripts.big`), because `TheLocalFileSystem` always prioritized loose files over archives. On Windows, the original `ShockWaveLauncher.exe` bypassed this by renaming stock `.scb` files to `.bcs`, which is absent on Linux/Flatpak/macOS manual installations. - Implemented `ArchiveFileSystem::hasModArchiveOverride` and updated `FileSystem::openFile` / `doesFileExist` to prioritize mod archives (`!*.big`, `-mod`, and custom script archives) over stock loose files, ensuring full mod compatibility without manual file manipulation. +- Refined archive routing to distinguish qualifying mod archives (`ARCHIVE_FILTER_MOD_ONLY`) from stock archives (`ARCHIVE_FILTER_NON_MOD_ONLY`), preserving the correct 3-tier lookup order (mod archive -> loose file -> stock archive) and fixed existence cache corruption caused by instance mutation. ## 01/10/2026 From 003a51463f81362f316daa39e59a14d30e79ba59 Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Fri, 2 Oct 2026 23:45:43 -0300 Subject: [PATCH 5/9] fix(fs): enforce directory boundary for m_modDir and exact match for m_modBIG --- .../Common/System/ArchiveFileSystem.cpp | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp index 2ff75c7fc4b..3a50e6cf549 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp @@ -429,14 +429,37 @@ Bool ArchiveFileSystem::isModArchive(ArchiveFile *archive, const Char *filename) // 3. Archives loaded explicitly via -mod command line parameter (directory or direct .big). if (TheGlobalData) { - if (TheGlobalData->m_modDir.isNotEmpty() && strstr(archiveName.str(), TheGlobalData->m_modDir.str()) != nullptr) + if (TheGlobalData->m_modDir.isNotEmpty()) { - return TRUE; + const char* match = strstr(archiveName.str(), TheGlobalData->m_modDir.str()); + if (match != nullptr) + { + char nextChar = match[TheGlobalData->m_modDir.getLength()]; + if (nextChar == '/' || nextChar == '\\' || nextChar == '\0') + { + return TRUE; + } + } } - if (TheGlobalData->m_modBIG.isNotEmpty() && - (archiveName.compareNoCase(TheGlobalData->m_modBIG) == 0 || strstr(archiveName.str(), TheGlobalData->m_modBIG.str()) != nullptr)) + if (TheGlobalData->m_modBIG.isNotEmpty()) { - return TRUE; + if (archiveName.compareNoCase(TheGlobalData->m_modBIG) == 0) + { + return TRUE; + } + const char* modBigRaw = TheGlobalData->m_modBIG.str(); + const char* modBigSlash = strrchr(modBigRaw, '/'); + const char* modBigBackslash = strrchr(modBigRaw, '\\'); + const char* modBigSplit = modBigSlash; + if (modBigSplit == nullptr || (modBigBackslash != nullptr && modBigBackslash > modBigSplit)) + { + modBigSplit = modBigBackslash; + } + const char* modBigBase = (modBigSplit != nullptr) ? (modBigSplit + 1) : modBigRaw; + if (stricmp(baseName, modBigBase) == 0) + { + return TRUE; + } } } From f8efd9360deb60d62b23f3267cd79d5afec62000 Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Fri, 2 Oct 2026 23:48:25 -0300 Subject: [PATCH 6/9] docs(howto): remove out-of-scope shockwave stub to focus PR on bugfix --- docs/HOWTO/README.md | 1 - docs/HOWTO/SHOCKWAVE_MOD.md | 5 ----- 2 files changed, 6 deletions(-) delete mode 100644 docs/HOWTO/SHOCKWAVE_MOD.md diff --git a/docs/HOWTO/README.md b/docs/HOWTO/README.md index 4cbd2d8a465..2633701226c 100644 --- a/docs/HOWTO/README.md +++ b/docs/HOWTO/README.md @@ -14,5 +14,4 @@ > - [macOS Crash Diagnostics](https://github.com/fbraz3/GeneralsX/wiki/How-to-Capture-&-Report-macOS-Crashes) > - [Linux Crash Diagnostics](https://github.com/fbraz3/GeneralsX/wiki/How-to-Capture-&-Report-Linux-Crashes) > - [How to Install & Play Custom Missions](https://github.com/fbraz3/GeneralsX/wiki/How-to-Install-&-Play-Custom-Missions) -> - [How to Install & Play Shockwave Mod](https://github.com/fbraz3/GeneralsX/wiki/How-to-Install-&-Play-Shockwave-Mod) diff --git a/docs/HOWTO/SHOCKWAVE_MOD.md b/docs/HOWTO/SHOCKWAVE_MOD.md deleted file mode 100644 index 097de2c9961..00000000000 --- a/docs/HOWTO/SHOCKWAVE_MOD.md +++ /dev/null @@ -1,5 +0,0 @@ -# Shockwave Mod Guide - -> [!NOTE] -> This article is part of the project wiki: -> 👉 **[How to Install & Play Shockwave Mod](https://github.com/fbraz3/GeneralsX/wiki/How-to-Install-&-Play-Shockwave-Mod)** From 2104bdd3d7385c07958e44c384cec22b2909cd80 Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Mon, 5 Oct 2026 21:51:36 -0300 Subject: [PATCH 7/9] fix(logic): import missing skirmish side definitions from SkirmishScripts chunk Custom maps created in standard WorldBuilder only define retail factions and lack mod-added skirmish generals (e.g. Armor, Salvage, and Special Weapons in Shockwave). When SkirmishScripts.scb is loaded, ParsePlayersDataChunk read sideDict from the ScriptsPlayers chunk but discarded it. Consequently: - Mod factions were missing from m_skirmishSides - ParseTeamsDataChunk skipped registering mod teams - prepareForMP_or_Skirmish pruned mod scripts - Player::initFromDict fell back to forceHuman, leaving AI idle Now SidesList::addSkirmishSide registers missing sides from sideDict, allowing custom and ranked maps to dynamically support mod generals. Also expands m_skirmishSides capacity to MAX_SKIRMISH_SIDES (64) to prevent overflow when maps define neutral/civilian players alongside large mod rosters. Fixes #335 --- Core/GameEngine/Include/GameLogic/SidesList.h | 6 ++-- .../Source/GameLogic/Map/SidesList.cpp | 34 ++++++++++++++++--- docs/WORKLOG/2026-10-DIARY.md | 8 +++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/Core/GameEngine/Include/GameLogic/SidesList.h b/Core/GameEngine/Include/GameLogic/SidesList.h index 3713fc1cc35..b2dccb96790 100644 --- a/Core/GameEngine/Include/GameLogic/SidesList.h +++ b/Core/GameEngine/Include/GameLogic/SidesList.h @@ -154,6 +154,7 @@ class SidesList : public SubsystemInterface, inline SidesInfo *getSideInfo(Int side); inline SidesInfo *getSkirmishSideInfo(Int side); Int getNumSkirmishSides() { return m_numSkirmishSides; } + void addSkirmishSide(const Dict* d); SidesInfo *findSideInfo(AsciiString name, Int* index = nullptr); SidesInfo *findSkirmishSideInfo(AsciiString name, Int* index = nullptr); @@ -180,7 +181,8 @@ class SidesList : public SubsystemInterface, enum { - MAX_TEAM_DEPTH = 3 + MAX_TEAM_DEPTH = 3, + MAX_SKIRMISH_SIDES = 64 }; protected: @@ -194,7 +196,7 @@ class SidesList : public SubsystemInterface, SidesInfo m_sides[MAX_PLAYER_COUNT]; Int m_numSkirmishSides; - SidesInfo m_skirmishSides[MAX_PLAYER_COUNT]; + SidesInfo m_skirmishSides[MAX_SKIRMISH_SIDES]; TeamsInfoRec m_teamrec; TeamsInfoRec m_skirmishTeamrec; diff --git a/Core/GameEngine/Source/GameLogic/Map/SidesList.cpp b/Core/GameEngine/Source/GameLogic/Map/SidesList.cpp index 6eb2b9ad762..77948220de3 100644 --- a/Core/GameEngine/Source/GameLogic/Map/SidesList.cpp +++ b/Core/GameEngine/Source/GameLogic/Map/SidesList.cpp @@ -421,7 +421,7 @@ SidesInfo *SidesList::findSkirmishSideInfo(AsciiString name, Int* index /*= null return nullptr; } -static AsciiString static_readPlayerNames[MAX_PLAYER_COUNT]; +static AsciiString static_readPlayerNames[SidesList::MAX_SKIRMISH_SIDES]; /** * ParsePlayersDataChunk - read players names data chunk. @@ -434,6 +434,7 @@ static AsciiString static_readPlayerNames[MAX_PLAYER_COUNT]; static Bool ParsePlayersDataChunk(DataChunkInput &file, DataChunkInfo *info, void *userData) { + SidesList *sides = (SidesList *)userData; Int readDicts = 0; if (info->version >= K_PLAYERS_NAMES_FOR_SCRIPTS_VERSION_2) { readDicts = file.readInt(); @@ -441,10 +442,24 @@ static Bool ParsePlayersDataChunk(DataChunkInput &file, DataChunkInfo *info, voi Int numNames = file.readInt(); Int i; for (i=0; i=MAX_PLAYER_COUNT) break; - static_readPlayerNames[i] = file.readAsciiString(); + AsciiString playerName = file.readAsciiString(); + if (i < SidesList::MAX_SKIRMISH_SIDES) { + static_readPlayerNames[i] = playerName; + } if (readDicts) { Dict sideDict = file.readDict(); + // GeneralsX @bugfix fbraz3/gpherai 05/10/2026 Import missing skirmish player definitions + // from SkirmishScripts chunk to dynamically support mod factions on custom/retail maps (#335). + if (sides && !playerName.isEmpty() && sides->findSkirmishSideInfo(playerName) == nullptr) { + if (sideDict.getAsciiString(TheKey_playerName).isEmpty()) { + sideDict.setAsciiString(TheKey_playerName, playerName); + } + sides->addSkirmishSide(&sideDict); +#ifdef ALLOW_DEBUG_UTILS + fprintf(stderr, "[SKIRMISH_DIAG] Imported missing skirmish side '%s' from SkirmishScripts\n", playerName.str()); + fflush(stderr); +#endif + } } } DEBUG_ASSERTCRASH(file.atEndOfChunk(), ("Unexpected data left over.")); @@ -487,7 +502,7 @@ void SidesList::prepareForMP_or_Skirmish() } m_teamrec.clear(); - for (i = 0; i < MAX_PLAYER_COUNT; i++) { + for (i = 0; i < MAX_SKIRMISH_SIDES; i++) { m_skirmishSides[i].clear(); } m_numSkirmishSides = 0; @@ -573,7 +588,7 @@ void SidesList::prepareForMP_or_Skirmish() getSkirmishSideInfo(curSide)->setScriptList(scripts[i]); scripts[i] = nullptr; } - for (i=0; i [!NOTE] > **AI-Generated Content Disclosure**: This worklog is automatically generated and maintained by AI coding agents to document daily progress, debugging sessions, and technical decisions. +## 05/10/2026 + +### Import missing skirmish player definitions from SkirmishScripts chunk (#335) +- Diagnosed user report on issue #335 where Shockwave skirmish generals were active on official maps (e.g. Alpine Assault) but remained idle on custom and ranked maps (e.g. "Dirt's Destruction Derby", "Woodland Siege 2"). +- Identified root cause in `SidesList`: custom maps authored with standard WorldBuilder only contain the 14 retail side definitions and lack mod-added skirmish factions (`SkirmishAmericaArmorGeneral`, `SkirmishGLASalvageGeneral`, `SkirmishChinaSpecialWeaponsGeneral`). +- When loading `Data/Scripts/SkirmishScripts.scb`, `ParsePlayersDataChunk` read the player dictionary (`sideDict`) provided by `ScriptsPlayers` (v2) but immediately discarded it instead of adding missing sides to `m_skirmishSides`. Consequently, `prepareForMP_or_Skirmish` pruned mod scripts, `ParseTeamsDataChunk` skipped mod teams, and `Player::initFromDict` failed to find matching skirmish templates, falling back to `forceHuman` which left AI units completely idle. +- Added `SidesList::addSkirmishSide`, expanded skirmish sides capacity to `MAX_SKIRMISH_SIDES = 64` to prevent buffer overflow when custom maps define civilian/neutral sides alongside full mod rosters, and dynamically imported missing skirmish player definitions and teams from `ScriptsPlayers`. + ## 02/10/2026 ### Fix mod archive precedence over stock loose files (#335) From ea62292612f6b59f5e1f8c5fb80d7d786b3d400c Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Mon, 5 Oct 2026 23:06:37 -0300 Subject: [PATCH 8/9] style(logic): add GeneralsX change annotations in SidesList --- Core/GameEngine/Include/GameLogic/SidesList.h | 3 +++ Core/GameEngine/Source/GameLogic/Map/SidesList.cpp | 1 + 2 files changed, 4 insertions(+) diff --git a/Core/GameEngine/Include/GameLogic/SidesList.h b/Core/GameEngine/Include/GameLogic/SidesList.h index b2dccb96790..5cb5815b8ba 100644 --- a/Core/GameEngine/Include/GameLogic/SidesList.h +++ b/Core/GameEngine/Include/GameLogic/SidesList.h @@ -154,6 +154,7 @@ class SidesList : public SubsystemInterface, inline SidesInfo *getSideInfo(Int side); inline SidesInfo *getSkirmishSideInfo(Int side); Int getNumSkirmishSides() { return m_numSkirmishSides; } + // GeneralsX @bugfix fbraz3 05/10/2026 Allow importing missing skirmish player definitions from SkirmishScripts chunk (#335). void addSkirmishSide(const Dict* d); SidesInfo *findSideInfo(AsciiString name, Int* index = nullptr); SidesInfo *findSkirmishSideInfo(AsciiString name, Int* index = nullptr); @@ -182,6 +183,7 @@ class SidesList : public SubsystemInterface, enum { MAX_TEAM_DEPTH = 3, + // GeneralsX @bugfix fbraz3 05/10/2026 Expand skirmish sides capacity to accommodate mod rosters on custom maps (#335). MAX_SKIRMISH_SIDES = 64 }; @@ -196,6 +198,7 @@ class SidesList : public SubsystemInterface, SidesInfo m_sides[MAX_PLAYER_COUNT]; Int m_numSkirmishSides; + // GeneralsX @bugfix fbraz3 05/10/2026 Size skirmish sides array with MAX_SKIRMISH_SIDES to prevent buffer overflow (#335). SidesInfo m_skirmishSides[MAX_SKIRMISH_SIDES]; TeamsInfoRec m_teamrec; diff --git a/Core/GameEngine/Source/GameLogic/Map/SidesList.cpp b/Core/GameEngine/Source/GameLogic/Map/SidesList.cpp index 77948220de3..3607bd94769 100644 --- a/Core/GameEngine/Source/GameLogic/Map/SidesList.cpp +++ b/Core/GameEngine/Source/GameLogic/Map/SidesList.cpp @@ -649,6 +649,7 @@ void SidesList::addSide(const Dict* d) m_sides[m_numSides++].init(d); } +// GeneralsX @bugfix fbraz3 05/10/2026 Add skirmish side definition imported from SkirmishScripts chunk (#335). void SidesList::addSkirmishSide(const Dict* d) { DEBUG_ASSERTCRASH(m_numSkirmishSides < MAX_SKIRMISH_SIDES, ("too many skirmish players")); From c3cb9eab20543eba25fb89dbc3dccca488982136 Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Mon, 5 Oct 2026 23:44:40 -0300 Subject: [PATCH 9/9] docs(howto): remove extraneous trailing newline in README --- docs/HOWTO/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/HOWTO/README.md b/docs/HOWTO/README.md index 2633701226c..acad896bd9c 100644 --- a/docs/HOWTO/README.md +++ b/docs/HOWTO/README.md @@ -14,4 +14,3 @@ > - [macOS Crash Diagnostics](https://github.com/fbraz3/GeneralsX/wiki/How-to-Capture-&-Report-macOS-Crashes) > - [Linux Crash Diagnostics](https://github.com/fbraz3/GeneralsX/wiki/How-to-Capture-&-Report-Linux-Crashes) > - [How to Install & Play Custom Missions](https://github.com/fbraz3/GeneralsX/wiki/How-to-Install-&-Play-Custom-Missions) -