Core concepts

Why a model instead of the object itself?

Pulse Save is built around one idea: you save a small, explicit model, not your live gameplay object.

Live object (AActor, UObject, ...)  <-- OnSave / OnPostLoad -->  UPulseSaveModel (persisted)

This is the same separation MVVM uses between a view and its view-model:

  • The model only contains the data that actually needs to persist (health, inventory ids, a position), declared explicitly as UPROPERTY() fields on a small UPulseSaveModel subclass.
  • Your live object stays free to have transient components, delegates, widgets, physics state, render state, etc. — none of that has to be save-safe or even considered, because it is never serialized directly.
  • Data moves between the two only at two points: IPulseSaveInterface::OnSave (copy live -> model) and IPulseSaveInterface::OnPostLoad (copy model -> live), giving you one obvious place to reason about save compatibility, versioning and migrations.

By default (bSerializeAllSaveGameProperties = false on UPulseSaveModel), nothing is copied automatically — you own the mapping explicitly:

By default (bSerializeAllSaveGameProperties = false on UPulseSaveModel), nothing is copied automatically — you own the mapping explicitly. If an object does not define its own UPulseSaveModel class, the default model class from the settings is used automatically. By default, UPulseDefaultObjectSaveModel is set as the standard model: it has bSerializeAllSaveGameProperties = true and automatically serializes all UPROPERTY(SaveGame) properties of the object in its OnPostSave method.

bool AMyActor::OnSave_Implementation(UPulseSaveManager* SaveManager, UPulseSaveModel* SaveModel)
{
    if (UMyActorModel* MyModel = Cast<UMyActorModel>(SaveModel))
    {
        MyModel->Health = Health;
        MyModel->InventoryIds = Inventory->GetItemIds();
    }
    return true;
}

bool AMyActor::OnPostLoad_Implementation(UPulseSaveManager* SaveManager, UPulseSaveModel* SaveModel)
{
    if (const UMyActorModel* MyModel = Cast<UMyActorModel>(SaveModel))
    {
        Health = MyModel->Health;
        Inventory->SetItemIds(MyModel->InventoryIds);
    }
    return true;
}

You can opt back into “serialize every SaveGame property automatically” per model by setting bSerializeAllSaveGameProperties = true on that model class — useful for quick prototypes, but the explicit approach above is recommended for anything you intend to keep long-term, since it keeps the model as a stable, minimal save contract that survives refactors of the live object.

Objects without a matching live instance to bind to (e.g. simple global/save-slot metadata such as a save name or playtime) don’t need this dance at all — see the static model API in Models and collectors.

Save game wrappers

Pulse Save uses three save-game wrapper structs:

  1. FPulseSaveGame — value handle to the shared, serialized payload.
  2. FPulseWeakSaveGame — weak reference wrapper.
  3. FPulseStrongSaveGame — strong reference wrapper; convert with Pin() back to FPulseSaveGame.

UPulseSaveManager::SetSaveGame takes a FPulseSaveGame directly and stores it internally as a strong reference, so the underlying data stays alive while the manager processes it. The manager does not expose a getter for its current save game — keep your own reference if you need to persist it to a slot.

Save records

Each model is serialized into FPulseSaveModelRecord:

  1. Type (EPulseSaveRecordType)
  2. Identifier (FName)
  3. bPersistent
  4. ModelClassPath and ModelVersion
  5. SaveData (TArray<uint8>)

FPulseSaveGame stores all records in one list. The record type says which kind of model a record belongs to, and the model chooses it (UPulseSaveModelBase::GetRecordType()):

Type Models How they are handled
Collected Object models (UPulseSaveModel) and custom models of collectors Gathered by collectors and processed asynchronously (chunks, processors, timeout) by Save()/Load().
Static Static models (UPulseStaticSaveModelBase, see Models and collectors) Requested by code with GetOrCreateStaticModel, loaded and saved synchronously. Always persistent.

Identifiers are unique per type: a static model and an object may use the same identifier.

Persistence is a property of the record (UPulseSaveModelBase::IsPersistent(), for object models the result of IPulseSaveInterface::IsPersistent()). PrepareForSave() runs when a save starts and removes the Collected records that are not persistent, so they have to be written again. Persistent records and Static records are kept.

A record also stores the ModelVersion (UPulseSaveModelBase::GetModelVersion()). Together with ModelClassPath it is used to migrate saves that were written by another model class or an older model version, see Save migration.

The save game format has a version (PULSE_SAVE_SAVE_GAME_VERSION, PULSE_SAVE_MODEL_RECORD_VERSION). A save game or record of an older format is not loaded: LoadGameFromByteArray returns an invalid save game and logs Save game version X is not supported.

Save interface contract

Objects must implement UPulseSaveInterface to participate in save/load. Every function is a BlueprintNativeEvent with a default (no-op / neutral) implementation, so a minimal, practical implementation only needs to:

  1. Return a stable, unique GetUniqueSaveIdentifier().
  2. Provide save/load readiness through IsReadyToSave / IsReadyToLoad if asynchronous behavior is needed (e.g. waiting for a streamed level or an async load to finish).
  3. Implement OnSave / OnPostLoad (see the MVVM section above) and any other callbacks your object needs (OnLoad, OnPostSave, OnLoadFailed, ShouldSave, …).

If identifiers are invalid (NAME_None) or duplicated across objects, the affected object/model is skipped and counted in FPulseSaveProcessReport (see Save manager workflow).

Save model lifecycle

The manager processes models in this order:

  1. OnPreSave() / OnPreLoad()
  2. Poll readiness (IsReadyToSave() / IsReadyToLoad()) until ready or timed out
  3. OnPostSave() / OnPostLoad()
  4. Serialize model data into save records (save path only)

Collectors

Collectors define which objects are included:

  1. UPulseSaveActorCollector — iterates world actors that implement IPulseSaveInterface.
  2. UPulseSaveRegisterCollector — manually register/unregister objects at runtime.
  3. Custom subclasses of UPulseSaveCollectorBase.

See Models and collectors for details on each.


This site uses Just the Docs, a documentation theme for Jekyll.