CharacterMovementComponent: the "received this frame" test in ServerMoveHandleClientError compares a float with a double, so later moves of a frame skip the position check

Summary

In UCharacterMovementComponent::ServerMoveHandleClientError, the test meant to let more recent moves received in the same server frame still be checked compares FNetworkPredictionData_Server_Character::LastUpdateTime (a float) with UWorld::TimeSeconds (a double). The float is almost never equal to the double, even in the same frame. So after a correction is decided in a server frame, every later move the server processes in that frame returns early through WithinUpdateDelayBounds and is never position-checked.

What Type of Bug are you experiencing?

Networking

Steps to Reproduce

  1. Open Engine/Source/Runtime/Engine/Private/Components/CharacterMovementComponent.cpp at UCharacterMovementComponent::ServerMoveHandleClientError and find the “received this frame” test (line 10204 in 5.8.3): if( (ServerData->LastUpdateTime != GetWorld()->TimeSeconds))
  2. Note the types: FNetworkPredictionData_Server_Character::LastUpdateTime is a float (CharacterMovementComponent.h:3295), and UWorld::TimeSeconds is a double (World.h:2010). LastUpdateTime is set from GetWorld()->TimeSeconds when a correction is decided (about line 10460).
  3. Run a dedicated server with a networked Character, and have a client whose moves disagree with the server while several of its moves are processed in the same server frame (for example, a client sending moves faster than the server ticks).
  4. Add a log or breakpoint in ServerExceedsAllowablePositionError, and log the time since LastUpdateTime for each move that reaches WithinUpdateDelayBounds.

Expected Result

As the comment above the test says, more recent moves received in the same frame as a correction decision are still checked: the test sees the same frame, skips the early return, and ServerExceedsAllowablePositionError runs for those moves.

Observed Result

The float is almost never equal to the double, so the same-frame branch effectively never applies. Every later move processed in that frame goes into AGameNetworkManager::WithinUpdateDelayBounds, where the time since the update is about 0 s, under CLIENTADJUSTUPDATECOST / CurrentNetSpeed, so the function returns before the position check. Those moves are never position-checked, and the pending adjustment keeps naming the frame’s first disagreeing move.

How we saw it: a server-side safeguard of ours runs inside ServerExceedsAllowablePositionError. It fired only when the move it needed was the first in its server frame: in our logs, once in 514 correction holds on one run, never in 676 holds on later runs. Logging the time since the engine’s last adjustment decision showed about 0 ms for the skipped moves.

Affects Versions

5.8

Platform(s)

Linux
Windows

Additional Notes

Engine: UE 5.8.3 (source build); the same code appears in earlier 5.x versions. We saw it with a Linux dedicated server and Windows clients; the code itself is platform-independent.

The three lines that show the comparison:

float LastUpdateTime;                                          // CharacterMovementComponent.h:3295 (FNetworkPredictionData_Server_Character)
double TimeSeconds;                                            // World.h:2010 (UWorld)
if( (ServerData->LastUpdateTime != GetWorld()->TimeSeconds))   // CharacterMovementComponent.cpp:10204

A fix: compare like with like, e.g. if (ServerData->LastUpdateTime != static_cast<float>(GetWorld()->TimeSeconds)). Or store LastUpdateTime as a double (it is serialized and used in WithinUpdateDelayBounds, which also takes a float). We work around it in our game’s override of ServerMoveHandleClientError by tracking the frame of the last decision ourselves.