LOG IN
Docs
By GitHub Copilot

Frame Command Packing And CRC Validation

How local game messages are wrapped into per-frame net commands, how frame counts gate lockstep execution, and where deterministic CRC checks actually happen.

This part of the multiplayer stack is easy to blur together because the code uses the word CRC in two different places. The lockstep path is really three separate mechanisms layered on top of each other:

  1. Local GameMessage objects are wrapped into NetCommandMsg objects tagged with a future execution frame.
  2. Each player later publishes one NETCOMMANDTYPE_FRAMEINFO packet saying how many synchronized commands belong to that frame.
  3. The game logic occasionally injects a MSG_LOGIC_CRC command into that same synchronized stream so every peer can compare deterministic sim state after executing the frame.

The important point is that the frame-count packets decide when a frame is runnable. The logic CRC only decides whether the peers stayed deterministic after running it.

How A Local Game Command Becomes A Future Frame Command

The handoff starts in Network::GetCommandsFromCommandList in Network.cpp. When a message on TheCommandList is considered transferable, the network layer does not execute it immediately. It calls m_conMgr->sendLocalGameMessage(msg, getExecutionFrame()) and removes the original GameMessage from the command list.

Network::getExecutionFrame computes that target frame as TheGameLogic->getFrame() + m_runAhead, while also keeping m_lastExecutionFrame monotonic:

Int logicFrame = TheGameLogic->getFrame() + m_runAhead;
if (logicFrame <= m_lastExecutionFrame)
    logicFrame = m_lastExecutionFrame + 1;  // never regress
m_lastExecutionFrame = logicFrame;
return logicFrame;

That is the same run-ahead window described in the separate lockstep article, but here the key detail is practical: every local command is stamped with the future frame where all peers are supposed to execute it.

ConnectionManager::sendLocalGameMessage in ConnectionManager.cpp then converts the GameMessage into a NetGameCommandMsg. It copies the message type and every argument, assigns the local player slot, stamps the execution frame, and generates a command ID when the command type requires one. From there it goes through sendLocalCommand, which does two things at once:

  • It inserts the command into the local player's FrameDataManager, so the sender also waits for the same frame gate as everyone else.
  • It forwards the same net command toward the packet router or directly to peers, depending on routing mode.

That means the sender is not special. Its own command joins the same per-frame buffers that remote commands use.

What Actually Gets Packed On The Wire

The wire format lives in NetPacket.cpp. NetPacket::FillBufferWithGameCommand writes a small fixed header first:

  • commandType
  • frame
  • relay
  • playerId
  • commandId

After that fixed header it writes the original game message payload: the GameMessage::Type, the number of argument-type groups, one (type, count) pair for each group, and then the raw argument bytes in order.

The receiver rebuilds that structure in NetPacket::ConstructNetCommandMsgFromRawData. The parser walks typed packet fields until it reaches Data, dispatches to readGameMessage or readFrameMessage depending on commandType, then reapplies the common metadata: execution frame, command ID, player ID, and relay mask. In other words, game commands and frame-info packets are not separate transport systems. They are siblings inside the same net-command packet family.

Connection::sendNetCommandMsg adds one more practical constraint: if a command does not fit in a single packet, it is split into wrapper packets with ConstructBigCommandPacketList before being queued for transport. Large commands still reassemble back into a normal NetCommandMsg before the lockstep layer sees them.

How The Frame Count Gates Lockstep

The actual lockstep barrier is not based on CRCs. It is based on command counts.

At the start of each new logic frame, Network::processCommand notices the frame transition and asks ConnectionManager::processFrameTick to publish finished frames up to the current execution horizon. processFrameTick creates a NetFrameCommandMsg, stamps the frame number, fills in the local command count for that frame, assigns an ID if needed, and sends it like any other synchronized net command.

When a remote peer receives that message, ConnectionManager::processFrameInfo stores the advertised count with m_frameData[playerID]->setFrameCommandCount(msg->getExecutionFrame(), msg->getCommandCount()).

Readiness is then evaluated by FrameData::allCommandsReady:

FrameDataReturnType FrameData::allCommandsReady(Bool debugSpewage) {
    if (m_frameCommandCount == m_commandCount) {
        return FRAMEDATA_READY;
    }
    if (m_commandCount > m_frameCommandCount) {
        // Log offending commands, reset the frame data.
        reset();
        return FRAMEDATA_RESEND;
    }
    return FRAMEDATA_NOTREADY;
}
  • FRAMEDATA_READY when m_frameCommandCount == m_commandCount.
  • FRAMEDATA_NOTREADY when fewer commands have arrived than promised.
  • FRAMEDATA_RESEND when more commands arrive than the published count.

The resend case matters. If m_commandCount > m_frameCommandCount, the code logs every buffered command with its frame/id, resets the frame data to empty, and returns FRAMEDATA_RESEND. The connection manager then requests a resend of the canonical frame-info. The frame-info packet is the authoritative declaration of how many synchronized commands belong to a frame — the command payloads are not enough by themselves.

At the top level, Network::AllCommandsReady(frame) simply delegates to the connection manager. Once that gate opens, Network::RelayCommandsToCommandList drains the NetCommandList for the frame in deterministic order and reconstructs normal GameMessage objects for NETCOMMANDTYPE_GAMECOMMAND entries.

Where Deterministic CRC Validation Fits

The sim CRC path lives above that packetization layer.

In GameLogic::update, the engine periodically computes m_CRC = getCRC(CRC_RECALC) whenever the current frame matches the configured CRC interval for multiplayer or replay. It then creates a GameMessage::MSG_LOGIC_CRC, appends the CRC value and a playback flag, and pushes that message into the normal synchronized message path.

That is why CRC validation behaves like lockstep traffic instead of an out-of-band diagnostic channel. MSG_LOGIC_CRC becomes a NetGameCommandMsg, gets assigned an execution frame, waits behind the same frame-count barrier, and is finally dispatched in GameLogicDispatch.cpp alongside other game messages.

GameLogicDispatch::logicMessageDispatcher caches each player's reported CRC in m_cachedCRCs when it sees MSG_LOGIC_CRC. If the local player processed the message, it enables m_shouldValidateCRCs. Later, after GameLogic::processCommandList finishes the frame, the code compares the cached CRCs from all connected players. If any value differs, it logs the mismatch and calls TheNetwork->setSawCRCMismatch().

So the checksum validation happens after synchronized execution, not before it. The CRC is evidence that peers remained deterministic. It is not the mechanism that tells the network layer when a frame is complete.

This Is Not The Same As Packet CRC

Transport.cpp also computes a CRC, but that one is only for packet integrity. Transport::send computes a CRC over the transport header and payload before encryption, stores it in TransportMessageHeader::crc, and Transport::isGeneralsPacket recomputes it on receive.

That transport CRC answers a much narrower question: did this UDP packet survive the network intact? It does not know anything about logic frames, command counts, or deterministic state. The gameplay desync checks are the MSG_LOGIC_CRC values generated by GameLogic, not the transport header CRC.

Quirks

  • The local sender is not special. It immediately inserts its own synchronized commands into its FrameDataManager, so even self-originated commands wait on the same frame-count contract as remote commands. Debugging a stall that only fires for the sender of a specific command means looking at local frame data, not network receive paths.
  • Frame readiness is intentionally count-based. Not "saw at least one packet for this frame." A frame is only executable when every player's declared command total matches the number of synchronized commands actually buffered. A flood of correctly-ordered commands with a missing FRAMEINFO still stalls.
  • Resend is a reset, not a retry. FRAMEDATA_RESEND clears the in-memory command buffer for that peer's frame and asks for retransmission of the canonical list. Any commands that were buffered locally and not yet advertised in a FRAMEINFO are discarded as part of the reset.
  • MSG_LOGIC_CRC rides the ordinary synchronized command stream. That is why replay playback can compare CRCs at the same logical instant as the original session — the CRC message is already sequenced alongside the inputs that produced it.
  • Packet integrity CRC and deterministic sim CRC are separate layers with separate failure modes. A bad transport CRC drops a packet. A bad logic CRC means the peers stayed in sync long enough to execute the frame but diverged in state. Confusing the two while debugging leads you to the wrong subsystem.
  • Big-command wrappers reassemble before lockstep sees them. Connection::sendNetCommandMsg splits commands that don't fit in a single packet via ConstructBigCommandPacketList. The receiver reassembles into one NetCommandMsg before ready-checks run. A unit-spawn MSG with a large argument payload looks identical to a tiny one from the frame data's perspective.