Save manager workflow

Manager state

UPulseSaveManager exposes EPulseSaveManagerStateType:

  1. Idle
  2. Saving
  3. Loading

Query it with GetCurrentState(), or use the convenience helpers IsIdle(), IsSaving(), IsLoading(), CanSave(), CanLoad() (the latter two also check that a save game is set and at least one collector is registered).

Save flow (UPulseSaveManager::Save / StartProcess(Save))

  1. Before anything else, PrepareForSave() removes the non-persistent collected records, and all registered static models are written into the save game (synchronously).
  2. Validates the manager is idle and a save game is set (CanStartProcess).
  3. Collects save objects from all registered collectors (GetSaveModels).
  4. Resolves or creates a UPulseSaveModel per object identifier (GetOrCreateModelForObject).
  5. Processes models per chunk (OnPreSave -> IsReadyToSave (polled) -> OnPostSave).
  6. Writes each model into a Collected FPulseSaveModelRecord (persistent or not, see Core concepts).
  7. Completes the process, updating GetLastProcessReport() and broadcasting OnProcessed() / OnProcessedEvent and OnProcessedWithReport() / OnProcessedWithReportEvent.

If a model is not yet ready, processing continues on subsequent ticks until it succeeds, fails, or times out (see below).

Save() returns false immediately (without any tick) if nothing was collected to save, e.g. no collector returned any object, or every object’s ShouldSave returned No.

Load flow (UPulseSaveManager::Load / StartProcess(Load))

  1. Before anything else, all registered static models are loaded in place from the save game (synchronously). They do not take part in the steps below.
  2. Loads models from the Collected records of the currently set save game.
  3. Collects load objects from all registered collectors and maps them to loaded models by identifier. Objects with no matching record trigger IPulseSaveInterface::OnLoadFailed with EPulseSaveLoadFailureType::ObjectNotFound.
  4. Groups the models into chunks, ordered by GetLoadPriority() (higher first). Models that only have the default priority (custom models of collectors) load after all object models.
  5. Processes models per chunk (OnPreLoad -> IsReadyToLoad (polled) -> OnPostLoad).
  6. Completes the process the same way as the save flow.

Single-object APIs

Process one object directly, independent of collectors and of GetCurrentState():

  1. SaveSingleObject(UObject* Object)
  2. LoadSingleObject(UObject* Object)

Blueprint node names are identical. Completion is reported through a dedicated delegate pair:

  • OnProcessedSingle() (native, FPulseSaveManagerProcessedSingleSignature)
  • OnProcessedSingleEvent (Blueprint-assignable)

One-shot completion callbacks

OnProcessed()/OnProcessedEvent/OnProcessedSingle()/OnProcessedSingleEvent are persistent multicast delegates: once bound, they fire on every future Save()/Load() (or SaveSingleObject()/LoadSingleObject()) call until manually unbound. Sometimes you only care about the result of one specific call — e.g. “run this callback once this particular save finishes, then forget about it”. For that, pass a one-shot completion delegate directly into the call itself; it fires exactly once for that call and is discarded automatically afterwards, no unbinding needed:

// Native C++
SaveManager->Save(FPulseSaveManagerProcessedDelegate::CreateLambda([](UPulseSaveManager*, bool bSuccess)
{
    UE_LOG(LogTemp, Log, TEXT("This specific save finished, success=%d"), bSuccess);
}));

SaveManager->LoadSingleObject(SpawnedActor, FPulseSaveManagerProcessedSingleDelegate::CreateLambda(
    [](UPulseSaveManager*, UObject* Object, bool bSuccess) { ... }));

The same overloads exist for Load() and StartProcess(ProcessType, OnComplete).

In Blueprint, use the dedicated “with callback” nodes instead, which expose a delegate pin you can bind with a custom event:

  • Save (With Callback) / Load (With Callback)
  • Save Single Object (With Callback) / Load Single Object (With Callback)

Or, for a proper exec-pin based node (recommended for most Blueprint use — see Async action nodes below), use Save (Async) / Load (Async) etc. instead.

Async action nodes

For Blueprint, PulseSaveAsyncActions.h provides UBlueprintAsyncActionBase-derived nodes with proper Succeeded/Failed exec pins, instead of a delegate pin (“with callback” nodes) or a persistent OnProcessed/OnProcessedEvent bind. Each node registers itself with the owning game instance so it stays alive until the operation finishes, then automatically destroys itself:

Blueprint node Wraps
Save (Async) Save()
Load (Async) Load()
Save Single Object (Async) SaveSingleObject()
Load Single Object (Async) LoadSingleObject()
Save To Slot (Async) SaveToSlotByName() (FPulseSaveSlot-based, see Slot convenience)
Load From Slot (Async) LoadFromSlotByName()
Save To Engine Slot (Async) SaveToEngineSlot()
Load From Engine Slot (Async) LoadFromEngineSlot()

These are purely a Blueprint convenience layer over the functions already described above — native C++ code should keep using the one-shot delegate overloads (Save(OnComplete), SaveToSlot(...), etc.) directly instead.

Diagnostics report

Every completed process (Save, Load, or a single-object call also updates OnProcessedSingle, but the report below only covers Save/Load) produces a FPulseSaveProcessReport, retrievable via GetLastProcessReport() or received directly through OnProcessedWithReport() / OnProcessedWithReportEvent:

Field Meaning
ProcessType Whether this report is for a save or a load.
CollectedCount Objects/records collected before deduplication/mapping.
ModelsCreatedCount Models newly created (not reused from cache).
ModelsReusedCount Models reused from the manager’s identifier cache.
SkippedDuplicateCount Objects skipped because another entry already used the same identifier.
SkippedInvalidIdentifierCount Objects skipped because they had no (valid) identifier.
StaticModelsLoadedCount Registered static models that were loaded from their record when Load() started.
MigratedModelsCount Records migrated to another model class or version (see Save migration).
MigrationFallbackCount Records without a registered migration that were handled by MigrationFallback.
FailedModels Array of {Identifier, Reason} for models that failed to process.
TimeoutCount Number of FailedModels that failed specifically due to a timeout.
DurationSeconds Total wall-clock duration of the process.
bWasSuccessful Whether the process completed successfully overall.

Use this to surface actionable diagnostics (e.g. in a debug UI or logs) without parsing engine logs.

The same information, plus a list of every processed model and the messages the models wrote, can be written to a report file and looked at in the editor. See Reports.

Progress events

GetLastProcessReport() / OnProcessedWithReport() only report final results, once the whole process is done. For progress bars or loading-screen feedback while Save()/Load() is still running, use:

  • OnChunkProcessed() / OnChunkProcessedEvent — fires after each chunk (see BuildChunks) finishes processing, with ChunkIndex (1-based, chunks completed so far) and ChunkCount (total chunks for the current process). Good for a coarse-grained progress bar.
  • OnModelProcessed() / OnModelProcessedEvent — fires after each individual model finishes processing within a chunk, with the model’s Identifier, whether it succeeded, and whether it specifically timed out. Fires more often than OnChunkProcessed, useful for finer-grained progress (e.g. “12 / 340 objects saved”).

Both are ordinary multicast events (native DECLARE_MULTICAST_DELEGATE_* + BlueprintAssignable dynamic equivalent), following the same pattern as OnProcessed()/OnProcessedEvent.

Timeout behavior

The default per-model timeout is UPulseSaveSettings::DefaultProcessTimeout (default 60.0f seconds, -1 disables the timeout). A model can override this by implementing:

virtual bool GetTimeoutOverride(float& OutTimeout) const override;

Process control

Other useful manager functions:

  1. ResetCachedSaveModels() — clears the object-model identifier cache used by the main process, GetOrCreateModelForObject, and the single-object functions. Static models are not part of it (they are managed by the static model registry). Clearing this cache forces new object models to be created on the next save/load access.
  2. AddCollector / AddCollectorByClass / RemoveCollector / GetCollectors() — manage collectors.
  3. GetOrCreateStaticModel / SaveStaticModel — global/save-slot metadata that isn’t bound to a live object. Static models are handled synchronously, outside of the chunks and processors. Registered static models are automatically written on every Save() and refreshed on every Load() (see Models and collectors).
  4. CancelProcess() — cancels the currently running Save()/Load(). Every running model processor is stopped (they are recorded as failed in GetLastProcessReport()), any pending chunks are discarded, and the manager returns to Idle. OnProcessed() / OnProcessedWithReport() still fire with bSuccess = false, so existing completion handling keeps working unchanged. Does nothing besides a warning log if the manager is already idle. Useful for level transitions, quitting, or a user-triggered cancel button while a save/load is in progress.

Creating managers and the manager pool

Pulse::Save::CreateManager (PulseSaveManager.h) creates a manager. Without an outer it uses the transient package. Every overload takes an optional manager class as its last parameter.

UPulseSaveManager* A = Pulse::Save::CreateManager();
UPulseSaveManager* B = Pulse::Save::CreateManager(GetGameInstance());
UPulseSaveManager* C = Pulse::Save::CreateManager(TEXT("Player profile"));               // context, transient package
UPulseSaveManager* D = Pulse::Save::CreateManager(GetGameInstance(), TEXT("World state")); // outer + context

FPulseSaveManagerPool (PulseSaveManagerPool.h) keeps managers so they can be shared. GetOrCreate(Class, Outer, Context) identifies a manager by its class and outer: the same class with another outer is another manager. The context is only used when the manager is created. The pool owns its managers and, if one is passed to the constructor, its outer, so they are not garbage collected while the pool lives. Without an outer the transient package is the default outer.

FPulseSaveManagerPool Pool(GetGameInstance());
UPulseSaveManager* Manager = Pool.GetOrCreate(UPulseSaveManager::StaticClass(), nullptr, TEXT("Player profile"));

Global pool: UPulseSaveManagerPoolSubsystem is a game instance subsystem (so the managers survive level changes) that holds a pool whose default outer is the subsystem. Use Pulse::Save::GetGlobalManagerPool(WorldContext) or Pulse::Save::GetOrCreateGlobalManager(WorldContext, Class, Outer, Context). Both return nullptr if the subsystem is not available. It is only created if you enable bEnableGlobalManagerPool in the PulseSave project settings (default off, restart required).

In Blueprint, UPulseSaveManagerPoolLibrary (category PulseSave|Manager|Pool) gives access to the global pool: Get Or Create Global Save Manager (class, optional outer and context; the output is typed by the class), Find Global Save Manager, Remove Global Save Manager, Reset Global Save Manager Pool and Is Global Save Manager Pool Available.

Slot convenience

Two independent slot systems exist in PulseSave, and the manager has convenience functions for combining Save()/Load() with either of them in a single call, instead of manually chaining process completion with a separate slot read/write:

  • FPulseSaveSlot (PulseSaveSlot.h) — a directory-based, multi-entry save system with a stage/flush workflow (write several entries, then commit them together) and optional .bak backup rotation. Use SaveToSlot / LoadFromSlot (native, taking a const FPulseSaveSlot&) or SaveToSlotByName / LoadFromSlotByName (Blueprint-callable, taking a SlotName + RootDirectory and building a temporary FPulseSaveSlot internally, since that type itself isn’t Blueprint-exposed). Prefer this when you need multiple named saves per slot (e.g. per-chunk/per-region data alongside a global save), staged writes, or backup rotation.
  • Engine slot (UPulseSaveUtils::SaveGameToSlot / LoadGameFromSlot, wrapping UGameplayStatics::SaveDataToSlot / LoadDataFromSlot) — the standard engine/platform save system: one blob per slot name + user index, no staging. Use SaveToEngineSlot / LoadFromEngineSlot when you just need a simple, single save file per slot and want to stay on the standard engine save path (e.g. for platform save icons/metadata integration).

SaveToSlot* runs Save() and, only if it succeeds, writes the resulting save game to the slot; the combined result (save AND slot-write success) is what reaches OnComplete. LoadFromSlot* synchronously reads the save game from the slot first, calls SetSaveGame() with it, and then runs Load(); it returns false immediately (without starting a process) if the slot read fails.

SaveToSlot / SaveToSlotByName / Save To Slot (Async) stage the save game into the entry and then commit it as EPulseSaveSlotFlushMode says (Blueprint: Flush Mode, default Entry):

Mode Effect
Entry Commits only the entry that was saved (FPulseSaveSlot::FlushEntry). Other staged entries stay staged.
Slot Commits every staged entry of the slot, including the saved one (FPulseSaveSlot::Flush).
None Only stages. Loading already prefers staged data, but nothing is final until you call FlushEntry / Flush yourself.

With None the result that reaches OnComplete means “saved and staged”. Use it for chunk-like saves where a later checkpoint flushes everything at once.


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