Skip to content

[ZH] Prevent hang in network lobby with long player names #1119

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions GeneralsMD/Code/GameEngine/Include/Common/AsciiString.h
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ class AsciiString
*/
void removeLastChar();

/**
Remove the final charCount characters in the string. If the string is empty,
do nothing.
*/
void truncate(UnsignedInt charCount);

/**
Analogous to sprintf() -- this formats a string according to the
given sprintf-style format string (and the variable argument list)
Expand Down
14 changes: 14 additions & 0 deletions GeneralsMD/Code/GameEngine/Include/GameNetwork/GameInfo.h
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,20 @@ void GameInfo::setOldFactionsOnly( Bool oldFactionsOnly ) { m_oldFactions
AsciiString GameInfoToAsciiString( const GameInfo *game );
Bool ParseAsciiStringToGameInfo( GameInfo *game, AsciiString options );

struct LengthIndexPair
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better move this into the cpp if it is only used there.

{
Int Length;
size_t Index;
friend bool operator<(const LengthIndexPair& lhs, const LengthIndexPair& rhs)
{
if (lhs.Length == rhs.Length)
return lhs.Index < rhs.Index;
return lhs.Length < rhs.Length;
}
friend bool operator>(const LengthIndexPair& lhs, const LengthIndexPair& rhs) { return rhs < lhs; }
friend bool operator<=(const LengthIndexPair& lhs, const LengthIndexPair& rhs) { return !(lhs > rhs); }
friend bool operator>=(const LengthIndexPair& lhs, const LengthIndexPair& rhs) { return !(lhs < rhs); }
};

/**
* The SkirmishGameInfo class holds information about the skirmish game and
Expand Down
13 changes: 10 additions & 3 deletions GeneralsMD/Code/GameEngine/Source/Common/System/AsciiString.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -331,15 +331,22 @@ void AsciiString::toLower()

// -----------------------------------------------------
void AsciiString::removeLastChar()
{
truncate(1U);
}

// -----------------------------------------------------
void AsciiString::truncate(UnsignedInt charCount)
{
validate();
if (m_data)
if (m_data && charCount > 0)
{
int len = strlen(peek());
size_t len = strlen(peek());
if (len > 0)
{
ensureUniqueBufferOfSize(len+1, true, NULL, NULL);
peek()[len - 1] = 0;
charCount = min(charCount, len);
peek()[len - charCount] = 0;
}
}
validate();
Expand Down
148 changes: 127 additions & 21 deletions GeneralsMD/Code/GameEngine/Source/GameNetwork/GameInfo.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
#include "GameNetwork/LANAPI.h" // for testing packet size
#include "GameNetwork/LANAPICallbacks.h" // for testing packet size
#include "strtok_r.h"
#include <algorithm>
#include <utility>

#ifdef RTS_INTERNAL
// for occasional debugging...
Expand Down Expand Up @@ -899,7 +901,97 @@ Bool GameInfo::isSandbox(void)

static const char slotListID = 'S';

AsciiString GameInfoToAsciiString( const GameInfo *game )
static AsciiStringVec BuildPlayerNames(const GameInfo& game)
{
AsciiStringVec playerNames;
playerNames.resize(MAX_SLOTS);

for (Int i = 0; i < MAX_SLOTS; ++i)
{
const GameSlot* slot = game.getConstSlot(i);
if (slot->isHuman())
{
playerNames[i] = WideCharStringToMultiByte(slot->getName().str()).c_str();
}
}

return playerNames;
}

static Bool TruncatePlayerNames(AsciiStringVec& playerNames, UnsignedInt truncateAmount)
{
// wont truncate any name to below this length
CONSTEXPR const Int MinimumNameLength = 2;
UnsignedInt availableForTruncation = 0;

// make length+index pairs for the player names
std::vector<LengthIndexPair> lengthIndex;
lengthIndex.resize(playerNames.size());
for (size_t pi = 0; pi < playerNames.size(); ++pi)
{
Int playerNameLength = playerNames[pi].getLength();
lengthIndex[pi].Length = playerNameLength;
lengthIndex[pi].Index = pi;
availableForTruncation += std::max(0, playerNameLength - MinimumNameLength);
}

if (truncateAmount > availableForTruncation)
{
DEBUG_LOG(("TruncatePlayerNames - Requested to truncate %u chars from player names, but only %u were available for truncation.\n", truncateAmount, availableForTruncation));
return false;
}

// sort based on length in descending order
std::sort(lengthIndex.begin(), lengthIndex.end(), std::greater<LengthIndexPair>());

// determine how long each of the player names should be
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop does not yet look optimal. It goes one char at a time, over all players.

It looks like it should be possible to skip ahead multiple characters by peeking into the next player name (if it is not the last one).

longplayername
dude
    ^ we already know that the first name is 10 characters longer than all others

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was a bit afraid of making it too complicated again, but the latest iteration will do greater steps in the target length. It's a bit more complex, but perhaps manageable?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In inquired with Chat GPT about it. Can you check?

Chat GPT

Great! To improve performance, we can avoid repeated scanning and dynamic target recomputation by switching to a leveling algorithm, inspired by how you might "flatten terrain" from the top down.


✅ Optimal Fair Truncation Algorithm — O(n log n)

📌 Key Ideas:

  1. Sort strings by length descending.

  2. Flatten in levels: repeatedly lower the longest group of strings to the height of the next group.

  3. For each level:

    • Compute how many characters you'd save by flattening all strings in that level.
    • If you can afford it, flatten and continue.
    • If not, stop and perform partial truncation evenly on that level.

This avoids the repeated loop-reset logic of your original code.


🔧 Optimized Implementation

#include <vector>
#include <string>
#include <algorithm>
#include <cassert>
#include <iostream>

using namespace std;

// Alias types for clarity
using AsciiStringVec = std::vector<std::string>;
using Int = int;
using UnsignedInt = unsigned int;
using Bool = bool;

struct LengthIndexPair {
    Int Length;
    size_t Index;
};

static Bool TruncatePlayerNames(AsciiStringVec& playerNames, UnsignedInt truncateAmount) {
    constexpr Int MinimumNameLength = 2;
    const size_t n = playerNames.size();
    if (n == 0 || truncateAmount == 0) return true;

    // Prepare length/index pairs and calculate available truncation budget
    vector<LengthIndexPair> entries(n);
    UnsignedInt availableForTruncation = 0;

    for (size_t i = 0; i < n; ++i) {
        Int len = static_cast<Int>(playerNames[i].size());
        entries[i] = { len, i };
        availableForTruncation += max(0, len - MinimumNameLength);
    }

    if (truncateAmount > availableForTruncation) {
        cerr << "Requested to truncate more than allowed.\n";
        return false;
    }

    // Sort by descending length
    sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) {
        return a.Length > b.Length;
    });

    // Process in levels
    size_t i = 0;
    while (i < n && truncateAmount > 0) {
        Int currentLength = entries[i].Length;

        // Find the next group with a smaller length
        size_t j = i + 1;
        while (j < n && entries[j].Length == currentLength) ++j;

        // Determine target level
        Int nextLength = (j < n) ? entries[j].Length : MinimumNameLength;
        Int maxReduction = currentLength - max(nextLength, MinimumNameLength);
        size_t groupSize = j;

        UnsignedInt cost = groupSize * maxReduction;

        if (cost <= truncateAmount) {
            // Flatten whole group
            for (size_t k = 0; k < groupSize; ++k)
                entries[k].Length -= maxReduction;
            truncateAmount -= cost;
            i = j; // move to next group
        } else {
            // Partial truncation to equal target
            Int cut = truncateAmount / groupSize;
            Int extra = truncateAmount % groupSize;

            for (size_t k = 0; k < groupSize; ++k) {
                entries[k].Length -= cut;
                if (extra > 0) {
                    entries[k].Length -= 1;
                    --extra;
                }
            }
            truncateAmount = 0;
            break;
        }
    }

    // Apply truncation
    for (const auto& entry : entries) {
        Int newLength = max(entry.Length, MinimumNameLength);
        if ((Int)playerNames[entry.Index].size() > newLength) {
            playerNames[entry.Index].resize(newLength);
        }
    }

    return true;
}

✅ Time Complexity

  • Sorting: O(n log n)
  • Leveling: O(n) (each index touched once)
  • Total: O(n log n)optimal for this problem

🧪 Example

Input:

playerNames = {"Jonathan", "Tom", "Christopher", "Al", "Dan"};
truncateAmount = 10;

After truncation:

  • Long names like "Jonathan" and "Christopher" will lose more characters than "Tom", "Dan", and "Al".
  • But none of the shorter names will be longer than truncated longer names.

Let’s compare the performance characteristics of your original implementation and the optimized one I provided, to show exactly why the latter is more efficient.


🧠 1. Your Original Implementation

🔁 Key Pattern:

while (truncateAmount > 0)
{
    for (size_t i = 0; i < lengthIndex.size(); ++i)
    {
        ...
        if (condition) {
            i = -1; // restart loop
            continue;
        }
    }
}

❌ Performance Implications:

  • The outer while loop may execute multiple full scans of the list (O(n) per scan).

  • The inner for loop is restarted (i = -1) every time currentTargetLength changes. This is a valid but costly retry strategy.

  • In the worst case, the target length changes frequently (e.g., strings with many different lengths), resulting in O(n²) time complexity.

🔄 Example Worst-Case Behavior:

If you have n strings with distinct lengths, it will:

  • Attempt to lower the longest string to the second longest.

  • Then restart and lower both to the third longest.

  • And so on…

That’s O(n²) operations in the worst case.


⚡ 2. Optimized Implementation

✅ Key Strategy:

  • Uses a leveling approach: rather than decrementing string lengths one-by-one, it processes all strings of the same length together.

  • Each “level” flattens a group of strings down to the next shorter length (or as far as allowed).

  • The algorithm processes each string exactly once in a sorted list.

📈 Performance Characteristics:

  • Sort once: O(n log n)

  • Single pass leveling: O(n)

  • Total time complexity: O(n log n)

  • No backtracking, no restarting loops, no wasted work.


💡 Analogy

Imagine you have towers of blocks (string lengths) and you want to remove N blocks total.

Your Approach:

"Try removing blocks from the tallest towers one by one. If we can't balance, restart the process with a new target height."

Problem: You often keep re-checking previous towers after recalculating your target — redundant work.

Optimized Approach:

"Sort the towers by height, and flatten them level by level, always removing as many blocks as possible at once."

Benefit: You never redo work; every block removal counts.


🧪 Example Timing Difference (Theoretical)

Strings Count Your Impl (Worst Case) Optimized Impl
1000 O(1,000,000) steps O(10,000) steps
10,000 O(100,000,000) steps O(140,000) steps

Int currentTargetLength = lengthIndex[0].Length - 1;
while (truncateAmount > 0)
{
for (size_t i = 0; i < lengthIndex.size(); ++i)
{
if (lengthIndex[i].Length > currentTargetLength)
{
Int truncateCurrent = std::min<Int>(truncateAmount, lengthIndex[i].Length - currentTargetLength);
lengthIndex[i].Length -= truncateCurrent;
truncateAmount -= truncateCurrent;
}

if (truncateAmount == 0)
{
break;
}

if (lengthIndex[i].Length < currentTargetLength)
{
// set target length to either the length of position i, or the remaining amount to truncate divided across all the previous entries, rounding upwards.
currentTargetLength = std::max(static_cast<UnsignedInt>(lengthIndex[i].Length), lengthIndex[0].Length - ((truncateAmount + i) / (i + 1)));
// start over again with new target length
i = -1;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe set i = 0 here and ++i at the end.

continue;
}
}

// All entries are of equal length, or we're finished. Figure out the length of all entries if truncated by the same amount, rounding upwards.
currentTargetLength = lengthIndex[0].Length - ((truncateAmount + lengthIndex.size() - 1) / lengthIndex.size());
}

// truncate each name to its new length
for (size_t ti = 0; ti < lengthIndex.size(); ++ti)
{
int charsToRemove = playerNames[lengthIndex[ti].Index].getLength() - lengthIndex[ti].Length;
if (charsToRemove > 0)
{
DEBUG_LOG(("TruncatePlayerNames - truncating '%s' by %d chars to ", playerNames[lengthIndex[ti].Index].str(), charsToRemove));
playerNames[lengthIndex[ti].Index].truncate(charsToRemove);
DEBUG_LOG(("'%s' (target length=%d).\n", playerNames[lengthIndex[ti].Index].str(), lengthIndex[ti].Length));
}
}

return true;
}

AsciiString GameInfoToAsciiString(const GameInfo *game, const AsciiStringVec& playerNames)
{
if (!game)
return AsciiString::TheEmptyString;
Expand All @@ -925,7 +1017,7 @@ AsciiString GameInfoToAsciiString( const GameInfo *game )
newMapName.concat(token);
mapName.nextToken(&token, "\\/");
}
DEBUG_LOG(("Map name is %s\n", mapName.str()));
DEBUG_LOG(("Map name is %s\n", newMapName.str()));
}

AsciiString optionsString;
Expand All @@ -943,23 +1035,13 @@ AsciiString GameInfoToAsciiString( const GameInfo *game )
AsciiString str;
if (slot && slot->isHuman())
{
AsciiString tmp; //all this data goes after name
tmp.format( ",%X,%d,%c%c,%d,%d,%d,%d,%d:",
slot->getIP(), slot->getPort(),
(slot->isAccepted()?'T':'F'),
(slot->hasMap()?'T':'F'),
str.format( "H%s,%X,%d,%c%c,%d,%d,%d,%d,%d:",
playerNames[i].str(), slot->getIP(),
slot->getPort(), (slot->isAccepted() ? 'T' : 'F'),
(slot->hasMap() ? 'T' : 'F'),
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() );
slot->getNATBehavior());
}
else if (slot && slot->isAI())
{
Expand Down Expand Up @@ -991,13 +1073,37 @@ 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!\n",
optionsString.getLength(), m_lanMaxOptionsLength));

return optionsString;
}

AsciiString GameInfoToAsciiString(const GameInfo* game)
{
if (!game)
{
return AsciiString::TheEmptyString;
}

AsciiStringVec playerNames = BuildPlayerNames(*game);
AsciiString infoString = GameInfoToAsciiString(game, playerNames);

// TheSuperHackers @bugfix Safely truncate the game info string by
// stripping characters off of player names if the overall length is too large.
if (TheLAN && (infoString.getLength() > m_lanMaxOptionsLength))
{
const UnsignedInt truncateAmount = infoString.getLength() - m_lanMaxOptionsLength;
if (!TruncatePlayerNames(playerNames, truncateAmount))
{
DEBUG_CRASH(("WARNING: options string is longer than expected! Length is %d, but max is %d. Attempted to truncate player names by %u characters, but was unsuccessful!\n",
infoString.getLength(), m_lanMaxOptionsLength, truncateAmount));
return AsciiString::TheEmptyString;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So originally it would have returned the long string if it was unable to (theoretically) truncate. What happens if we return an empty string or a string that is longer than the cap?

}

infoString = GameInfoToAsciiString(game, playerNames);
}

return infoString;
}

static Int grabHexInt(const char *s)
{
char tmp[5] = "0xff";
Expand Down
2 changes: 1 addition & 1 deletion GeneralsMD/Code/GameEngine/Source/GameNetwork/LANAPI.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ void LANAPI::handleRequestGameInfo( LANMessage *msg, UnsignedInt senderIP )

AsciiString gameOpts = GameInfoToAsciiString(m_currentGame);
strncpy(reply.GameInfo.options,gameOpts.str(),m_lanMaxOptionsLength);
reply.GameInfo.options[m_lanMaxOptionsLength] = 0;
wcsncpy(reply.GameInfo.gameName, m_currentGame->getName().str(), g_lanGameNameLength);
reply.GameInfo.gameName[g_lanGameNameLength] = 0;
reply.GameInfo.inProgress = m_currentGame->isGameInProgress();
Expand Down
Loading