Save manager workflow
Manager state
UPulseSaveManager exposes EPulseSaveManagerStateType:
IdleSavingLoading
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))
- Before anything else,
PrepareForSave()removes the non-persistent collected records, and all registered static models are written into the save game (synchronously). - Validates the manager is idle and a save game is set (
CanStartProcess). - Collects save objects from all registered collectors (
GetSaveModels). - Resolves or creates a
UPulseSaveModelper object identifier (GetOrCreateModelForObject). - Processes models per chunk (
OnPreSave -> IsReadyToSave (polled) -> OnPostSave). - Writes each model into a
CollectedFPulseSaveModelRecord(persistent or not, see Core concepts). - Completes the process, updating
GetLastProcessReport()and broadcastingOnProcessed()/OnProcessedEventandOnProcessedWithReport()/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))
- 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.
- Loads models from the
Collectedrecords of the currently set save game. - Collects load objects from all registered collectors and maps them to loaded models by identifier. Objects with no matching record trigger
IPulseSaveInterface::OnLoadFailedwithEPulseSaveLoadFailureType::ObjectNotFound. - 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. - Processes models per chunk (
OnPreLoad -> IsReadyToLoad (polled) -> OnPostLoad). - Completes the process the same way as the save flow.
Single-object APIs
Process one object directly, independent of collectors and of GetCurrentState():
SaveSingleObject(UObject* Object)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 (seeBuildChunks) finishes processing, withChunkIndex(1-based, chunks completed so far) andChunkCount(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’sIdentifier, whether it succeeded, and whether it specifically timed out. Fires more often thanOnChunkProcessed, 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:
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.AddCollector/AddCollectorByClass/RemoveCollector/GetCollectors()— manage collectors.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 everySave()and refreshed on everyLoad()(see Models and collectors).CancelProcess()— cancels the currently runningSave()/Load(). Every running model processor is stopped (they are recorded as failed inGetLastProcessReport()), any pending chunks are discarded, and the manager returns toIdle.OnProcessed()/OnProcessedWithReport()still fire withbSuccess = 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.bakbackup rotation. UseSaveToSlot/LoadFromSlot(native, taking aconst FPulseSaveSlot&) orSaveToSlotByName/LoadFromSlotByName(Blueprint-callable, taking aSlotName+RootDirectoryand building a temporaryFPulseSaveSlotinternally, 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, wrappingUGameplayStatics::SaveDataToSlot/LoadDataFromSlot) — the standard engine/platform save system: one blob per slot name + user index, no staging. UseSaveToEngineSlot/LoadFromEngineSlotwhen 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.