[Mover] FMoverDataCollection has no copy constructor: input structs get shared between Network Prediction frames, so GetLastInputCmd() on interpolated sim proxies jumps between frames

Summary

FMoverDataCollection implements a deep-copying operator=, but no copy constructor. The implicit copy constructor copies DataArray’s TSharedPtrs, so the copy and the original share the same data structs.

This breaks bSyncInputsForSimProxy on interpolated simulated proxies. The inputs travel in the sync state inside FMoverInputContainerDataStruct, whose Clone() uses that copy constructor. As a result, the input structs of buffered Network Prediction frames share memory with each other and with the latest received state, which NetSerialize writes into in place. On other clients, GetLastInputCmd() then returns inputs from other frames, often newer ones: values jump ahead and step back.

Location, velocity and orientation are not affected (FMoverDefaultSyncState copies correctly). Only data nested in FMoverInputContainerDataStruct is.

Affects 5.8.4.

What Type of Bug are you experiencing?

Networking

Steps to Reproduce

  1. In Project Settings > Network Prediction, use Fixed ticking (I use 100 Hz) and set Simulated Proxy Network LOD to Interpolated (the default).
  2. Use a pawn with a CharacterMoverComponent (Network Prediction backend) that writes the controller’s rotation to FCharacterDefaultInputs::ControlRotation in its input producer.
  3. On the Mover component, enable “Sync Inputs For Sim Proxy” (bSyncInputsForSimProxy, under Advanced).
  4. On non-locally controlled pawns, log GetLastInputCmd().InputCollection.FindDataByType()->ControlRotation every frame, e.g. from an anim instance’s NativeUpdateAnimation or the pawn’s Tick. Log the same value on the server, and on the owning client from GetControlRotation().
  5. Play in Editor with a dedicated server and 2 clients. On client A, stand still and turn the camera smoothly in one direction.
  6. Compare the logged values for client A’s pawn on the server, client A and client B.

Expected Result

On client B (simulated proxy), ControlRotation follows the same path as on the server, delayed by the interpolation buffer. While client A turns in one direction, client B’s value only moves in that direction and never steps backward. Inputs read through GetLastInputCmd() on a simulated proxy match the frame currently being displayed.

Observed Result

On client B, ControlRotation zig-zags while client A turns smoothly in one direction.

In one capture (dedicated server + 2 clients, ~41 fps):

  • Direction reversals of ControlRotation.Yaw: owner 27, server 27, simulated proxy 279.
  • The proxy repeatedly shows a value that is exactly the server’s value from 5-7 frames later (about the newest frame received), then steps back to older values. Example: at server frame 1783 the proxy shows yaw 27.0483, which is the server’s value at frame 1790, then 19.25, 24.20, and 27.05 again at 1790.

Other input structs in the same container are affected in the same way.

Running “np.Interpolation.Disable 1” on the viewing client hides the problem, because that path uses the received state directly.

Affects Versions

5.8

Platform(s)

Windows

Additional Notes

Root cause (Engine/Plugins/Experimental/Mover/Source/Mover):

  1. Public/MoverTypes.h: FMoverDataCollection declares operator= (a deep copy, Private/MoverTypes.cpp) but no copy constructor. The implicit copy constructor copies TArray<TSharedPtr> DataArray, so both collections point to the same structs.
  2. Private/InputContainerStruct.cpp: FMoverInputContainerDataStruct::Clone() is new FMoverInputContainerDataStruct(*this), so the cloned container’s InputCollection shares its input structs with the original.
  3. Private/MoverTypes.cpp, FMoverDataCollection::operator=: when the two DataArrays differ in size (e.g. an empty frame buffer slot), it falls back to Clone() for each element, which creates that sharing. Later in-place copies (CopyScriptStruct) then write through the shared pointers.
  4. Private/MoverTypes.cpp, NetSerializeDataArray: when loading, an existing struct of the same type is reused and serialized into in place, so every new packet overwrites the shared input structs.

Suggested fix: a deep-copying copy constructor that delegates to operator=. DataArray is still empty at that point, so operator= takes its Clone() path for every element.

Public/MoverTypes.h:

	UE_API FMoverDataCollection();

	/** Copy constructor - deep copy, same as operator=. The implicit one would share the data structs between both collections. */
	UE_API FMoverDataCollection(const FMoverDataCollection& Other);

Private/MoverTypes.cpp:

FMoverDataCollection::FMoverDataCollection(const FMoverDataCollection& Other)
{
	// DataArray is still empty, so operator= clones every data struct rather than copying into existing ones
	*this = Other;
}

Since a copy constructor is now user-declared, no move constructor is generated implicitly, so moves fall back to this deep copy. It behaves the same, at the cost of a few extra allocations.

With this change applied on 5.8.4, the same capture gives identical direction reversal counts on the owner, server and proxy (43 / 43 / 43 for yaw, 54 / 54 / 54 for pitch), and the proxy no longer shows values from newer frames.

The root cause analysis and the suggested patch were made with the help of an AI assistant (Claude) and verified in my project.