Complex question but how to handle a native C++ class pointer in a USTRUCT?

Hey! I’m working on an expression based condition system, and I ran into the good old serialization / instance issue related to Unreal’s class default (CDO) system.

Basically I want to have something similar to a TObjectPtr that is nullptr on the CDO, and when the user edits instances of the actor in the editor, only that instance changes without ever affecting the CDO. The issue is, I don’t know where to initialize my pointer (new expression on heap) and also how to handle the “reset to default” because that would make it nullptr instead of whatever default I want.

Here’s my setup.

// Base class for expression to inherit from
// Each expression can contain child expressions.
// They all have Serialize methods that converts them to/from string.
class ACTORIO_API FActorIOExpressionBase : public TSharedFromThis<FActorIOExpressionBase>
{
	// Stuff such as Serialize, Evaluate, ToString, FromString, etc..
}

// The struct that stores the expressions in Unreal ecosystem.
// It has detail customization in the editor for editing expressions.
USTRUCT()
struct ACTORIO_API FActorIOScriptCondition
{
	GENERATED_BODY()

public:
	
	FActorIOScriptCondition();

	// This calls Expr = MakeShared<FActorIOGroupExpression>();
	void Initialize();

	bool operator==(const FActorIOScriptCondition& Other) const;
	bool operator!=(const FActorIOScriptCondition& Other) const { return !(*this == Other); }

	bool Serialize(FArchive& Ar);
	bool ExportTextItem(FString& ValueStr, FActorIOScriptCondition const& DefaultValue, UObject* Parent, int32 PortFlags, UObject* ExportRootScope) const;
	bool ImportTextItem(const TCHAR*& Buffer, int32 PortFlags, UObject* Parent, FOutputDevice* ErrorText);

	bool Evaluate(UObject* Executor);

	FActorIOGroupExpression* GetExpression() const { return Expr.Get(); }

protected:

	// The expression owned by this struct.
	// Should be TUniquePtr but had weird PoD type compile issues.
	TSharedPtr<FActorIOGroupExpression> Expr;
}

template<>
struct TStructOpsTypeTraits<FActorIOScriptCondition> : public TStructOpsTypeTraitsBase2<FActorIOScriptCondition>
{
	enum
	{
		WithSerializer = true,
		WithExportTextItem = true,
		WithImportTextItem = true,
		WithIdenticalViaEquality = true
	};
};
// An actor that wants to store and use condition expressions.
UCLASS()
class ACTORIO_API ALogicCondition : public ALogicActorBase
{
	GENERATED_BODY()

public:

	// Struct instance. This holds the expressions.
	// Question is where to initialize this?
	UPROPERTY(EditInstanceOnly)
	FActorIOScriptCondition Condition;
}

So far I tried initializing during serialization when loading, but serialization runs a bunch of times so it doesn’t seem like a good fit. Also tried during actor PostLoad and PostActorCreated but every single time when I don’t save my changes and just start the map, the “reset to default” button on the property simply sets the expressions back to whatever the last edited state was. This persists to different maps as well, so I must be modifying the CDO which shouldn’t happen.

Obviously this is a complex question / issue, but unfortunately apart from surface level stuff there aren’t any resources online about this topic and I feel like this isn’t something I can deduct from looking at the engine code.

If somehow you can solve this or give me directions, I’ll buy you a beer!

I have to say I don’t quite understand your issue. It sounds like you’re saying you want the user to be able to edit a FActorIOScriptCondition in the details panel? But I don’t know what you mean by the question of “when to allocate it”.

This sort of structure is inherently uneditable unless you make a details customization, so I’d expect the details customization to take care of allocating your pointer as needed. I believe a details customization can also override “Reset to Default” behaviour, if you need to do that.

Of course, deserialization (the Serializefunction that takes an FArchive) would also need to allocate it when reading – you can check in the Serialize function whether you’re reading or writing, and allocate only when reading.

Yes, so I already have serialization and the detail customization done. The issue is that say I call FActorIOScriptCondition::Initialize on the constructor of the actor. It runs on the CDO only, and since instances of the actor are created from the CDO the instances will all point to the same “default” expression. When the user edits the expression in the detail panel, he is modifying the CDO. Stuff like TObjectPtr have some transient / duplication logic when loading.

The Serialize function also doesn’t seem like a good fit because like I said, it is not only used for saving and loading. It is also used for transactions and to figure out if “any changes were made”. From debugging I saw it runs multiple times.

I know I can override the “reset to default” behaviour, but that’s just a bandaid fix for one specific case. There is a fundamental issue here with my approach I think. Even if I fix that specific issue there I don’t know what other cases could go wrong.

So the problem is that instances of the class point to the same struct, right? Then it sounds like you need WithCopy = true in your struct traits, and implement a copy constructor / assignment that copies the value pointed to rather than just the pointer. Then each actor has its own pointer.

Regardless, Serialize is definitely needed if you want this value to be saved in the asset file.

I’ll look into WithCopy thank you!

I’m not sure why you’re doing the shared pointer stuff. That’s fundamentally why it’s getting shared across instances.

I would suggest doing this whole thing with Instanced Structs because 1) it’ll do more exactly what you want and 2) you don’t have to do nearly as much custom work (though you still can).

What this would look like (some code/markup omitted for clarity only):

USTRUCT( )
struct FActorIOExpressionBase {  }

USTRUCT( )
struct FActorIOScriptCondition
{
     GENERATED_BODY( )
public:
     UPROPERTY( )
     TInstancedStruct< FActorIOExpressionBase > Expr;
}

// On Actors
UPROPERTY( )
FActorIOScriptCondition Condition;

By doing this, everything is happening through “regular” Unreal reflection and properties. The CDO will have an empty condition (unless you configure it in the constructor) and any per-instance configuration will be limited to the instance.

New expression types can be derived from the Base and will show up in the drop-down for the instanced struct property. You won’t be able to make types from blueprint, but you can’t do that with your current setup either, so no great loss there.

You also won’t need to manually deal with serialization. And you may be able to drop your details customization, but again you don’t have to.