Renamed GAS attribute results in duplicate AttributeValueChangeDelegates entries in UE 5.8

FGameplayAttribute has a hash/equality mismatch. GetTypeHash is hashed by the resolved pointer. The equality operator compares the stored path strings in TFieldPath::UEOpEquals.

When a CoreRedirect renames an attribute, two UGameplayEffect assets referencing the same property - one saved before the rename, one saved after - deserialize into FGameplayAttribute instances whose TFieldPath::Path arrays differ: [“OldName”] vs [“NewName”].

Both of those attributes resolve to the same FProperty*. However, a TMap<FGameplayAttribute, …> will create a duplicate entry instead of resolving to the same one. FActiveGameplayEffectsContainer::AttributeValueChangeDelegates is an example where this is problematic. A broadcast against the delegate drops anything registered with Path = [“OldName”].

UE 5.7 handles such discrepencies in FGameplayAttribute::PostSerialize:

// Once struct is loaded, check if redirectors apply to the imported attribute field path
const FString PathName = Attribute.ToString();
const FString RedirectedPathName = FFieldPathProperty::RedirectFieldPathName(PathName);
if (!RedirectedPathName.Equals(PathName))
{
    // If the path got redirected, attempt to resolve the new property
    ...
}

However, UE 5.8 resolves Attribute.ToString() correctly to the redirected name even though the underlying path does not match in the equality operator. This seems to be from an addition to FFieldPath::TryToResolvePath:

Result = FindFProperty<FField>(Owner, Path[PathIndex]);
if (!Result)
{
	const FName RedirectedName = FProperty::FindRedirectedPropertyName(Owner, Path[PathIndex]);
	if (!RedirectedName.IsNone())
	{
		Result = FindFProperty<FField>(Owner, RedirectedName);
	}
}
PathIndex--;

So in 5.8, stale references to an attribute in an asset no longer update to the correct path as part of PostSerialize.

Can you advise on the proper intended fix? I see the following paths:

  1. Update FGameplayAttribute operator== to compare the resolved attribute instead of relying on FFieldPath equality? Eg Attribute.Get() == Other.Attribute.Get() instead of Attribute == Other.Attribute
  2. Update FFieldPath equality to account for the resolution redirecting flow?
  3. Update FGameplayAttribute PostSerialize to detect staleness some other way
    [Attachment Removed]

Steps to Reproduce
Compare the following in UE 5.7 vs 5.8:

  1. Add an AttributeSet with one attribute, eg OldHealth
  2. Create GE_Old with a modifier that references OldHealth and save it
  3. Rename the C++ property OldHealth to NewHealth, using a PropertyRedirects CoreRedirect
  4. Create GE_New with a modifier that references NewHealth and save it. Do not resave GE_Old.
  5. Load both assets and put their FGameplayAttribute properties into a TSet or TMap.
  6. Note that calling .Get() on each attribute resolves to the same property pointer
  7. Note that inspecting the set or map shows 1 entry in 5.7 and 2 entries in 5.8
    [Attachment Removed]

Hey Phil, thanks for the comprehensive report and the effort you’ve taken to diagnose the issue and source already.

I was able to repro the problem with your steps and can confirm it’s a regression. My gut says (1), so updating the FGameplayAttribute operator==, is safe to implement locally. Meanwhile, (2) is probably the long-term engine solution that I’ll suggest to our UE dev team because the problem you found is likely going to affect any TSet or TMap of FFieldPaths or any struct that uses the FFieldPath value for hashing and indexing.

I’m not sure yet what the fix for (2) will look like, so I suggest implementing (1, FGameplayAttribute operator==) locally to branch minimally from engine source, or implementing some fix for (2) yourself if you don’t mind deviating from engine source.

[Attachment Removed]