Changing a default subobject name in C++ class leads to UActorComponent being marked as PendingKill immediately after being instantiated when loading from .uasset, and is eventually reset to NULL by GC

RocknRolla
Wed, 24 Mar 2021 09:47:09 +0000
C++

Short Description

So, I’ve been investigating this bug for quite some time. I have also found several posts on UE forums on this issue. It affects several engine versions – I have personally reproduced it on 4.25.4 and 4.26.1 (using both Launcher version and recompiled from source).
The issue happens only with blueprints. There are 2 symptoms I know of:

UPROPERTY() component pointer is somehow set to NULL in BeginPlay() though it has been set to a non-NULL object in C++ constructor using CreateDefaultSubobject.

When opening component details tab, no component properties are visible.

Generally, the advice on the forums is to either reparent a Blueprint to some another parent and the reparent back to the original parent, or to just re-create Blueprint from scratch. In both cases you lose component settings stored in Blueprint, which leads to certain amount of work needed to be redone.

How to reproduce

1. Create a new UE4 project using Blank C++ template without starter content.

2. Create a new actor C++ class ATestActor.

3. Add component property to this class, e.g.:

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Test")
class UCapsuleComponent* CapsuleComponent;

4. Create default subobject for a component in constructor, e.g.:

ATestActor::ATestActor()
{
    // Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
    PrimaryActorTick.bCanEverTick = true;
    CapsuleComponent = CreateDefaultSubobject<UCapsuleComponent>(TEXT("OldComponentName"));
    CapsuleComponent->SetupAttachment(RootComponent);
}

5. Add NULL-check to BeginPlay:

void ATestActor::BeginPlay()
{
    Super::BeginPlay();
    checkf(IsValid(CapsuleComponent), TEXT("Capsule component is NULL"));
}

6. Re-compile project and launch the engine.

7. Create Blueprint parented to ATestActor (e.g., BP_TestActor) and open it in Full Blueprint Editor.
Click on CapsuleComponent in Components tab and verify that component properties are visible in Details tab.
Save and close the engine.

8. Change the name of the component default subobject created in C++ constructor, e.g.:

CapsuleComponent = CreateDefaultSubobject<UCapsuleComponent>(TEXT("NewComponentName"));

9. Launch engine and open BP_TestActor in Full Blueprint Editor.
Click on CapsuleComponent in Components tab and verify that component NO properties are visible in Details tab (Details tab is empty).

10. Drag and drop BP_TestActor onto a default map and click Play. Editor crashes on our check in ATestActor::BeginPlay() because CapsuleComponent == nullptr.

Demo

I have created a simple demo project using the above scenario and published it on github: GitHub - vogoltsov/UE4_ActorComponent_PendingKill_Demo

Bug details

Notes:

During loading, if project files remain unchanged, UE4 allocates the same object identifiers each time the project is loaded. So, I am using object identifiers from my demo project onwards.

I am using Unreal Engine 4.26.1 recompiled from source. As I have added several pragmas to disable optimization on certain functions, so actual line numbers in code may differ a little.

I’ve traced the modifications of ATestActor’s member property CapsuleComponent:

1. Default_TestActor[InternalIndex=19506] (CDO for ATestActor) is created and CapsuleComponent is set to a non-NULL object UCapsuleComponent[InternalIndex=19507].

2. BP_TestActor.uasset starts loading.

3. Default_BP_TestActor[InternalIndex=23596] (CDO for BP_TestActor) is created and CapsuleComponent is set to a non-NULL object UCapsuleComponent[InternalIndex=23595].

4. Default_BP_TestActor.CapsuleComponent is set to Default_TestActor.CapsuleComponent[InternalIndex=19507].

5. Default_BP_TestActor.CapsuleComponent is reverted to UCapsuleComponent[InternalIndex=23595] (value set in 3).

6. Default_BP_TestActor.CapsuleComponent is set to a newly created UCapsuleComponent[InternalIndex=23594].

If we inspect GUObjectArray.ObjObjects.Objects[0][23594].Flags, we can see that this object is already marked as RF_PendingKill.

7. During garbage collection, Default_BP_TestActor.CapsuleComponent is set to NULL because the component instance is marked as RF_PendingKill.

After that, I started investigating how UCapsuleComponent[InternalIndex=23594] is created and why it is marked as RF_PendingKill. The problem lies in how the object property is being de-serialized from the archive. This process can be summarized to:

1. Save current object property value to a local variable (this is essentially a default subobject created in C++ constructor). Link to source.

2. Read new property value from archive slot. Link to source.

3. Obtain template to be used as new property value archetype. Link to source.

4. Archetype object is searched in outer class CDO (which is Default_TestActor). Link to source.

5. To locate an archetype, perform thread-safe lookup in object outer hash map using StaticFindObjectFastInternalThreadSafe(). Link to source.

6. Lookup is performed by name supplied in archive (Export.ObjectName variable) which appears to be “OldComponentName”. But the object created in constructor has been registered in outer map using name supplied in ATestActor constructor, which is “NewComponentName”. Thus, outer hash map lookup fails, and NULL is returned.

7. Because the object could not be found in outer hash map, class default object is returned as the archetype.
Link to source.

8. Construct new object using template.
Link to source.

9. Now everything goes well, until execution hits AActorComponent::PostInitProperties(). In normal circumstances, newly created component should be added as owned component to outer object (which is Default_BP_TestActor).
But because MyArchetype != GetClass()->ClassDefaultObject check fails, MarkPendingKill() is called.
Link to source.

10. Replace property value with new pointer. Link to source.

Proposed solution

I understand that changing component names is generally to be avoided, but it can sometimes be done “unintentionally” – e.g., to fix spelling errors during refactoring. The problem is not that component renaming may not be supported, but that Unreal Engine just silently sets component property to NULL without raising an error or at least issuing a warning.

Personally, I would prefer a fail-fast solution when performing an invalid operation (i.e., renaming a component) leads to an error, so this can be immediately addressed. We would also need to add a new core redirect type to allow renaming components without breaking things. (This whole situation is somewhat similar to renaming C++ class which have existing child Blueprints.)

One way to achieve this would be to modify UActorComponent::PostInitProperties() and move MyArchetype != GetClass()->ClassDefaultObject inside ensureAlwaysMsgs() or checkf(), e.g.:

ensureAlwaysMsgf(MyArchetype != GetClass()->ClassDefaultObject, TEXT("Failed to get archetype for natively created component. Owner class: %s. Owner name: %s. Component class: %s. Component name: %s"), *OwnerPrivate->GetClass()->GetPathName(), *OwnerPrivate->GetFName().ToString(), *GetClass()->GetPathName(), *GetFName().ToString());

This would result at least in the following error being written to Output Log:

LogOutputDevice: Error: Ensure condition failed: MyArchetype != GetClass()->ClassDefaultObject [File:C:\Development\UnrealEngine\Engine\Source\Runtime\Engine\Private\Components\ActorComponent.cpp] [Line: 293]
Failed to get archetype for natively created component. Owner class: /Game/BP_TestActor.BP_TestActor_C. Owner name: Default__BP_TestActor_C. Component class: /Script/Engine.CapsuleComponent. Component name: OldComponentName

I have attached a sample patch. I have tested on several of my pet projects as well as ActionRPG sample project, and this seems to be working fine. But more tests are needed for sure.

diff --git a/Engine/Source/Runtime/Engine/Private/Components/ActorComponent.cpp b/Engine/Source/Runtime/Engine/Private/Components/ActorComponent.cpp
index 87eee3ccab3..342449f3b07 100644
--- a/Engine/Source/Runtime/Engine/Private/Components/ActorComponent.cpp
+++ b/Engine/Source/Runtime/Engine/Private/Components/ActorComponent.cpp
@@ -289,7 +289,8 @@ void UActorComponent::PostInitProperties()
                if (!FPlatformProperties::RequiresCookedData() && CreationMethod == EComponentCreationMethod::Native && HasAllFlags(RF_NeedLoad|RF_DefaultSubObject))
                {
                        UObject* MyArchetype = GetArchetype();
-                       if (!MyArchetype->IsPendingKill() && MyArchetype != GetClass()->ClassDefaultObject)
+                       ensureAlwaysMsgf(MyArchetype != GetClass()->ClassDefaultObject, TEXT("Failed to get archetype for natively created component. Owner class: %s. Owner name: %s. Component class: %s. Component name: %s"), *OwnerPrivate->GetClass()->GetPathName(), *OwnerPrivate->GetFName().ToString(), *GetClass()->GetPathName(), *GetFName().ToString());
+                       if (!MyArchetype->IsPendingKill())
                        {
                                OwnerPrivate->AddOwnedComponent(this);
                        }

9 posts - 9 participants