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)