SaveGame Pointers and Structs

Overview I've writing this tutorial to share what I learn't when trying to setup a save game function for a strategy game I'm developing. This is really just expanding on a post on the Fortnite Sav...

Updated about 2 years ago Edit Page Revisions

Overview

I've written this tutorial to share what I learned when trying to set up a save game function for a strategy game I'm developing. This is really just expanding on a post on the Fortnite Save system that was posted by Ben Zeigler here and some of Rama's excellent posts. I just found those didn't quite explain the method of saving pointers for Actors and UObjects from start to finish so hopefully, I can just add to that information and show my whole system.

I've set up this system in c++ to be called from blueprints for saving the data. This using my project a 4X turn-based strategy game as an example so I will talk about saving Planets, empires (players), corps (AI industry), etc

Here is an early shot of the galaxy map in case it helps picture what I'm saving\loading:

File:WorldsCollideEarlyShot.png

Blueprint Setup

Blueprint Saving Setup

File:SaveMain.png

File:SaveState.png

Blueprint Loading Setup

Here is the main layout and order of loading, you will see I just spawn all actors first then all saved UObject's get constructed (preloaders). Next, I de-serialize all data into the actors then objects, doing it in bulk means all objects exist when de-serializing so the save archive can successfully find the objects for any pointers that you serialized when saving.

File:LoadingMain.png

At the end I call the post loading event, this will just loop through all objects that were created (AActors and UObjects) and call the interface "Post Loading Initialize" on them this allows any final calculations or setup to be run at the end when all data has finished loading. Using an interface is good because you only have to setup the interface call for objects that need anything done at this point.

File:PostLoadingEvent.png

The interface 'Post Loading' for "Planet" actor will setup effects, non saved actors, or any actor data that can be calculated instead of save\loaded, for example, "Apply Influence" function will spawn and configure an actor to display the team's borders using a "Current Influence" value that was loaded when de-serializing data (LoadData Function):

File:PlanetPostLoading.png

This is the interface Post Loading I use for my 'Empire' UObjects. Here I call a function "Assign Controller" which will Assign the first empire to PlayerController and for the other empires spawn AI controllers and link to the empire.

File:EmpirePostLoading.png

CPP setup

Below I've set up the c++ code, which I mostly copy-and-pasted though some lines may have been edited to make it easier to read compared to how it interfaces with my project. So though it may not work directly as copy and paste code you should be able to get the idea any use for you own project with minor tweaking at most.

Save Game Archive

WCSaveGameArchive.h

This is a very basic setup and all I needed. Setting the default for ArIsSaveGame = true will make sure UPROPERTY() have to be tagged with SaveGame to save. This allows you to skip saving\loading any properties that can be calculated or is a temp value in the class\struct saved.

That my understanding so far anyway!

#include "Serialization/ObjectAndNameAsStringProxyArchive.h"
#include "WCSaveGameArchive.generated.h"
/**
 * Save Game Archiver.
 */
struct FWCSaveGameArchive : public FObjectAndNameAsStringProxyArchive
{
    FWCSaveGameArchive(FArchive& InInnerArchive) 
             : FObjectAndNameAsStringProxyArchive(InInnerArchive,true)
    {
        ArIsSaveGame = true;
                ArNoDelta = true; // Optional, useful when saving/loading variables without resetting the level.
                                  // Serialize variables even if weren't modified and mantain their default values.
    }
};

Object Record Struct

This saves all the data needed to recreate a UObject back in the game. Biggest thing I learned here was to save the Outer as well. UObjects are required to have the same outer when saving and loading as this is used to restore pointers to the right objects. I save pointers to the outer objects for outers that are part of the map or otherwise already existent, OuterID will be used if Outer is a previously loaded object and the ID will be the index for the TempObjects Array the SaveGame object uses. This is so I don't have to structure the load functions with the right outers for the right objects.

The rule you have to make sure you follow here is that all outers actually get created\loaded before the objects they are an outer for does. Generally, this works ok if you save in the right order it will load that way too. e.g. I save an array of 'Empire' objects for my game first then an array of 'Corps' that use its owning empire object as an outer for it.

Outer layout and save order for me: Planets > Empires > Corps > ResearchedTechs

I still want to tidy up how the outers are managed but at least is functional like this. Also, not fully sure if there is much benefit to using specific outers, instead you could just have GameState as the outer for all objects and then don't need to save or load them. I thought it might affect garbage collection performance but haven't properly investigated it yet.

ObjectRecord.h

USTRUCT(BlueprintType)
struct FObjectRecord
{

    GENERATED_USTRUCT_BODY()

public:

    // class that this object is
    UPROPERTY(BlueprintReadWrite)
    UClass* Class;

    // save the outer used for object so they get loaded back in the correct hierachy
    UPROPERTY(BlueprintReadWrite)
    UObject* Outer;

    // save the outer used for object so they get loaded back in the correct hierachy
    UPROPERTY(BlueprintReadWrite)
    int32 OuterID;

    // if the outer is an actor otherwise will be UObject
    UPROPERTY(BlueprintReadWrite)
    bool bActor;

    // this is for loading only, store a pointer for the loaded object here so you can loop for the records later to de-serialize all the data
    UPROPERTY(BlueprintReadWrite)
    UObject* Self;

    // Name of the object
    UPROPERTY(BlueprintReadWrite)
    FName Name;

    // serialized data for all UProperties that are 'SaveGame' enabled
    UPROPERTY(BlueprintReadWrite)
    TArray Data;

    // Spawn location if it's an actor
    UPROPERTY(BlueprintReadWrite)
    FTransform Transform;

    FObjectRecord()
    {
        Class = nullptr;
        Outer = nullptr;
        Self = nullptr;
    }
};

Save Game Object

SaveGame.h


/**
 * 
 */
UCLASS(Blueprintable)
class WORLDSCOLLIDE_API USaveGameC : public USaveGame
{
    GENERATED_BODY()

public:
    
    

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TArray GalaxyData;

    // All object data in one array
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TArray ObjectRecords;

    // used for temp loading objects before serializing but after loading
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TArray TempObjects;

    // outers that are part of the map or otherwise preloaded so won't be in the list of TempObjects
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TArray PersistentOuters;

public:

    // basically just a wrapper so you don't have to do a for loop in blueprints
    UFUNCTION(BlueprintCallable)
    void ActorArraySaver(UPARAM(ref)TArray& SaveActors);

    // Save individual Actors
    UFUNCTION(BlueprintCallable)
    void ActorSaver(AActor* SaveActor);

    // Create all saved actors without any data serialized yet
    UFUNCTION(BlueprintCallable)
    void ActorPreloader(AActor* WorldActor, FObjectRecord& ActorRecord);

    // basically just a wrapper so you don't have to do a for loop in blueprints
    UFUNCTION(BlueprintCallable)
    void UObjectArraySaver(UPARAM(ref) TArray& SaveObjects);

    // Save individual objects
    UFUNCTION(BlueprintCallable)
    void UObjectSaver(UObject* SaveObject);

    // create all saved objects without any data serialized yet
    UFUNCTION(BlueprintCallable)
    void UObjectsPreloader(AActor* WorldActor);

    // load all data after all objects exist so all pointers will load
    UFUNCTION(BlueprintCallable)
    void UObjectDataLoader();

    // serialize the data
    UFUNCTION(BlueprintCallable)
    void SaveData(UObject* Object, TArray& Data);

    // de-serialize the data
    UFUNCTION(BlueprintCallable)
    void LoadData(UObject* Object, UPARAM(ref) TArray& Data);
};

SaveGame.cpp


// Fill out your copyright notice in the Description page of Project Settings.

#include "SaveGameC.h"
#include "GameFramework/Actor.h"
#include "Serialization/MemoryReader.h"
#include "Serialization/MemoryWriter.h"
#include "Engine/World.h"
#include "GameStateC.h"

DEFINE_LOG_CATEGORY(LogSaveGame)

void USaveGameC::ActorArraySaver(UPARAM(ref) TArray& SaveActors)
{
    for (AActor* SaveActor : SaveActors)
    {
        ActorSaver(SaveActor);
    }
}

void USaveGameC::ActorSaver(AActor* SaveActor)
{
    

    int32 Index = ObjectRecords.Emplace();
    FObjectRecord& ObjectRecord = ObjectRecords[Index];

    ObjectRecord.Name = SaveActor->GetFName();
    ObjectRecord.Transform = SaveActor->GetTransform();
    ObjectRecord.Class = SaveActor->GetClass();
    ObjectRecord.bActor = true;

    SaveData(SaveActor, ObjectRecord.Data);

    this->TempObjects.Add(SaveActor);
    UE_LOG(LogSaveGame, Display, TEXT("Complete Save Actor %s"), *SaveActor->GetName())
    
}

void USaveGameC::ActorPreloader(AActor* WorldActor, FObjectRecord& ActorRecord)
{

    FActorSpawnParameters SpawnParams;
    SpawnParams.Name = ActorRecord.Name;

    // TODO: change this to SpawnActorDeferred so you can de-serialize and apply data before it calls constructor\BeginPlay
    AActor* NewActor = WorldActor->GetWorld()->SpawnActor(ActorRecord.Class, ActorRecord.Transform, SpawnParams);
    //AActor* NewActor = WorldActor->GetWorld()->SpawnActorDeferred
        
    // BUG? actor doesn't appear to load scale correctly using transform so I specifically apply the scale after loading
    NewActor->SetActorScale3D(ActorRecord.Transform.GetScale3D());

    // don't load now, load after all objects are preloaded
    //LoadData(LoadObject, ObjectRecord.Data);

    // add to temp array for lookup it another object using already loaded objects as outers (array gets cleared once all objects loaded)
    this->TempObjects.Add(NewActor);

    UE_LOG(LogSaveGame, Display, TEXT("Complete Load Actor %s"), *NewActor->GetPathName())
}

void USaveGameC::UObjectArraySaver(UPARAM(ref) TArray& SaveObjects)
{
    for (UObject* SaveObject : SaveObjects)
    {
        UObjectSaver(SaveObject);
    }
}

void USaveGameC::UObjectSaver(UObject* SaveObject)
{
    if (SaveObject == nullptr)
    {
        UE_LOG(LogSaveGame, Error, TEXT("Invalid Save Object!"))
        return;
    }

    if (SaveObject->HasAnyFlags(EObjectFlags::RF_Transient))
    {
        UE_LOG(LogSaveGame, Warning, TEXT("Saving RF_Transient object"))
        return;
    }

    if (SaveObject->IsA())
    {
        ActorSaver(Cast(SaveObject));
        return;
    }

    int32 Index = ObjectRecords.Emplace();
    FObjectRecord& ObjectRecord = ObjectRecords[Index];

    // Use custom IDs for save\retrieving outer pointers
    // * Negative IDs if outer is a permanent map object (i.e. not loaded from SaveGame)
    // * Negative IDs start from -2 because -1 is already assigned to INDEX_NONE, and 0+ is used for SaveGame loaded objects
    ObjectRecord.OuterID = TempObjects.Find(SaveObject->GetOuter());
    ObjectRecord.bActor = false;

    // if outer is a saved object then don't try to save the direct object pointer
    if (ObjectRecord.OuterID == INDEX_NONE)
    {
        ObjectRecord.OuterID = PersistentOuters.Find(SaveObject->GetOuter());
        if (ObjectRecord.OuterID != INDEX_NONE)
        {
            ObjectRecord.OuterID = -(ObjectRecord.OuterID + 2);
        }
        else
        {
            int32 Index = PersistentOuters.Add(SaveObject->GetOuter());
            ObjectRecord.OuterID = -(Index + 2);
            UE_LOG(LogSaveGame, Display, TEXT("Save Outer %s"), *SaveObject->GetOuter()->GetPathName())

        }
    }

    ObjectRecord.Name = SaveObject->GetFName();
    ObjectRecord.Class = SaveObject->GetClass();

    SaveData(SaveObject, ObjectRecord.Data);

    this->TempObjects.Add(SaveObject);

    UE_LOG(LogSaveGame, Display, TEXT("Complete Save UObject %s"), *SaveObject->GetName())
}

void USaveGameC::UObjectsPreloader(AActor* WorldActor)
{
    UObject* LoadOuter = nullptr;

    for (FObjectRecord& ObjectRecord : ObjectRecords)
    {
        if (ObjectRecord.bActor == false)
        {
            if (ObjectRecord.OuterID != INDEX_NONE)
            {
                if (TempObjects.IsValidIndex(ObjectRecord.OuterID) == true)
                {
                    LoadOuter = TempObjects[ObjectRecord.OuterID];
                    if (LoadOuter == nullptr)
                    {
                        UE_LOG(LogSaveGame, Error, TEXT("Unable to find Outer for object (invalid array object)"))
                    }
                }
                else
                {
                    int32 NewIndex = FMath::Abs(ObjectRecord.OuterID) - 2;

                    if (PersistentOuters.IsValidIndex(NewIndex)) 
                    {
                        LoadOuter = PersistentOuters[NewIndex];
                    }
                    else 
                    {
                        UE_LOG(LogSaveGame, Error, TEXT("Unable to find Outer for object (invalid ID)"))
                    }   
                }
            }
            if (LoadOuter == nullptr)
            {
                UE_LOG(LogSaveGame, Error, TEXT("Unable to find Outer for object (no pointer)"))
                continue;
            }

            UObject* LoadObject = NewObject(LoadOuter, ObjectRecord.Class, ObjectRecord.Name);
            
            if (LoadObject == nullptr) return;

            // don't load now, load after all objects are preloaded
            //LoadData(LoadObject, ObjectRecord.Data);

            // add to here to cycle through and keep a pointer temporarly to avoid garbage collection (not sure if required but to be safe)
            this->TempObjects.Add(LoadObject);

            UE_LOG(LogSaveGame, Display, TEXT("Complete Load UObject %s %d"), *LoadObject->GetPathName(), this->TempObjects.Num() - 1)
        }

        else
        {
            ActorPreloader(WorldActor, ObjectRecord);
        }
    }
}

void USaveGameC::UObjectDataLoader()
{
    for (int32 a = 0 ; ObjectRecords.IsValidIndex(a) ; a++)
    {
        // Load now after all objects are preloaded
        LoadData(TempObjects[a], ObjectRecords[a].Data);
    }
}

void USaveGameC::SaveData(UObject* Object, TArray& Data)
{
    if (Object == nullptr) return;

    FMemoryWriter MemoryWriter = FMemoryWriter(Data, true);
    FWCSaveGameArchive MyArchive = FWCSaveGameArchive(MemoryWriter);

    Object->Serialize(MyArchive);
}

void USaveGameC::LoadData(UObject* Object, UPARAM(ref) TArray& Data)
{
    if (Object == nullptr) return;

    FMemoryReader MemoryReader(Data, true);

    FWCSaveGameArchive Ar(MemoryReader);
    Object->Serialize(Ar);
}

Conclusion

That's the first implementation of my save system so there is probably plenty of room for improvement still. Hope it can help others understand how the FObjectAndNameAsStringProxyArchive can be used with object pointers and what you need to watch out for. Feel free to PM me with any questions or suggestions to expand or explain further anything I've shown here.

Thanks!