Custom versions from packets missing in replays

I spotted a potential issue with replays where the custom versions that are used while serializing packets aren’t properly exported.

TL;DR: all the custom versions used by packets are never stored in the replay so it fallbacks to the current version, breaking backwards compatibility.

I made a parser for fortnite replays some time ago which also included the ability to parse FActorInstanceHandle in gameplay cues. That class recently got an update which now uses the FFortniteValkyrieBranchObjectVersion custom version. So I added the check and looked at the versions I got from the meta and header but as it turns out: the custom version doesn’t exist.

From what I’ve seen in the UE code it looks like that the custom versions in the header and meta are written when the replay recording is started and I assume it only exports versions that were used at that point. That means when a gameplay cue that contains a FHitResult is replicated to the replay it cant add the custom version to the header or meta so its never stored.

To be fair I’ve only tried this with fortnite and fortnite doesn’t focus on replay backwards compatibility and I don’t have access to fortnites source code to see if something is off there but I didn’t see anything in the EU source code that would allow this issue to be fixed and neither could I find any documentation that mentioned custom versions in combination with replays. And this would be a huge issue if pretty much all the structs that use custom versions in replicated properties are broken for games that actually have backwards compatibility.

A potential solution for this could be every time a chunk is exported that contains a new custom version just export an event chunk that contains all new custom versions at that point. This would allow all custom versions used in the replay to be exported while not having to change the meta and header.

The versions I used as reference are
Fortnite: ++Fortnite+Release-28.10-CL-30835064
UE Source: ue5-main (latest commit: a4f8b85727eeb01bbaaca8b524044d7566ea8a0a)

I have since had time to setup a proper test environment and make sure this issue exists.

First i created a custom version for my test struct

// TestStruct.h
struct TESTGAME_API FTestStructCustomVersion
{
	enum Type
	{
		// Before any version changes were made in the plugin
		BeforeCustomVersionWasAdded = 0,

		// -----<new versions can be added above this line>-------------------------------------------------
		VersionPlusOne,
		LatestVersion = VersionPlusOne - 1
	};

	// The GUID for this custom version number
	const static FGuid GUID;

private:
	FTestStructCustomVersion() {}
};

// TestStruct.cpp

const FGuid FTestStructCustomVersion::GUID(0x2EB1FDBD, 0x01AA4D10, 0x8136B38F, 0x3392A5DA);

// Register the custom version with core
FCustomVersionRegistration GRegisterTestStructCustomVersion(FTestStructCustomVersion::GUID, FTestStructCustomVersion::LatestVersion, TEXT("TestStructVer"));

then i created the struct itself. Its just a simple struct containing one int to make sure something is serialized.

// TestStruct.h

USTRUCT(BlueprintType)
struct TESTGAME_API FTestStruct
{
	GENERATED_BODY()
	
	UPROPERTY()
	int Test;

	bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess);
};

template <>
struct TStructOpsTypeTraits<FTestStruct> : public TStructOpsTypeTraitsBase2<FTestStruct>
{
	enum
	{
		WithNetSerializer = true,
	};
};


// TestStruct.cpp

bool FTestStruct::NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess)
{
	Ar.UsingCustomVersion(FTestStructCustomVersion::GUID);

	const int32 CustomTestVersion = Ar.CustomVer(FTestStructCustomVersion::GUID);

	Ar << Test;

	return true;	
}

Then i used that struct in an object. For simplicity i just put it in the player pawn

UPROPERTY(EditAnywhere, replicated)
FTestStruct TheTestStruct;

And of course made sure its properly replicated

void ACustomPlayerPawn::GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
	
	DOREPLIFETIME(ACustomPlayerPawn, TheTestStruct);
}

And finally i set the property to a value

void ACustomPlayerPawn::BeginPlay()
{
	Super::BeginPlay();

	FTestStruct NewStruct = FTestStruct();

	NewStruct.Test = 15;

	TheTestStruct = NewStruct;
}

Then i just went into the editor, pressed play and typed the following commands to record a replay.

demorec test
demostop

After i added a new version to my custom version

enum Type
	{
		// Before any version changes were made in the plugin
		BeforeCustomVersionWasAdded = 0,
		
		SomeTestVersion = 0,

		// -----<new versions can be added above this line>-------------------------------------------------
		VersionPlusOne,
		LatestVersion = VersionPlusOne - 1
	};

and added a check in the serializer to check for the version.

bool FTestStruct::NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess)
{
	Ar.UsingCustomVersion(FTestStructCustomVersion::GUID);

	const int32 CustomTestVersion = Ar.CustomVer(FTestStructCustomVersion::GUID);

	Ar << Test;

	if (CustomTestVersion >= FTestStructCustomVersion::SomeTestVersion)
	{
		UE_LOG(LogTemp, Error, TEXT("This should not log"));
	}

	return true;	
}

now everything left to do is go into the editor and play the replay previously recorded with demoplay test and check if This should not log is being logged. And as it turns out it actually is

image

this proofs that this issue is in fact present and backwards compatibility for replays isnt properly working because structs will always be serialized as if its the newest version even if its recorded with an older version.

A potential solution would be to just export the custom versions in FReplayHelper::WriteDemoFrame everytime one is used. Not sure what i was thinking when i suggested using events for this in the original post. this would definitely be a better solution.