Arrays of structs containing instanced objects do not serialise correctly

When modifying the default values of a struct contained in an array UPROPERTY via the blueprint editor the values will not be updated for actors placed in unloaded levels, if the struct being modified has an Instanced UObject with a non-None value, even though the affected properties on these actor instances have not been otherwise modified.

[Attachment Removed]

Steps to Reproduce

  1. Create a BP class which inherits from the AInstancedPropertyTest
  2. Add an element to “Structs”
  3. Assign a value to the Asset field
  4. Select Instanced Object for the value of InstancedValue
  5. Place an instance of this BP class in a level and save
  6. Unload the level (i.e. load a different level)
  7. Change the value of “Asset” in the BP’s defaults + compile/save
  8. Reload the level with the instance in
  9. Observe it has not updated its values and requires resetting to default to update
  10. Repeat this with InstancedValue left as None and observe the Asset updating correctly in the placed actor
#pragma once

#include "GameFramework/Character.h"
#include "InstancedPropertyTest.generated.h"


UCLASS(DefaultToInstanced, EditInlineNew, BlueprintType, Blueprintable)
class QAGAME_API UInstancedObject : public UObject
{
  GENERATED_BODY()

public:

  UPROPERTY(EditAnywhere, BlueprintReadWrite)
  bool bExampleValue = false;
};


USTRUCT(BlueprintType)
struct FInstancedPropertyTestStruct
{
  GENERATED_BODY()

  UPROPERTY(EditAnywhere, BlueprintReadWrite)
  TSoftObjectPtr<class UObject> Asset;

  UPROPERTY(EditAnywhere, BlueprintReadWrite, Instanced, meta = (EditInline = true))
  class UInstancedObject* InstancedValue;
};


UCLASS()
class AInstancedPropertyTest : public AActor
{
  GENERATED_BODY()
public:

  UPROPERTY(EditAnywhere, BlueprintReadOnly)
  TArray<FInstancedPropertyTestStruct> Structs;
};

[Attachment Removed]

Hello, thank you for taking the time to provide that repro code. This bug is known and unfortunately a limitation that we won’t fix.

UE-96195 explains the bug in more detail. Fixing it would require fundamentally reworking container serialization rules and it not something we’re going to attempt during UE5’s life cycle.

[Attachment Removed]

Hey again. I apologize for the very long wait - getting back to cases now that I’ve returned from travels.

You’re right about this being a different bug: failure to inherit with zero changes to the array. Apologies for lumping them together. This is an interesting one, since a fix for this is more within reach than changing array serialization.

I’ve confirmed that I can repro the problem at will. Rather than arrays, the instanced subobjects are the main factor here. Instanced subobjects are unique constructed per subclass CDO (default subobject) and per instance in the map. This means that shallow comparison of parent vs. child or instance will always result in detecting a difference. I’ve also done some testing and see that:

  • For loaded objects, propagating parent value changes to instances works because it uses deep comparison (PPF_DeepCompareInstances)
  • At save-time, delta-serialization is done using shallow comparison for non-DSO instanced objects. This includes custom instanced properties.

You can detect the latter by putting a breakpoint on FObjectProperty::Identical. When it triggers on save, it won’t go the deep comparison route.

I drafted a little workaround that forces deep comparison for UPROPERTY(Instanced). Adopt this locally if you wish. As for fixing this in engine, it needs some more discussion internally - which I’ll start. This snippet is a modification to FObjectProperty::Identical:

	// If a deep comparison is required, resolve the object handles and run the deep comparison logic
	// If a deep comparison is not required, avoid resolving the object handles because resolving declares
	// a cook dependency.
	if ((PortFlags & (PPF_DeepCompareInstances | PPF_DeepComparison)) != 0)
	{
		bool bPerformDeepComparison = true;
		uint32 DeepComparePortFlags = PortFlags;
		if ((PortFlags & PPF_DeepCompareDSOsOnly) != 0)
		{
			UClass* ClassA = ObjectA.GetClass();
			UObject* DSO = ClassA ? ClassA->GetDefaultSubobjectByName(ObjectA.GetFName()) : nullptr;
			bPerformDeepComparison = DSO != nullptr;
 
			// START ENGINE MOD: Deep compare instanced subobject references
			if (!bPerformDeepComparison && HasAnyPropertyFlags(CPF_PersistentInstance))
			{
				bPerformDeepComparison = true;
				DeepComparePortFlags &= ~PPF_DeepCompareDSOsOnly;
			}
			// END ENGINE MOD: Deep compare instanced subobject references
		}
		if (bPerformDeepComparison && ObjectA.GetClass() == ObjectB.GetClass() && ObjectA.GetFName() == ObjectB.GetFName())
		{
			return FObjectPropertyBase::StaticIdentical(ObjectA.Get(), ObjectB.Get(), DeepComparePortFlags);
		}

As for why I’m using CPF_PersistentInstance, it’s to specifically target UPROPERTY(Instance) instead of any UPROPERTY of a DefaultToInstanced type. Since so far this is just a workaround, I wanted to be more targeted. More on those property flags on [this [Content removed] started by your colleague. Hope this helps!

[Attachment Removed]

Adding some critical info from a colleague here. Cooked builds currently depend on the subobject always being instantiated for actor instances, because cooked builds won’t instantiate a subobject with inherited defaults for you at runtime which causes the subobject ptr to point to the archetype (blueprint CDO’s subobject).

That last behavior may be fine if your subobject is intended as immutable. Otherwise, if you implement my workaround from above, you’ll need to make an exception during cooking to make sure that the subobject on actor instances gets serialized despite not being modified.

[Attachment Removed]

Hey, thank you for the reply.

I believe the bug you have linked is slightly different to the one I am describing.

In the linked bug the child actor has been modified, and thus gets fully serialised breaking the connection to the parent.

However in the case I describe the instance of the actor placed in the level has not been modified and yet still fails to reflect the values of the default class once they have been updated. (I would understand if it was just the instanced object which wasn’t updating, but the fact that its mere presence causes an unrelated field to break is the crux of the issue here)

[Attachment Removed]