Concept: World State & Identity
World-actor state is stored in slabs - one UCrimsonSaveWorldData per level - held at UCrimsonSaveGameManagerSubsystem (GameInstance) scope, so they outlive any single level or UWorldSubsystem and survive streaming and seamless travel. Each slab is a TMap<FGuid, FCrimsonSaveWorldActorData>. An actor opts in by implementing ICrimsonSaveableActor; on every save the world subsystem scans loaded levels and captures each one automatically.
OnActorStateChanged / OnActorDestroyed are optional incremental hooks, not a requirement. They change when a change is recorded, not whether your actor is captured - and neither writes to disk. FlushDirtyWorldState() is what writes, and it writes only the actors that changed.What is captured automatically
| Captured | Notes |
|---|---|
| Transform | Movable actors only (root mobility != Static). |
Actor UPROPERTY(SaveGame) fields | The actor's own tagged properties, via the engine proxy archive. |
Component UPROPERTY(SaveGame) fields | Cascades into every component - state on health / inventory / movement components persists with no extra code. |
| Non-property state | Hidden flag, physics & movement velocities, and pawn controller rotation - no SaveGame flag needed. |
| Cross-object references | SaveGame AActor* / TWeakObjectPtr<AActor> pointers are stored as stable GUIDs and re-resolved to live instances after load (same level or persistent level). |
| Custom payload | Optional FInstancedStruct from GatherActorSaveData for anything beyond the above. |
Identity
Every saveable actor is keyed by a stable GUID so it can be matched across sessions. Getting this right is most of what makes world saving work: an identity that shifts means an actor silently loses its history, and an identity two actors can share means one inherits the other's.
| Actor kind | Identity key |
|---|---|
| Level-placed, with a Crimson Persistent Id component | The id baked into the level by that component - the engine's own ActorGuid, carried through the cook. Survives renaming the actor, moving it between World Partition cells, rebuilding the grid, and an actor being deleted and its name later reused. |
| Level-placed, without one | A GUID derived from the actor's authored object path, normalized so a World Partition cell name never enters it. Stable against everything above except name reuse - see the warning below. |
| Runtime-spawned | A session FGuid assigned by the subsystem; the actor's class (TSoftClassPtr) is recorded so it can be re-spawned on load. |
ActorGuid into a normal property, which the cook keeps. That is the whole trick - the engine's own copy is editor-only and does not exist in a packaged build, which is why a save system cannot simply read it.ACrimsonPersistentActor already carries one. Add it to any other saveable actor. The id populates when the level is next saved in the editor, so a map authored before this existed keeps using the path fallback until you resave it.StaticMeshActor_3 frees that name, and the next actor to take it inherits the deleted actor's saved state. The fallback path cannot tell the two apart, because from its side they are the same actor. The id component is the fix, and it is why it is worth adding rather than relying on the default.Capture and restore flow
- Save - the subsystem scans loaded levels for
ICrimsonSaveableActoractors and writes each into its level slab: transform, the actor'sSaveGameblob, per-componentSaveGameblobs, non-property state, and cross-object reference GUIDs. - Flush - slabs are written to
WorldSlab_<Level>.savin the active slot (as part of the main save, or viaSaveWorldState). - Load - slabs are read back into memory when the slot loads; each level's slab is applied when the level is present (
LoadWorldState, or automatically as sublevels stream in). - Resolve - after every actor in a level is restored / re-spawned, a deferred pass rewrites
SaveGameactor-pointer properties from their stored GUIDs to the live instances.
Deletion
Destruction is recorded from the engine's actor-destroyed event on the server, so only a real Destroy() removes an actor from the save - an actor simply being absent does not.
| Actor kind | On destroy | Save-file cost |
|---|---|---|
| Runtime-spawned | Its slab entry is removed entirely - it simply stops being saved. | None - a removed entry means nothing to re-spawn. |
| Placed (level) | A minimal GUID-only tombstone is written; the copy reloaded from the level is destroyed on load. | A few bytes, bounded by how many map actors you actually delete. |
ShouldRespawnAfterDestroyed() (default false) and return true to skip the tombstone for a placed actor, so it respawns from the level on the next load. Runtime actors ignore this flag. See How-To: Persist a Level Actor.ICrimsonSaveableActor overrides
All interface methods are optional BlueprintNativeEvents. Implement the interface and you are done; override these only for custom state or lifecycle handling.
| Override | When used |
|---|---|
GatherActorSaveData() | Return an FInstancedStruct for data that cannot be expressed as UPROPERTY(SaveGame). Empty (default) means only auto-captured state is saved. |
RestoreFromSaveData(ActorData) | Receives the FInstancedStruct. Transform, SaveGame fields, and component state are already restored before this runs. |
OnPreRestoreState() | Before any restoration. Disable physics, clear containers. |
OnPostRestoreState() | After all restoration. Re-enable physics, broadcast events. |
ShouldRespawnAfterDestroyed() | Return true so a destroyed placed actor respawns from the level instead of staying deleted. Default false. Runtime actors ignore it. |
Streaming & World Partition
A sublevel's state is applied when it streams in and its actors are re-captured just before it streams out, so revisiting a region restores it. World Partition runtime cells stream as levels through that same path, and nothing here treats them as a special case - there is no World Partition setting to enable, because there is no World Partition code path to enable.
So nothing does. Both the actor's identity and the level its state is grouped under normalize the generated segment away, leaving the world that owns the cell - which is authored, and therefore stable. A world that is not partitioned has no such segment and passes through the same code unchanged. This mirrors the engine's own
FWorldPartitionHelpers::ConvertRuntimePathToEditorPath.SaveGame-only property never reaches clients unless it is also Replicated - see Concept: Multiplayer & Save Scopes.Driving world state manually (optional)
With bIncludeWorldStateInMainSave (default), a normal save persists world actors too. To drive world state on its own:
Reach the subsystem with Get World Subsystem (Class = Crimson Save World Manager Subsystem), then drag off its output for the call you want. Save World State captures every saveable actor in every loaded level and writes the slabs. Load World State applies them, re-spawns runtime actors, and runs the cross-object reference resolve pass. Flush Dirty World State writes only what changed since the last flush.
On Actor State Changed (In Actor, defaulting to Self) is the optional incremental capture of a single actor, and On Actor Destroyed (In Actor) its optional destruction counterpart - see How-To: Configure Auto-Saving. Every one of these is server-authority only, so gate the chain on Has Authority (Branch, True). With bIncludeWorldStateInMainSave at its default you need none of them.