From 79e8b4d7d6aa80114b9952eb007417afef975221 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Mon, 10 Aug 2026 12:18:14 -0400 Subject: [PATCH 1/6] bugfix(network): Prevent LAN lobby hang with long player names --- Core/GameEngine/Include/GameNetwork/LANAPI.h | 1 + .../Source/GameNetwork/GameInfo.cpp | 35 +++++++++++++++++-- Core/GameEngine/Source/GameNetwork/LANAPI.cpp | 2 +- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/Core/GameEngine/Include/GameNetwork/LANAPI.h b/Core/GameEngine/Include/GameNetwork/LANAPI.h index df22c116d9f..250d5b71e80 100644 --- a/Core/GameEngine/Include/GameNetwork/LANAPI.h +++ b/Core/GameEngine/Include/GameNetwork/LANAPI.h @@ -263,6 +263,7 @@ struct LANMessage { char options[m_lanMaxOptionsLength+1]; } GameOptions; + static_assert(ARRAY_SIZE(GameOptions.options) > m_lanMaxOptionsLength, "GameOptions.options buffer must be larger than m_lanMaxOptionsLength"); }; }; diff --git a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp index 5b05e9eb369..ca6fc99acfc 100644 --- a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp @@ -891,6 +891,36 @@ Bool GameInfo::isSandbox() static const char slotListID = 'S'; +static Bool isUtf8ContinuationByte(Char c) +{ + return (static_cast(c) & 0xC0) == 0x80; +} + +// TheSuperHackers @bugfix Truncates the name to at most maxByteCount bytes without splitting +// a multibyte UTF-8 character. A non-positive budget empties the name; retail spun forever +// there, because removing the last character of an already empty string is a no-op. +static void truncatePlayerName(AsciiString& name, Int maxByteCount) +{ + if (maxByteCount <= 0) + { + name.clear(); + return; + } + + if (name.getLength() <= maxByteCount) + { + return; + } + + Int truncatedLength = maxByteCount; + while (truncatedLength > 0 && isUtf8ContinuationByte(name.getCharAt(truncatedLength))) + { + --truncatedLength; + } + + name.truncateTo(truncatedLength); +} + AsciiString GameInfoToAsciiString( const GameInfo *game ) { if (!game) @@ -953,8 +983,7 @@ AsciiString GameInfoToAsciiString( const GameInfo *game ) int lenRem = m_lanMaxOptionsLength - lenCur; //length remaining before overflowing int lenMax = lenRem / (MAX_SLOTS-i); //share lenRem with all remaining slots AsciiString name = WideCharStringToMultiByte(slot->getName().str()).c_str(); - while( name.getLength() > lenMax ) - name.removeLastChar(); //what a horrible way to truncate. I hate AsciiString. + truncatePlayerName( name, lenMax ); str.format( "H%s%s", name.str(), tmp.str() ); } @@ -988,7 +1017,7 @@ AsciiString GameInfoToAsciiString( const GameInfo *game ) } optionsString.concat(';'); - DEBUG_ASSERTCRASH(!TheLAN || (optionsString.getLength() < m_lanMaxOptionsLength), + DEBUG_ASSERTCRASH(!TheLAN || (optionsString.getLength() <= m_lanMaxOptionsLength), ("WARNING: options string is longer than expected! Length is %d, but max is %d!", optionsString.getLength(), m_lanMaxOptionsLength)); diff --git a/Core/GameEngine/Source/GameNetwork/LANAPI.cpp b/Core/GameEngine/Source/GameNetwork/LANAPI.cpp index 8cbfbdea6c5..a5c8793e6aa 100644 --- a/Core/GameEngine/Source/GameNetwork/LANAPI.cpp +++ b/Core/GameEngine/Source/GameNetwork/LANAPI.cpp @@ -834,7 +834,7 @@ void LANAPI::RequestGameStartTimer( Int seconds ) void LANAPI::RequestGameOptions( AsciiString gameOptions, Bool isPublic, UnsignedInt ip /* = 0 */ ) { - DEBUG_ASSERTCRASH(gameOptions.getLength() < m_lanMaxOptionsLength, ("Game options string is too long!")); + DEBUG_ASSERTCRASH(gameOptions.getLength() <= m_lanMaxOptionsLength, ("Game options string is too long!")); if (!m_currentGame) return; From 2a1b69e825f0c0cb004e750e157fb03148f03885 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Tue, 11 Aug 2026 17:30:34 -0400 Subject: [PATCH 2/6] refactor(utf8): Move the UTF-8 truncation rule into WWLib --- .../Source/GameNetwork/GameInfo.cpp | 19 ++--------------- Core/Libraries/Source/WWVegas/WWLib/utf8.cpp | 21 +++++++++++++++++++ Core/Libraries/Source/WWVegas/WWLib/utf8.h | 6 ++++++ 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp index ca6fc99acfc..dbe4e6073d0 100644 --- a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp @@ -44,6 +44,7 @@ #include "GameNetwork/LANAPI.h" // for testing packet size #include "GameNetwork/LANAPICallbacks.h" // for testing packet size #include "WWLib/strtok_r.h" +#include "WWLib/utf8.h" @@ -891,11 +892,6 @@ Bool GameInfo::isSandbox() static const char slotListID = 'S'; -static Bool isUtf8ContinuationByte(Char c) -{ - return (static_cast(c) & 0xC0) == 0x80; -} - // TheSuperHackers @bugfix Truncates the name to at most maxByteCount bytes without splitting // a multibyte UTF-8 character. A non-positive budget empties the name; retail spun forever // there, because removing the last character of an already empty string is a no-op. @@ -907,18 +903,7 @@ static void truncatePlayerName(AsciiString& name, Int maxByteCount) return; } - if (name.getLength() <= maxByteCount) - { - return; - } - - Int truncatedLength = maxByteCount; - while (truncatedLength > 0 && isUtf8ContinuationByte(name.getCharAt(truncatedLength))) - { - --truncatedLength; - } - - name.truncateTo(truncatedLength); + name.truncateTo(static_cast(Utf8_Truncate_Len(name.str(), name.getLength(), maxByteCount))); } AsciiString GameInfoToAsciiString( const GameInfo *game ) diff --git a/Core/Libraries/Source/WWVegas/WWLib/utf8.cpp b/Core/Libraries/Source/WWVegas/WWLib/utf8.cpp index 9faa1d64ef1..578299fbe3d 100644 --- a/Core/Libraries/Source/WWVegas/WWLib/utf8.cpp +++ b/Core/Libraries/Source/WWVegas/WWLib/utf8.cpp @@ -281,3 +281,24 @@ size_t Utf8_To_Wide(wchar_t* dest, size_t destLen, const char* src, size_t srcLe } return needed; } + +// A UTF-8 continuation byte matches 10xxxxxx, so it can never start a sequence. +static bool Utf8_Is_Continuation_Byte(char c) +{ + return ((unsigned char)c & 0xC0) == 0x80; +} + +size_t Utf8_Truncate_Len(const char* src, size_t srcLen, size_t maxLen) +{ + if (srcLen <= maxLen) + { + return srcLen; + } + + size_t len = maxLen; + while (len > 0 && Utf8_Is_Continuation_Byte(src[len])) + { + --len; + } + return len; +} diff --git a/Core/Libraries/Source/WWVegas/WWLib/utf8.h b/Core/Libraries/Source/WWVegas/WWLib/utf8.h index 7424943b014..0a4f92f95ee 100644 --- a/Core/Libraries/Source/WWVegas/WWLib/utf8.h +++ b/Core/Libraries/Source/WWVegas/WWLib/utf8.h @@ -55,3 +55,9 @@ size_t Wide_To_Utf8(char* dest, size_t destLen, const wchar_t* src, size_t srcLe // that many wide characters plus one for the terminator. Pass destLen 0 to measure without writing. // Returns UTF8_INVALID if src is not well-formed UTF-8, setting dest[0] to L'\0' if destLen > 0. size_t Utf8_To_Wide(wchar_t* dest, size_t destLen, const char* src, size_t srcLen); + +// Returns the largest length not greater than maxLen at which the srcLen bytes of the UTF-8 string +// src can be cut without splitting a multibyte sequence, by backing off the continuation bytes at +// the cut point. Returns srcLen when the string already fits in maxLen. Returns 0 when no whole +// sequence fits, which is also what malformed UTF-8 yields once it has no lead byte to back off to. +size_t Utf8_Truncate_Len(const char* src, size_t srcLen, size_t maxLen); From ff1e7afea0053cf79130874d3ba3601f3ed10110 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Sun, 23 Aug 2026 09:35:18 -0500 Subject: [PATCH 3/6] refactor(network): Clarify player name truncation --- Core/GameEngine/Source/GameNetwork/GameInfo.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp index dbe4e6073d0..7c4c0f319d5 100644 --- a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp @@ -903,7 +903,8 @@ static void truncatePlayerName(AsciiString& name, Int maxByteCount) return; } - name.truncateTo(static_cast(Utf8_Truncate_Len(name.str(), name.getLength(), maxByteCount))); + const size_t truncatedLength = Utf8_Truncate_Len(name.str(), name.getLength(), maxByteCount); + name.truncateTo(static_cast(truncatedLength)); } AsciiString GameInfoToAsciiString( const GameInfo *game ) From 7699fa344336e13c6445efd51e906f588e825983 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Sun, 23 Aug 2026 12:08:24 -0500 Subject: [PATCH 4/6] bugfix(network): Keep LAN game options within wire limit --- .../Source/GameNetwork/GameInfo.cpp | 123 +++++++++++++++--- 1 file changed, 106 insertions(+), 17 deletions(-) diff --git a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp index 7c4c0f319d5..554ae919463 100644 --- a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp @@ -892,9 +892,8 @@ Bool GameInfo::isSandbox() static const char slotListID = 'S'; -// TheSuperHackers @bugfix Truncates the name to at most maxByteCount bytes without splitting -// a multibyte UTF-8 character. A non-positive budget empties the name; retail spun forever -// there, because removing the last character of an already empty string is a no-op. +// TheSuperHackers @bugfix bobtista 23/08/2026 Truncate player names without splitting a +// multibyte UTF-8 character. A non-positive budget empties the name instead of looping forever. static void truncatePlayerName(AsciiString& name, Int maxByteCount) { if (maxByteCount <= 0) @@ -907,11 +906,65 @@ static void truncatePlayerName(AsciiString& name, Int maxByteCount) name.truncateTo(static_cast(truncatedLength)); } -AsciiString GameInfoToAsciiString( const GameInfo *game ) +static Int getMinimumPlayerNameLength(const AsciiString& name) { - if (!game) - return AsciiString::TheEmptyString; + for (Int maxByteCount = 1; maxByteCount <= name.getLength(); ++maxByteCount) + { + const size_t truncatedLength = Utf8_Truncate_Len(name.str(), name.getLength(), maxByteCount); + if (truncatedLength > 0) + { + return static_cast(truncatedLength); + } + } + + return 0; +} + +static Bool truncatePlayerNames(const GameInfo *game, AsciiString playerNames[MAX_SLOTS], Int maxTotalLength) +{ + Int minimumLengths[MAX_SLOTS] = { 0 }; + Int minimumTotalLength = 0; + Int playerCount = 0; + + for (Int i = 0; i < MAX_SLOTS; ++i) + { + const GameSlot *slot = game->getConstSlot(i); + if (slot && slot->isHuman()) + { + minimumLengths[i] = getMinimumPlayerNameLength(playerNames[i]); + if (minimumLengths[i] == 0) + { + return false; + } + minimumTotalLength += minimumLengths[i]; + ++playerCount; + } + } + + if (playerCount == 0 || maxTotalLength < minimumTotalLength) + { + return false; + } + + Int remainingLength = maxTotalLength; + for (Int i = 0; i < MAX_SLOTS; ++i) + { + const GameSlot *slot = game->getConstSlot(i); + if (slot && slot->isHuman()) + { + const Int extraLength = (remainingLength - minimumTotalLength) / playerCount; + truncatePlayerName(playerNames[i], minimumLengths[i] + extraLength); + remainingLength -= playerNames[i].getLength(); + minimumTotalLength -= minimumLengths[i]; + --playerCount; + } + } + + return true; +} +static AsciiString buildGameInfoAsciiString(const GameInfo *game, const AsciiString playerNames[MAX_SLOTS]) +{ AsciiString mapName = game->getMap(); mapName = TheGameState->realMapPathToPortableMapPath(mapName); AsciiString newMapName; @@ -964,14 +1017,8 @@ AsciiString GameInfoToAsciiString( const GameInfo *game ) slot->getColor(), slot->getPlayerTemplate(), slot->getStartPos(), slot->getTeamNumber(), slot->getNATBehavior() ); - //make sure name doesn't cause overflow of m_lanMaxOptionsLength - int lenCur = tmp.getLength() + optionsString.getLength() + 2; //+2 for H and trailing ; - int lenRem = m_lanMaxOptionsLength - lenCur; //length remaining before overflowing - int lenMax = lenRem / (MAX_SLOTS-i); //share lenRem with all remaining slots - AsciiString name = WideCharStringToMultiByte(slot->getName().str()).c_str(); - truncatePlayerName( name, lenMax ); - - str.format( "H%s%s", name.str(), tmp.str() ); + + str.format( "H%s%s", playerNames[i].str(), tmp.str() ); } else if (slot && slot->isAI()) { @@ -1003,9 +1050,51 @@ AsciiString GameInfoToAsciiString( const GameInfo *game ) } optionsString.concat(';'); - DEBUG_ASSERTCRASH(!TheLAN || (optionsString.getLength() <= m_lanMaxOptionsLength), - ("WARNING: options string is longer than expected! Length is %d, but max is %d!", - optionsString.getLength(), m_lanMaxOptionsLength)); + return optionsString; +} + +AsciiString GameInfoToAsciiString( const GameInfo *game ) +{ + if (!game) + { + return AsciiString::TheEmptyString; + } + + AsciiString playerNames[MAX_SLOTS]; + Int playerNamesLength = 0; + for (Int i = 0; i < MAX_SLOTS; ++i) + { + const GameSlot *slot = game->getConstSlot(i); + if (slot && slot->isHuman()) + { + playerNames[i] = WideCharStringToMultiByte(slot->getName().str()).c_str(); + playerNamesLength += playerNames[i].getLength(); + } + } + + AsciiString optionsString = buildGameInfoAsciiString(game, playerNames); + // TheSuperHackers @bugfix bobtista 23/08/2026 Build with full names first so the second pass + // can reserve the exact fixed-field length and divide the remaining wire budget between names. + if (TheLAN && optionsString.getLength() > m_lanMaxOptionsLength) + { + const Int fixedLength = optionsString.getLength() - playerNamesLength; + const Int maxPlayerNamesLength = m_lanMaxOptionsLength - fixedLength; + if (!truncatePlayerNames(game, playerNames, maxPlayerNamesLength)) + { + DEBUG_CRASH(("WARNING: options string is longer than expected! Length is %d, but max is %d!", + optionsString.getLength(), m_lanMaxOptionsLength)); + return AsciiString::TheEmptyString; + } + + optionsString = buildGameInfoAsciiString(game, playerNames); + } + + if (TheLAN && optionsString.getLength() > m_lanMaxOptionsLength) + { + DEBUG_CRASH(("WARNING: options string is longer than expected after truncation! Length is %d, but max is %d!", + optionsString.getLength(), m_lanMaxOptionsLength)); + return AsciiString::TheEmptyString; + } return optionsString; } From 865c66ef5a5e64dacc099a8fa485783b831474c3 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Sun, 23 Aug 2026 12:21:36 -0500 Subject: [PATCH 5/6] fix(network): Support VC6 loop variable scoping --- Core/GameEngine/Source/GameNetwork/GameInfo.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp index 554ae919463..8449b89f35f 100644 --- a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp @@ -925,8 +925,9 @@ static Bool truncatePlayerNames(const GameInfo *game, AsciiString playerNames[MA Int minimumLengths[MAX_SLOTS] = { 0 }; Int minimumTotalLength = 0; Int playerCount = 0; + Int i; - for (Int i = 0; i < MAX_SLOTS; ++i) + for (i = 0; i < MAX_SLOTS; ++i) { const GameSlot *slot = game->getConstSlot(i); if (slot && slot->isHuman()) @@ -947,7 +948,7 @@ static Bool truncatePlayerNames(const GameInfo *game, AsciiString playerNames[MA } Int remainingLength = maxTotalLength; - for (Int i = 0; i < MAX_SLOTS; ++i) + for (i = 0; i < MAX_SLOTS; ++i) { const GameSlot *slot = game->getConstSlot(i); if (slot && slot->isHuman()) @@ -1749,4 +1750,3 @@ void SkirmishGameInfo::loadPostProcess() { } - From 654d854d03dc67245695f5d9c5b526082c262527 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Tue, 15 Sep 2026 16:54:05 -0400 Subject: [PATCH 6/6] refactor(network): Address LAN serializer review feedback --- .../Source/GameNetwork/GameInfo.cpp | 78 +++++++++---------- Core/GameEngine/Source/GameNetwork/LANAPI.cpp | 11 ++- .../Source/GameNetwork/LANAPIhandlers.cpp | 28 ++++--- 3 files changed, 62 insertions(+), 55 deletions(-) diff --git a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp index 8449b89f35f..fe823e29f9a 100644 --- a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp @@ -892,21 +892,14 @@ Bool GameInfo::isSandbox() static const char slotListID = 'S'; -// TheSuperHackers @bugfix bobtista 23/08/2026 Truncate player names without splitting a -// multibyte UTF-8 character. A non-positive budget empties the name instead of looping forever. -static void truncatePlayerName(AsciiString& name, Int maxByteCount) +// Shorten player names without cutting a UTF-8 character in half. +static void truncatePlayerNameToByteCount(AsciiString& name, Int maxByteCount) { - if (maxByteCount <= 0) - { - name.clear(); - return; - } - const size_t truncatedLength = Utf8_Truncate_Len(name.str(), name.getLength(), maxByteCount); name.truncateTo(static_cast(truncatedLength)); } -static Int getMinimumPlayerNameLength(const AsciiString& name) +static Int getMinPlayerNameLength(const AsciiString& name) { for (Int maxByteCount = 1; maxByteCount <= name.getLength(); ++maxByteCount) { @@ -920,43 +913,44 @@ static Int getMinimumPlayerNameLength(const AsciiString& name) return 0; } -static Bool truncatePlayerNames(const GameInfo *game, AsciiString playerNames[MAX_SLOTS], Int maxTotalLength) +static Bool truncatePlayerNames(const GameInfo& game, AsciiString playerNames[MAX_SLOTS], Int maxPlayerNamesLength) { - Int minimumLengths[MAX_SLOTS] = { 0 }; - Int minimumTotalLength = 0; + Int minLengths[MAX_SLOTS] = { 0 }; + Int minTotalLength = 0; Int playerCount = 0; Int i; for (i = 0; i < MAX_SLOTS; ++i) { - const GameSlot *slot = game->getConstSlot(i); + const GameSlot *slot = game.getConstSlot(i); if (slot && slot->isHuman()) { - minimumLengths[i] = getMinimumPlayerNameLength(playerNames[i]); - if (minimumLengths[i] == 0) + minLengths[i] = getMinPlayerNameLength(playerNames[i]); + if (minLengths[i] == 0) { + // Every serialized human must retain at least one complete UTF-8 character. return false; } - minimumTotalLength += minimumLengths[i]; + minTotalLength += minLengths[i]; ++playerCount; } } - if (playerCount == 0 || maxTotalLength < minimumTotalLength) + if (playerCount == 0 || maxPlayerNamesLength < minTotalLength) { return false; } - Int remainingLength = maxTotalLength; + Int remainingLength = maxPlayerNamesLength; for (i = 0; i < MAX_SLOTS; ++i) { - const GameSlot *slot = game->getConstSlot(i); + const GameSlot *slot = game.getConstSlot(i); if (slot && slot->isHuman()) { - const Int extraLength = (remainingLength - minimumTotalLength) / playerCount; - truncatePlayerName(playerNames[i], minimumLengths[i] + extraLength); + const Int extraLength = (remainingLength - minTotalLength) / playerCount; + truncatePlayerNameToByteCount(playerNames[i], minLengths[i] + extraLength); remainingLength -= playerNames[i].getLength(); - minimumTotalLength -= minimumLengths[i]; + minTotalLength -= minLengths[i]; --playerCount; } } @@ -964,9 +958,9 @@ static Bool truncatePlayerNames(const GameInfo *game, AsciiString playerNames[MA return true; } -static AsciiString buildGameInfoAsciiString(const GameInfo *game, const AsciiString playerNames[MAX_SLOTS]) +static AsciiString buildGameInfoAsciiString(const GameInfo& game, const AsciiString playerNames[MAX_SLOTS]) { - AsciiString mapName = game->getMap(); + AsciiString mapName = game.getMap(); mapName = TheGameState->realMapPathToPortableMapPath(mapName); AsciiString newMapName; if (!mapName.isEmpty()) @@ -992,12 +986,12 @@ static AsciiString buildGameInfoAsciiString(const GameInfo *game, const AsciiStr AsciiString optionsString; #if RTS_GENERALS - optionsString.format("M=%2.2x%s;MC=%X;MS=%d;SD=%d;C=%d;", game->getMapContentsMask(), newMapName.str(), - game->getMapCRC(), game->getMapSize(), game->getSeed(), game->getCRCInterval()); + optionsString.format("M=%2.2x%s;MC=%X;MS=%d;SD=%d;C=%d;", game.getMapContentsMask(), newMapName.str(), + game.getMapCRC(), game.getMapSize(), game.getSeed(), game.getCRCInterval()); #else - optionsString.format("US=%d;M=%2.2x%s;MC=%X;MS=%d;SD=%d;C=%d;SR=%u;SC=%u;O=%c;", game->getUseStats(), game->getMapContentsMask(), newMapName.str(), - game->getMapCRC(), game->getMapSize(), game->getSeed(), game->getCRCInterval(), game->getSuperweaponRestriction(), - game->getStartingCash().countMoney(), game->oldFactionsOnly() ? 'Y' : 'N' ); + optionsString.format("US=%d;M=%2.2x%s;MC=%X;MS=%d;SD=%d;C=%d;SR=%u;SC=%u;O=%c;", game.getUseStats(), game.getMapContentsMask(), newMapName.str(), + game.getMapCRC(), game.getMapSize(), game.getSeed(), game.getCRCInterval(), game.getSuperweaponRestriction(), + game.getStartingCash().countMoney(), game.oldFactionsOnly() ? 'Y' : 'N' ); #endif //add player info for each slot @@ -1005,7 +999,7 @@ static AsciiString buildGameInfoAsciiString(const GameInfo *game, const AsciiStr optionsString.concat('='); for (Int i=0; igetConstSlot(i); + const GameSlot *slot = game.getConstSlot(i); AsciiString str; if (slot && slot->isHuman()) @@ -1073,26 +1067,24 @@ AsciiString GameInfoToAsciiString( const GameInfo *game ) } } - AsciiString optionsString = buildGameInfoAsciiString(game, playerNames); - // TheSuperHackers @bugfix bobtista 23/08/2026 Build with full names first so the second pass - // can reserve the exact fixed-field length and divide the remaining wire budget between names. - if (TheLAN && optionsString.getLength() > m_lanMaxOptionsLength) + // TheSuperHackers @bugfix bobtista 23/08/2026 Prevent an infinite loop when player names exceed + // the LAN options limit by rebuilding the payload with bounded UTF-8 names. + AsciiString optionsString = buildGameInfoAsciiString(*game, playerNames); + Bool optionsFit = TheLAN == nullptr || optionsString.getLength() <= m_lanMaxOptionsLength; + if (!optionsFit) { const Int fixedLength = optionsString.getLength() - playerNamesLength; const Int maxPlayerNamesLength = m_lanMaxOptionsLength - fixedLength; - if (!truncatePlayerNames(game, playerNames, maxPlayerNamesLength)) + if (truncatePlayerNames(*game, playerNames, maxPlayerNamesLength)) { - DEBUG_CRASH(("WARNING: options string is longer than expected! Length is %d, but max is %d!", - optionsString.getLength(), m_lanMaxOptionsLength)); - return AsciiString::TheEmptyString; + optionsString = buildGameInfoAsciiString(*game, playerNames); + optionsFit = optionsString.getLength() <= m_lanMaxOptionsLength; } - - optionsString = buildGameInfoAsciiString(game, playerNames); } - if (TheLAN && optionsString.getLength() > m_lanMaxOptionsLength) + if (!optionsFit) { - DEBUG_CRASH(("WARNING: options string is longer than expected after truncation! Length is %d, but max is %d!", + DEBUG_CRASH(("WARNING: options string cannot fit within the expected length! Length is %d, but max is %d!", optionsString.getLength(), m_lanMaxOptionsLength)); return AsciiString::TheEmptyString; } diff --git a/Core/GameEngine/Source/GameNetwork/LANAPI.cpp b/Core/GameEngine/Source/GameNetwork/LANAPI.cpp index a5c8793e6aa..7ee56b9da75 100644 --- a/Core/GameEngine/Source/GameNetwork/LANAPI.cpp +++ b/Core/GameEngine/Source/GameNetwork/LANAPI.cpp @@ -710,11 +710,16 @@ void LANAPI::RequestGameAnnounce() { if (m_currentGame->getIP(0) == m_localIP || (m_currentGame->isGameInProgress() && TheNetwork && TheNetwork->isPacketRouter())) // if we're in game we should reply if we're the packet router { + AsciiString gameOpts = GameInfoToAsciiString(m_currentGame); + if (gameOpts.isEmpty()) + { + return; + } + LANMessage reply; fillInLANMessage( &reply ); reply.messageType = LANMessage::MSG_GAME_ANNOUNCE; - AsciiString gameOpts = GameInfoToAsciiString(m_currentGame); strlcpy(reply.GameInfo.options,gameOpts.str(), ARRAY_SIZE(reply.GameInfo.options)); wcslcpy(reply.GameInfo.gameName, m_currentGame->getName().str(), ARRAY_SIZE(reply.GameInfo.gameName)); reply.GameInfo.inProgress = m_currentGame->isGameInProgress(); @@ -836,8 +841,10 @@ void LANAPI::RequestGameOptions( AsciiString gameOptions, Bool isPublic, Unsigne { DEBUG_ASSERTCRASH(gameOptions.getLength() <= m_lanMaxOptionsLength, ("Game options string is too long!")); - if (!m_currentGame) + if (!m_currentGame || gameOptions.isEmpty()) + { return; + } LANMessage msg; fillInLANMessage( &msg ); diff --git a/Core/GameEngine/Source/GameNetwork/LANAPIhandlers.cpp b/Core/GameEngine/Source/GameNetwork/LANAPIhandlers.cpp index 7f88b835f86..d90ed145e5c 100644 --- a/Core/GameEngine/Source/GameNetwork/LANAPIhandlers.cpp +++ b/Core/GameEngine/Source/GameNetwork/LANAPIhandlers.cpp @@ -58,16 +58,19 @@ void LANAPI::handleRequestLocations( LANMessage *msg, UnsignedInt senderIP ) { if (m_currentGame->getIP(0) == m_localIP) { - LANMessage reply; - fillInLANMessage( &reply ); - reply.messageType = LANMessage::MSG_GAME_ANNOUNCE; AsciiString gameOpts = GenerateGameOptionsString(); - strlcpy(reply.GameInfo.options, gameOpts.str(), ARRAY_SIZE(reply.GameInfo.options)); - wcslcpy(reply.GameInfo.gameName, m_currentGame->getName().str(), ARRAY_SIZE(reply.GameInfo.gameName)); - reply.GameInfo.inProgress = m_currentGame->isGameInProgress(); - reply.GameInfo.isDirectConnect = m_currentGame->getIsDirectConnect(); - - sendMessage(&reply); + if (!gameOpts.isEmpty()) + { + LANMessage reply; + fillInLANMessage( &reply ); + reply.messageType = LANMessage::MSG_GAME_ANNOUNCE; + strlcpy(reply.GameInfo.options, gameOpts.str(), ARRAY_SIZE(reply.GameInfo.options)); + wcslcpy(reply.GameInfo.gameName, m_currentGame->getName().str(), ARRAY_SIZE(reply.GameInfo.gameName)); + reply.GameInfo.inProgress = m_currentGame->isGameInProgress(); + reply.GameInfo.isDirectConnect = m_currentGame->getIsDirectConnect(); + + sendMessage(&reply); + } } else { @@ -188,11 +191,16 @@ void LANAPI::handleRequestGameInfo( LANMessage *msg, UnsignedInt senderIP ) { if (m_currentGame->getIP(0) == m_localIP || (m_currentGame->isGameInProgress() && TheNetwork && TheNetwork->isPacketRouter())) // if we're in game we should reply if we're the packet router { + AsciiString gameOpts = GameInfoToAsciiString(m_currentGame); + if (gameOpts.isEmpty()) + { + return; + } + LANMessage reply; fillInLANMessage( &reply ); reply.messageType = LANMessage::MSG_GAME_ANNOUNCE; - AsciiString gameOpts = GameInfoToAsciiString(m_currentGame); strlcpy(reply.GameInfo.options,gameOpts.str(), ARRAY_SIZE(reply.GameInfo.options)); wcslcpy(reply.GameInfo.gameName, m_currentGame->getName().str(), ARRAY_SIZE(reply.GameInfo.gameName)); reply.GameInfo.inProgress = m_currentGame->isGameInProgress();