The trick with pointers to the Default Subobject

1. Background

I work for a company that writes a first person shooter in Unreal Engine. About two weeks ago, strange behavior began to appear in the game. The game logic began to assume that all the characters in the game are on the same team.

Our team affiliation information is stored in the character component:

Class structure

Class structure

Due to the use of a memory breakpoint, the root cause was found quite quickly. It turned out that all character blueprint instances created on stage BP_Character_Player pointers TeamMemberComponent store the same address.

This was the address of the component whose Outer was Class Default Object (hereinafter – CDO) parental For BP_Character_Player class BP_Character (more specifically, an object called Default_BP_Character_С).

Mysterious Outer

Mysterious Outer

I gathered my courage and plunged into the study of the problem for several days.

2. Superficial analysis of the problem

Debug showed that invalid pointers get into instances BP_Character_Player when filling the fields of these instances from the value of the corresponding CDO fields. It happened in a function FObjectInstancingGraph::GetInstancedSubobject(). Unlike pointers to other components, for a field TeamMemberComponent the following check failed:

UObject* FObjectInstancingGraph::GetInstancedSubobject( ... )
{
    ...
   bool bShouldInstance = ... ||  >>> SourceSubobject->IsIn(SourceRoot) <<<;
    ...  
}

Note: The project uses engine version 4.27

The check did not pass because in the CDO class BP_Character_Player pointer TeamMemberComponent pointed not to this CDO, but to the CDO of the base class BP_Character. Because of this, the pointers were not associated with the components of the created instance, but continued to point to the parent components by default.

Further deepening into the code showed that the value of all fields except the problematic field Team Member Component for CDO class BP_Character_Player (Default_BP_Character_С) were read… from disk, from the BP_Character_Player blueprint file, more specifically, in the method UStruct::SerializeVersionedTaggedProperties().

Actually, at this point it became approximately clear what the problem could be related to. Questions were raised by the very fact of storing and reading information about the blueprint from the blueprint file. signs associated with the Default Subobject – after all, they, obviously, can be contacted by the FName-names of the instances. What for to store on a disk pointers on objects which are created in the constructor?

After debugging a little more in the serialization logic, I found a code that checks whether it is necessary to serialize / deserialize fields:

FProperty::ShouldSerializeValue(FArchive& Ar)

This is all that separates your fields from being saved to disk:

bool FProperty::ShouldSerializeValue( FArchive& Ar ) const
{
	if (Ar.ShouldSkipProperty(this))
	{
		return false;
	}
    //Через побитовые операции проверяется текущее состояние PropertyFlags.
	if (!(PropertyFlags & CPF_SaveGame) && Ar.IsSaveGame())
	{
		return false;
	}

	const uint64 SkipFlags = CPF_Transient | CPF_DuplicateTransient | CPF_NonPIEDuplicateTransient | CPF_NonTransactional | CPF_Deprecated | CPF_DevelopmentAssets | CPF_SkipSerialization;
	if (!(PropertyFlags & SkipFlags))
	{
		return true;
	}

	bool Skip =
			((PropertyFlags & CPF_Transient) && Ar.IsPersistent() && !Ar.IsSerializingDefaults())
		||	((PropertyFlags & CPF_DuplicateTransient) && (Ar.GetPortFlags() & PPF_Duplicate))
		||	((PropertyFlags & CPF_NonPIEDuplicateTransient) && !(Ar.GetPortFlags() & PPF_DuplicateForPIE) && (Ar.GetPortFlags() & PPF_Duplicate))
		||	((PropertyFlags & CPF_NonTransactional) && Ar.IsTransacting())
		||	((PropertyFlags & CPF_Deprecated) && !Ar.HasAllPortFlags(PPF_UseDeprecatedProperties) && (Ar.IsSaving() || Ar.IsTransacting() || Ar.WantBinaryPropertySerialization()))
		||  ((PropertyFlags & CPF_SkipSerialization) && (Ar.WantBinaryPropertySerialization() || !Ar.HasAllPortFlags(PPF_ForceTaggedSerialization)))
		||  (IsEditorOnlyProperty() && Ar.IsFilterEditorOnly());

	return !Skip;
}

A crutch was needed to solve the problem.

3. Problem Solving

Solution: Explicitly mark pointers to the Default Subobject as Transient.

Small crutch in UProperty

Small crutch in UProperty

This change fixes the issue.

4. Why publish without deep analysis?

Yes, it didn’t work out for me reproduce problem. I tried different variations of saving blueprints – the problem was not observed. I have only vague ideas about its root causes. Given the known circumstances, I have no strength to go into details further. It also failed to find detailed publications on the topic.

Actually, the lack of popular publications on the topic is one of the key reasons why I took the liberty of sharing the problem. It is worth discussing it at least somehow – after all, it can occur even on the basis of code from official example work with components on the Epic Games website (that is, based on the code for training newbies).

I think a small amount of discussion of the problem is related to the clutter and confusion of the system for initializing and loading the states of UObjects in Unreal. Few people have the strength to fully understand all the nuances, and after that no one wants to share raw assumptions.

In the process of debugging, I remembered that the guys from the previous project faced the described problem. Then I was kind of lucky – she banged when I was busy with the less Unreal-specific part of the project. Faced with it now, I contacted former colleagues and it turned out that they also did not fully understand the problem. Decided like me: Transient helps – and okay. I can’t blame them for that attitude – debugging serialization code in Unreal is really hard.

This situation gave me an analogy. We are like electricians working with a line of electric motors without understanding the nuance: that you need to pull a poorly documented lever somewhere inside that relieves voltage from the circuit. From time to time we are beaten like this and everyone, rummaging through the chains, using the scientific poke method, pulling the necessary lever such as “uu, well, it’s clear, we should always do this now.” But he does not tell others – because the lever is not clear why it helps.

Summing up: comparing the risk of getting hit on the head for raw material and the likelihood of benefiting the community by starting a discussion on the topic – I chose the second. I apologize in advance to those who may be offended by such a shallow approach.

If anyone knows about the problem – share the information. This will benefit the entire community.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *