Persistence¶
Summary: Persistence saves and restores a player's complete character — inventory, equipment, glyphs, quests, compendium, and world-map progress — through a single component that delegates to a swappable provider. A
UPersistenceComponenton the player controller selects one of three providers (local file, remote backend, or read-only curated preset) based on net mode and dev settings, then drives auto-save and an initial auto-load. The provider abstraction lets the same save-data shape and restore pipeline serve a solo file, a dedicated-server REST call, or a shipped demo "play-as" build.
Table of Contents¶
- Architecture Overview
- Core Concepts
- Provider Selection
- The Read-Only Preset Provider
- Save Data Shape
- Build & Restore Pipeline
- Auto-Load & Travel Gate
- Identity & Multiplayer
- Public Contracts
- Source References
- Related Systems
- Recent Changes
Architecture Overview¶
+--------------------------------------------------------------+
| AEternalPlayer (PlayerController) |
| |
| +------------------------------------------------------+ |
| | UPersistenceComponent | |
| | - CharacterId / AccountId (FGuid identity) | |
| | - Auto-save timer + save/load guards | |
| | - Selects ONE provider in InitializeProvider() | |
| +-----------------------+------------------------------+ |
| | IPersistenceProvider |
+---------------------------|----------------------------------+
|
+-------------------+-------------------+
v v v
+----------------+ +----------------+ +-------------------------+
| Local provider | | Remote provider| | Preset provider |
| JSON file | | REST API | | (READ-ONLY) |
+-------+--------+ +-------+--------+ +-----------+-------------+
| | |
v v v
Saved/SaveGames/ eternal-server UPresetCharacterAsset
{CharacterId}.json (PersistenceApi) .Payload (cooked asset)
All three speak the SAME FCharacterSaveData struct,
and load runs the SAME restore pipeline:
UPersistenceHelpers::RestoreCharacterFromSaveData()
Key Design Principles¶
| Principle | Rationale |
|---|---|
| One component, swappable provider | Same save/load lifecycle whether solo, networked, or demo |
| Provider is an interface, not a branch | IPersistenceProvider keeps storage mechanism out of the component |
| One save-data struct for all backends | Local file, REST body, and preset payload are identical FCharacterSaveData JSON |
| Component lives on the controller | Persistence must outlive pawn death (see System Ownership) |
| Server authority on save/load | Clients never write; only the authority builds and restores state |
Core Concepts¶
Why a Provider Abstraction?¶
The character-save shape and the restore logic are stable, but where the data lives is not: a solo player
saves to a local file, a multiplayer session round-trips through the backend, and a shipped demo loads a curated
character that is never written back. Rather than branch on net mode throughout the component, the storage mechanism
is hidden behind IPersistenceProvider. The component asks the provider to SaveCharacter / LoadCharacter and
reacts to a completion delegate — async for remote, immediate for local — without knowing which backing store it is
talking to.
Why the Component Lives on the Player Controller¶
Inventory and equipment must survive pawn death, so the save/load owner sits on AEternalPlayer (the player
controller) rather than the pawn. During teardown the pawn — and the equipment/glyph data it transitively reaches
through PlayerState — is destroyed before the component's EndPlay. The component therefore caches the equipment and
quest component pointers in BeginPlay, and the authoritative save is the auto-save timer, supplemented by an
explicit RequestSave() from AEternalGameMode::Logout while the pawn is still intact. EndPlay deliberately does
not save.
Provider Selection¶
UPersistenceComponent::InitializeProvider() (called from BeginPlay) picks exactly one provider. A configured
preset always wins; otherwise the choice is local vs. remote based on forced mode or net mode.
Provider Modes¶
EPersistenceProviderMode (Project Settings > Game > Persistence (Dev)):
| Mode | Behaviour |
|---|---|
Auto |
Local in Standalone; Remote in DedicatedServer / ListenServer |
Local |
Always the local JSON-file provider |
Remote |
Always the remote backend provider |
Preset |
Always load the configured read-only DefaultPreset (demo / meta build) |
Precedence Chain¶
Preset selection is resolved first, by ResolveActivePreset(), and short-circuits local/remote selection entirely:
1. GameInstance PendingPreset ──► menu selection survives OpenLevel() ──► PRESET (read-only)
| (null)
v
2. ProviderMode == Preset ──► forced demo mode ──► PRESET (read-only)
| (no)
v
3. bAutoLoadPresetInPackaged- ──► packaged build only (#if !WITH_EDITOR),
Build && DefaultPreset no explicit selection ──► PRESET (read-only)
| (no / editor)
v
4. ProviderMode == Remote OR (Auto && (DedicatedServer || ListenServer)) ──► REMOTE
| (no)
v
5. otherwise ──► LOCAL
| Precedence | Source | Resolves To |
|---|---|---|
| Highest | UEternalGameInstance::GetPendingPreset() (menu selection) |
Preset provider |
| ↓ | ProviderMode == Preset |
Preset provider (loads DefaultPreset) |
| ↓ | bAutoLoadPresetInPackagedBuild (packaged builds only) |
Preset provider (loads DefaultPreset) |
| ↓ | Remote mode, or Auto on a server net mode |
Remote provider |
| Lowest | Everything else | Local provider |
The menu-selected preset is carried on the GameInstance because it is the only object that survives the OpenLevel()
from the menu into the gameplay map (see Game Framework → Pending Preset Carrier).
The Read-Only Preset Provider¶
UPresetPersistenceProvider serves a curated character that loads instead of a real save and is never written
back. It is the in-window addition to the persistence system, supporting shipped demo / "play-as" builds.
Read-Only Contract¶
| Method | Behaviour |
|---|---|
IsReadOnly() |
Returns true (the base interface default is false) |
LoadCharacter() |
Returns the cached FCharacterSaveData snapshot every time |
SaveCharacter() |
No-op (callback reports success so callers don't error) |
DeleteSaveData() |
No-op |
HasSaveData() |
Always reports the preset is available |
The provider is initialized from a UPresetCharacterAsset::Payload via Initialize(const FCharacterSaveData&), which
caches the snapshot in PresetData.
How Read-Only Changes Component Behaviour¶
UPersistenceComponent checks Provider->IsReadOnly() and adjusts its lifecycle:
| Aspect | Normal provider | Read-only preset |
|---|---|---|
| Auto-save timer | Started if AutoSaveInterval > 0 |
Never started |
IsUsingPreset() |
false |
true |
Auto-load on BeginPlay |
Only if bLoadOnPIEStart |
Always auto-loads |
GameMode::Logout save |
Saves | Save is a no-op (writes ignored) |
UPresetCharacterAsset — The Curated Source¶
A UPresetCharacterAsset is a cooked UPrimaryDataAsset (primary asset type "CharacterPreset") shipped with
the build. Its Payload is the same FCharacterSaveData a real save file holds, so a preset loads through the exact
same restore pipeline as a normal save. It also carries menu metadata (DisplayName, Description, Icon,
ClassTag) for a character-select screen, plus an Availability gate controlling whether it is listed.
UPresetCharacterAsset
+-- DisplayName / Description / Icon / ClassTag (menu metadata)
+-- Availability : EPresetAvailability (menu listing gate)
+-- Payload : FCharacterSaveData (curated snapshot, loaded read-only)
EPresetAvailability — Listing Gate¶
| Value | Behavior |
|---|---|
Available |
Listed on the character-select War Table in every build |
DevOnly |
Hidden in packaged builds, listed in editor/dev builds so WIP presets stay testable |
Hidden |
Never listed; still reachable by name for regression testing |
This gates listing only — a hidden preset still cooks into the build and stays loadable by name. The menu paths
(CharacterSelectController, MainMenuController) filter on it via IsListedInMenu(); Eternal.PlayPreset
deliberately ignores it, which is what keeps hidden presets usable as regression fixtures.
Why it lives on the asset rather than a config deny-list: a deny-list keyed by asset name silently stops matching the moment a preset is renamed, and the failure mode is a WIP build appearing in a shipped menu.
Authoring a Preset: Eternal.ExportPreset¶
Presets are authored by capturing a live in-game character. With a possessed character in play (editor/PIE), run in the console:
This editor-only console command snapshots the live local AEternalPlayer through the same
UPersistenceHelpers::BuildCharacterSaveData path the auto-save uses, strips the per-machine identity
(CharacterId / AccountId) and timestamp so the preset is identity-agnostic, then writes
/Game/DataAssets/Presets/Preset_<AssetName>. Re-running overwrites in place: only the gameplay Payload is
refreshed, so hand-edited display metadata is preserved.
Save Data Shape¶
FCharacterSaveData is the top-level aggregate serialized to JSON for every provider. Fields below are exactly what
the struct persists — items are stored as a flat array referenced by slot assignments, and item template data
(icon, fragments, weapon stats) is not saved; it is reconstructed from the item registry by ItemID.
| Group | Fields | Notes |
|---|---|---|
| Identity | CharacterId, AccountId, CharacterName, PlayerClassTemplateId |
GUID identity + class template reference |
| Currency | CraftingResource |
|
| Play time | PlayTimeSeconds |
|
| Items | Items: TArray<FItemSaveState> |
Flat instance list; slots reference these by InstanceId |
| Inventory | InventorySlots: TArray<FInventorySlotSaveState> |
Item instance → grid top-left index |
| Equipment | EquipmentSlots: TArray<FEquipmentSlotSaveState> |
Item instance → slot FGameplayTag |
| Glyphs | StonePlates: TArray<FStonePlateSaveState> |
Plates + per-socket glyph placements |
| Quests | QuestProgress: FQuestSaveState |
Received / active / completed quest tag containers |
| Compendium | CompendiumEntries: TArray<FCompendiumEntry> |
|
| World map | WorldMapProgress: FWorldMapSaveState |
Per-node + per-domain state, plus CurrentNodeID |
| Versioning | SaveVersion, SaveTimestamp |
Per-Item Instance State¶
FItemSaveState carries only instance-specific fields, not the template:
| Field | Purpose |
|---|---|
InstanceId |
Matches UItemObject::InstanceId |
ItemID |
Template reference resolved via the item registry |
StackCount / ItemLevel / ConsumableUsagesLeft |
Per-instance counters |
Modifiers : FEquipmentModifiers |
Rolled affixes, reused directly from the equipment fragment |
StateModules : TArray<FItemStateModuleSaveEntry> |
Polymorphic per-instance runtime state (e.g. Remnant) |
Each FItemStateModuleSaveEntry records a StructName plus a nested FJsonObjectWrapper so per-instance state
serializes as introspectable JSON the backend can query — see Item State Modules.
⚠️ FJsonObjectWrapper persists
JsonString, notJsonObject.JsonObjectis a transientTSharedPtr; only theJsonStringUPROPERTY serializes (PostSerialize re-parses it on load). SettingWrapper.JsonObject = XwithoutWrapper.JsonObjectToString(Wrapper.JsonString)saves an empty payload — and on reload the empty string parses into a valid-but-empty object, soIsValid()guards pass andJsonObjectToUStruct"succeeds" into a default-constructed struct. Nothing errors. Always flushJsonStringbefore save; guard readers withJsonObject->Values.IsEmpty()("no state") not justIsValid(). (This silently dropped all Remnant state from preset exports — seeLearnings/fjsonobjectwrapper-persists-jsonstring-only.md.)
Build & Restore Pipeline¶
Both save and load are orchestrated by static helpers in UPersistenceHelpers, keeping the conversion between live
game objects and persistence structs out of the component.
SAVE LOAD
---- ----
RequestSave() provider->LoadCharacter() -> callback
| |
v v
BuildCharacterSaveData(PlayerController) RestoreCharacterFromSaveData(PlayerController, SaveData)
+ CollectAllItems (inv/equip/glyph) + CreateItemFromSaveState (registry lookup)
+ CollectInventorySlots + RepopulateModifierCache / RepopulateEchoModCache
+ CollectEquipmentSlots + RestoreInventory / RestoreEquipment / RestoreGlyphs
+ CollectGlyphState + RestoreQuestProgress
+ CollectQuestProgress + RestoreCompendium
+ CollectCompendiumEntries + RestoreWorldMapProgress
+ CollectWorldMapProgress |
| v
v OnLoadComplete -> OnLoadCompleted.Broadcast(bSuccess)
provider->SaveCharacter() -> OnSaveComplete
On restore, items are rebuilt from their template by ItemID (via the registry), instance fields are overwritten,
and CachedDefinition pointers on rolled modifiers — including Echo Mods inside Remnant state modules — are
repopulated so live modifier logic works after a fresh load. A successful load seeds LastSaveTime so the auto-save
timer does not immediately fire over freshly loaded state.
Auto-Load & Travel Gate¶
On BeginPlay, the component schedules an initial load deferred to the next tick (so PostLogin can disable
persistence first), when either the provider is a read-only preset (always) or bLoadOnPIEStart is set — and only on
authority. WillAutoLoad() exposes whether that load was scheduled, and HasCompletedInitialLoad() whether it has
finished.
AEternalGameMode keys its world-map travel on these flags: an auto-load must restore the saved/preset world-map node
before the game reads the travel target, so the GameMode waits for the load to complete before traveling.
GameMode::HandleStartingNewPlayer
|
+-- Persistence->WillAutoLoad() && HasAuthority() ?
| |
| yes | no
v v
HasCompletedInitialLoad() ? TravelToCurrentNode() (start node, immediately)
| |
| yes | no
v v
TravelTo... bind OnLoadCompleted -> OnPlayerPersistenceLoaded -> TravelTo...
If a scheduled auto-load cannot proceed (disabled, no CharacterId, or no provider), DeferredAutoLoad still marks
the load complete and broadcasts failure so the travel gate is released and startup does not stall.
See Game Framework for the GameMode/GameInstance lifecycle this plugs into.
Identity & Multiplayer¶
Per-Player Identity¶
When no login system has set an identity, the component auto-assigns one in InitializeLocalIdentity() (authority
only). It first tries to load a previously saved AccountId / CharacterId from
Saved/Config/PersistenceLocal.ini; failing that it generates deterministic GUIDs seeded by the player's
LocalPlayer controller id (ULocalPlayer::GetControllerId()), so split-screen / multiple local players get
distinct, stable identities, and persists them for future sessions. It then ensures the backend account exists
(a 409 Conflict is treated as success).
The controller id is the per-player seed deliberately — it is stable across map transitions, unlike a PlayerState player id, which can change.
Server Authority¶
Persistence is server-authoritative. Identity assignment, auto-save scheduling, and the deferred auto-load all
gate on GetOwner()->HasAuthority(); clients never save or load directly. In multiplayer the authority uses the
remote provider to round-trip the backend; in solo / standalone it uses the local file. See
Server Authority and Backend Server.
Public Contracts¶
Component Methods (UPersistenceComponent)¶
| Method | Parameters | Purpose |
|---|---|---|
RequestSave |
() |
Trigger a manual save; no-op if a save is in progress |
RequestLoad |
() |
Trigger a load; no-op if a load is in progress |
SetCharacterId / GetCharacterId |
(FGuid) |
Session character identity (must be set before save/load) |
SetAccountId / GetAccountId |
(FGuid) |
Session account identity |
SetDisabled |
(bool) |
Disable all persistence (call before BeginPlay to prevent auto-load) |
IsOperationInProgress |
() |
True while a save or load is running |
IsUsingRemoteProvider |
() |
True when the remote provider is active |
IsUsingPreset |
() |
True when the active provider is a read-only preset |
WillAutoLoad |
() |
True if BeginPlay scheduled an initial auto-load |
HasCompletedInitialLoad |
() |
True once the initial auto-load has finished |
GetProviderLocationString |
() |
Human-readable backing-store location (file path or URL) |
Provider Interface (IPersistenceProvider)¶
| Method | Parameters | Purpose |
|---|---|---|
SaveCharacter |
(CharacterId, SaveData, Callback) |
Persist a snapshot (async remote / immediate local) |
LoadCharacter |
(CharacterId, Callback) |
Load a snapshot, returned via the load delegate |
HasSaveData |
(CharacterId) |
Whether a save exists |
DeleteSaveData |
(CharacterId, Callback) |
Remove a save |
IsReadOnly |
() |
Skip auto-save / save-on-exit when true (default false) |
Events¶
| Delegate | Payload | When Fired |
|---|---|---|
OnSaveCompleted |
bool bSuccess |
After a save attempt resolves |
OnLoadCompleted |
bool bSuccess |
After a load + restore resolves (also released when auto-load can't proceed) |
Authoring Command¶
| Command | Form | Purpose |
|---|---|---|
Eternal.ExportPreset |
Eternal.ExportPreset <AssetName> (editor only) |
Capture the live character into /Game/DataAssets/Presets/Preset_<AssetName> |
Source References¶
| Component / Concept | Path |
|---|---|
UPersistenceComponent |
Source/ProjectEternal/Public/Persistence/Components/PersistenceComponent.h |
| Component implementation (provider selection, auto-load) | Source/ProjectEternal/Private/Persistence/Components/PersistenceComponent.cpp |
IPersistenceProvider (interface, IsReadOnly) |
Source/ProjectEternal/Public/Persistence/Providers/PersistenceProvider.h |
ULocalPersistenceProvider |
Source/ProjectEternal/Public/Persistence/Providers/LocalPersistenceProvider.h |
URemotePersistenceProvider |
Source/ProjectEternal/Public/Persistence/Providers/RemotePersistenceProvider.h |
UPresetPersistenceProvider (read-only) |
Source/ProjectEternal/Public/Persistence/Providers/PresetPersistenceProvider.h |
UPresetCharacterAsset |
Source/ProjectEternal/Public/Persistence/Presets/PresetCharacterAsset.h |
Eternal.ExportPreset command |
Source/ProjectEternal/Private/Persistence/Presets/PresetExportCommand.cpp |
FCharacterSaveData + save structs |
Source/ProjectEternal/Public/Persistence/Types/PersistenceTypes.h |
UPersistenceHelpers (build/restore) |
Source/ProjectEternal/Public/Persistence/PersistenceHelpers.h |
UPersistenceDevSettings / EPersistenceProviderMode |
Source/ProjectEternal/Public/Persistence/PersistenceDevSettings.h |
PendingPreset carrier |
Source/ProjectEternal/Public/GameMode/EternalGameInstance.h |
| Travel-gate consumer | Source/ProjectEternal/Private/GameMode/EternalGameMode.cpp |
Related Systems¶
- Game Framework - GameInstance pending-preset carrier, GameMode travel gate
- Backend Server - Remote provider's REST persistence backend
- Server Authority - Authority gating for save/load
- Item State Modules - Polymorphic per-instance state serialized into saves
- Inventory System - Container slots restored on load
- Equipment System - Equipment slots restored on load
Recent Changes¶
| Date | Change | Impact |
|---|---|---|
| 2026-08-06 | EPresetAvailability on UPresetCharacterAsset |
Presets carry their own menu-listing gate (Available / DevOnly / Hidden) instead of a rename-fragile config deny-list; menu paths filter on it, Eternal.PlayPreset ignores it so hidden presets stay usable as regression fixtures |
| 2026-06-17 | Initial documentation | Persistence system documented end-to-end; read-only Preset provider (UPresetPersistenceProvider / UPresetCharacterAsset / Eternal.ExportPreset) covered for demo "play-as" characters |