fix(network): correctly map PlayerIndex to SlotIndex in CRC validation#2857
fix(network): correctly map PlayerIndex to SlotIndex in CRC validation#2857fbraz3 wants to merge 1 commit into
Conversation
|
| Filename | Overview |
|---|---|
| Generals/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp | Core fix: replaces direct PlayerIndex pass to isPlayerConnected with getSlotIndex lookup first; logic is correct since Byte is signed char and -1 sentinel sign-extends cleanly to Int |
| Generals/Code/GameEngine/Source/Common/RTS/PlayerList.cpp | Adds resolveSlotIndices() with TheGameInfo null-guard, sets up PlayerIndex→SlotIndex mapping using "player{N}" naming convention; approach differs from onLogicCrc's display-name match but relies on the same underlying slot-naming invariant |
| Generals/Code/GameEngine/Include/Common/PlayerList.h | Adds three new public methods and Byte m_slotIndices[MAX_PLAYER_COUNT] member; declaration is clean and consistent with existing style |
| GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp | Mirror of the Generals fix for Zero Hour; identical change, same correctness assessment |
| GeneralsMD/Code/GameEngine/Source/Common/RTS/PlayerList.cpp | Mirror of the Generals PlayerList changes for Zero Hour; same resolveSlotIndices logic, same considerations apply |
| GeneralsMD/Code/GameEngine/Include/Common/PlayerList.h | Mirror header for Zero Hour; identical additions to the Generals header, no issues |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant N as Network
participant GLD as GameLogicDispatch
participant GL as GameLogic::processCommandList
participant PL as PlayerList
participant GI as TheGameInfo
Note over PL,GI: Game start – PlayerList::newGame()
PL->>GI: getSlot(i) for each slot
GI-->>PL: "GameSlot* (occupied/empty)"
PL->>PL: "findPlayerWithNameKey("player{i}")"
PL->>PL: setSlotIndex(playerIndex, slotIndex)
Note over N,GL: During game – CRC validation
N->>GLD: onLogicCrc(msg)
GLD->>N: "getPlayerName(i) == displayName?"
GLD->>N: isPlayerConnected(slotIndex)
GLD->>GL: store CRC in m_cachedCRCs[playerIndex]
GL->>GL: processCommandList (iterate m_cachedCRCs)
GL->>PL: getSlotIndex(playerIndex)
PL-->>GL: slotIndex (or -1 if unmapped)
alt "slotIndex >= 0"
GL->>N: isPlayerConnected(slotIndex)
alt disconnected
GL->>GL: continue (skip CRC comparison)
else connected
GL->>GL: compare CRC vs referenceCRC
alt mismatch
GL->>GL: "sawCRCMismatch = TRUE"
end
end
else "slotIndex == -1 (unmapped)"
GL->>GL: compare CRC vs referenceCRC (conservative)
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant N as Network
participant GLD as GameLogicDispatch
participant GL as GameLogic::processCommandList
participant PL as PlayerList
participant GI as TheGameInfo
Note over PL,GI: Game start – PlayerList::newGame()
PL->>GI: getSlot(i) for each slot
GI-->>PL: "GameSlot* (occupied/empty)"
PL->>PL: "findPlayerWithNameKey("player{i}")"
PL->>PL: setSlotIndex(playerIndex, slotIndex)
Note over N,GL: During game – CRC validation
N->>GLD: onLogicCrc(msg)
GLD->>N: "getPlayerName(i) == displayName?"
GLD->>N: isPlayerConnected(slotIndex)
GLD->>GL: store CRC in m_cachedCRCs[playerIndex]
GL->>GL: processCommandList (iterate m_cachedCRCs)
GL->>PL: getSlotIndex(playerIndex)
PL-->>GL: slotIndex (or -1 if unmapped)
alt "slotIndex >= 0"
GL->>N: isPlayerConnected(slotIndex)
alt disconnected
GL->>GL: continue (skip CRC comparison)
else connected
GL->>GL: compare CRC vs referenceCRC
alt mismatch
GL->>GL: "sawCRCMismatch = TRUE"
end
end
else "slotIndex == -1 (unmapped)"
GL->>GL: compare CRC vs referenceCRC (conservative)
end
Reviews (6): Last reviewed commit: "fix(network): Pre-compute player slot in..." | Re-trigger Greptile
| if (slotIndex >= 0 && !TheNetwork->isPlayerConnected(slotIndex)) | ||
| continue; |
There was a problem hiding this comment.
Silent disconnection guard bypass when slot lookup fails
When slotIndex remains -1 (either because getNthPlayer returns null, or because no network slot's getPlayerName() matches the player's display name), the condition slotIndex >= 0 && !isPlayerConnected(slotIndex) short-circuits to false, so the continue is never taken. That means a disconnected player whose name has already been cleared by the network layer (common on disconnect) will still have their stale cached CRC included in the comparison, potentially producing the same spurious mismatch that Caball009's original commit was trying to avoid.
The same pattern appears identically in GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp line 2688–2689.
If the intent is to treat "unresolvable slot" conservatively (always check), the current behaviour is defensible. But if getPlayerName() can return empty after disconnection, you may want to also continue when slotIndex == -1 — or at least add a comment explaining why proceeding is safe in that case.
Prompt To Fix With AI
This is a comment left during a code review.
Path: Generals/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp
Line: 2362-2363
Comment:
**Silent disconnection guard bypass when slot lookup fails**
When `slotIndex` remains `-1` (either because `getNthPlayer` returns null, or because no network slot's `getPlayerName()` matches the player's display name), the condition `slotIndex >= 0 && !isPlayerConnected(slotIndex)` short-circuits to `false`, so the `continue` is never taken. That means a disconnected player whose name has already been cleared by the network layer (common on disconnect) will still have their stale cached CRC included in the comparison, potentially producing the same spurious mismatch that Caball009's original commit was trying to avoid.
The same pattern appears identically in `GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp` line 2688–2689.
If the intent is to treat "unresolvable slot" conservatively (always check), the current behaviour is defensible. But if `getPlayerName()` can return empty after disconnection, you may want to also `continue` when `slotIndex == -1` — or at least add a comment explaining why proceeding is safe in that case.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
The new @bugfix comment only explains the index mapping fix, but doesn't address what happens when slotIndex == -1 — which is the edge case my review flagged. A reader looking at:
if (slotIndex >= 0 && !TheNetwork->isPlayerConnected(slotIndex))
continue;has no way to know whether falling through when slotIndex == -1 is intentional ("always validate CRC if we can't resolve the slot") or an oversight ("might miss the disconnected-player guard").
The comment should be extended to make the intent explicit. For example:
| if (slotIndex >= 0 && !TheNetwork->isPlayerConnected(slotIndex)) | |
| continue; | |
| // TheSuperHackers @bugfix Caball009 14/06/2026 Check if player is still connected, | |
| // to avoid spurious mismatches at low CRC intervals, e.g. every frame. | |
| // TheSuperHackers @bugfix felipebraz 05/07/2026 Map PlayerIndex to SlotIndex since isPlayerConnected expects a SlotIndex. | |
| // If slotIndex cannot be resolved (player not found or name cleared), fall through and validate the CRC anyway. |
Alternatively, if the intent is actually to skip unresolvable slots (matching the conservative spirit of Caball009's original fix), the guard should be:
if (slotIndex < 0 || !TheNetwork->isPlayerConnected(slotIndex))
continue;Which of those two behaviours is intended?
|
Good catch. It would be better if the conversion from player index to slot index was extracted to a separate function. It'd also be useful in other places in the code. An example is the recorder code, so I don't think it should rely on anything from the network. I think it can be done once, before the start of the match. Perhaps something like this://class PlayerList
//Byte m_slotIndices[MAX_PLAYER_COUNT];
//memset(m_slotIndices, -1, sizeof(m_slotIndices));
void PlayerList::setSlotIndex(PlayerIndex playerIndex, Byte slotIndex)
{
if (playerIndex >= 0 && playerIndex < ARRAY_SIZE(m_slotIndices))
{
m_slotIndices[playerIndex] = slotIndex;
}
}
Byte PlayerList::getSlotIndex(PlayerIndex playerIndex) const
{
if (playerIndex >= 0 && playerIndex < ARRAY_SIZE(m_slotIndices))
{
return m_slotIndices[playerIndex];
}
return -1;
}
void resolveSlotIndices()
{
AsciiString playerName;
for (int i = 0; i < MAX_SLOTS; ++i)
{
const GameSlot* slot = TheGameInfo->getSlot(i);
if (!slot || !slot->isOccupied())
continue;
playerName.format("player%d", i);
Player* player = ThePlayerList->findPlayerWithNameKey(TheNameKeyGenerator->nameToKey(playerName));
if (player)
{
ThePlayerList->setSlotIndex(player->getPlayerIndex(), i);
}
}
}The change to const int slotIndex = ThePlayerList->getSlotIndex(it->first);
if (slotIndex >= 0 && !TheNetwork->isPlayerConnected(slotIndex))
continue; |
|
thanks for the feedback. I've updated the code as suggested. |
f228557 to
06a8cea
Compare
06a8cea to
4554403
Compare
aaa158c to
0030e5f
Compare
|
@Caball009 build pipeline fixed, please review. |
0030e5f to
77b165b
Compare
|
Thanks @greptile-apps! Fixed the null dereference bug in |
|
The null guard is already visible in the diff and the thread is marked resolved — looks good. The There's still the open P2 comment about the Tip: You can customize Greptile's behavior for this repo with |
| m_slotIndices[playerIndex] = slotIndex; | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Style: nearby functions have a line comment above them
| } | ||
| } | ||
|
|
||
|
|
| { | ||
| m_playerCount = 1; | ||
| m_players[0]->init(nullptr); | ||
| memset(m_slotIndices, -1, sizeof(m_slotIndices)); |
There was a problem hiding this comment.
Better: std::fill
memset only works here because the indices are 1 byte each.
| */ | ||
| Player *findPlayerWithNameKey(NameKeyType key); | ||
|
|
||
| void setSlotIndex(Int playerIndex, Byte slotIndex); |
There was a problem hiding this comment.
This is not supposed to be public, right?
|
|
||
| void setSlotIndex(Int playerIndex, Byte slotIndex); | ||
| Byte getSlotIndex(Int playerIndex) const; | ||
| void resolveSlotIndices(); |
There was a problem hiding this comment.
This is not supposed to be public, right?
| if (slotIndex >= 0 && !TheNetwork->isPlayerConnected(slotIndex)) | ||
| continue; | ||
|
|
||
|
|
| Player *m_local; | ||
| Int m_playerCount; | ||
| Player *m_players[MAX_PLAYER_COUNT]; | ||
| Byte m_slotIndices[MAX_PLAYER_COUNT]; |
There was a problem hiding this comment.
Any reason why the PlayerList class owns the slot indices as opposed to the GameInfo class owning the player indices?
I am not sure right now which one is better, but my first instinct would have been to tie it to GameInfo class, because that is closer to the slots. Do we have any opinions on this?
There was a problem hiding this comment.
I think PlayerList is preferable because there are a couple of places where it's not clear to me if we could easily access GameInfo.
You can search the code base for player%d to find them; one example:
GeneralsGameCode/Core/GameEngine/Source/GameNetwork/NetCommandMsg.cpp
Lines 156 to 158 in 480a9a7
Description
A recent bugfix intended to prevent spurious mismatches from disconnected players has inadvertently disabled CRC mismatch detection for all active players. This allows games to completely desync (e.g., destroyed buildings on one client but not on the other) without the game ever raising the "Mismatch" error screen or halting the match.
This was discovered during a GeneralsX test running a LAN game between Mac and Linux. The main base on one side had been completely destroyed, while on the other side it still had about 30% life remaining, yet no mismatch error was triggered and the game continued to run smoothly in a completely desynchronized state.
Root Cause
The issue stems from the commit by
Caball009on14/06/2026inGameLogic::processCommandList(GameLogic.cpp):In the
m_cachedCRCsmap, the key (it->first) is the Player Index (e.g., 3, 4). However, theTheNetwork->isPlayerConnected()function expects a Network Slot Index (e.g., 0, 1).Because the
PlayerIndexdoes not match theSlotIndex, the function checks the connection status of invalid or empty network slots, returningFALSEfor all human players. Consequently, the loopcontinues for every CRC, skipping thereferenceCRC != crccheck entirely.Changes
To correctly determine if the player is connected, we must first map their
PlayerIndexback to theirSlotIndexby matching the network display name, similar to how it is handled when receiving the CRC inGameLogicDispatch::onLogicCrc.This fix correctly maps the
PlayerIndexto theSlotIndexbefore evaluatingisPlayerConnected(), restoring proper CRC mismatch detection for active players while retaining the intent of ignoring disconnected players.