Serialization
Pulse plugins do not depend on PulseSave. They offer SaveToBytes / LoadFromBytes instead, and the save bridges put the bytes into save models. Pulse::Serialization writes those bytes the same way in every plugin.
Struct to bytes
FMySaveData data;
data.Version = FMySaveData::CurrentVersion;
...
TArray<uint8> bytes;
Pulse::Serialization::StructToBytes(data, bytes);
FMySaveData loaded;
switch (Pulse::Serialization::ReadVersioned(bytes, FMySaveData::CurrentVersion, loaded))
{
case EPulseReadResult::Success: ...
case EPulseReadResult::TooNew: // written by a newer build: do not overwrite it
case EPulseReadResult::Empty:
case EPulseReadResult::Corrupt: ...
}
- Properties are written as tagged properties: added and removed properties are fine; renamed properties need a
PropertyRedirectsentry. - Object references and names (gameplay tags too) are written as strings, so the data survives asset moves with redirects.
- Transient properties are skipped.
- Versioning: give the struct an
int32 Versionproperty.ReadVersionedrejects data with a higher version, so an older build never misreads newer data. Migrate older versions after reading. - The format is the one PulseSkill, PulseAchievement and PulseCore wrote before, so their existing save data reads unchanged (those helpers also wrote transient properties; reading such data is fine).
InstancedStructToBytes / BytesToInstancedStruct also store the struct type, for payloads whose type is only known at runtime.
Import reports
Applying loaded data can partly fail: a skill that no longer exists, an item asset that was deleted, a value outside its range. FPulseImportReport collects what happened, so the game, the save bridges and the Pulse Debugger can show it.
| Field / function | Meaning |
|---|---|
Messages | FPulseImportMessage: Severity (Info, Warning, Error), Text, Context (tag, asset path, entry id), Code (machine readable reason) |
NumImported, NumSkipped | Counters (CountImported, CountSkipped, AddSkipped) |
DataVersion, MigratedToVersion | Set by NoteMigration(From, To) |
AddInfo / AddWarning / AddError | Add a message |
Append(Other, Prefix) | Merges the report of a part (e.g. "Inventory/") |
HasErrors, HasWarnings, IsClean, GetMaxSeverity, Count, CountCode | Evaluation |
GetSummary() | “12 imported, 1 skipped, 0 error(s), 1 warning(s), 2 info(s) (migrated 1 -> 2)” |
LogTo(LogCategory, Title) | Logs the summary and each message with the verbosity of its severity |
Severity guide: Info = adjusted on purpose (migrated, default filled in); Warning = data lost or changed (entry skipped, value clamped); Error = the import failed or is incomplete.
Suggested codes: UnknownTag, MissingAsset, Clamped, Migrated, Duplicate, NotAllowed, Corrupt.
FPulseReportBuilder::AddImportReport turns an import report into a section of a debug report (see Debugging).
Saveable as bytes
Components that save themselves implement IPulseByteSaveable (Serialization/PulseByteSaveable.h, C++ only). A save system needs nothing else: PulseSave keeps every object with the interface in one general model, so no save bridge per plugin is needed, and components of your game work the same way.
| Function | What it does |
|---|---|
SaveToBytes(Bytes) | Writes the state (usually Pulse::Serialization::StructToBytes of a struct with a Version) |
LoadFromBytes(Bytes, Report) | Reads a state of this or an older version and reports what was read or skipped |
GetSaveId() | The id of what is saved, the same in every session; default MakeSaveKey (below) |
GetSaveVersion() | The version SaveToBytes writes now (default 1), for reports and checks |
ShouldSaveBytes() | False leaves the object out of this save |
GetLoadPriority() | Higher loads first (default 0) |
GetSaveTiming() | Synchronous (default) or Asynchronous |
BeginSaveToBytes(Done), BeginLoadFromBytes(Bytes, Done) | The asynchronous way; by default they call the two functions above at once |
class UMyStashComponent : public UActorComponent, public IPulseByteSaveable
{
virtual bool SaveToBytes(TArray<uint8>& OutBytes) const override
{
return Pulse::Serialization::StructToBytes(Data, OutBytes);
}
virtual bool LoadFromBytes(const TArray<uint8>& InBytes, FPulseImportReport& OutReport) override
{
return Pulse::Serialization::BytesToStruct(InBytes, Data) == EPulseReadResult::Success;
}
};
Save identity
The id should not depend on object names: the names of components added at runtime, of subsystems and of spawned actors differ between sessions and builds, and renaming a component in a Blueprint would lose its saves. So an object that saves itself says what it saves with an FPulseSaveIdentity and returns it from GetSaveIdentity(); the default GetSaveId() builds the key from it.
| Field | Meaning |
|---|---|
Tag | What is saved: Pulse.<Plugin>.Save.<Thing>, or a tag of your game |
Key | Which one, when an owner has several with the same tag (a second stash), or a fixed key of a placed object (“North”) |
Scope | Owner (default): the player id or the actor comes first. World: once per world or game (a component of the game state, a subsystem) |
Keys made from an identity:
| Object | Key |
|---|---|
| Component of a player actor | Player.<player id>.<Tag> (.<Key> appended when set) |
| Component of an actor in the persistent level or a World Partition cell | <Actor>.<Tag> |
| Component of an actor in a sublevel or level instance | <level package>.<Actor>.<Tag> (the long package name without PIE prefix, unique per instance) |
Scope World, or an object without actor | World.<Tag>.<Key> |
Scope World is ignored on player actors (every player would write the same record): the key stays per player and a warning is logged once per class. With scope Owner on a placed actor the key contains the actor’s name, so renaming that actor loses its saves; objects that must survive a rename use scope World with a Key. A key never starts with Player unless it belongs to a player, also for a game tag such as Player.Stats (World.Player.Stats).
UMyStashComponent::UMyStashComponent()
{
SaveIdentity.Tag = TAG_MyGame_Save_Stash;
}
FPulseSaveIdentity UMyStashComponent::GetSaveIdentity() const
{
return SaveIdentity; // UPROPERTY(EditAnywhere) FPulseSaveIdentity SaveIdentity;
}
Without an identity the key is made from names as before: Pulse::Serialization::MakeSaveKey(Object) gives Player.<player id>.<component> for player actors (player id from player resolution), <actor>.<component> for components of other actors and the name of other objects. DescribeSaveId(Object) tells how far a default id can be trusted (Stable, NameBased, SpawnOrder for the name of an actor spawned while playing, None); PulseSave’s Pulse Check warns about name-based ids. A default id made from the name of a spawned actor is also logged once per class (Log level).
Automatic components are named Pulse_<Class>_0 in every build (see Workflow), so their name-based keys are stable too; they should still get an identity, because renaming the class would change the name.