From b8e66b17ddedfa0ed8ab4bfb97f427e3f6601272 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Mon, 10 Aug 2026 12:18:14 -0400 Subject: [PATCH 1/5] 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 2f84d0f71bd3b964b107451b28b340fca704cbb2 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Tue, 11 Aug 2026 17:30:34 -0400 Subject: [PATCH 2/5] 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 8ab91194578e07127ec4ef1ad0019eb55c03d0b0 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Sun, 23 Aug 2026 09:35:18 -0500 Subject: [PATCH 3/5] 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 3fd9deebbfea51fef559f299df7f74c3b47e5f2c Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Sun, 23 Aug 2026 12:08:24 -0500 Subject: [PATCH 4/5] 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 a4e99ac1bc1094f2aea3bbe12f1bc7e7e69d8175 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Sun, 23 Aug 2026 12:21:36 -0500 Subject: [PATCH 5/5] 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() { } -