Posting again because I got it (almost) all working, so I thought I’d share a general outline of what I actually did. I won’t post the full code, because the exact details are going to be project-dependent, but I’ll post a few snippets.
List of data stored
The ability system component as a whole is represented by the following values in the save data:
- A map of gameplay attributes,
TMap<FGameplayAttribute, float>
- A list of data for each gameplay effect that has is currently applied
- A list of data for each granted ability
For each applied effect, I store the following data:
- The effect’s class
- Data from the Gameplay Effect Context used when the effect was applied (see below for more detail on this)
- The level of the effect
- Dynamic asset tags and dynamic granted tags
- Set by caller magnitudes, as a
TMap<FGameplayTag, float>
- The number of stacks applied
- The current elapsed duration on the effect (that is, the time elapsed since it was applied); I store this even for infinite effects, though I’m uncertain whether it’s useful there.
- The time left until the effect’s next periodic trigger (that is, the effect’s period minus the time since it last triggered), only for periodic effects
- Data from the effect object itself (only if it was a dynamically constructed effect); this would vary depending on its class
For the Gameplay Effect Context, I store the following data:
- Paths to the instigator, the original instigator (if different from the instigator), the effect causer, and the source object
- Path to the hit actor, if any, and the name of the hit component
- Paths to any other involved actors (perhaps from target data, for example)
- The hit result and origin, if any
- The ability that initiated it, if any
For each granted ability, I store the following data:
- The ability’s class
- The class of the gameplay effect that granted the ability, if any
- Path to the source object that granted the ability
- The level of the ability
- Path to the Enhanced Input Action that triggers the ability (I have a system that maps these to the Ability System’s input IDs)
- Dynamic ability tags
- If the ability is an instanced ability, a list of all active instances. I don’t store inactive instances, but you could if you needed to have abilities that save state across activations.
For each active ability, I store the following data:
- Persistent data required by the specific ability
- Data from the gameplay event that triggered the ability, if any; there’s not much more to say here, as it’s literally everything that’s in the
FGameplayEventData struct. The only complicated wrinkle is the need to store the objects as string paths (eg TSoftObjectPtr).
- A list of Ability Tasks current present on the ability, along with any data they need for their work, including the bindings on the delegates that will be called when they complete.
Some things were tricky to deal with. In some cases, there are no proper public setters for the values I needed to change, so I needed to find workarounds to revive them from the save data. For example, using the reflection system to set a private property, or making a dummy class inheriting from the class so that I can access a protected method.
The two hardest things to handle on restore were probably ability instances and the gameplay effect context.
Restoring Ability Instances
The code to restore instances for one granted ability looks something like this:
for(int i = 0; i < this->instances.Num(); i++) {
FGameplayEventData* event = nullptr;
if(bHasEventData) {
event = &this->StoredEventData;
}
// Construct a new ability object
// grantedSpec is obtained from the return value of GiveAbility
UMyGameplayAbility* abilityInstance = asc->ReviveAbilityInstance(*grantedSpec, event);
// Copy reflected properties from the save data to the ability
instances[i].Restore(abilityInstance);
grantedSpec->ActiveCount++;
}
Where ReviveAbilityInstance is defined like this:
class AbilityInfo_ProtectedWorkaround : public UGameplayAbility {
public:
// This parameter MUST not be UMyGameplayAbility. It only works if it's exactly UGameplayAbility.
static void SetInfo(UGameplayAbility* abil, const FGameplayAbilitySpecHandle spec, const FGameplayAbilityActorInfo* actor) {
// This needs to be a static_cast instead of Cast because the ability is not actually of the AbilityInfo_ProtectedWorkaround class; I just need to make the compiler think it is so I can call the protected function on it.
auto workaround = static_cast<AbilityInfo_ProtectedWorkaround*>(abil);
workaround->SetCurrentInfo(spec, actor, FGameplayAbilityActivationInfo());
}
};
UGameplayAbility* UMyAbilitySystemComponent::ReviveAbilityInstance(FGameplayAbilitySpec& spec, FGameplayEventData* event) {
auto abil = CreateNewInstanceOfAbility(spec, spec.Ability);
AbilityInfo_ProtectedWorkaround::SetInfo(abil, spec.Handle, AbilityActorInfo.Get());
if(event) {
if(auto myAbil = Cast<UMyGameplayAbility>(abil)) {
myAbil->ReviveEventData(*event);
}
}
return abil;
}
The ReviveEventData function just assigns the CurrentEventData, so not really worth showing (I did also make it set a boolean that the ability was activated by an event). The other key piece of the puzzle is restarting tasks:
class AbilityTask_ProtectedWorkaround : public UAbilityTask {
public:
static void Init(UAbilityTask* task, FAbilityTaskInfo taskData, IGameplayTaskOwnerInterface& owner, bool pause) {
// Again, use static_cast to fool the compiler so I can call protected methods
auto workaround = static_cast<AbilityTask_ProtectedWorkaround*>(task);
workaround->InitTask(owner, taskData.Task->GetPriority());
workaround->InstanceName = taskData.Name;
// Copy any necessary data into the task.
// This depends on the specific task, and may require very ugly hacks as some of the built-in tasks don't even mark their data with UPROPERTY()
// I will not show any such hacks here
taskData.Restore(task);
workaround->ReadyForActivation();
if(pause) workaround->Pause();
}
};
void UMyGameplayAbility::RestartTasks() {
// AllTasks is populated during gameplay by overriding the OnGameplayTaskXXX methods
// On restore, it's populated as part of restoring the ability's data (the instances[i].Restore() call earlier)
auto toRestart = AllTasks;
AllTasks.Empty();
for(const auto taskData : toRestart) {
auto task = NewObject<UAbilityTask>(GetTransientPackage(), taskData.Task->GetClass());
AbilityTask_ProtectedWorkaround::Init(task, taskData, *this, taskData.State == EGameplayTaskState::Paused);
}
}
Restoring the Gamplay Effect Context
The Gameplay Effect Context is a polymorphic struct, which massively complicates serializing and deserializing it. The Unreal reflection system has a big assumption built into it: a class is always polymorphic and a struct is never polymorphic. So, the reflection system doesn’t directly handle a polymorphic struct.
So, the first step in restoring it is to figure out the real struct that needs to be restored. It’s not that hard – I’d already stored the struct path into the save data (it can be obtained from FGameplayContext::GetScriptStruct()), so it’s just a matter of constructing a new one of that type… simple, right?
Well… not really.
This is my solution for the simple act of allocating an instance of the correct sub-struct:
// Deleter functor to deallocate a struct which is determined at runtime and was allocated with FMemory::Malloc.
struct FDynamicStructDeleter {
TStrongObjectPtr<UStruct> Def;
FDynamicStructDeleter(UStruct* d) : Def(d) {}
void operator()(void* ptr) {
Def->DestroyStruct(ptr);
FMemory::Free(ptr);
}
};
TSharedPtr<FGameplayEffectContext> AllocateGameplayEffectContext(UStruct* def) {
if(def->IsChildOf(FGameplayEffectContext::StaticStruct())) {
if(void* ptr = FMemory::Malloc(def->GetStructureSize(), def->GetMinAlignment())) {
def->InitializeStruct(ptr);
return MakeShareable(reinterpret_cast<FGameplayEffectContext*>(ptr), FDynamicStructDeleter(def));
}
}
return nullptr;
}
It might be overkill. For any give game, the specific sub-struct is probably known, so I could’ve just hard-coded it for that one sub-struct. But I wanted to come up with code that I could copy-paste to a new project later with minimal changes, so I ended up with this more generic solution.
Restoring the data itself is not that complicated. Nearly everything in FGameplayEffectContext is either public or has a public setter. The only wrinkle I ran into is that there’s no way to directly set HasOrigin. It’s set to true when you call AddOrigin, but there’s no way to directly set it to false. So, I ended up using the reflection system to set it.
There was one last remaining wrinkle to get past: all of this work gave me a TSharedPtr<FGameplayEffectContext>, which I then needed to turn into an FGameplayEffectContextHandle. But there is, surprisingly, absolutely no way to do that.
- The
FGameplayEffectContextHandle constructors only take a raw pointer, which they immediately take ownership of and wrap in a TSharedPtr, but that would not do here, because said pointer was already owned by a different TSharedPtr. Having a pointer owned by two different TSharedPtrs is jstu asking for trouble. (Hint: It would definitely crash.)
- The
FGameplayEffectContextHandle only has one member, which is the TSharedPtr I needed to update. So my first instinct was to use type punning to set it, as the address of a struct’s first member is normally the same as the address of the struct itself. But no, FGameplayEffectContextHandle has virtual functions, so its first member is the invisible vtable, and I can only guess at the address of the TSharedPtr. This is silly – it doesn’t need them to be virtual, as it’s not meant to ever be inherited from. But it is what it is.
I ended up using the clone functionality to make a copy of the FGameplayEffectContext I’d just restored, passing that into the FGameplayEffectContextHandle:
TSharedPtr<FGameplayEffectContext> data = AllocateContext(def);
// restore data here
FGameplayEffectContextHandle handle(data->Duplicate());
return handle
I’m open to questions if anyone is interested in other details of what I did.