From 8657eb2efee970618ad55dc4da87e2693a410d06 Mon Sep 17 00:00:00 2001
From: tintinhamans <5984296+tintinhamans@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:09:26 +0200
Subject: [PATCH 1/8] fix(userdata): Support non-ASCII Documents paths with a
UTF-8 code page and Unicode window
---
.../Source/GameClient/GUI/IMEManager.cpp | 5 +-
.../GameEngine/Source/Common/GlobalData.cpp | 60 ++++++++++++++++---
.../GeneralsOnline/OnlineServices_Init.cpp | 19 ++++--
.../W3DDevice/GameClient/W3DFileSystem.cpp | 6 +-
.../Win32Device/Common/Win32GameEngine.cpp | 7 ++-
GeneralsMD/Code/Main/WinMain.cpp | 16 ++---
GeneralsMD/Code/Main/app.manifest | 2 +
7 files changed, 88 insertions(+), 27 deletions(-)
diff --git a/Core/GameEngine/Source/GameClient/GUI/IMEManager.cpp b/Core/GameEngine/Source/GameClient/GUI/IMEManager.cpp
index 5de6d1a5c54..74cdec5f02e 100644
--- a/Core/GameEngine/Source/GameClient/GUI/IMEManager.cpp
+++ b/Core/GameEngine/Source/GameClient/GUI/IMEManager.cpp
@@ -673,14 +673,15 @@ Bool IMEManager::serviceIMEMessage( void *windowsHandle, UnsignedInt message, In
// --------------------------------------------------------------------
case WM_IME_CHAR:
{
- WideChar wchar = convertCharToWide(wParam);
+ // Unicode windows get UTF-16, ANSI windows get the character in the ANSI code page
+ WideChar wchar = IsWindowUnicode( (HWND)windowsHandle ) ? (WideChar) (wParam & 0xffff) : convertCharToWide(wParam);
#ifdef DEBUG_IME
DEBUG_LOG(("IMM: WM_IME_CHAR - '%hc'0x%04x", wchar, wchar ));
#endif
if ( m_window && (wchar > 32 || wchar == VK_RETURN ))
{
- TheWindowManager->winSendInputMsg( m_window, GWM_IME_CHAR, (wParam & 0xffff), lParam );
+ TheWindowManager->winSendInputMsg( m_window, GWM_IME_CHAR, wchar, lParam );
m_result = 0;
return TRUE;
}
diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp
index 5a0d746d251..04063cf38f6 100644
--- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp
@@ -69,6 +69,49 @@ GlobalData* TheWritableGlobalData = nullptr; ///< The global data singleton
//-------------------------------------------------------------------------------------------------
GlobalData* GlobalData::m_theOriginal = nullptr;
+#ifndef WC_NO_BEST_FIT_CHARS
+#define WC_NO_BEST_FIT_CHARS 0x00000400
+#endif
+
+//-------------------------------------------------------------------------------------------------
+// Converts to the process code page that every char* file API uses; fails instead of mangling characters it can't represent
+static Bool wideToProcessCodePage(const wchar_t* widePath, AsciiString& out)
+{
+ const Bool isUtf8 = GetACP() == CP_UTF8;
+ const UINT codePage = isUtf8 ? CP_UTF8 : CP_ACP;
+ const DWORD flags = isUtf8 ? 0 : WC_NO_BEST_FIT_CHARS;
+ BOOL usedDefaultChar = FALSE;
+ BOOL* pUsedDefaultChar = isUtf8 ? nullptr : &usedDefaultChar;
+
+ const int len = WideCharToMultiByte(codePage, flags, widePath, -1, nullptr, 0, nullptr, pUsedDefaultChar);
+ if (len <= 0 || usedDefaultChar)
+ return FALSE;
+
+ char* buffer = new char[len];
+ const int written = WideCharToMultiByte(codePage, flags, widePath, -1, buffer, len, nullptr, pUsedDefaultChar);
+ if (written > 0 && !usedDefaultChar)
+ out = buffer;
+ delete[] buffer;
+ return written > 0 && !usedDefaultChar;
+}
+
+//-------------------------------------------------------------------------------------------------
+// Narrow form of a wide path; falls back to the ASCII 8.3 short path when the code page can't hold it (pre UTF-8 Windows)
+static AsciiString widePathToFileApiPath(const wchar_t* widePath)
+{
+ AsciiString path;
+ if (wideToProcessCodePage(widePath, path))
+ return path;
+
+ wchar_t shortPath[_MAX_PATH + 1];
+ const DWORD shortLen = GetShortPathNameW(widePath, shortPath, _MAX_PATH + 1);
+ if (shortLen > 0 && shortLen <= _MAX_PATH && wideToProcessCodePage(shortPath, path))
+ return path;
+
+ DEBUG_LOG(("Documents path can't be represented in code page %u and has no short name", GetACP()));
+ return AsciiString::TheEmptyString;
+}
+
///////////////////////////////////////////////////////////////////////////////////////////////////
// PRIVATE DATA ///////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
@@ -1075,10 +1118,13 @@ GlobalData::GlobalData()
#else
// Set user data directory based on registry settings instead of INI parameters. This allows us to
// localize the leaf name.
- char temp[_MAX_PATH + 1];
- if (::SHGetSpecialFolderPath(nullptr, temp, CSIDL_PERSONAL, true))
+ wchar_t temp[_MAX_PATH + 1];
+ AsciiString myDocumentsDirectory;
+ if (::SHGetSpecialFolderPathW(nullptr, temp, CSIDL_PERSONAL, true))
+ myDocumentsDirectory = widePathToFileApiPath(temp);
+
+ if (!myDocumentsDirectory.isEmpty())
{
- AsciiString myDocumentsDirectory = temp;
if (myDocumentsDirectory.getCharAt(myDocumentsDirectory.getLength() - 1) != '\\')
myDocumentsDirectory.concat('\\');
@@ -1421,14 +1467,14 @@ AsciiString GlobalData::BuildUserDataPathFromRegistry()
HRESULT hr = pSHGetKnownFolderPath(FOLDERID_Documents, KF_FLAG_DEFAULT, nullptr, &pszPath);
if (SUCCEEDED(hr) && pszPath) {
- myDocumentsDirectory.translate(pszPath);
+ myDocumentsDirectory = widePathToFileApiPath(pszPath);
CoTaskMemFree(pszPath);
}
}
else {
- char temp[_MAX_PATH + 1];
- if (SHGetSpecialFolderPath(nullptr, temp, CSIDL_PERSONAL, true)) {
- myDocumentsDirectory = temp;
+ wchar_t temp[_MAX_PATH + 1];
+ if (SHGetSpecialFolderPathW(nullptr, temp, CSIDL_PERSONAL, true)) {
+ myDocumentsDirectory = widePathToFileApiPath(temp);
}
}
diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp
index b23632f803e..affb6706f76 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp
@@ -1059,16 +1059,27 @@ void NGMP_OnlineServicesManager::InitSentry()
curl_global_init(CURL_GLOBAL_DEFAULT);
#if !_DEBUG
- std::string strDumpPath = std::format("{}/GeneralsOnlineCrashData/", TheGlobalData->getPath_UserData().str());
- if (!std::filesystem::exists(strDumpPath))
+ // game paths are in the process code page, sentry's char* API wants UTF-8, so hand it the wide path
+ std::wstring strDumpPath;
+ const AsciiString& strUserDataPath = TheGlobalData->getPath_UserData();
+ const int wideLen = MultiByteToWideChar(CP_ACP, 0, strUserDataPath.str(), -1, nullptr, 0);
+ if (!strUserDataPath.isEmpty() && wideLen > 1)
{
- std::filesystem::create_directory(strDumpPath);
+ std::wstring strUserDataPathW(wideLen - 1, L'\0');
+ MultiByteToWideChar(CP_ACP, 0, strUserDataPath.str(), -1, strUserDataPathW.data(), wideLen);
+ strDumpPath = strUserDataPathW + L"GeneralsOnlineCrashData\\";
+
+ std::error_code ec;
+ std::filesystem::create_directories(strDumpPath, ec);
}
sentry_options_t* options = sentry_options_new();
sentry_options_set_dsn(options, "https://61750bebd112d279bcc286d617819269@o4509316925554688.ingest.us.sentry.io/4509316927586304");
- sentry_options_set_database_path(options, strDumpPath.c_str());
+ if (!strDumpPath.empty())
+ {
+ sentry_options_set_database_pathw(options, strDumpPath.c_str());
+ }
std::string strVersionStr = std::format("generalsonline-client@{}", GENERALS_ONLINE_VERSION_STRING);
sentry_options_set_release(options, strVersionStr.c_str());
diff --git a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DFileSystem.cpp b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DFileSystem.cpp
index 80097fe1ead..c4629eb8c85 100644
--- a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DFileSystem.cpp
+++ b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DFileSystem.cpp
@@ -282,13 +282,13 @@ char const * GameFileClass::Set_Name( char const *filename )
{
if( fileType == FILE_TYPE_W3D )
{
- sprintf(m_filePath,USER_W3D_DIR_PATH, TheGlobalData->getPath_UserData().str());
+ snprintf(m_filePath, ARRAY_SIZE(m_filePath), USER_W3D_DIR_PATH, TheGlobalData->getPath_UserData().str());
strlcat(m_filePath, filename, ARRAY_SIZE(m_filePath));
}
else if( isImageFileType(fileType) )
{
- sprintf(m_filePath,USER_TGA_DIR_PATH, TheGlobalData->getPath_UserData().str());
+ snprintf(m_filePath, ARRAY_SIZE(m_filePath), USER_TGA_DIR_PATH, TheGlobalData->getPath_UserData().str());
strlcat(m_filePath, filename, ARRAY_SIZE(m_filePath));
}
@@ -304,7 +304,7 @@ char const * GameFileClass::Set_Name( char const *filename )
{
if( fileType == FILE_TYPE_TGA ) // just TGA, since we don't do dds previews
{
- sprintf(m_filePath,MAP_PREVIEW_DIR_PATH, TheGlobalData->getPath_UserData().str());
+ snprintf(m_filePath, ARRAY_SIZE(m_filePath), MAP_PREVIEW_DIR_PATH, TheGlobalData->getPath_UserData().str());
strlcat(m_filePath, filename, ARRAY_SIZE(m_filePath));
}
diff --git a/GeneralsMD/Code/GameEngineDevice/Source/Win32Device/Common/Win32GameEngine.cpp b/GeneralsMD/Code/GameEngineDevice/Source/Win32Device/Common/Win32GameEngine.cpp
index 9c7ac114db8..a9535aa2d5a 100644
--- a/GeneralsMD/Code/GameEngineDevice/Source/Win32Device/Common/Win32GameEngine.cpp
+++ b/GeneralsMD/Code/GameEngineDevice/Source/Win32Device/Common/Win32GameEngine.cpp
@@ -148,11 +148,12 @@ void Win32GameEngine::serviceWindowsOS()
// see if we have any messages to process, a nullptr window handle tells the
// OS to look at the main window associated with the calling thread, us!
//
- while( PeekMessage( &msg, nullptr, 0, 0, PM_NOREMOVE ) )
+ // W variants: an A pump would convert the Unicode window's WM_CHAR back to the code page
+ while( PeekMessageW( &msg, nullptr, 0, 0, PM_NOREMOVE ) )
{
// get the message
- returnValue = GetMessage( &msg, nullptr, 0, 0 );
+ returnValue = GetMessageW( &msg, nullptr, 0, 0 );
// this is one possible way to check for quitting conditions as a message
// of WM_QUIT will cause GetMessage() to return 0
@@ -169,7 +170,7 @@ void Win32GameEngine::serviceWindowsOS()
TheMessageTime = msg.time;
// translate and dispatch the message
TranslateMessage( &msg );
- DispatchMessage( &msg );
+ DispatchMessageW( &msg );
TheMessageTime = 0;
}
diff --git a/GeneralsMD/Code/Main/WinMain.cpp b/GeneralsMD/Code/Main/WinMain.cpp
index 90e852b502a..76552f857b6 100644
--- a/GeneralsMD/Code/Main/WinMain.cpp
+++ b/GeneralsMD/Code/Main/WinMain.cpp
@@ -687,7 +687,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message,
}
return 0;*/
- return DefWindowProc(hWnd, message, wParam, lParam);
+ return DefWindowProcW(hWnd, message, wParam, lParam);
}
@@ -700,14 +700,14 @@ static Bool initializeAppWindows(HINSTANCE hInstance, Int nCmdShow, Bool runWind
Int startWidth = DEFAULT_DISPLAY_WIDTH,
startHeight = DEFAULT_DISPLAY_HEIGHT;
- // register the window class
+ // register the window class, Unicode so WM_CHAR and WM_IME_CHAR carry UTF-16 regardless of the code page
- WNDCLASS wndClass = { CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS, WndProc, 0, 0, hInstance,
- LoadIcon(hInstance, MAKEINTRESOURCE(IDI_ApplicationIcon)),
+ WNDCLASSW wndClass = { CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS, WndProc, 0, 0, hInstance,
+ LoadIconW(hInstance, MAKEINTRESOURCEW(IDI_ApplicationIcon)),
nullptr/*LoadCursor(nullptr, IDC_ARROW)*/,
(HBRUSH)GetStockObject(BLACK_BRUSH), nullptr,
- TEXT("Game Window") };
- RegisterClass(&wndClass);
+ L"Game Window" };
+ RegisterClassW(&wndClass);
// Create our main window
windowStyle = WS_POPUP | WS_VISIBLE;
@@ -730,8 +730,8 @@ static Bool initializeAppWindows(HINSTANCE hInstance, Int nCmdShow, Bool runWind
gInitializing = true;
- HWND hWnd = CreateWindow(TEXT("Game Window"),
- TEXT("Command and Conquer Generals"),
+ HWND hWnd = CreateWindowW(L"Game Window",
+ L"Command and Conquer Generals",
windowStyle,
(GetSystemMetrics(SM_CXSCREEN) / 2) - (startWidth / 2), // original position X
(GetSystemMetrics(SM_CYSCREEN) / 2) - (startHeight / 2),// original position Y
diff --git a/GeneralsMD/Code/Main/app.manifest b/GeneralsMD/Code/Main/app.manifest
index 207a0886b37..77f28a3c213 100644
--- a/GeneralsMD/Code/Main/app.manifest
+++ b/GeneralsMD/Code/Main/app.manifest
@@ -23,6 +23,8 @@
true/pm
PerMonitorV2
+
+ UTF-8
From f73b0d1bc6b31d8816067a9ca227c506974fd348 Mon Sep 17 00:00:00 2001
From: tintinhamans <5984296+tintinhamans@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:25:34 +0200
Subject: [PATCH 2/8] fix(lobby): Send and resolve lobby map names as UTF-8
---
.../GameNetwork/GeneralsOnline/NGMP_include.h | 2 +
.../OnlineServices_LobbyInterface.h | 2 +-
.../GUICallbacks/Menus/WOLMapSelectMenu.cpp | 4 +-
.../GameNetwork/GeneralsOnline/NGMPGame.cpp | 2 +-
.../GeneralsOnline/NGMP_Helpers.cpp | 38 +++++++++++++++++++
.../OnlineServices_LobbyInterface.cpp | 12 +++---
.../OnlineServices_MatchmakingInterface.cpp | 1 +
7 files changed, 52 insertions(+), 9 deletions(-)
diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h
index 6bee19efb2e..342de5a1087 100644
--- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h
+++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h
@@ -27,6 +27,8 @@ void NetworkLog(ELogVerbosity logVerbosity, const char* fmt, ...);
std::string to_utf8(const std::wstring& wstr);
std::wstring from_utf8(const std::string& utf8_str);
+std::string local_to_utf8(const std::string& local_str);
+std::string utf8_to_local(const std::string& utf8_str);
std::wstring NormalizeSingleLineText(const std::wstring& text);
std::wstring WidenFormatSpecifiers(const std::wstring& format);
diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h
index 98974b8391a..5e89ed8d313 100644
--- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h
+++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h
@@ -209,7 +209,7 @@ class NGMP_OnlineServices_LobbyInterface
// updates
- void UpdateCurrentLobby_Map(AsciiString strMap, AsciiString strMapPath, bool bIsOfficial, int newMaxPlayers);
+ void UpdateCurrentLobby_Map(UnicodeString strMap, AsciiString strMapPath, bool bIsOfficial, int newMaxPlayers);
void UpdateCurrentLobby_LimitSuperweapons(bool bLimitSuperweapons);
void UpdateCurrentLobby_StartingCash(UnsignedInt startingCashValue);
diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLMapSelectMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLMapSelectMenu.cpp
index 20e2490f850..d898c52b9ff 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLMapSelectMenu.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLMapSelectMenu.cpp
@@ -457,7 +457,7 @@ WindowMsgHandledType WOLMapSelectMenuSystem( GameWindow *window, UnsignedInt msg
asciiMap.translate( map );
int newMaxPlayers = -1;
- AsciiString strMapName;
+ UnicodeString strMapName;
bool bOfficialMap = false;
TheNGMPGame->setMap(asciiMap);
@@ -470,7 +470,7 @@ WindowMsgHandledType WOLMapSelectMenuSystem( GameWindow *window, UnsignedInt msg
TheNGMPGame->setMapSize( it->second.m_filesize );
newMaxPlayers = it->second.m_numPlayers;
- strMapName.translate(it->second.m_displayName);
+ strMapName = it->second.m_displayName;
bOfficialMap = it->second.m_isOfficial;
}
diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp
index 834bd86851a..62f7b703cba 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp
@@ -570,7 +570,7 @@ void NGMPGame::launchGame(void)
if (pos != std::string::npos) { strMapName.erase(pos, strExt.size()); }
UnicodeString msg;
- msg.format(L"Map: %hs\nPress F5 or INSERT to open the communicator.", strMapName.c_str());
+ msg.format(L"Map: %s\nPress F5 or INSERT to open the communicator.", from_utf8(strMapName).c_str());
showNotificationBox(AsciiString::TheEmptyString, msg, false);
}
diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp
index 3a943805065..77223c06347 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp
@@ -36,6 +36,44 @@ std::wstring from_utf8(const std::string& utf8_str)
return result;
}
+#ifndef WC_NO_BEST_FIT_CHARS
+#define WC_NO_BEST_FIT_CHARS 0x00000400
+#endif
+
+// Process code page text (paths from local file APIs) to UTF-8 for the wire
+std::string local_to_utf8(const std::string& local_str)
+{
+ if (local_str.empty() || GetACP() == CP_UTF8)
+ return local_str;
+
+ int wlen = MultiByteToWideChar(CP_ACP, 0, local_str.data(), (int)local_str.size(), nullptr, 0);
+ if (wlen <= 0)
+ return local_str;
+
+ std::wstring wide(wlen, L'\0');
+ MultiByteToWideChar(CP_ACP, 0, local_str.data(), (int)local_str.size(), wide.data(), wlen);
+ return to_utf8(wide);
+}
+
+// UTF-8 from the wire to the process code page that local file APIs expect; unrepresentable characters become '_'
+std::string utf8_to_local(const std::string& utf8_str)
+{
+ if (utf8_str.empty() || GetACP() == CP_UTF8)
+ return utf8_str;
+
+ std::wstring wide = from_utf8(utf8_str);
+ if (wide.empty())
+ return std::string();
+
+ int len = WideCharToMultiByte(CP_ACP, WC_NO_BEST_FIT_CHARS, wide.data(), (int)wide.size(), nullptr, 0, "_", nullptr);
+ if (len <= 0)
+ return utf8_str;
+
+ std::string result(len, '\0');
+ WideCharToMultiByte(CP_ACP, WC_NO_BEST_FIT_CHARS, wide.data(), (int)wide.size(), result.data(), len, "_", nullptr);
+ return result;
+}
+
// Legacy format strings take player names as narrow %hs arguments, which get widened byte by byte and mangle UTF-8.
// Rewriting %hs to %s lets callers pass a from_utf8() decoded name instead.
std::wstring WidenFormatSpecifiers(const std::wstring& format)
diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp
index 664f69f1a86..74211f8ecfb 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp
@@ -36,7 +36,7 @@ UnicodeString NGMP_OnlineServices_LobbyInterface::GetCurrentLobbyMapDisplayName(
if (IsInLobby())
{
- strDisplayName.format(L"%hs", m_CurrentLobby.map_name.c_str());
+ strDisplayName = UnicodeString(from_utf8(m_CurrentLobby.map_name).c_str());
}
return strDisplayName;
@@ -77,7 +77,7 @@ enum class ELobbyUpdateField
JOINABILITY = 18
};
-void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_Map(AsciiString strMap, AsciiString strMapPath, bool bIsOfficial, int newMaxPlayers)
+void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_Map(UnicodeString strMap, AsciiString strMapPath, bool bIsOfficial, int newMaxPlayers)
{
// reset autostart if host changes anything (because ready flag will reset too)
#if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT)
@@ -101,8 +101,8 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_Map(AsciiString strM
nlohmann::json j;
j["field"] = ELobbyUpdateField::LOBBY_MAP;
- j["map"] = strMap.str();
- j["map_path"] = sanitizedMapPath.str();
+ j["map"] = to_utf8(strMap.str());
+ j["map_path"] = local_to_utf8(sanitizedMapPath.str());
j["map_official"] = bIsOfficial;
j["max_players"] = newMaxPlayers;
std::string strPostData = j.dump();
@@ -578,6 +578,7 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function
lobbyEntryIter["Name"].get_to(lobbyEntry.name);
lobbyEntryIter["MapName"].get_to(lobbyEntry.map_name);
lobbyEntryIter["MapPath"].get_to(lobbyEntry.map_path);
+ lobbyEntry.map_path = utf8_to_local(lobbyEntry.map_path); // local file path from here on
lobbyEntryIter["IsMapOfficial"].get_to(lobbyEntry.map_official);
lobbyEntryIter["NumCurrentPlayers"].get_to(lobbyEntry.current_players);
lobbyEntryIter["MaxPlayers"].get_to(lobbyEntry.max_players);
@@ -907,6 +908,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::function
Date: Tue, 29 Sep 2026 20:25:34 +0200
Subject: [PATCH 3/8] fix(string): Convert AsciiString and UnicodeString
through the process code page
---
.../Source/Common/System/AsciiString.cpp | 41 ++++++++++++++++++-
.../Source/Common/System/UnicodeString.cpp | 33 ++++++++++++++-
.../Source/GameNetwork/ConnectionManager.cpp | 12 ++++--
Core/GameEngine/Source/GameNetwork/LANAPI.cpp | 2 +-
.../Source/GameNetwork/LANAPICallbacks.cpp | 2 +-
.../GUICallbacks/Menus/LanGameOptionsMenu.cpp | 4 +-
.../GUICallbacks/Menus/WOLGameSetupMenu.cpp | 8 ++--
.../GUICallbacks/Menus/WOLQuickMatchMenu.cpp | 4 +-
.../OnlineServices_LobbyInterface.cpp | 4 +-
9 files changed, 91 insertions(+), 19 deletions(-)
diff --git a/Core/GameEngine/Source/Common/System/AsciiString.cpp b/Core/GameEngine/Source/Common/System/AsciiString.cpp
index 233272abf0c..afa1b96186b 100644
--- a/Core/GameEngine/Source/Common/System/AsciiString.cpp
+++ b/Core/GameEngine/Source/Common/System/AsciiString.cpp
@@ -46,6 +46,9 @@
#include "Common/CriticalSection.h"
+#if defined(_WIN32) && !defined(WC_NO_BEST_FIT_CHARS)
+#define WC_NO_BEST_FIT_CHARS 0x00000400
+#endif
// -----------------------------------------------------
@@ -307,9 +310,43 @@ char* AsciiString::getBufferForRead(Int len)
void AsciiString::translate(const UnicodeString& stringSrc)
{
validate();
- /// @todo srj put in a real translation here; this will only work for 7-bit ascii
- clear();
Int len = stringSrc.getLength();
+#ifdef _WIN32
+ // non-ASCII text becomes process code page bytes (UTF-8 on modern Windows); pure ASCII takes the loop below
+ Bool isAscii = true;
+ for (Int i = 0; i < len; i++)
+ {
+ if (stringSrc.getCharAt(i) >= 0x80)
+ {
+ isAscii = false;
+ break;
+ }
+ }
+ if (!isAscii)
+ {
+ // a legacy code page can't hold everything; substitute '_' so generated file names stay valid
+ const Bool isUtf8 = GetACP() == CP_UTF8;
+ const UINT codePage = isUtf8 ? CP_UTF8 : CP_ACP;
+ const DWORD flags = isUtf8 ? 0 : WC_NO_BEST_FIT_CHARS;
+ const char* defaultChar = isUtf8 ? nullptr : "_";
+ const Int narrowLen = WideCharToMultiByte(codePage, flags, stringSrc.str(), len, nullptr, 0, defaultChar, nullptr);
+ if (narrowLen > 0)
+ {
+ char* narrowBuf = new char[narrowLen + 1];
+ const Int written = WideCharToMultiByte(codePage, flags, stringSrc.str(), len, narrowBuf, narrowLen, defaultChar, nullptr);
+ if (written > 0)
+ {
+ narrowBuf[written] = 0;
+ set(narrowBuf);
+ delete[] narrowBuf;
+ validate();
+ return;
+ }
+ delete[] narrowBuf;
+ }
+ }
+#endif
+ clear();
for (Int i = 0; i < len; i++)
concat((char)stringSrc.getCharAt(i));
validate();
diff --git a/Core/GameEngine/Source/Common/System/UnicodeString.cpp b/Core/GameEngine/Source/Common/System/UnicodeString.cpp
index 386778d321b..e21c0ea38dc 100644
--- a/Core/GameEngine/Source/Common/System/UnicodeString.cpp
+++ b/Core/GameEngine/Source/Common/System/UnicodeString.cpp
@@ -221,9 +221,38 @@ WideChar* UnicodeString::getBufferForRead(Int len)
void UnicodeString::translate(const AsciiString& stringSrc)
{
validate();
- /// @todo srj put in a real translation here; this will only work for 7-bit ascii
- clear();
Int len = stringSrc.getLength();
+#ifdef _WIN32
+ // bytes >= 0x80 are process code page text (UTF-8 on modern Windows); pure ASCII takes the loop below
+ Bool isAscii = true;
+ for (Int i = 0; i < len; i++)
+ {
+ if ((unsigned char)stringSrc.getCharAt(i) >= 0x80)
+ {
+ isAscii = false;
+ break;
+ }
+ }
+ if (!isAscii)
+ {
+ const Int wideLen = MultiByteToWideChar(CP_ACP, 0, stringSrc.str(), len, nullptr, 0);
+ if (wideLen > 0)
+ {
+ WideChar* wideBuf = new WideChar[wideLen + 1];
+ const Int written = MultiByteToWideChar(CP_ACP, 0, stringSrc.str(), len, wideBuf, wideLen);
+ if (written > 0)
+ {
+ wideBuf[written] = 0;
+ set(wideBuf);
+ delete[] wideBuf;
+ validate();
+ return;
+ }
+ delete[] wideBuf;
+ }
+ }
+#endif
+ clear();
for (Int i = 0; i < len; i++)
concat((WideChar)stringSrc.getCharAt(i));
validate();
diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp
index 20454323318..6118eb377a4 100644
--- a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp
+++ b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp
@@ -809,7 +809,9 @@ void ConnectionManager::processFile(NetFileCommandMsg *msg)
{
#ifdef DEBUG_LOGGING
UnicodeString log;
- log.format(L"Saw file transfer: '%hs' of %d bytes from %d", msg->getPortableFilename().str(), msg->getFileLength(), msg->getPlayerID());
+ UnicodeString uFilename;
+ uFilename.translate(msg->getPortableFilename());
+ log.format(L"Saw file transfer: '%s' of %d bytes from %d", uFilename.str(), msg->getFileLength(), msg->getPlayerID());
DEBUG_LOG(("%ls", log.str()));
#endif
@@ -2515,7 +2517,9 @@ UnsignedShort ConnectionManager::sendFileAnnounce(AsciiString path, UnsignedByte
if (!theFile || !theFile->size())
{
UnicodeString log;
- log.format(L"Not sending file '%hs' to %X", path.str(), playerMask);
+ UnicodeString uPath;
+ uPath.translate(path);
+ log.format(L"Not sending file '%s' to %X", uPath.str(), playerMask);
DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("%ls", log.str()));
if (TheLAN)
TheLAN->OnChat(L"sendFile", 0, log, LANAPI::LANCHAT_SYSTEM);
@@ -2553,7 +2557,9 @@ void ConnectionManager::sendFile(AsciiString path, UnsignedByte playerMask, Unsi
if (!theFile || !theFile->size())
{
UnicodeString log;
- log.format(L"Not sending file '%hs' to %X", path.str(), playerMask);
+ UnicodeString uPath;
+ uPath.translate(path);
+ log.format(L"Not sending file '%s' to %X", uPath.str(), playerMask);
DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("%ls", log.str()));
if (TheLAN)
TheLAN->OnChat(L"sendFile", 0, log, LANAPI::LANCHAT_SYSTEM);
diff --git a/Core/GameEngine/Source/GameNetwork/LANAPI.cpp b/Core/GameEngine/Source/GameNetwork/LANAPI.cpp
index 5d8914ffcd5..ef244dd8675 100644
--- a/Core/GameEngine/Source/GameNetwork/LANAPI.cpp
+++ b/Core/GameEngine/Source/GameNetwork/LANAPI.cpp
@@ -769,7 +769,7 @@ void LANAPI::RequestHasMap()
}
else
{
- mapDisplayName.format(L"%hs", TheGameState->getMapLeafName(m_currentGame->getMap()).str());
+ mapDisplayName.translate(TheGameState->getMapLeafName(m_currentGame->getMap()).str());
willTransfer = WouldMapTransfer(m_currentGame->getMap());
}
if (willTransfer)
diff --git a/Core/GameEngine/Source/GameNetwork/LANAPICallbacks.cpp b/Core/GameEngine/Source/GameNetwork/LANAPICallbacks.cpp
index e99c5c8c25c..701ef3e987e 100644
--- a/Core/GameEngine/Source/GameNetwork/LANAPICallbacks.cpp
+++ b/Core/GameEngine/Source/GameNetwork/LANAPICallbacks.cpp
@@ -155,7 +155,7 @@ void LANAPI::OnHasMap( UnsignedInt playerIP, Bool status )
}
else
{
- mapDisplayName.format(L"%hs", m_currentGame->getMap().str());
+ mapDisplayName.translate(m_currentGame->getMap().str());
willTransfer = WouldMapTransfer(m_currentGame->getMap());
}
if (!status)
diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LanGameOptionsMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LanGameOptionsMenu.cpp
index 3866e6a1671..53cda5a2a2e 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LanGameOptionsMenu.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/LanGameOptionsMenu.cpp
@@ -329,7 +329,7 @@ void StartPressed()
}
else
{
- mapDisplayName.format(L"%hs", myGame->getMap().str());
+ mapDisplayName.translate(myGame->getMap().str());
willTransfer = WouldMapTransfer(myGame->getMap());
}
for( i = 0; i < MAX_SLOTS; i++ )
@@ -955,7 +955,7 @@ void updateGameOptions()
{
s = s.reverseFind('\\') + 1;
}
- mapDisplayName.format(L"%hs", s.str());
+ mapDisplayName.translate(s.str());
}
UnicodeString old = GadgetStaticTextGetText(textEntryMapDisplay);
if(old.compare(mapDisplayName) != 0)
diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp
index 0f4a9c7edce..dbcb3422e87 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp
@@ -1033,7 +1033,7 @@ static void StartPressed()
}
else
{
- mapDisplayName.format(L"%hs", myGame->getMap().str());
+ mapDisplayName.translate(myGame->getMap().str());
willTransfer = WouldMapTransfer(myGame->getMap());
}
for( int i = 0; i < MAX_SLOTS; i++ )
@@ -1967,7 +1967,7 @@ void WOLGameSetupMenuInit( WindowLayout *layout, void *userData )
}
else
{
- mapDisplayName.format(L"%hs", TheNGMPGame->getMap().str());
+ mapDisplayName.translate(TheNGMPGame->getMap().str());
willTransfer = WouldMapTransfer(TheNGMPGame->getMap());
}
@@ -3176,7 +3176,7 @@ void WOLGameSetupMenuUpdate( WindowLayout * layout, void *userData)
}
else
{
- mapDisplayName.format(L"%hs", TheGameState->getMapLeafName(game->getMap()).str());
+ mapDisplayName.translate(TheGameState->getMapLeafName(game->getMap()).str());
willTransfer = WouldMapTransfer(game->getMap());
}
if (willTransfer)
@@ -3334,7 +3334,7 @@ void WOLGameSetupMenuUpdate( WindowLayout * layout, void *userData)
}
else
{
- mapDisplayName.format(L"%hs", game->getMap().str());
+ mapDisplayName.translate(game->getMap().str());
willTransfer = WouldMapTransfer(game->getMap());
}
UnicodeString text;
diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp
index d5ec34b9fa3..0cdabf7d55e 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp
@@ -1225,7 +1225,7 @@ void WOLQuickMatchMenuInit( WindowLayout *layout, void *userData )
for (PlaylistEntry& playlist : vecPlaylists)
{
- s.format(L"%hs", playlist.Name.c_str());
+ s = UnicodeString(from_utf8(playlist.Name).c_str());
GadgetComboBoxAddEntry(comboBoxNumPlayers, s, GameSpyColor[GSCOLOR_DEFAULT]);
}
@@ -1241,7 +1241,7 @@ void WOLQuickMatchMenuInit( WindowLayout *layout, void *userData )
pLobbyInterface->RegisterForMatchmakingMessageCallback([](std::string strMsg)
{
UnicodeString uMsg;
- uMsg.format(L"%hs", strMsg.c_str());
+ uMsg = UnicodeString(from_utf8(strMsg).c_str());
Int index = GadgetListBoxAddEntryText(quickmatchTextWindow, uMsg, GameSpyColor[GSCOLOR_DEFAULT], -1, -1);
GadgetListBoxSetItemData(quickmatchTextWindow, (void*)-1, index);
diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp
index 74211f8ecfb..01dfdfdcc59 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp
@@ -1167,7 +1167,7 @@ void NGMP_OnlineServices_LobbyInterface::JoinLobby(LobbyEntry lobbyInfo, std::st
if (!strPassword.empty())
{
- j["password"] = strPassword.c_str();
+ j["password"] = local_to_utf8(strPassword);
}
std::string strPostData = j.dump();
@@ -1506,7 +1506,7 @@ void NGMP_OnlineServices_LobbyInterface::CreateLobby(UnicodeString strLobbyName,
j["track_stats"] = bTrackStats;
j["starting_cash"] = startingCash;
j["passworded"] = bPassworded;
- j["password"] = strPassword;
+ j["password"] = local_to_utf8(strPassword);
j["allow_observers"] = bAllowObservers;
j["exe_crc"] = TheGlobalData->m_exeCRC;
j["ini_crc"] = TheGlobalData->m_iniCRC;
From 5b715ba230c7c12cdb63e8afeb6a03129e859756 Mon Sep 17 00:00:00 2001
From: tintinhamans <5984296+tintinhamans@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:27:12 +0200
Subject: [PATCH 4/8] fix(mapcache): Survive unreadable or partial MapCache.ini
and map files
---
Core/GameEngine/Include/GameClient/MapUtil.h | 2 +-
Core/GameEngine/Source/Common/INI/INI.cpp | 6 ++++
.../Source/Common/System/LocalFile.cpp | 9 ++++-
Core/GameEngine/Source/GameClient/MapUtil.cpp | 36 ++++++++++++++++---
.../Source/GameNetwork/ConnectionManager.cpp | 5 +++
.../MilesAudioDevice/MilesAudioManager.cpp | 3 ++
.../Source/Compression/CompressionManager.cpp | 5 +++
.../Source/Common/System/DataChunk.cpp | 5 +++
.../Source/Common/System/DataChunk.cpp | 5 +++
9 files changed, 70 insertions(+), 6 deletions(-)
diff --git a/Core/GameEngine/Include/GameClient/MapUtil.h b/Core/GameEngine/Include/GameClient/MapUtil.h
index 50ce0d6073c..c8b2bec0873 100644
--- a/Core/GameEngine/Include/GameClient/MapUtil.h
+++ b/Core/GameEngine/Include/GameClient/MapUtil.h
@@ -118,7 +118,7 @@ class MapCache : public std::map
private:
void prepareUnseenMaps(const AsciiString &mapDir);
Bool clearUnseenMaps(const AsciiString &mapDir);
- void loadMapsFromMapCacheINI(const AsciiString &mapDir);
+ Bool loadMapsFromMapCacheINI(const AsciiString &mapDir); // returns false if the cache file was unreadable
Bool loadMapsFromDisk(const AsciiString &mapDir, Bool isOfficial, Bool filterByAllowedMaps = FALSE); // returns true if we needed to (re)parse a map
Bool addMap(const AsciiString &mapDir, const AsciiString &fname, const AsciiString &lowerFname, FileInfo &fileInfo, Bool isOfficial); ///< returns true if it had to (re)parse the map
void writeCacheINI(const AsciiString &mapDir);
diff --git a/Core/GameEngine/Source/Common/INI/INI.cpp b/Core/GameEngine/Source/Common/INI/INI.cpp
index 2c73a39872e..c03028c6d4c 100644
--- a/Core/GameEngine/Source/Common/INI/INI.cpp
+++ b/Core/GameEngine/Source/Common/INI/INI.cpp
@@ -297,6 +297,12 @@ void INI::prepFile( AsciiString filename, INILoadType loadType )
m_readBufferNext = 0;
m_readBufferUsed = file->size();
m_readBuffer = file->readEntireAndClose();
+ if (m_readBuffer == nullptr)
+ {
+ m_readBufferUsed = 0;
+ DEBUG_CRASH(( "INI::load, cannot read file '%s'", filename.str() ));
+ throw INI_CANT_OPEN_FILE;
+ }
// save our filename
m_filename = filename;
diff --git a/Core/GameEngine/Source/Common/System/LocalFile.cpp b/Core/GameEngine/Source/Common/System/LocalFile.cpp
index 431906a8ddd..3ec759fcc80 100644
--- a/Core/GameEngine/Source/Common/System/LocalFile.cpp
+++ b/Core/GameEngine/Source/Common/System/LocalFile.cpp
@@ -731,9 +731,16 @@ char* LocalFile::readEntireAndClose()
UnsignedInt fileSize = size();
char* buffer = NEW char[fileSize];
- read(buffer, fileSize);
+ const Int bytesRead = read(buffer, fileSize);
close();
+ // a short read leaves uninitialized bytes in the buffer, which callers would parse as file content
+ if (bytesRead < 0 || (UnsignedInt)bytesRead != fileSize)
+ {
+ delete[] buffer;
+ return nullptr;
+ }
+
return buffer;
}
diff --git a/Core/GameEngine/Source/GameClient/MapUtil.cpp b/Core/GameEngine/Source/GameClient/MapUtil.cpp
index 28814458683..8d854e0244f 100644
--- a/Core/GameEngine/Source/GameClient/MapUtil.cpp
+++ b/Core/GameEngine/Source/GameClient/MapUtil.cpp
@@ -501,7 +501,7 @@ Bool MapCache::clearUnseenMaps( const AsciiString &mapDir )
return erasedSomething;
}
-void MapCache::loadMapsFromMapCacheINI( const AsciiString &mapDir )
+Bool MapCache::loadMapsFromMapCacheINI( const AsciiString &mapDir )
{
INI ini;
AsciiString fname;
@@ -509,8 +509,18 @@ void MapCache::loadMapsFromMapCacheINI( const AsciiString &mapDir )
if (TheFileSystem->doesFileExist(fname.str()))
{
- ini.load( fname, INI_LOAD_OVERWRITE, nullptr );
+ // a partial or unreadable cache must not abort startup, the disk scan re-caches the maps and rewrites it
+ try
+ {
+ ini.load( fname, INI_LOAD_OVERWRITE, nullptr );
+ }
+ catch (...)
+ {
+ DEBUG_LOG(("MapCache::loadMapsFromMapCacheINI - ignoring unreadable '%s'", fname.str()));
+ return FALSE;
+ }
}
+ return TRUE;
}
Bool MapCache::loadMapsFromDisk( const AsciiString &mapDir, Bool isOfficial, Bool filterByAllowedMaps )
@@ -634,7 +644,25 @@ Bool MapCache::addMap(
DEBUG_LOG(("MapCache::addMap(): caching '%s' because '%s' was not found", fname.str(), lowerFname.str()));
- loadMap(fname); // Just load for querying the data, since we aren't playing this map.
+ // Just load for querying the data, since we aren't playing this map.
+ Bool loaded = FALSE;
+ try
+ {
+ loaded = loadMap(fname);
+ }
+ catch (...)
+ {
+ loaded = FALSE;
+ }
+
+ const UnsignedInt mapCRC = loaded ? calcCRC(fname) : 0;
+ if (!loaded || mapCRC == 0)
+ {
+ // never cache a map we could not read; drop a stale entry so writeCacheINI does not persist it
+ DEBUG_LOG(("MapCache::addMap(): could not read '%s', not caching it", fname.str()));
+ resetMap();
+ return erase(lowerFname) > 0;
+ }
// The map is now loaded. Pick out what we need.
MapMetaData md;
@@ -649,7 +677,7 @@ Bool MapCache::addMap(
md.m_timestamp.m_lowTimeStamp = fileInfo.timestampLow;
md.m_supplyPositions = m_supplyPositions;
md.m_techPositions = m_techPositions;
- md.m_CRC = calcCRC(fname);
+ md.m_CRC = mapCRC;
Bool exists = false;
AsciiString nameLookupTag = worldDict.getAsciiString(TheKey_mapName, &exists);
diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp
index 6118eb377a4..f347db5a83f 100644
--- a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp
+++ b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp
@@ -2568,6 +2568,11 @@ void ConnectionManager::sendFile(AsciiString path, UnsignedByte playerMask, Unsi
Int len = theFile->size();
char *buf = theFile->readEntireAndClose();
+ if (buf == nullptr)
+ {
+ DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("Not sending file '%s' to %X, it could not be read", path.str(), playerMask));
+ return;
+ }
NetCommandDataChunk rawDataChunk(buf, len);
// compress Targas
diff --git a/Core/GameEngineDevice/Source/MilesAudioDevice/MilesAudioManager.cpp b/Core/GameEngineDevice/Source/MilesAudioDevice/MilesAudioManager.cpp
index e1cd3e9dbec..c6b2f6e9198 100644
--- a/Core/GameEngineDevice/Source/MilesAudioDevice/MilesAudioManager.cpp
+++ b/Core/GameEngineDevice/Source/MilesAudioDevice/MilesAudioManager.cpp
@@ -3118,6 +3118,9 @@ void *AudioFileCache::openFile( AudioEventRTS *eventToOpenFrom )
UnsignedInt fileSize = file->size();
char* buffer = file->readEntireAndClose();
+ if (buffer == nullptr) {
+ return nullptr;
+ }
OpenAudioFile openedAudioFile;
openedAudioFile.m_eventInfo = eventToOpenFrom->getAudioEventInfo();
diff --git a/Core/Libraries/Source/Compression/CompressionManager.cpp b/Core/Libraries/Source/Compression/CompressionManager.cpp
index 00e7d91ead7..2bb9cb0d443 100644
--- a/Core/Libraries/Source/Compression/CompressionManager.cpp
+++ b/Core/Libraries/Source/Compression/CompressionManager.cpp
@@ -390,6 +390,11 @@ void DoCompressTest()
DEBUG_LOG(("***************************\nTesting '%s'\n", it->first.str()));
Int origSize = f->size();
UnsignedByte *buf = (UnsignedByte *)f->readEntireAndClose();
+ if (buf == nullptr)
+ {
+ ++it;
+ continue;
+ }
UnsignedByte *uncompressedBuf = NEW UnsignedByte[origSize];
CompData d = s_sizes[it->first];
diff --git a/Generals/Code/GameEngine/Source/Common/System/DataChunk.cpp b/Generals/Code/GameEngine/Source/Common/System/DataChunk.cpp
index 2b90ade8db2..29ef854a4d8 100644
--- a/Generals/Code/GameEngine/Source/Common/System/DataChunk.cpp
+++ b/Generals/Code/GameEngine/Source/Common/System/DataChunk.cpp
@@ -57,6 +57,11 @@ Bool CachedFileInputStream::open(AsciiString path)
if (m_size) {
m_buffer = file->readEntireAndClose();
file = nullptr;
+ if (m_buffer == nullptr)
+ {
+ m_size = 0;
+ return FALSE;
+ }
}
m_pos=0;
}
diff --git a/GeneralsMD/Code/GameEngine/Source/Common/System/DataChunk.cpp b/GeneralsMD/Code/GameEngine/Source/Common/System/DataChunk.cpp
index 0d5d2ce7fe6..8ad0d62a49e 100644
--- a/GeneralsMD/Code/GameEngine/Source/Common/System/DataChunk.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/Common/System/DataChunk.cpp
@@ -57,6 +57,11 @@ Bool CachedFileInputStream::open(AsciiString path)
if (m_size) {
m_buffer = file->readEntireAndClose();
file = nullptr;
+ if (m_buffer == nullptr)
+ {
+ m_size = 0;
+ return FALSE;
+ }
}
m_pos=0;
}
From 55e50f4b4393fe72b073f477f6eaf2de0391c7a6 Mon Sep 17 00:00:00 2001
From: tintinhamans <5984296+tintinhamans@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:27:31 +0200
Subject: [PATCH 5/8] fix(saveload): Don't crash when a save file can't be
opened or read
---
.../GameEngine/Include/Common/GameState.h | 2 +-
.../Common/System/SaveGame/GameState.cpp | 51 +++++++++++++++++--
.../Common/System/SaveGame/GameStateMap.cpp | 3 ++
3 files changed, 50 insertions(+), 6 deletions(-)
diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GameState.h b/GeneralsMD/Code/GameEngine/Include/Common/GameState.h
index 16bc991b69a..cfc477c138b 100644
--- a/GeneralsMD/Code/GameEngine/Include/Common/GameState.h
+++ b/GeneralsMD/Code/GameEngine/Include/Common/GameState.h
@@ -170,7 +170,7 @@ class GameState : public SubsystemInterface,
// manipulating files
Bool doesSaveGameExist( AsciiString filename ); ///< does the save file exist
void populateSaveGameListbox( GameWindow *listbox, SaveLoadLayoutType layoutType ); ///< populate listbox with available save games
- void getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *saveGameInfo ); ///< get save game info from file
+ Bool getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *saveGameInfo ); ///< get save game info from file, false if it can't be read
void friend_xferSaveDataForCRC( Xfer *xfer, SnapshotType which ); ///< This should only be called to DeepCRC sanity checking
diff --git a/GeneralsMD/Code/GameEngine/Source/Common/System/SaveGame/GameState.cpp b/GeneralsMD/Code/GameEngine/Source/Common/System/SaveGame/GameState.cpp
index 368cd590513..6649a0c9f65 100644
--- a/GeneralsMD/Code/GameEngine/Source/Common/System/SaveGame/GameState.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/Common/System/SaveGame/GameState.cpp
@@ -669,7 +669,23 @@ SaveCode GameState::loadGame( AvailableGameInfo gameInfo )
// open the save file
XferLoad xferLoad;
- xferLoad.open( filepath );
+ try
+ {
+ xferLoad.open( filepath );
+ }
+ catch( ... )
+ {
+ // the file vanished or is locked, nothing has been reset yet so just report it
+ UnicodeString ufilepath;
+ ufilepath.translate(filepath);
+
+ UnicodeString msg;
+ msg.format( TheGameText->fetch("GUI:ErrorLoadingGame"), ufilepath.str() );
+
+ MessageBoxOk(TheGameText->fetch("GUI:Error"), msg, nullptr);
+
+ return SC_FILE_NOT_FOUND;
+ }
// clear out the game engine
TheGameEngine->reset();
@@ -960,7 +976,7 @@ Bool GameState::doesSaveGameExist( AsciiString filename )
// ------------------------------------------------------------------------------------------------
/** Get save game info from the filename specified */
// ------------------------------------------------------------------------------------------------
-void GameState::getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *saveGameInfo )
+Bool GameState::getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *saveGameInfo )
{
AsciiString token;
Int blockSize;
@@ -972,13 +988,20 @@ void GameState::getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *sav
{
DEBUG_CRASH(( "GameState::getSaveGameInfoFromFile - Illegal parameters" ));
- return;
+ return FALSE;
}
// open file for partial loading
XferLoad xferLoad;
- xferLoad.open( filename );
+ try
+ {
+ xferLoad.open( filename );
+ }
+ catch( ... )
+ {
+ return FALSE;
+ }
//
// disable post processing cause we're not really doing a load of game data that
@@ -986,7 +1009,11 @@ void GameState::getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *sav
//
xferLoad.setOptions( XO_NO_POST_PROCESSING );
+ Bool found = FALSE;
+
// read all data blocks in the file
+ try
+ {
while( done == FALSE )
{
@@ -1037,6 +1064,7 @@ void GameState::getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *sav
// data was found, copy game state info over
*saveGameInfo = *tempGameState.getSaveGameInfo();
+ found = TRUE;
// we're all done with this file now
done = TRUE;
@@ -1056,10 +1084,19 @@ void GameState::getSaveGameInfoFromFile( AsciiString filename, SaveGameInfo *sav
}
}
+ }
+ catch( ... )
+ {
+ // a corrupt or truncated file just isn't a usable save
+ xferLoad.close();
+ return FALSE;
+ }
// close the file
xferLoad.close();
+ return found;
+
}
// ------------------------------------------------------------------------------------------------
@@ -1076,7 +1113,8 @@ static void addGameToAvailableList( AsciiString filename, void *userData )
try {
// get header info from this listbox
SaveGameInfo saveGameInfo;
- TheGameState->getSaveGameInfoFromFile( filename, &saveGameInfo );
+ if( TheGameState->getSaveGameInfoFromFile( filename, &saveGameInfo ) == FALSE )
+ return;
// allocate new info
AvailableGameInfo *newInfo = new AvailableGameInfo;
@@ -1266,7 +1304,10 @@ void GameState::iterateSaveFiles( IterateSaveFileCallback callback, void *userDa
// start search
hFile = FindFirstFile( "*", &item );
if( hFile == INVALID_HANDLE_VALUE )
+ {
+ SetCurrentDirectory( currentDirectory );
return;
+ }
// we are no longer on our first item
first = FALSE;
diff --git a/GeneralsMD/Code/GameEngine/Source/Common/System/SaveGame/GameStateMap.cpp b/GeneralsMD/Code/GameEngine/Source/Common/System/SaveGame/GameStateMap.cpp
index e4057379f31..df0197f1654 100644
--- a/GeneralsMD/Code/GameEngine/Source/Common/System/SaveGame/GameStateMap.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/Common/System/SaveGame/GameStateMap.cpp
@@ -484,7 +484,10 @@ void GameStateMap::clearScratchPadMaps()
// start search
hFile = FindFirstFile( "*", &item );
if( hFile == INVALID_HANDLE_VALUE )
+ {
+ SetCurrentDirectory( currentDirectory );
return;
+ }
// we are no longer on our first item
first = FALSE;
From b2948eb0ffbae0adb3bf8a8608abd5d2c892d74b Mon Sep 17 00:00:00 2001
From: tintinhamans <5984296+tintinhamans@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:30:34 +0200
Subject: [PATCH 6/8] fix(prefs): Never overwrite user data files that failed
to load and write them atomically
---
.../Include/Common/UserPreferences.h | 1 +
.../Source/Common/UserPreferences.cpp | 51 ++++++++++--
Core/GameEngine/Source/GameClient/MapUtil.cpp | 25 +++++-
.../GeneralsOnline_Settings.cpp | 78 ++++++++++++++++---
.../GeneralsOnline/OnlineServices_Auth.cpp | 60 +++++++++-----
.../GeneralsOnline/OnlineServices_Init.cpp | 38 ++++++---
6 files changed, 204 insertions(+), 49 deletions(-)
diff --git a/Core/GameEngine/Include/Common/UserPreferences.h b/Core/GameEngine/Include/Common/UserPreferences.h
index 541303f6866..f1b9f3ea786 100644
--- a/Core/GameEngine/Include/Common/UserPreferences.h
+++ b/Core/GameEngine/Include/Common/UserPreferences.h
@@ -68,6 +68,7 @@ class UserPreferences : public PreferenceMap
protected:
AsciiString m_filename;
+ Bool m_loadFailed; ///< the file exists but could not be read; write() must not replace it
};
//-----------------------------------------------------------------------------
diff --git a/Core/GameEngine/Source/Common/UserPreferences.cpp b/Core/GameEngine/Source/Common/UserPreferences.cpp
index 844b511922b..ae9f7636b7b 100644
--- a/Core/GameEngine/Source/Common/UserPreferences.cpp
+++ b/Core/GameEngine/Source/Common/UserPreferences.cpp
@@ -110,7 +110,7 @@ static AsciiString realAsStr(Real val)
// UserPreferences Class
//-----------------------------------------------------------------------------
-UserPreferences::UserPreferences()
+UserPreferences::UserPreferences() : m_loadFailed(FALSE)
{
}
@@ -126,8 +126,14 @@ Bool UserPreferences::load(AsciiString fname)
m_filename = TheGlobalData->getPath_UserData();
m_filename.concat(fname);
+ m_loadFailed = FALSE;
FILE *fp = fopen(m_filename.str(), "r");
+ if (fp == nullptr && errno != ENOENT)
+ {
+ DEBUG_LOG(("UserPreferences::load - could not open '%s', errno %d", m_filename.str(), errno));
+ m_loadFailed = TRUE;
+ }
if (fp)
{
char buf[LINE_LEN];
@@ -138,6 +144,8 @@ Bool UserPreferences::load(AsciiString fname)
AsciiString key, val;
line.nextToken(&key, "=");
+ if (line.isEmpty()) // no '=' in the line, nothing follows the key
+ continue;
val = line.str() + 1;
key.trim();
@@ -148,7 +156,14 @@ Bool UserPreferences::load(AsciiString fname)
(*this)[key] = val;
}
+ const Bool readFailed = ferror(fp) != 0;
fclose(fp);
+ if (readFailed)
+ {
+ DEBUG_LOG(("UserPreferences::load - read error in '%s'", m_filename.str()));
+ m_loadFailed = TRUE;
+ return false;
+ }
return true;
}
return false;
@@ -159,17 +174,43 @@ Bool UserPreferences::write()
if (m_filename.isEmpty())
return false;
- FILE *fp = fopen(m_filename.str(), "w");
+ // the existing file is unreadable, replacing it with what we hold would lose the user's settings
+ if (m_loadFailed)
+ return false;
+
+ // write to a temp file and swap it in so a failed write never leaves a truncated preference file
+ AsciiString tempFilename = m_filename;
+ tempFilename.concat(".tmp");
+
+ FILE *fp = fopen(tempFilename.str(), "w");
if (fp)
{
+ Bool ok = TRUE;
PreferenceMap::const_iterator it = begin();
while (it != end())
{
- fprintf(fp, "%s = %s\n", it->first.str(), it->second.str());
+ if (fprintf(fp, "%s = %s\n", it->first.str(), it->second.str()) < 0)
+ ok = FALSE;
++it;
}
- fclose(fp);
- return true;
+ if (fflush(fp) != 0 || ferror(fp))
+ ok = FALSE;
+ if (fclose(fp) != 0)
+ ok = FALSE;
+
+#ifdef _WIN32
+ if (ok && !MoveFileExA(tempFilename.str(), m_filename.str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
+ ok = FALSE;
+#else
+ if (ok && rename(tempFilename.str(), m_filename.str()) != 0)
+ ok = FALSE;
+#endif
+ if (!ok)
+ {
+ DEBUG_LOG(("UserPreferences::write - failed to write '%s'", m_filename.str()));
+ remove(tempFilename.str());
+ }
+ return ok;
}
return false;
}
diff --git a/Core/GameEngine/Source/GameClient/MapUtil.cpp b/Core/GameEngine/Source/GameClient/MapUtil.cpp
index 8d854e0244f..ef776c2e1f7 100644
--- a/Core/GameEngine/Source/GameClient/MapUtil.cpp
+++ b/Core/GameEngine/Source/GameClient/MapUtil.cpp
@@ -342,8 +342,12 @@ void MapCache::writeCacheINI( const AsciiString &mapDir )
TheFileSystem->createDirectory(mapDir);
filepath.concat(m_mapCacheName);
- FILE *fp = fopen(filepath.str(), "w");
- DEBUG_ASSERTCRASH(fp != nullptr, ("Failed to create %s", filepath.str()));
+
+ // write to a temp file and swap it in so a failed write never leaves a truncated MapCache.ini
+ AsciiString tempFilepath = filepath;
+ tempFilepath.concat(".tmp");
+ FILE *fp = fopen(tempFilepath.str(), "w");
+ DEBUG_ASSERTCRASH(fp != nullptr, ("Failed to create %s", tempFilepath.str()));
if (fp == nullptr) {
return;
}
@@ -406,7 +410,22 @@ void MapCache::writeCacheINI( const AsciiString &mapDir )
}
}
- fclose(fp);
+ Bool ok = fflush(fp) == 0 && !ferror(fp);
+ if (fclose(fp) != 0)
+ ok = FALSE;
+
+#ifdef _WIN32
+ if (ok && !MoveFileExA(tempFilepath.str(), filepath.str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
+ ok = FALSE;
+#else
+ if (ok && rename(tempFilepath.str(), filepath.str()) != 0)
+ ok = FALSE;
+#endif
+ if (!ok)
+ {
+ DEBUG_LOG(("MapCache::writeCacheINI - failed to write '%s'", filepath.str()));
+ remove(tempFilepath.str());
+ }
}
void MapCache::updateCache()
diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp
index d8c249e5c82..fb8e72f33e0 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp
@@ -72,21 +72,31 @@ void GenOnlineSettings::Load(void)
std::string strSettingsFilePath = std::format("{}/{}", strSettingsFileDir, SETTINGS_FILENAME);
std::string strSettingsFilePathLegacy = std::format("{}/{}", GameDir, SETTINGS_FILENAME_LEGACY);
+ std::error_code ec;
+
// create directories we need
- if (!std::filesystem::exists(strSettingsFileDir))
+ if (!std::filesystem::exists(strSettingsFileDir, ec))
{
- std::filesystem::create_directory(strSettingsFileDir);
+ std::filesystem::create_directory(strSettingsFileDir, ec);
}
// NGMP_NOTE: Prior to 6/23, we used the game dir for settings, this code migrates any legacy settings file to the new location (game user data dir)
- if (std::filesystem::exists(strSettingsFilePathLegacy))
+ if (std::filesystem::exists(strSettingsFilePathLegacy, ec))
{
- std::filesystem::copy(strSettingsFilePathLegacy, strSettingsFilePath, std::filesystem::copy_options::overwrite_existing);
- std::filesystem::remove(strSettingsFilePathLegacy);
+ // only drop the legacy file once its copy is in place
+ std::filesystem::copy(strSettingsFilePathLegacy, strSettingsFilePath, std::filesystem::copy_options::overwrite_existing, ec);
+ if (!ec)
+ {
+ std::filesystem::remove(strSettingsFilePathLegacy, ec);
+ }
}
bool bApplyDefaults = false;
+ // a settings file we couldn't read or parse must not be silently replaced by defaults
+ bool bReadFailed = false;
+ bool bParseFailed = false;
+
std::vector vecBytes;
FILE* file = fopen(strSettingsFilePath.c_str(), "rb");
if (file)
@@ -97,10 +107,22 @@ void GenOnlineSettings::Load(void)
if (fileSize > 0)
{
vecBytes.resize(fileSize);
- fread(vecBytes.data(), 1, fileSize, file);
+ if (fread(vecBytes.data(), 1, fileSize, file) != static_cast(fileSize))
+ {
+ vecBytes.clear();
+ bReadFailed = true;
+ }
+ }
+ else if (fileSize < 0)
+ {
+ bReadFailed = true;
}
fclose(file);
}
+ else if (errno != ENOENT)
+ {
+ bReadFailed = true;
+ }
if (!vecBytes.empty())
@@ -117,9 +139,11 @@ void GenOnlineSettings::Load(void)
{
jsonSettings = nullptr;
bApplyDefaults = true;
+ bParseFailed = true;
}
if (!bApplyDefaults && jsonSettings != nullptr)
+ try
{
if (jsonSettings.contains(SETTINGS_KEY_CAMERA))
{
@@ -271,6 +295,12 @@ void GenOnlineSettings::Load(void)
}
}
}
+ catch (...)
+ {
+ // a value of the wrong type
+ bApplyDefaults = true;
+ bParseFailed = true;
+ }
}
else // setup defaults
@@ -281,7 +311,7 @@ void GenOnlineSettings::Load(void)
if (bApplyDefaults)
{
m_Camera_MinHeight = m_Camera_MinHeight_default;
- m_Camera_MaxHeight_LobbyHost = m_Camera_MaxHeight_LobbyHost;
+ m_Camera_MaxHeight_LobbyHost = GENERALS_ONLINE_DEFAULT_LOBBY_CAMERA_ZOOM;
m_bVerbose = false;
m_Render_LimitFramerate = true;
m_Render_FramerateLimit_FPSVal = 60;
@@ -294,6 +324,19 @@ void GenOnlineSettings::Load(void)
m_Social_Notification_FriendGoesOffline_Gameplay = true;
}
+ if (bReadFailed)
+ {
+ // keep the file on disk, the defaults above only apply to this session
+ NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Could not read settings file, keeping it untouched");
+ return;
+ }
+
+ if (bParseFailed)
+ {
+ // keep the unparseable file for recovery before the defaults replace it
+ std::filesystem::rename(strSettingsFilePath, strSettingsFilePath + ".bad", ec);
+ }
+
// Always save so we re-serialize anything new or missing
Save();
}
@@ -378,10 +421,25 @@ void GenOnlineSettings::Save()
std::string strData = root.dump(1);
std::string strSettingsFilePath = std::format("{}/GeneralsOnlineData/{}", TheGlobalData->getPath_UserData().str(), SETTINGS_FILENAME);
- FILE* file = fopen(strSettingsFilePath.c_str(), "wb");
+
+ // write to a temp file and swap it in so a failed write never leaves a truncated settings file
+ std::string strTempPath = strSettingsFilePath + ".tmp";
+ bool bSaved = false;
+ FILE* file = fopen(strTempPath.c_str(), "wb");
if (file)
{
- fwrite(strData.data(), 1, strData.size(), file);
- fclose(file);
+ bSaved = fwrite(strData.data(), 1, strData.size(), file) == strData.size();
+ if (fclose(file) != 0)
+ bSaved = false;
+ }
+
+ if (bSaved)
+ bSaved = MoveFileExA(strTempPath.c_str(), strSettingsFilePath.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
+
+ if (!bSaved)
+ {
+ NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Failed to save settings file");
+ std::error_code ec;
+ std::filesystem::remove(strTempPath, ec);
}
}
diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp
index 810c2438af5..e72240f63f7 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp
@@ -611,9 +611,10 @@ void NGMP_OnlineServices_AuthInterface::LogoutOfMyAccount()
// delete local credentials cache
std::string strCredentialsCachePath = GetCredentialsFilePath();
- if (std::filesystem::exists(strCredentialsCachePath))
+ std::error_code ec;
+ if (std::filesystem::exists(strCredentialsCachePath, ec))
{
- std::filesystem::remove(strCredentialsCachePath);
+ std::filesystem::remove(strCredentialsCachePath, ec);
}
}
@@ -632,30 +633,49 @@ void NGMP_OnlineServices_AuthInterface::SaveCredentials(const char* szRefreshTok
std::string strData = root.dump(1);
- FILE* file = fopen(GetCredentialsFilePath().c_str(), "wb");
- if (file)
- {
+ // encrypt before touching the file so a failure doesn't truncate the credentials already stored
+ std::string strFileData;
#if defined(GENERALS_ONLINE_ENCRYPT_CREDENTIALS)
- DATA_BLOB inputBlob;
- DATA_BLOB outputBlob;
+ DATA_BLOB inputBlob;
+ DATA_BLOB outputBlob;
- inputBlob.pbData = (BYTE*)strData.c_str();
- inputBlob.cbData = static_cast(strData.size());
+ inputBlob.pbData = (BYTE*)strData.c_str();
+ inputBlob.cbData = static_cast(strData.size());
- if (CryptProtectData(&inputBlob, L"GO Credentials", nullptr, nullptr, nullptr, 0, &outputBlob))
- {
- fwrite(outputBlob.pbData, 1, outputBlob.cbData, file);
- LocalFree(outputBlob.pbData);
- }
- else
- {
- // TODO_JWT: Handle failure case
- }
+ if (CryptProtectData(&inputBlob, L"GO Credentials", nullptr, nullptr, nullptr, 0, &outputBlob))
+ {
+ strFileData.assign((const char*)outputBlob.pbData, outputBlob.cbData);
+ LocalFree(outputBlob.pbData);
+ }
+ else
+ {
+ NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Failed to encrypt credentials, keeping the stored ones");
+ return;
+ }
#else
- fwrite(strData.data(), 1, strData.size(), file);
+ strFileData = strData;
#endif
- fclose(file);
+ // write to a temp file and swap it in so a failed write never leaves truncated credentials
+ std::string strCredentialsPath = GetCredentialsFilePath();
+ std::string strTempPath = strCredentialsPath + ".tmp";
+ bool bSaved = false;
+ FILE* file = fopen(strTempPath.c_str(), "wb");
+ if (file)
+ {
+ bSaved = fwrite(strFileData.data(), 1, strFileData.size(), file) == strFileData.size();
+ if (fclose(file) != 0)
+ bSaved = false;
+ }
+
+ if (bSaved)
+ bSaved = MoveFileExA(strTempPath.c_str(), strCredentialsPath.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
+
+ if (!bSaved)
+ {
+ NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Failed to save credentials");
+ std::error_code ec;
+ std::filesystem::remove(strTempPath, ec);
}
}
diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp
index affb6706f76..741f156d0c4 100644
--- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp
@@ -471,15 +471,20 @@ void NGMP_OnlineServicesManager::ContinueUpdate()
std::map mapHeaders;
m_pHTTPManager->SendGETRequest(strDownloadPath.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, [=](bool bSuccess, int statusCode, std::string strBody, HTTPRequest* pReq)
{
+ auto fnShowUpdateFailed = []()
+ {
+ // show msg
+ ClearGSMessageBoxes();
+ MessageBoxOk(UnicodeString(L"Update Failed"), UnicodeString(L"Could not download the updater. Press below to exit."), []()
+ {
+ TheGameEngine->setQuitting(TRUE);
+ });
+ ShellExecuteA(NULL, "open", "https://www.playgenerals.online/updatefailed", NULL, NULL, SW_SHOWNORMAL);
+ };
+
if (statusCode != 200)
{
- // show msg
- ClearGSMessageBoxes();
- MessageBoxOk(UnicodeString(L"Update Failed"), UnicodeString(L"Could not download the updater. Press below to exit."), []()
- {
- TheGameEngine->setQuitting(TRUE);
- });
- ShellExecuteA(NULL, "open", "https://www.playgenerals.online/updatefailed", NULL, NULL, SW_SHOWNORMAL);
+ fnShowUpdateFailed();
}
else
{
@@ -502,15 +507,26 @@ void NGMP_OnlineServicesManager::ContinueUpdate()
std::vector vecBuffer = pReq->GetBuffer();
size_t bufSize = pReq->GetBufferSize();
- if (!std::filesystem::exists(strPatchDir))
+ std::error_code ec;
+ if (!std::filesystem::exists(strPatchDir, ec))
{
- std::filesystem::create_directory(strPatchDir);
+ std::filesystem::create_directory(strPatchDir, ec);
}
+ // a partially written updater must not be treated as downloaded
+ bool bSaved = false;
FILE* pFile = fopen(strOutPath.c_str(), "wb");
if (pFile != nullptr) {
- fwrite(vecBuffer.data(), sizeof(uint8_t), bufSize, pFile);
- fclose(pFile);
+ bSaved = fwrite(vecBuffer.data(), sizeof(uint8_t), bufSize, pFile) == bufSize;
+ if (fclose(pFile) != 0)
+ bSaved = false;
+ }
+
+ if (!bSaved)
+ {
+ NetworkLog(ELogVerbosity::LOG_RELEASE, "Failed to save downloaded file: %s", strOutPath.c_str());
+ fnShowUpdateFailed();
+ return;
}
// call continue update again, thisll check if we're done or have more work to do
From 9cc471ac3bcb1e4c37424dc579e735da8cd37a62 Mon Sep 17 00:00:00 2001
From: tintinhamans <5984296+tintinhamans@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:40:06 +0200
Subject: [PATCH 7/8] fix(filesystem): Harden BIG archive header parsing and
small file-handling leaks
---
.../Source/GameClient/System/Image.cpp | 4 +-
.../Win32Device/Common/Win32BIGFileSystem.cpp | 52 +++++++++++++++----
.../Source/Common/System/DataChunk.cpp | 10 ++++
.../Source/Common/System/DataChunk.cpp | 10 ++++
4 files changed, 65 insertions(+), 11 deletions(-)
diff --git a/Core/GameEngine/Source/GameClient/System/Image.cpp b/Core/GameEngine/Source/GameClient/System/Image.cpp
index 847259e28e3..bf41ee17d8c 100644
--- a/Core/GameEngine/Source/GameClient/System/Image.cpp
+++ b/Core/GameEngine/Source/GameClient/System/Image.cpp
@@ -253,8 +253,10 @@ void ImageCollection::load( Int textureSize )
if(TheGlobalData)
{
userDataPath.format("%sINI\\MappedImages\\*.ini",TheGlobalData->getPath_UserData().str());
- if(FindFirstFile(userDataPath.str(), &findData) !=INVALID_HANDLE_VALUE)
+ HANDLE hFind = FindFirstFile(userDataPath.str(), &findData);
+ if(hFind != INVALID_HANDLE_VALUE)
{
+ FindClose(hFind);
userDataPath.format("%sINI\\MappedImages",TheGlobalData->getPath_UserData().str());
ini.loadDirectory(userDataPath, INI_LOAD_OVERWRITE, nullptr );
}
diff --git a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp b/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp
index 64401c47eff..b824dcaed57 100644
--- a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp
+++ b/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp
@@ -96,9 +96,9 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) {
AsciiString asciibuf;
char buffer[_MAX_PATH];
- fp->read(buffer, 4); // read the "BIG" at the beginning of the file.
+ // read the "BIG" at the beginning of the file.
buffer[4] = 0;
- if (strcmp(buffer, BIGFileIdentifier) != 0) {
+ if (fp->read(buffer, 4) != 4 || strcmp(buffer, BIGFileIdentifier) != 0) {
DEBUG_CRASH(("Error reading BIG file identifier in file %s", filename));
fp->close();
fp = nullptr;
@@ -106,7 +106,12 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) {
}
// read in the file size.
- fp->read(&archiveFileSize, 4);
+ if (fp->read(&archiveFileSize, 4) != 4) {
+ DEBUG_CRASH(("Error reading BIG file size in file %s", filename));
+ fp->close();
+ fp = nullptr;
+ return nullptr;
+ }
DEBUG_LOG(("Win32BIGFileSystem::openArchiveFile - size of archive file is %d bytes", archiveFileSize));
@@ -114,7 +119,12 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) {
// read in the number of files contained in this BIG file.
// change the order of the bytes cause the file size is in reverse byte order for some reason.
- fp->read(&numLittleFiles, 4);
+ if (fp->read(&numLittleFiles, 4) != 4) {
+ DEBUG_CRASH(("Error reading BIG file count in file %s", filename));
+ fp->close();
+ fp = nullptr;
+ return nullptr;
+ }
numLittleFiles = betoh(numLittleFiles);
DEBUG_LOG(("Win32BIGFileSystem::openArchiveFile - %d are contained in archive", numLittleFiles));
@@ -131,11 +141,14 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) {
// TheSuperHackers @fix Mauller 23/04/2025 Create new file handle when necessary to prevent memory leak
ArchiveFile *archiveFile = NEW Win32BIGFile(filename, AsciiString::TheEmptyString);
+ Bool corrupt = FALSE;
for (Int i = 0; i < numLittleFiles; ++i) {
Int filesize = 0;
Int fileOffset = 0;
- fp->read(&fileOffset, 4);
- fp->read(&filesize, 4);
+ if (fp->read(&fileOffset, 4) != 4 || fp->read(&filesize, 4) != 4) {
+ corrupt = TRUE;
+ break;
+ }
filesize = betoh(filesize);
fileOffset = betoh(fileOffset);
@@ -145,11 +158,21 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) {
fileInfo->m_size = filesize;
// read in the path name of the file.
+ // the name must terminate within the buffer
Int pathIndex = -1;
- do {
- ++pathIndex;
- fp->read(buffer + pathIndex, 1);
- } while (buffer[pathIndex] != 0);
+ Bool terminated = FALSE;
+ while (++pathIndex < (Int)sizeof(buffer)) {
+ if (fp->read(buffer + pathIndex, 1) != 1)
+ break;
+ if (buffer[pathIndex] == 0) {
+ terminated = TRUE;
+ break;
+ }
+ }
+ if (!terminated) {
+ corrupt = TRUE;
+ break;
+ }
Int filenameIndex = pathIndex;
while ((filenameIndex >= 0) && (buffer[filenameIndex] != '\\') && (buffer[filenameIndex] != '/')) {
@@ -171,6 +194,15 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) {
archiveFile->addFile(path, fileInfo);
}
+ if (corrupt) {
+ DEBUG_CRASH(("Truncated or corrupt directory in BIG file %s", filename));
+ delete archiveFile;
+ delete fileInfo;
+ fp->close();
+ fp = nullptr;
+ return nullptr;
+ }
+
archiveFile->attachFile(fp);
delete fileInfo;
diff --git a/Generals/Code/GameEngine/Source/Common/System/DataChunk.cpp b/Generals/Code/GameEngine/Source/Common/System/DataChunk.cpp
index 29ef854a4d8..fd83eea0eb5 100644
--- a/Generals/Code/GameEngine/Source/Common/System/DataChunk.cpp
+++ b/Generals/Code/GameEngine/Source/Common/System/DataChunk.cpp
@@ -251,12 +251,22 @@ DataChunkOutput::~DataChunkOutput()
m_contents.write(*m_pOut);
// Rewind the temp m_tmp_file
+ if (m_tmp_file == nullptr)
+ {
+ DEBUG_CRASH(("DataChunkOutput - temp file was never opened"));
+ return;
+ }
::fclose(m_tmp_file);
AsciiString tmpFileName = TheGlobalData->getPath_UserData();
tmpFileName.concat(TEMP_FILENAME);
m_tmp_file = ::fopen( tmpFileName.str(), "rb" );
+ if (m_tmp_file == nullptr)
+ {
+ DEBUG_CRASH(("DataChunkOutput - could not reopen temp file '%s'", tmpFileName.str()));
+ return;
+ }
::fseek(m_tmp_file, 0, SEEK_SET);
// append the temp m_tmp_file m_contents
diff --git a/GeneralsMD/Code/GameEngine/Source/Common/System/DataChunk.cpp b/GeneralsMD/Code/GameEngine/Source/Common/System/DataChunk.cpp
index 8ad0d62a49e..27e85a45bd2 100644
--- a/GeneralsMD/Code/GameEngine/Source/Common/System/DataChunk.cpp
+++ b/GeneralsMD/Code/GameEngine/Source/Common/System/DataChunk.cpp
@@ -251,12 +251,22 @@ DataChunkOutput::~DataChunkOutput()
m_contents.write(*m_pOut);
// Rewind the temp m_tmp_file
+ if (m_tmp_file == nullptr)
+ {
+ DEBUG_CRASH(("DataChunkOutput - temp file was never opened"));
+ return;
+ }
::fclose(m_tmp_file);
AsciiString tmpFileName = TheGlobalData->getPath_UserData();
tmpFileName.concat(TEMP_FILENAME);
m_tmp_file = ::fopen( tmpFileName.str(), "rb" );
+ if (m_tmp_file == nullptr)
+ {
+ DEBUG_CRASH(("DataChunkOutput - could not reopen temp file '%s'", tmpFileName.str()));
+ return;
+ }
::fseek(m_tmp_file, 0, SEEK_SET);
// append the temp m_tmp_file m_contents
From dffc07649bb8aa41cdf6fda12ee5c5dca3c3fc9b Mon Sep 17 00:00:00 2001
From: tintinhamans <5984296+tintinhamans@users.noreply.github.com>
Date: Tue, 29 Sep 2026 21:42:05 +0200
Subject: [PATCH 8/8] fix(settings): Reject out-of-range lobby host camera
heights
---
.../GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h
index e9453892018..611bec0c9b4 100644
--- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h
+++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h
@@ -30,7 +30,7 @@ class GenOnlineSettings
void Save_Camera_MaxHeight_WhenLobbyHost(float maxHeight)
{
- if (maxHeight >= GENERALS_ONLINE_MIN_LOBBY_CAMERA_ZOOM || maxHeight <= GENERALS_ONLINE_MAX_LOBBY_CAMERA_ZOOM)
+ if (maxHeight >= GENERALS_ONLINE_MIN_LOBBY_CAMERA_ZOOM && maxHeight <= GENERALS_ONLINE_MAX_LOBBY_CAMERA_ZOOM)
{
m_Camera_MaxHeight_LobbyHost = maxHeight;
Save();