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..fe823e29f9a 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,12 +892,75 @@ Bool GameInfo::isSandbox() static const char slotListID = 'S'; -AsciiString GameInfoToAsciiString( const GameInfo *game ) +// Shorten player names without cutting a UTF-8 character in half. +static void truncatePlayerNameToByteCount(AsciiString& name, Int maxByteCount) { - if (!game) - return AsciiString::TheEmptyString; + const size_t truncatedLength = Utf8_Truncate_Len(name.str(), name.getLength(), maxByteCount); + name.truncateTo(static_cast(truncatedLength)); +} + +static Int getMinPlayerNameLength(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 maxPlayerNamesLength) +{ + 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); + if (slot && slot->isHuman()) + { + minLengths[i] = getMinPlayerNameLength(playerNames[i]); + if (minLengths[i] == 0) + { + // Every serialized human must retain at least one complete UTF-8 character. + return false; + } + minTotalLength += minLengths[i]; + ++playerCount; + } + } + + if (playerCount == 0 || maxPlayerNamesLength < minTotalLength) + { + return false; + } + + Int remainingLength = maxPlayerNamesLength; + for (i = 0; i < MAX_SLOTS; ++i) + { + const GameSlot *slot = game.getConstSlot(i); + if (slot && slot->isHuman()) + { + const Int extraLength = (remainingLength - minTotalLength) / playerCount; + truncatePlayerNameToByteCount(playerNames[i], minLengths[i] + extraLength); + remainingLength -= playerNames[i].getLength(); + minTotalLength -= minLengths[i]; + --playerCount; + } + } - AsciiString mapName = game->getMap(); + return true; +} + +static AsciiString buildGameInfoAsciiString(const GameInfo& game, const AsciiString playerNames[MAX_SLOTS]) +{ + AsciiString mapName = game.getMap(); mapName = TheGameState->realMapPathToPortableMapPath(mapName); AsciiString newMapName; if (!mapName.isEmpty()) @@ -922,12 +986,12 @@ AsciiString GameInfoToAsciiString( const GameInfo *game ) 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 @@ -935,7 +999,7 @@ AsciiString GameInfoToAsciiString( const GameInfo *game ) optionsString.concat('='); for (Int i=0; igetConstSlot(i); + const GameSlot *slot = game.getConstSlot(i); AsciiString str; if (slot && slot->isHuman()) @@ -948,15 +1012,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 +1045,49 @@ 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(); + } + } + + // 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)) + { + optionsString = buildGameInfoAsciiString(*game, playerNames); + optionsFit = optionsString.getLength() <= m_lanMaxOptionsLength; + } + } + + if (!optionsFit) + { + 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; + } return optionsString; } @@ -1645,4 +1742,3 @@ void SkirmishGameInfo::loadPostProcess() { } - diff --git a/Core/GameEngine/Source/GameNetwork/LANAPI.cpp b/Core/GameEngine/Source/GameNetwork/LANAPI.cpp index 8cbfbdea6c5..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(); @@ -834,10 +839,12 @@ 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) + 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(); 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);