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..8449b89f35f 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,80 @@ Bool GameInfo::isSandbox() static const char slotListID = 'S'; -AsciiString GameInfoToAsciiString( const GameInfo *game ) +// 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 (!game) - return AsciiString::TheEmptyString; + 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) +{ + 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; + Int i; + + for (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 (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; @@ -948,15 +1018,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(); - while( name.getLength() > lenMax ) - name.removeLastChar(); //what a horrible way to truncate. I hate AsciiString. - - str.format( "H%s%s", name.str(), tmp.str() ); + + str.format( "H%s%s", playerNames[i].str(), tmp.str() ); } else if (slot && slot->isAI()) { @@ -988,9 +1051,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; } @@ -1645,4 +1750,3 @@ void SkirmishGameInfo::loadPostProcess() { } - 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; 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);