Download

[BUG] Hard-to-find error with NetworkPredictionInterface / CharacterMovement

This took me ages to track down but I finally found it… my game uses ‘floaty’ vehicle physics, and my custom movement component is written in exactly the same way as the Character Movement Component - and uses the INetworkPredictionInterface. I was having an issue where clients would be catapulted into the air when they initially spawn in - and the altitude would depend on how long the server instance of the game had been running.

I have finally tracked the issue down the way that APlayerController interacts with the interface - and while this does also affect the engines ‘Character Movement Component’, the issue is less prevalent there because of the way CMC’s movement is calculated. Note that this issue ONLY affects clients, it does not affect the server. The fix should be pretty simple, it just means changes to this section of engine code:

PlayerController.cpp



	if ((GetRemoteRole() == ROLE_AutonomousProxy) && !IsNetMode(NM_Client) && !IsLocalPlayerController())
	{
		// force physics update for clients that aren't sending movement updates in a timely manner 
		// this prevents cheats associated with artificially induced ping spikes
		// skip updates if pawn lost autonomous proxy role (e.g. TurnOff() call)
		if (GetPawn() && !GetPawn()->IsPendingKill() && GetPawn()->GetRemoteRole() == ROLE_AutonomousProxy && GetPawn()->bReplicateMovement)
		{
			INetworkPredictionInterface* NetworkPredictionInterface = Cast<INetworkPredictionInterface>(GetPawn()->GetMovementComponent());
			if (NetworkPredictionInterface)
			{
				FNetworkPredictionData_Server* ServerData = NetworkPredictionInterface->GetPredictionData_Server();
				const float TimeSinceUpdate = ServerData ? GetWorld()->GetTimeSeconds() - ServerData->ServerTimeStamp : 0.f;
				const float PawnTimeSinceUpdate = TimeSinceUpdate * GetPawn()->CustomTimeDilation;
				if (PawnTimeSinceUpdate > FMath::Max<float>(DeltaSeconds+0.06f,AGameNetworkManager::StaticClass()->GetDefaultObject<AGameNetworkManager>()->MAXCLIENTUPDATEINTERVAL * GetPawn()->GetActorTimeDilation()))
				{
					//UE_LOG(LogPlayerController, Warning, TEXT("ForcedMovementTick. PawnTimeSinceUpdate: %f, DeltaSeconds: %f, DeltaSeconds+: %f"), PawnTimeSinceUpdate, DeltaSeconds, DeltaSeconds+0.06f);
					const USkeletalMeshComponent* PawnMesh = GetPawn()->FindComponentByClass<USkeletalMeshComponent>();
					if (!PawnMesh || !PawnMesh->IsSimulatingPhysics())
					{
						NetworkPredictionInterface->ForcePositionUpdate(PawnTimeSinceUpdate);
						ServerData->ServerTimeStamp = GetWorld()->GetTimeSeconds();
					}					
				}
			}
		}


What this block is doing is getting the servers prediction data for a client-controlled pawn - and trying to work out when the client last sent an update to the server. If the time between updates is too long, then the server force-calculates the position locally by calling ‘ForcePositionUpdate’ on the object.

The problem is - it uses ‘CurrentWorldTime - LastRecievedTime’ essentially. The problem is that when ‘FNetworkPredictionData_Server’ is created by a Pawns movement component, ‘ServerTimeStamp’ is initialized to 0 instead of the current world time. This means that when a player joins a match late, or when they are respawned with a new pawn during gameplay - ForcePositionUpdate gets called with a HUGE value for DeltaSeconds.

In my game, this was causing clients to be catapulted into the air when the respawned - and the altitude depended on how long it had been since the world was created. A simple fix would be to make sure that when ‘FNetworkPredictionData_Server’ is created, it is initialized with the current world time in seconds. This is how the prediction data is created in CharacterMovementComponent:

CharacterMovementComponent.cpp



	if (!ServerPredictionData)
	{
		UCharacterMovementComponent* MutableThis = const_cast<UCharacterMovementComponent*>(this);
		MutableThis->ServerPredictionData = new FNetworkPredictionData_Server_Character(*this);
	}

	return ServerPredictionData;


But the constructor for FNetworkPredictionData_Server is this:
(NetworkPredictionInterface.h)



	FNetworkPredictionData_Server()
	: ServerTimeStamp(0.f)
	{}


In both CMC and my custom Movement Component, ForcePositionUpdate just runs the movement code with whatever delta time you pass in, which in some cases can be an insanely huge number. This might in some cases, cause client characters to also move with extreme values until they recieve an update from a client.

CharacterMovementComponent.cpp - Line 7082



	PerformMovement(DeltaTime);


So, I would request that this be fixed so that when created - FNetworkPredictionData_Server initializes ServerTimeStamp to whatever the current server time is. I’ve fixed this in my own game, but I can’t change CharacterMovementComponent. Could submit a PR if required.

Here’s how I fixed my custom network prediction data btw. When constructed, I just initialize ServerTimeStamp.

Character can probably use the same thing!



FNetworkPredictionData_Server_Vehicle::FNetworkPredictionData_Server_Vehicle(const UBZGame_VehicleMovement& ServerMovement)
	: PendingAdjustment()
	, CurrentClientTimeStamp(0.f)
	, LastUpdateTime(0.f)
	, ServerTimeStampLastServerMove(0.f)
	, MaxMoveDeltaTime(0.125f)
	, bForceClientUpdate(false)
	, LifetimeRawTimeDiscrepancy(0.f)
	, TimeDiscrepancy(0.f)
	, bResolvingTimeDiscrepancy(false)
	, TimeDiscrepancyResolutionMoveDeltaOverride(0.f)
	, TimeDiscrepancyAccumulatedClientDeltasSinceLastServerTick(0.f)
	, WorldCreationTime(0.f)
{
	AGameNetworkManager* GameNetworkManager = AGameNetworkManager::StaticClass()->GetDefaultObject<AGameNetworkManager>();
	if (GameNetworkManager)
	{
		MaxMoveDeltaTime = GameNetworkManager->MaxMoveDeltaTime;
		if (GameNetworkManager->MaxMoveDeltaTime > GameNetworkManager->MAXCLIENTUPDATEINTERVAL)
		{
			UE_LOG(LogNetVehicleMovement, Warning, TEXT("GameNetworkManager::MaxMoveDeltaTime (%f) is greater than GameNetworkManager::MAXCLIENTUPDATEINTERVAL (%f)! Server will interfere with move deltas that large!"), GameNetworkManager->MaxMoveDeltaTime, GameNetworkManager->MAXCLIENTUPDATEINTERVAL);
		}
	}

	const UWorld* MyWorld = ServerMovement.GetWorld();
	if (MyWorld)
	{
		WorldCreationTime = MyWorld->GetTimeSeconds();
		ServerTimeStamp = MyWorld->GetTimeSeconds();		// Added: This prevents clients being jettisoned into the air when initially created! See APlayerController:TickActor()
	}
}


Thanks for the heads up!

Do you think it would also work to check for a zero ServerTimeStamp in the PlayerController code, and avoid the force update in that case?

Yeah that probably is also a good idea! The fix I mentioned in the second post worked perfectly for me, everything seems to work as intended now when late-joining or respawning etc!

EDIT: This is my version of the ForceUpdatePosition() function now. I added an extra safety check and clamped the incoming delta to the max value I allow for simulation delta, which in my case just prevents the server from accidentally launching clients into the air during a lagspike or somesuch. This is probably a bit jarring for a client and they might rubber band more during a lagspike, but that’s probably better than being catapulted in my case :stuck_out_tongue:



void UBZGame_VehicleMovement::ForcePositionUpdate(float DeltaTime)
{
	if (!HasValidData())
	{
		return;
	}

	ASSERTV(VehicleOwner->Role == ROLE_Authority, TEXT("ForcePositionUpdate: No Authority"));
	ASSERTV(VehicleOwner->GetRemoteRole() == ROLE_AutonomousProxy, TEXT("ForcePositionUpdate: Incorrect Remote Role"));

	UE_LOG(LogVehicleMovement, Warning, TEXT("Server called ForcePositionUpdate with %f DeltaTime. Max = %f, delta may be clamped."), DeltaTime, MaxVehicleSimuationTimeStep);

	// Clamp the maximum time step so we don't get catapulted into the air if the client hasn't sent an update for a long time...
	const float SimulationDeltaTime = FMath::Min(DeltaTime, MaxVehicleSimuationTimeStep);
	PerformMovement(SimulationDeltaTime, ReplicatedControlInput);
}


I see this has been fixed in 4.16 :slight_smile: Thanks Zak!