Hello,
I have noticed two very weird things:
- ALyraCharacterWithAbilities does create two default subobjects: HealthSet and CombatSet
- During construction, the post init properties resets those properties to nullptr based on the CDO
- The created actor therefore does not have valid pointers. Not that this has been noticed because the ability system can retrieve the owned subobjects, but this seems like a very strange behavior nonetheless. As far as I know, the CDO should have all of the default subobjects instanciated? I’ve used that behavior many times before and don’t understand why it doesn’t work here.
If the actor is destroyed, it is marked as MirroredGarbage and its pointer is effectively invalid for all of the relevant tests (::IsValid, TObjectPtr::IsValid etc…). However, the subobjects are not, even though they are effectively unreachable, and have no external reference besides the actor and the ability system component. That is until the garbage collector kicks in of course, it correctly recognizes and destroys those objects.
My questions now:
- Is there any way to detect that this pointer is for all intents and purposes unreachable and invalid? All of my tests return the wrong result (valid, reachable…)
- Would this be the case for subobjects even if there wasn’t the strange reference being reset during construction issue? Or are subobjects somehow more properly marked as garbage during the outer actor’s deletion.
Thanks in advance.
----
Repro:
Reproducible in Lyra example projet.
- Construct a ALyraCharacterWithAbilities, put a breakpoint, observe that the attribute set pointers to default subobjects are null, for instances and the CDO.
- Keep an external reference to HealthSet somehow, for instance by storing it in a non UPROPERTY TObjectPtr on the class. Observe that those pointers remain valid and reachable according to UObject semantics as the subobjects do not get flagged for deletion.
“As far as I know, the CDO should have all of the default subobjects instanciated? I’ve used that behavior many times before and don’t understand why it doesn’t work here.”
That is correct, in the destructor ~FObjectInitializer() which is ran immediately after the native constructor, the call to InstanceSubobjects() should result in creating subobjects or refinding the UObject that was created in the constructor. However, if the references broke in the past and the asset has been saved since then, the null references will be stored and the blueprint is in a broken state. If your broken blueprint was made via duplication, it’s possible the subobjects broke due to UE-273844, a bug I’ve fixed for UE 5.6 but will only prevent breaking actor blueprints duplicated from now on.
You’re correct that even if the character’s references to HealthSet and CombatSet are null and even if those sets aren’t referenced, they will still be found by GAS. This is via the GetObjectsWithOuter call in UAbilitySystemComponent::InitializeComponent() that will find even unreachable objects because it iterates the global static UObject list. Attribute sets found that way will then stay reachable due to being referenced by ASC->SpawnedAttributes. Indeed that limits the impact of “orphaned” subobjects (valid but not referenced by the Lyra character class). Even if the actor’s references to the subobjects is null, the subobjects created by the constructor are still found via GetObjectsWithOuter.
A valid reference to the objects is still best though:
- If the blueprint has been duplicated before, you may have to fix up the references manually on the CDO by finding the subobjects by path after construction to save the blueprint in a valid state again (uncorrupt it, basically). Or cherrypick the fix commit for UE-273844 and duplicate the blueprint again.
- Indeed keep an external UPROPERTY reference to the sets.
In your version of Lyra, are the HealthSet and CombatSet stored as UPROPERTIES? I remember their references used to not be stored, until I introduced UPROPERTIES for them but I don’t recall exactly whether that change was in 5.4 yet. In general they must be stored as UPROPERTIES, otherwise garbage collection may unintentionally delete them before UAbilitySystemComponent::InitializeComponent() finds and references them.
Thanks for your response. A few follow-up questions:
- If those blueprints are broken, how to resave them with the correct values in the cached fields? It seems like CDO is recreated with null references, so apart from manually skipping some code to resave it I couldn’t find any other way. (Yes those objects are marked UPROPERTY in my version of Lyra, without custom changes)
- My original problem isn’t so much fixing this blueprint as the GAS system makes it “work”. But it is to determine whether a pointer to that object is valid and reachable. All of the IsValid() methods test the flags and those are not marked as garbage, so the pointers will only become invalid after a GC pass actually destroys them for being unreachable. During that time, they are for all intents and purposes reachable objects, which doesn’t make sense to me.
Cheers,
Samuel.
Thanks for all of those details, this confirms my research and expectations.
After the actor is destroyed, pointers to the HealthSet remain valid, even though it’s technically unreachable. I was sort of expecting default subobjects to be considered owned and marked for destruction alongside their outer, but i suppose i was wrong here.
I haven’t tested this thoroughly but a broken blueprint whose UPROPERTIES are nulled can be fixed up like this, by overriding the actor’s Serialize function like below.
`void AAbilitiesLabCharacter::Serialize(FArchive& Ar)
{
const bool bHealthSetValidBefore = IsValid(HealthSet);
Super::Serialize(Ar);
const bool bHealthSetValidAfter = IsValid(HealthSet);
if (bHealthSetValidBefore && !bHealthSetValidAfter)
{
ULabHealthAttributeSet* OrphanedSet = Cast(StaticFindObjectFast(ULabHealthAttributeSet::StaticClass(), this, TEXT(“HealthSet”)));
if (OrphanedSet)
{
HealthSet = OrphanedSet;
UE_LOG(LogTemp, Display, TEXT(“Recovered unreferenced health set. Set HealthSet of %s to: %s”), *GetPathName(), *HealthSet->GetPathName());
// Mark package as dirty
Modify();
}
else
{
UE_LOG(LogTemp, Warning, TEXT(“Could not restore null HealthSet on %s”), *GetPathName());
}
}
}`The reference to the subobject attribute set is treated as follows:
- Constructor creates the DefaultSubobject and stores it in the UPROPERTY
- PostInitProperties initializes values from parent class, which temporarily overwrites the HealthSet/CombatSet UPROPERTY values to point at the parent class’s DSOs. This is just a consequence of propagation being implemented by first always adopting parent values.
- InstanceSubobjects is supposed to refind the objects created with CreateDefaultSubobject so that HealthSet/CombatSet point to the same value. If it fails that (such as due to UE-273844), then soon after the references HealthSet/CombatSet are nulled because pointing to the parent class’s DSOs is not allowed (external objects).
- After these initial values, either SerializeTaggedVersionedProperties (on first load) or CopyPropertiesForUnrelatedObjects (on recompile) copies any modifications stored on the blueprint. If the asset was ever saved with the pointers as null, now you’ll have to detect and prevent that the null values are retained.
The idea behind my override of Serialize is: you detect when that last step goes wrong and fix it up manually, like this. This is my local project, not Lyra code so replace class names as needed. StaticFindObjectFast, or just caching the pointer before the call to Super::Serialize should do. Then resave your asset so the good value is retained.
“My original problem isn’t so much fixing this blueprint as the GAS system makes it “work”. But it is to determine whether a pointer to that object is valid and reachable.”
By our definition, finding an object via GetObjectsWithOuter doesn’t necessarily count as the object being reachable. Reachability is defined as: part of root set, or referenced (indirectly) from the root set of objects and FGCObject implementations.
IsValid will succeed on unreachable objects indeed, until a GC pass happens and the object is conclusively decided to be unreachable and destroyed. There is no way to quickly predict whether an object is unreachable. If you want to know for sure whether an object is unreferenced, you can perform the expensive operation of finding referencers to an object with FReferenceFinder.
Indeed, both components and other subobjects of the actor eventually get destroyed by being detected as unreachable by GC:
> UnrealEditor-Engine.dll!UActorComponent::DestroyComponent(bool bPromoteChildren) Line 1561 C++ UnrealEditor-Engine.dll!USceneComponent::DestroyComponent(bool bPromoteChildren) Line 1188 C++ UnrealEditor-Engine.dll!UCameraComponent::OnComponentDestroyed(bool bDestroyingHierarchy) Line 143 C++ UnrealEditor-Engine.dll!UActorComponent::BeginDestroy() Line 826 C++ UnrealEditor-CoreUObject.dll!UObject::ConditionalBeginDestroy() Line 1220 C++ UnrealEditor-CoreUObject.dll!UnhashUnreachableObjects(bool bUseTimeLimit, double TimeLimit) Line 5928 C++ UnrealEditor-CoreUObject.dll!UE::GC::PostCollectGarbageImpl<1>(EObjectFlags KeepFlags) Line 5547 C++ UnrealEditor-CoreUObject.dll!UE::GC::FReachabilityAnalysisState::PerformReachabilityAnalysisAndConditionallyPurgeGarbage(bool bReachabilityUsingTimeLimit) Line 5746 C++
Glad to provide this info!
That said, on actor destruction components are Unregistered, but that’s separate from being destroyed and cleaned up. The latter happens due to unreachability.
I’ll close this thread but if you have followup questions feel free to respond here or open a new one!