Save migration
Games change between versions. A save that was written with version 1.0 has to load in version 1.1, even if an actor now uses another save model or a model got new or renamed properties.
Every save record stores the class path and the model version of the model that wrote it. When a record is loaded, Pulse Save compares them with the model that should load it and runs a migration if they differ.
What is detected
| Situation | Detected by |
|---|---|
An object now uses another model class than the record was saved with (GetSaveModelClass or a factory changed). | The record’s class path differs from the class resolved for the object (see Model factories and settings). |
| The model class of the record was removed or renamed. | The class path cannot be resolved. The record is kept as a placeholder (UPulseSaveUnresolvedModel). |
| The data of a model changed inside the same class. | The record’s model version is lower than GetModelVersion(). |
A static model (GetOrCreateStaticModel) is requested with another class than the record was saved with. | InitializeFromRecord on the requested model. |
Records that are not older than the model and have the same class need no migration and load as before.
Model versions
UPulseSaveModelBase::GetModelVersion() returns the schema version of a model class (default 0). Increase it whenever the saved data changes in a way that needs a migration.
In Blueprint, implement the event Get Model Version in your model class (an object model or a static model) and return the version.
UCLASS()
class UMyActorModel : public UPulseSaveActorModel
{
GENERATED_BODY()
public:
virtual int32 GetModelVersion() const override { return 2; }
...
};
You do not need a migration for properties that were only added or removed. Model data is stored with property names, so new properties keep their default and unknown properties are skipped.
Writing a migration
Derive from UPulseSaveModelMigration (C++ or Blueprint) and describe one step, (SourceClass, SourceVersion) -> (TargetClass, TargetVersion):
// Version 1.1: the actor uses UMyActorModel instead of UPulseSaveActorModel.
UCLASS()
class UMyActorMigration_1_0 : public UPulseSaveModelMigration
{
GENERATED_BODY()
public:
UMyActorMigration_1_0()
{
SourceClass = UPulseSaveActorModel::StaticClass();
SourceVersion = 0;
TargetClass = UMyActorModel::StaticClass();
TargetVersion = 2;
}
virtual bool Migrate_Implementation(const FPulseSaveMigrationContext& Context) override
{
const UPulseSaveActorModel* Old = Cast<UPulseSaveActorModel>(Context.OldModel);
UMyActorModel* New = Cast<UMyActorModel>(Context.NewModel);
if (!Old || !New)
return false;
New->Health = 100.f; // New field, default for old saves.
return true;
}
};
- Leave
TargetClassempty for a migration inside the same class (version only). - Several steps are chained automatically, e.g.
0 -> 1and1 -> 2. A chain has at most 32 steps. - Return
falsefromMigrateto fail the load of this model. - The source class is a soft class pointer. It may point to a class that no longer exists.
FPulseSaveMigrationContext
| Field | Meaning |
|---|---|
Manager | The save manager. May be null outside of a manager. |
Identifier | Identifier of the record. |
SaveObject | The live object. nullptr for static models, for objects that are spawned during load, and for version migrations (they run while the record is loaded, before objects are mapped). |
OldModel | A model of the class the record was saved with, loaded from the record. nullptr if that class no longer exists. The same instance as NewModel for migrations inside one class. Do not store it. |
NewModel | The model that receives the data. Everything that has the same property name and type in the record is already filled in. When an object is migrated to another model class, InitializeNewModel() was called before. |
OldModelClassPath, OldModelVersion | What the record was saved with. |
Because NewModel is pre-filled by property name, a migration only has to handle what changed: renamed properties, changed units, split or merged fields, new defaults.
Registering migrations
- Project settings:
Project Settings > Pulse > Save > Migrations, add your migration classes. Shortcut: right click a migration Blueprint in the Content Browser and choose Add to Pulse Save Migrations. The entry only appears if the migration is not listed yet. It writesDefaultGame.ini. - Blueprint: the nodes Register Migration / Unregister Migration (
UPulseSaveMigrationLibrary). - C++:
IPulseSave::Get().GetMigrationRegister().RegisterMigration(UMyActorMigration_1_0::StaticClass());
Register migrations before a save game is loaded, e.g. when the game instance initializes.
A migration written in Blueprint sets Source Class, Source Version, Target Class and Target Version in its class defaults and implements the event Migrate (Context.Old Model and Context.New Model can be cast to your model classes).
The class default object of the migration is used, so a migration should not keep state.
Class redirects
If a model class was only renamed or moved, no code is needed. Add a redirect under Project Settings > Pulse > Save > Class Redirects (or with the Blueprint node Register Class Redirect / FPulseSaveMigrationRegister::RegisterClassRedirect):
| Old class path | New class |
|---|---|
/Script/MyGame.MyOldModel | MyNewModel |
Records saved with the old class are treated as if they were saved with the new one. The data is transferred by property name and type. The old class does not have to exist.
Shortcut: right click a save model Blueprint in the Content Browser and choose Add Pulse Save Class Redirect:
- Use as Old Class… adds the Blueprint as the old class path. Optionally pick the new class in the dialog.
- Use as New Class… adds the Blueprint as the new class. Optionally enter the old class path in the dialog.
The redirect is written to DefaultGame.ini. If one side is left empty the entry is added to the settings but not registered until you complete it there (FPulseSaveMigrationRegister::AddClassRedirectToSettings, editor only). Use as Old Class is disabled if the class already has a redirect.
When there is no migration
Project Settings > Pulse > Save > Migration Fallback (EPulseSaveMigrationFallback) decides what happens when the class or version of a record does not match and no migration is registered:
| Value | Behavior |
|---|---|
Fail (default) | The model is not loaded. The object receives OnLoadFailed with EPulseSaveLoadFailureType::MigrationFailed, and the model is listed in FPulseSaveProcessReport::FailedModels. |
ReinterpretByProperty | The data is loaded into the new model by property name and type. Properties that do not exist in the new model are dropped. A warning is logged. |
KeepOldModel | The model of the record’s class is kept. This is what happened before migrations existed: an object that expects the new model class will not find its data. |
For static models KeepOldModel behaves like Fail, because the requested model is the one that has to receive the data.
Static models
Static models (GetOrCreateStaticModel, LoadStaticModelFromSlot) have no save object and their class is chosen by the code that requests them, so that class is the migration target. Migrations for static models work like all others, Context.SaveObject is nullptr:
GetOrCreateStaticModel<UMyNewMeta>(Id)migrates the record ofIdif it was saved with another class or an olderGetModelVersion(). It returnsnullptrif the migration fails.- A static model that is already registered with the manager is migrated when
Load()starts, before any collector runs. A failure is listed inFPulseSaveProcessReport::FailedModels. - A static model that is requested after
Load()is migrated when it is requested.Load()does not touch the static records of models nobody requested, so they never fail and stay in the save game. UPulseSaveUtils::LoadStaticModelFromSlotand any model created outside of a manager are migrated as well. The migration has noManagerin its context in that case.- The identifier of a static model must stay the same between versions, because it is what finds the record. Use a redirect or migration for the class, not for the identifier.
Missing model classes
If the model class of a record no longer exists and no redirect is set, the manager creates a UPulseSaveUnresolvedModel for it. That placeholder is mapped to its object like any other model, and the manager tries to migrate it to the class the object uses now. Without a migration for that class path it is reported as failed (Model class '...' not found and no migration possible) and the object receives MigrationFailed.
A placeholder that no object was found for (for example the record of an object that is not in the level) is skipped with a warning. It is not a failure and the record stays in the save game. Static records never become placeholders, because Load() only processes Collected records.
Objects that are spawned during load (UPulseSaveActorModel with ExistingOrSpawn) can only be migrated from a missing class if a redirect or migration exists, because the class of the actor is stored in the unreadable data.
Report
FPulseSaveProcessReport has two migration counters:
| Field | Meaning |
|---|---|
MigratedModelsCount | Records that were migrated by migrations or by ReinterpretByProperty. |
MigrationFallbackCount | Records that had no migration and were handled by the fallback setting. |
After migrating
Migrated data is written in the new format with the next Save(). The record then contains the new class path and GetModelVersion(), so the migration is not needed again for it. Keep migrations around for as long as saves of the old version can still exist.
Records that are not saved again are not rewritten. Persistent records that are not touched by a Save() keep their old class path and version and are migrated again on every load.
Things to know
- Which class an object uses is decided the same way as when saving:
GetSaveModelClass, then factories, then the default model class. A migration runs after the models are mapped to their objects, so it needs an object (or, for spawn-on-load actors, the class default object of the saved actor class). IPulseSaveModelFactory::GetSaveModelClassis an optional cheap way for factories to name their model class. Factories that do not implement it are asked to create a model, which is discarded. TheDEFINE_SAVE_MODEL_FACTORYmacro implements it.UPulseDefaultObjectSaveModel(and any model withbSerializeAllSaveGameProperties) can be loaded from a record of a class that did not serialize the object data. In that case no object data is restored.