Custom Serialized Structure Does Not Save/Load to map in editor

Hello Unreal Engine forum goers! I need some help from someone knowledgeable about Unreal Engine’s map saving and serialization systems. I really need fresh eyes on this problem, hopefully I’m just missing something simple that I can feel stupid about later.

I have a property (decorated with the UPROPERTY macro) whose type is a custom structure I defined (which is decorated with USTRUCT macro). This custom structure contains a member variable whose type is a custom templated type. I need this member variable to be saved along with the rest of the members of the custom structure when I modify it in a map in the editor. Unfortunately, because this member variable is a custom templated type, it cannot be an Unreal Engine property (i.e. it cannot be decorated with UPROPERTY), so I need to manually create all the typical property functionality (i.e. I need to customize how this custom structure is displayed in the details panel so that this member can be modified in editor, and I need to provide a Serialize function so that it can be saved/loaded). I thought I had done all of this, and most of the functionality seems to work, except for one key detail (as I mentioned earlier): when I save a modified asset in my map that has this custom structure as a property, the templated type I made custom serialization functionality for does not get saved with the rest of the asset (or it does gets saved and doesn’t get loaded, hard to tell).

So, now that I’ve outlined the problem, I will share a minimal example that I created to showcase my problem. Start with a blank C++ project. Then we’ll create our custom structure with a templated member variable:

#pragma once

#include "StructTest.generated.h"

template<typename T>
struct TTemplateType
{
    constexpr TTemplateType& operator=( const TTemplateType& rOther ) = default;

    T m_tTemplatedMember;

    friend FArchive& operator<< <>( FArchive& rArchive, TTemplateType<T>& rBitSet );
};

template<typename T>
FArchive& operator<<( FArchive& rArchive, TTemplateType<T>& rToArchive )
{
    rArchive << rToArchive.m_tTemplatedMember;
    bool bIsLoading = rArchive.IsLoading( );
    return rArchive;
}

USTRUCT( )
struct FStructTest
{
    GENERATED_BODY( )
public:
    FStructTest( ) = default;

    FStructTest( uint8 unValue )
    {
        m_oCustomSerializedMember = TTemplateType{ unValue };
    }

    bool Serialize( FArchive& Ar )
    {
        Ar << m_oCustomSerializedMember;
        return true;
    };

    TTemplateType<uint8> m_oCustomSerializedMember;
};

template<> struct TStructOpsTypeTraits<FStructTest> : public TStructOpsTypeTraitsBase2<FStructTest>
{
    enum
    {
        WithSerializer = true
    };
};

You’ll note the usage of TStructOpsTypeTraitsBase2<FTestStruct> to notify the engine that my FTestStruct contains a custom serialization function, as well as operator<< (that is a friend of TTemplateType) which facilitates the serialization.

With that defined, we’ll make use of it in a custom actor, like this:

#pragma once

#include "StructTest.h"

#include "SerializationTestActor.generated.h"

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

private:
    UPROPERTY( EditInstanceOnly )
    FStructTest m_oToBeSerialized;
};

Finally, we need to tell the editor how to display an FStructTest property, so we’ll create a customization class for that purpose. Its header file looks like this:

#pragma once

#include "StructTest.h"

class FStructTestCustomization : public IPropertyTypeCustomization
{
public:
    // Makes a new instance of this customization for a specific detail view requesting it.
    static TSharedRef<IPropertyTypeCustomization> MakeInstance( );

    // START IPropertyTypeCustomization interface.
    virtual void CustomizeHeader( TSharedRef<class IPropertyHandle> pInPropertyHandle,
                                  class FDetailWidgetRow& rHeaderRow,
                                  IPropertyTypeCustomizationUtils& rCustomizationUtils ) override;

    virtual void CustomizeChildren( TSharedRef<class IPropertyHandle> pInPropertyHandle,
                                    class IDetailChildrenBuilder& rStructBuilder,
                                    IPropertyTypeCustomizationUtils& rCustomizationUtils ) override;
    // END IPropertyTypeCustomization interface.

private:
    // Callback when the property value changed.
    void OnValueChanged( uint8 unNewValue );

    // Gets the property value.
    FPropertyAccess::Result GetValue( FStructTest& rOutValue ) const;

    // Indicates if this property can be edited.
    bool CanEdit( ) const;

private:
    // The property handle we are customizing.
    TSharedPtr<IPropertyHandle> m_pPropertyHandle;
};

And its source file looks like this:

#include "StructTestCustomization.h"

#include "Editor/PropertyEditor/Public/DetailLayoutBuilder.h"
#include "Editor/PropertyEditor/Public/DetailWidgetRow.h"
#include "Editor/PropertyEditor/Public/IDetailChildrenBuilder.h"

#include "Runtime/Slate/Public/Widgets/Input/SNumericEntryBox.h"
#include "Runtime/Slate/Public/Widgets/Text/STextBlock.h"

///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
TSharedRef<IPropertyTypeCustomization> FStructTestCustomization::MakeInstance( )
{
    return MakeShareable( new FStructTestCustomization );
}

///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
void FStructTestCustomization::CustomizeHeader( TSharedRef<IPropertyHandle> pInPropertyHandle,
                                                FDetailWidgetRow& rHeaderRow,
                                                IPropertyTypeCustomizationUtils& rCustomizationUtils )
{
    m_pPropertyHandle = pInPropertyHandle;
    FProperty* pProperty = pInPropertyHandle->GetProperty( );

    check( CastField<FStructProperty>( pProperty ) &&
           FStructTest::StaticStruct( ) == CastFieldChecked<const FStructProperty>( pProperty )->Struct );

    auto GetValueToDisplay = [this]( ) -> TOptional<uint8>
        {
            FStructTest oCurrValue;
            FPropertyAccess::Result eResult = GetValue( oCurrValue );

            if( eResult == FPropertyAccess::Result::Success )
            {
                return oCurrValue.m_oCustomSerializedMember.m_tTemplatedMember;
            }

            return TOptional<uint8>( );
        };

    rHeaderRow.NameContent( )
        [
            pInPropertyHandle->CreatePropertyNameWidget( )
        ]
    .ValueContent( )
        [
            SNew( SNumericEntryBox<uint8> )
            .OnValueChanged( this, &FStructTestCustomization::OnValueChanged )
            .Value_Lambda( GetValueToDisplay )
        ]
    .IsEnabled( MakeAttributeSP( this, &FStructTestCustomization::CanEdit ) );
}

///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
void FStructTestCustomization::CustomizeChildren( TSharedRef<IPropertyHandle> pInPropertyHandle,
                                                       IDetailChildrenBuilder& rStructBuilder,
                                                       IPropertyTypeCustomizationUtils& rCustomizationUtils )
{
    // Not really sure when this is called or what it should actually do, so its possible this is entirely wrong
    // and/or useless.
    uint32 unNumberOfChild;

    if( pInPropertyHandle->GetNumChildren( unNumberOfChild ) == FPropertyAccess::Success )
    {
        for( uint32 unIndex = 0; unIndex < unNumberOfChild; ++unIndex )
        {
            TSharedRef<IPropertyHandle> pChildPropertyHandle =
                pInPropertyHandle->GetChildHandle( unIndex ).ToSharedRef( );

            pChildPropertyHandle->SetOnPropertyValueChanged(
                FSimpleDelegate::CreateLambda( []( ){ } ) );

            rStructBuilder.AddProperty( pChildPropertyHandle )
                .ShowPropertyButtons( true )
                .IsEnabled( MakeAttributeSP( this, &FStructTestCustomization::CanEdit ) );
        }
    }
}

///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
FPropertyAccess::Result FStructTestCustomization::GetValue( FStructTest& rOutValue ) const
{
    // Potentially accessing the value while garbage collecting or saving the package could trigger a crash. So we fail
    // to get the value when that is occurring.
    if( GIsSavingPackage || IsGarbageCollecting( ) )
    {
        return FPropertyAccess::Fail;
    }

    FPropertyAccess::Result eResult = FPropertyAccess::Fail;

    if( m_pPropertyHandle.IsValid( ) && m_pPropertyHandle->IsValidHandle( ) )
    {
        TArray<void*> oRawData;
        m_pPropertyHandle->AccessRawData( oRawData );

        if( oRawData.Num( ) > 1 )
        {
            eResult = FPropertyAccess::MultipleValues;
        }

        if( oRawData.Num( ) == 1 && oRawData[0] != nullptr )
        {
            rOutValue = *reinterpret_cast<const FStructTest*>( oRawData[0] );
            eResult = FPropertyAccess::Success;
        }
    }

    return eResult;
}

///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
bool FStructTestCustomization::CanEdit( ) const
{
    return m_pPropertyHandle.IsValid( ) ? !m_pPropertyHandle->IsEditConst( ) : true;
}

///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
void FStructTestCustomization::OnValueChanged( uint8 unNewValue )
{
    FStructTest oNewValue{ unNewValue };

    TArray<void*> oRawDataPtrs;
    m_pPropertyHandle->AccessRawData( oRawDataPtrs );

    if( oRawDataPtrs.Num( ) == 1 )
    {
        FScopedTransaction Transaction( FText::FromString( TEXT( "Set Struct Value" ) ) );

        m_pPropertyHandle->NotifyPreChange( );
        FStructTest* pRawData = static_cast<FStructTest*>( oRawDataPtrs[0] );
        *pRawData = oNewValue;
        m_pPropertyHandle->NotifyPostChange( EPropertyChangeType::ValueSet );
    }
}

And we need to inform the engine of the customization class, so we need to make some modifications to our game module. Here’s the header:

#pragma once

#include "Runtime/Core/Public/Modules/ModuleInterface.h"

class FTestStructSerializeModule : public IModuleInterface
{
public:
    // IModuleInterface implementation 
    virtual void StartupModule( ) override;
};

and the source file:

#include "TestStructSerialize.h"

#include "StructTestCustomization.h"

#include "Runtime/Core/Public/Modules/ModuleManager.h"

///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
void FTestStructSerializeModule::StartupModule( )
{
    // This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin
    // file per-module
    FPropertyEditorModule& rPropertyModule =
        FModuleManager::LoadModuleChecked<FPropertyEditorModule>( "PropertyEditor" );

    rPropertyModule.RegisterCustomPropertyTypeLayout(
        "StructTest",
        FOnGetPropertyTypeCustomizationInstance::CreateStatic( &FStructTestCustomization::MakeInstance ) );
}

IMPLEMENT_MODULE( FTestStructSerializeModule, TestStructSerialize );

And finally we add the dependencies we need to our build.cs file:

PublicDependencyModuleNames.AddRange(new string[] { "PropertyEditor", "SlateCore", "Slate", "UnrealEd" });

The important things I think that I should highlight would be first the lambda we pass to the SNumericEntryBox<uint8>'s Value_Lambda member in FStructTestCustomization::CustomizeHeader; that is used to update the details panel with whatever value is currently stored in the FTestStruct::m_oCustomSerializedMember.

Then, perhaps most importantly, the function FStructTestCustomization::OnValueChanged which modifies the property when the numeric entry box in the details panel is changed. This is where we start a transaction so that the changes can be undone/redone and call NotifyPreChange and NotifyPostChange, which in turn mark the outer object as dirty. The transaction seems to be working currectly, I can make a modification and undo and redo the change and see the details panel update correctly, and I can see in the bottom right corner of the editor the “All Saved” text changes to “1 unsaved”, and if I press that button, a dialog appears which correctly identifies an asset of type ASerializationTestActor has been modified. If I then select “Save Selected”, I can see that the asset file mentioned in that dialog has had its last modified date updated to the current time. However, when I reload the map that I added the actor to, it does not load the value I previously set. This is the problem. Placing a breakpoint in the serialization function (the operator<< function defined near FStructTest), I can see that it is never called with an archive that has FArchiveState::ArIsLoading set to true. It is called several times by the transactions, so the TStructOpsTypeTraits<FStructTest> definition seems to be detected correctly by the engine.

I have looked at other customization functions in the Unreal Engine source code that behave like mine does (by searching for things like AccessRawData, or WithSerialization), and they don’t seem to be doing anything more than what I’m doing. I’m really at a loss as to what I could be missing here.

For those of you willing, I have attached a zip of the test project (made in UE5.3.2) that contains the code in this post, along with a map (called NewMap) that contains an ASerializationTestActor in it so that this code can be tested. If anyone has any ideas, I would LOVE to hear them.

TestStructSerialize.zip (25.2 MB)

1 Like

Alright, 1 week later and no one saved me from having to dive into the source code to find out what I was missing :frowning:. But I did figure out the problem :slight_smile:!

Turns out, it was a pretty simple thing I was missing. In order for the engine to detect if a non-property member (i.e. member variables that are not decorated with the UPROPERTY macro) of a struct decorated with the USTRUCT macro that you want to be serialized via a custom serializer (for example, because that struct is being used as a property in some other object) has changed (and therefore it needs to be serialized), you need to provide a comparison function and notify the engine of that comparison function’s existence.

That comparison function can either be an operator== member function or an Identical(const T* Other, uint32 PortFlags) member function in the struct class. Once you have one of these, you can inform the engine of its existence by adding WithIdenticalViaEquality = true or WithIdentical = true (respectively) to your TStructOpsTypeTraits class (alongside the WithSerializer = true you added to inform the engine of your custom serialization function’s existence).

For the example project I posted, this means making the following modifications:

  • In our definition of FStructTest, we can simply add a default comparison operator like this:
USTRUCT( )
struct FStructTest
{
    GENERATED_BODY( )
public:
    FStructTest( ) = default;

    FStructTest( uint8 unValue )
    {
        m_oCustomSerializedMember = TTemplateType{ unValue };
    }

    // !! Add this function !!
    bool operator==( const FStructTest& rOtherStruct ) const = default;
    // !! Add this function !!

    bool Serialize( FArchive& Ar )
    {
        Ar << m_oCustomSerializedMember;
        return true;
    };

    TTemplateType<uint8> m_oCustomSerializedMember;
};

In order to support this default comparison operator, we also add a default comparison operator to TTemplateType’s definition, like this:

template<typename T>
struct TTemplateType
{
    constexpr TTemplateType& operator=( const TTemplateType& rOther ) = default;

    // !! Add this function !!
    bool operator==( const TTemplateType<T>& rOtherStruct ) const = default;
    // !! Add this function !!

    T m_tTemplatedMember;

    friend FArchive& operator<< <>( FArchive& rArchive, TTemplateType<T>& rBitSet );
};

Then we notify the engine of FStructTest’s new comparison operator by modifying our TStructOpsTypeTraits specialization to set WithIdenticalViaEquality = true, like this:

template<> struct TStructOpsTypeTraits<FStructTest> : public TStructOpsTypeTraitsBase2<FStructTest>
{
    enum
    {
        WithSerializer = true,

        // !! Add this line !!
        WithIdenticalViaEquality = true
        // !! Add this line !!
    };
};

And that’s it! Our custom property now properly saves and loads when you make changes to it in the details panel.

3 Likes

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.