ZOA Persistence
One save file, sections owned by packages, per-section schema versions, and a migration chain that refuses to guess.
Persistence writes a single SaveRoot document through a pluggable backend and reads it back the same way. The ownership model is the interesting part. The save carries a list of PackageSaveSection entries, each one a JSON payload owned by exactly one package and stamped with that package's own schema version, so no single schema has to describe everything in the file.
A package participates by implementing ISavePackageOwner, declaring a PackageId and a CurrentSchemaVersion, and providing CaptureState and RestoreState over JSON. It registers itself with the ISaveOwnershipRegistry that ZOASaveSystem publishes to the service registry during Awake. From then on, saving and loading that package's state is automatic and no other package has to be edited.
Version handling runs on two levels and both refuse to guess. SaveMigrationRunner chains ISaveMigration steps to bring the whole SaveRoot from any older version up to the current one. PackageMigrationRunner does the same per section with IPackageSaveMigration, on the payload JSON. Where a chain is missing, the load reports the failure rather than dropping data silently.
Backends are swappable through a registry. Local JSON files and PlayerPrefs are always available. SQLite, MySQL, and Redis are reflection-only providers that report themselves unavailable with a reason when their assemblies are not present in the project, so the package compiles and runs with no optional third-party references at all.
How it works
Concepts
The save is a root plus a list of owned sections
SaveRoot holds a version number, an ISO-8601 UTC creation timestamp, the legacy top-level lists for inventories, embedded inventories, loot containers, weapon mods, and UI panels, and the packageSections list added in schema v2.
A PackageSaveSection is minimal: a packageId such as com.zoa.inventory, a schemaVersion for that package alone, a payload string holding whatever JSON that package produced, and a lastModifiedUtc timestamp. The persistence package never parses the payload and holds no knowledge of what an inventory looks like.
That opacity is what composes. A package can change its own payload shape and ship its own migration without touching SaveRoot or coordinating a global version bump, because its schema version lives inside its own section.
Ownership is registration, not inheritance
ISavePackageOwner is four members: PackageId, CurrentSchemaVersion, CaptureState returning JSON, and RestoreState taking JSON and returning whether it worked. Anything can implement it, including a MonoBehaviour such as the shipped PlayerStateSaveOwner.
SaveOwnershipRegistry keys owners by package id and raises OwnerRegistered and OwnerUnregistered so adapters can react to registration order. Registering a second owner for a package id that already has one throws, which is the right outcome: two owners for one section means silent overwrites at save time.
ZOASaveSystem runs at execution order minus 9100, before scene installers, and publishes its registry as ISaveOwnershipRegistry through FoundryServiceRegistry during Awake. The ordering lets package adapters auto-register during their own Awake with no explicit bootstrap sequencing. An existing registration is honoured, so a test or a custom publisher wins.
Capture and restore have explicit truth contracts
PackageSectionPersistence is the engine-free core of the section model, and ZOASaveSystem delegates to it so the behaviour is unit-testable without a scene. On capture it walks the registry, calls CaptureState on each owner, and stamps the section with the owner's current schema version. An owner that throws is reported and skipped: one broken package must not cost everyone else their state.
On restore each section resolves to one of six PackageSectionRestoreStatus outcomes. Restored and RestoredAfterMigration are the good paths. PreservedNoOwner means the section belonged to a package that is not present right now, and the section is kept verbatim rather than dropped, so uninstalling a package temporarily does not destroy its data. FailedNewerThanOwner means the section is from a newer build than the code reading it, and the restore refuses to downgrade rather than guessing at a backward transform. FailedMigration means a chain was missing or a step threw. FailedRestore means the owner returned false or threw.
Every one of those comes back in a PackageSectionRestoreResult list with a detail string, plus an aggregated failure text. The load surfaces what happened instead of returning a bare boolean.
Two migration ladders
SaveMigrationRunner targets the whole SaveRoot. Register ISaveMigration steps with a FromVersion and a ToVersion, and TryMigrate walks from the file's version up to the runner's CurrentVersion, applying one contiguous step at a time. It sorts on first use, throws on two migrations claiming the same FromVersion, refuses a save that is newer than the current version, and reports a missing intermediate step by naming the exact version gap.
PackageMigrationRunner is the same ladder per package. IPackageSaveMigration adds a PackageId and transforms the payload JSON rather than an object graph, and the runner keeps a separate sorted chain per package.
The shipped example is small. SaveRootV1ToV2PackageSectionsMigration upgrades a v1 root to v2 by ensuring packageSections exists. Unity's JsonUtility already deserialises a missing list field as empty, so the transform is trivially safe; the migration exists to make the version bump explicit, auditable, and covered by the versioned fixture suite.
MigrationReplayResult and MigrationReplayStep record a replay for diagnostics: the start and end version, each step's input and output payload, whether it succeeded, and how many milliseconds it took. That is the artefact you attach to a bug report about a bad load.
Backends are providers, resolved by reflection
ISaveBackend is four members: a BackendId, DescribeTarget for a human-readable target, and TryWrite, TryRead, and TryDelete, each returning a bool and a reason. Everything else in the package is written against that.
ISaveBackendProvider is the factory in front of it, and it exists so optional dependencies stay optional. A provider reports IsAvailable and, when false, an UnavailableReason, without ever taking a compile-time reference to the third-party assembly. LocalFileSaveBackendProvider and PlayerPrefsSaveBackendProvider are always available. Sqlite, MySql, and Redis probe for their ADO.NET or StackExchange.Redis types at runtime and report themselves unavailable with an explanation when absent.
AdoNetJsonSaveBackend is the shared implementation behind SQLite and MySQL. It uses reflection only to locate the connection type and then works entirely through IDbConnection. RedisSaveBackend keeps every piece of Redis reflection inside a single private RedisApi facade rather than scattering dynamic calls through the backend.
SaveBackendRegistry.CreateBackendOrDefault falls back rather than failing, and SaveBackendCatalog exposes ids, display names, availability, and unavailability reasons as plain strings so editor tooling in other packages can build a backend dropdown by reflection without depending on this package.
Diffing and compatibility over snapshots
SaveSnapshot is an immutable capture of a save at a moment: a snapshot id, a UTC timestamp, the global version, a SaveSchemaVersionMap, and a read-only map from package id to payload. SaveSnapshot.FromSaveRoot builds one from a loaded root and its sections.
SaveDiffCalculator.ComputeDiff compares two snapshots section by section and produces a SaveDiffReport carrying every SaveDiffEntry with an operation of Added, Removed, Modified, or Unchanged, a path such as section:com.zoa.inventory, the old and new payloads, and the owning package id. The report pre-counts additions, removals, and modifications and can be filtered with GetEntriesForPackage or GetEntriesByOperation.
SaveCompatibilityValidator answers a different question: can this save be loaded at all. Given a SaveRoot and the current SaveSchemaVersionMap it produces a SaveCompatibilityReport of SaveCompatibilityIssue entries, each carrying the affected package, a severity, a message, the saved version, and the current version. It is constructed with a map of how far each package can migrate, so a section that is behind but reachable is not reported as a problem while one that is behind with no route is.
Player state ships as an ordinary save owner
PlayerStateSnapshot in this package is the save-side player record, distinct from the per-tick network snapshot of the same name in the networking package. It carries a schema version, snapshot and session and profile ids, level, scene, mode, and spawn point ids, a capture reason, a capture timestamp in Unix milliseconds, and then transform, vitals, inventory, and equipment sub-snapshots plus a list of extensible PlayerSnapshotSection entries.
Vector and quaternion values go through SerializableVector3 and SerializableQuaternion rather than the Unity types, so the record serialises through plain JSON with no engine dependency in the core assembly.
PlayerStateSaveOwner is the MonoBehaviour that wires that record into the section model, under the package id com.zoa.player-state. It reaches the player snapshot service by type name through reflection so this package takes no hard dependency on the gameplay package that owns it, and it retries a pending restore until the rig exists, so a load that arrives before the rig spawns still completes.
Multiplayer splits persistence by scope
The multiplayer architecture divides persistence into three scopes and only the first belongs here in full. Offline and local player persistence is this package, optionally synchronised through Steam Cloud. Multiplayer session continuation, the scene-to-scene handoff, is owned by the authority runtime. Server-local dedicated persistence, meaning config, bans, rulesets, match logs, and server stats, lives behind the dedicated boundary in the networking package.
A file-based handoff is therefore a valid offline pattern and an invalid multiplayer one. A listen host's save is not a source of ranked truth: progression from a host session carries a LocalOnly trust policy.
Some persistence logic is duplicated across those three scopes. A single shared store would have to be trusted differently depending on who ran it, and the duplication buys clearer ownership instead.
In the editor
Screens
Screenshot pending
/screenshots/persistence-workbench-backends.png
The Backend Configuration capability showing the provider list with Local File and PlayerPrefs available and at least one of SQLite, MySQL, or Redis showing its unavailability reason.
Screenshot pending
/screenshots/persistence-workbench-save-test.png
The Save / Load Test capability after a successful save, with the resolved target path visible and the result message shown.
Screenshot pending
/screenshots/persistence-scene-setup.png
The Scene Setup capability listing scene objects, distinguishing those that already carry a PersistentGuidComponent from those that do not.
Setup
Workflow
- 01
Install the save system into the scene
Run Tools/ZOA/Advanced/Build/World/Persistence/Create Save System, or use the Scene Setup capability in the Persistence Workbench module. ZOAPersistenceSceneInstaller is idempotent: it finds an existing ZOASaveSystem before it creates one, so re-running it on an already configured scene is safe.
- 02
Choose a backend
Create the SaveBackendSettings asset at Assets/Resources/ZOA/SaveBackendSettings.asset and set the active backend id and save slot. The Backend Configuration capability in the Workbench lists every registered provider with its availability, so an unavailable SQLite or Redis provider explains itself rather than failing at write time.
- 03
Give scene objects stable ids
Add a PersistentGuidComponent to any inventory, container, or object whose state has to survive a reload. Without a stable id, save and load cannot reliably map back to the same object, and the Scene Setup capability exists to find the ones you missed.
- 04
Own a section from your package
Implement ISavePackageOwner, pick a package id that matches your UPM id, and register with the ISaveOwnershipRegistry resolved from the service registry. Because ZOASaveSystem publishes the registry at execution order minus 9100, registering from your own Awake is early enough.
- 05
Version your payload from day one
Return a real CurrentSchemaVersion rather than a hard-coded 1 you never revisit. When the payload shape changes, bump it and register an IPackageSaveMigration for the step. A section that is older than its owner and has no migration chain is a reported load failure, which is exactly the signal you want during development and exactly the crash you do not want after shipping.
- 06
Verify with the Workbench before writing code
The Save / Load Test capability triggers save, load, and delete against the configured backend without a play session harness. Use it to confirm the backend target and the section list before wiring anything into gameplay.
Surface
Key types
SaveRoot
class
The serialised document. A version, a UTC creation timestamp, the legacy top-level lists, and the packageSections list that schema v2 introduced.
- int version
- string createdUtc
- List<InventorySaveData> inventories
- List<EmbeddedInventorySaveData> embeddedInventories
- List<LootContainerSaveData> lootContainers
- List<WeaponModSaveData> weaponMods
- List<UiPanelSaveData> uiPanels
- List<PackageSaveSection> packageSections
PackageSaveSection
class
One package's slice of the save: the owning packageId, that package's own schemaVersion, an opaque JSON payload, and a lastModifiedUtc stamp.
ISavePackageOwner
interface
What a package implements to own a section. Four members and no base class, so an existing MonoBehaviour or service can adopt it without restructuring.
- string PackageId { get; }
- int CurrentSchemaVersion { get; }
- string CaptureState()
- bool RestoreState(string payloadJson)
ISaveOwnershipRegistry
interface
The lookup the save system walks at capture and restore time. Published to the service registry by ZOASaveSystem so adapters can self-register during Awake.
- void RegisterOwner(string packageId, ISavePackageOwner owner)
- void UnregisterOwner(string packageId)
- bool TryGetOwner(string packageId, out ISavePackageOwner owner)
- IReadOnlyList<string> RegisteredPackages { get; }
SaveOwnershipRegistry
class
Default registry. Raises OwnerRegistered and OwnerUnregistered, and throws when a second owner claims a package id that is already taken.
- event Action<string, ISavePackageOwner> OwnerRegistered
- event Action<string> OwnerUnregistered
PackageSectionPersistence
class
The engine-free capture and restore engine. Skips and reports an owner that throws on capture, and classifies every restore into an explicit status rather than a bare success flag.
- static List<PackageSaveSection> Capture(ISaveOwnershipRegistry registry, out string failures, DateTime? utcNow = null)
- static List<PackageSectionRestoreResult> Restore(IReadOnlyList<PackageSaveSection> sections, ISaveOwnershipRegistry registry, PackageMigrationRunner migrations, out string failures)
PackageSectionRestoreStatus
enum
Restored, RestoredAfterMigration, PreservedNoOwner, FailedMigration, FailedNewerThanOwner, FailedRestore. PreservedNoOwner is a success case: an absent package's data is kept, not dropped.
ISaveBackend
interface
Storage adapter for a single SaveRoot. Every operation returns a bool and a reason string, so a failed write is a reportable condition rather than an exception.
- string BackendId { get; }
- string DescribeTarget()
- bool TryWrite(SaveRoot root, out string reason)
- bool TryRead(out SaveRoot root, out string reason)
- bool TryDelete(out string reason)
ISaveBackendProvider
interface
Factory and availability descriptor in front of a backend. It holds no compile-time reference to any optional assembly; availability is probed by reflection.
- string Id { get; } / string DisplayName { get; }
- bool IsAvailable { get; } / string UnavailableReason { get; }
- ISaveBackend CreateBackend(SaveBackendSettings settings, out string reason)
- string DescribeTarget(SaveBackendSettings settings)
SaveBackendRegistry
service
Registry of built-in providers, extensible at runtime by external packages. Falls back to the default provider when the configured id is unknown or unavailable.
- static IReadOnlyList<ISaveBackendProvider> Providers { get; }
- static void Register(ISaveBackendProvider provider)
- static bool TryGetProvider(string id, out ISaveBackendProvider provider)
- static ISaveBackendProvider GetProviderOrDefault(string id)
- static ISaveBackend CreateBackendOrDefault(SaveBackendSettings settings, out string reason)
SaveBackendIds
class
The stable backend id strings: local-file, player-prefs, sqlite, mysql, redis.
JsonFileSaveBackend
class
The default backend. Writes SaveRoot as JSON to a directory and file name, optionally pretty-printed, and reports FullPath as its target.
PlayerPrefsSaveBackend
class
Stores the same JSON under a PlayerPrefs key. Convenient for prototypes; DescribeTarget reports PlayerPrefs followed by the key so tooling can tell it apart from a file.
RedisSaveBackend
class
StackExchange.Redis backend storing the document under a single key. Reflection-only, with all reflection and optional-parameter handling confined to one internal facade.
ZOASaveSystem
component
The scene coordinator. Owns the ownership registry, both migration runners, and the backend selection, and drives auto-load on start and auto-save on quit. Execution order minus 9100 so it is ready before scene installers Awake.
- const int CurrentSaveVersion = 2
- bool TrySave(out string reason) / bool TryLoad(out string reason)
- SaveRoot CaptureRoot()
- bool ApplyLoadedRoot(SaveRoot root, out string reason)
- bool TryDeleteSave(out string reason) / static void WipeSaveData()
- void RegisterMigration(ISaveMigration migration)
- void RegisterPackageMigration(IPackageSaveMigration migration)
- ISaveOwnershipRegistry OwnershipRegistry { get; }
- static SaveRoot LastLoadedRoot { get; }
- string SaveFilePath { get; }
ISaveMigration
interface
One step in the whole-document ladder. Declares FromVersion and ToVersion and mutates a SaveRoot in place.
- int FromVersion { get; } / int ToVersion { get; }
- void Migrate(SaveRoot root)
SaveMigrationRunner
class
Chains ISaveMigration steps from the file's version up to CurrentVersion. Refuses a save newer than current, and names the exact missing version gap when a chain is incomplete.
- SaveMigrationRunner(int currentVersion)
- void Register(ISaveMigration migration)
- bool TryMigrate(SaveRoot root, out string reason)
- int CurrentVersion { get; }
- IReadOnlyList<ISaveMigration> Migrations { get; }
IPackageSaveMigration
interface
One step in a package's own ladder. Adds a PackageId to the version pair and transforms the payload JSON directly.
- string PackageId { get; }
- int FromVersion { get; } / int ToVersion { get; }
- string Migrate(string payloadJson)
PackageMigrationRunner
class
Per-package migration chains, sorted independently. TryMigrate brings one section up to a target version or explains why it could not.
- void Register(IPackageSaveMigration migration)
- bool TryMigrate(PackageSaveSection section, int targetVersion, out string reason)
SaveRootV1ToV2PackageSectionsMigration
class
The shipped root migration. Guarantees packageSections exists on a v1 document, and exists mainly so the version bump is explicit and covered by the fixture suite.
MigrationReplayResult
class
Diagnostic record of a replayed migration chain: package id, start and end version, per-step MigrationReplayStep entries with input, output, success and duration, plus an error message on failure.
SaveSnapshot
class
Immutable capture used for diffing and validation: snapshot id, capture time, global version, schema version map, and a read-only package-id-to-payload map.
- static SaveSnapshot FromSaveRoot(SaveRoot saveRoot, IEnumerable<PackageSaveSection> sections)
SaveDiffCalculator
class
Compares two snapshots section by section into a SaveDiffReport.
- SaveDiffReport ComputeDiff(SaveSnapshot before, SaveSnapshot after)
SaveDiffReport
class
The diff result: every entry, pre-counted AddedCount, RemovedCount, and ModifiedCount, a HasChanges flag, and filters by package or by operation.
- IReadOnlyList<SaveDiffEntry> Entries { get; }
- IReadOnlyList<SaveDiffEntry> GetEntriesForPackage(string packageId)
- IReadOnlyList<SaveDiffEntry> GetEntriesByOperation(SaveDiffOperation operation)
SaveDiffEntry
class
One change: a SaveDiffOperation of Added, Removed, Modified, or Unchanged, a path such as section:com.zoa.inventory, the old and new values, and the owning package id.
ISaveCompatibilityValidator
interface
Answers whether a save can be loaded at all, given the current schema versions and how far each package can migrate.
- SaveCompatibilityReport Validate(SaveRoot root, SaveSchemaVersionMap currentVersions)
SaveCompatibilityIssue
class
One reported incompatibility: the affected package, a severity, a human-readable message, the saved version, and the current version.
SaveSchemaVersionMap
class
Package-id-to-schema-version table used by snapshots and validation. GetVersion returns minus one for a package it has never seen, which is distinguishable from version zero.
- void SetVersion(string packageId, int version)
- int GetVersion(string packageId)
- bool Contains(string packageId)
- IReadOnlyList<PackageSchemaEntry> GetAll()
PlayerStateSnapshot
class
The save-side player record: schema version, snapshot and session and profile ids, level, scene, mode and spawn point, capture reason and time, plus transform, vitals, inventory, equipment, and extensible sections.
- const int CurrentSchemaVersion = 1
PlayerStateSaveOwner
component
The shipped ISavePackageOwner for player state under com.zoa.player-state. Reaches the snapshot service by type name so this package keeps no hard dependency on the gameplay package, and can retry a pending restore until the rig exists.
- const string DefaultPackageId = "com.zoa.player-state"
PersistentGuidComponent
component
A stable identifier on a scene object, so save and load can map to the same inventory or container across sessions instead of relying on names or hierarchy position.
ZOAUiPanelPersistence
component
Marks a UI panel so its position and size persist. Identified by a PanelId and restored through ZOASaveSystem.ApplySavedPanelState.
Surface
Authoring assets
SaveBackendSettings
asset
Central persistence configuration, loaded from Resources at ZOA/SaveBackendSettings. Holds the active backend id, the save slot, and a nested config block per backend. Menu path Tools/ZOA/Persistence/Save Backend Settings.
- static SaveBackendSettings Instance { get; }
- string ActiveBackendId { get; set; }
- string SaveSlot { get; set; }
- LocalFileBackendConfig localFile (directoryPath, fileName, prettyPrint)
- PlayerPrefsBackendConfig playerPrefs (keyPrefix)
- SqliteBackendConfig sqlite / MySqlBackendConfig mysql / RedisBackendConfig redis
Usage
Examples
using System.Collections.Generic;
using UnityEngine;
using ZOA.Messaging;
using ZOA.Persistence.Core.Boundaries;
public sealed class CraftingSaveOwner : MonoBehaviour, ISavePackageOwner
{
[System.Serializable]
private sealed class Payload
{
public string[] unlockedRecipeIds;
public int workbenchTier;
}
private List<string> _unlocked = new List<string>();
private int _tier;
public string PackageId => "com.zoa.crafting";
// Bump this whenever the payload shape changes, and register a
// matching IPackageSaveMigration for the step.
public int CurrentSchemaVersion => 2;
private void Awake()
{
// ZOASaveSystem publishes the registry at execution order
// -9100, so it is already resolvable here.
if (FoundryServiceRegistry.TryResolve<ISaveOwnershipRegistry>(out var registry))
registry.RegisterOwner(PackageId, this);
}
private void OnDestroy()
{
if (FoundryServiceRegistry.TryResolve<ISaveOwnershipRegistry>(out var registry))
registry.UnregisterOwner(PackageId);
}
public string CaptureState() =>
JsonUtility.ToJson(new Payload
{
unlockedRecipeIds = _unlocked.ToArray(),
workbenchTier = _tier,
});
public bool RestoreState(string payloadJson)
{
if (string.IsNullOrEmpty(payloadJson))
return false;
var payload = JsonUtility.FromJson<Payload>(payloadJson);
if (payload == null)
return false;
_unlocked = new List<string>(payload.unlockedRecipeIds ?? System.Array.Empty<string>());
_tier = payload.workbenchTier;
return true;
}
}using UnityEngine;
using ZOA.Persistence.Core.Migration;
public sealed class CraftingV1ToV2 : IPackageSaveMigration
{
public string PackageId => "com.zoa.crafting";
public int FromVersion => 1;
public int ToVersion => 2;
// v1 stored a single recipe id; v2 stores an array.
public string Migrate(string payloadJson)
{
var old = JsonUtility.FromJson<V1>(payloadJson);
return JsonUtility.ToJson(new V2
{
unlockedRecipeIds = string.IsNullOrEmpty(old.unlockedRecipeId)
? System.Array.Empty<string>()
: new[] { old.unlockedRecipeId },
workbenchTier = old.workbenchTier,
});
}
[System.Serializable] private sealed class V1 { public string unlockedRecipeId; public int workbenchTier; }
[System.Serializable] private sealed class V2 { public string[] unlockedRecipeIds; public int workbenchTier; }
}
// Registered once, on the save system that will load the file.
saveSystem.RegisterPackageMigration(new CraftingV1ToV2());using ZOA.Persistence.Unity.Components;
// Both return a reason string on failure. Neither throws for an
// ordinary condition such as a missing file or a locked path.
if (!saveSystem.TrySave(out var saveReason))
Debug.LogWarning(saveReason);
if (!saveSystem.TryLoad(out var loadReason))
Debug.LogWarning(loadReason);
// SaveFilePath describes where the active backend reads and writes,
// derived through the provider rather than assumed to be a file.
Debug.Log(saveSystem.SaveFilePath);
// Capture without writing, for a diff or an in-memory checkpoint.
var root = saveSystem.CaptureRoot();
// Apply a root you already hold. Migration to CurrentSaveVersion
// runs first, and a failed migration aborts the apply.
if (!saveSystem.ApplyLoadedRoot(root, out var applyReason))
Debug.LogWarning(applyReason);using ZOA.Persistence.Core.Backends;
using ZOA.Persistence.Core.SaveData;
using ZOA.Persistence.Unity.Backends;
using ZOA.Persistence.Unity.Settings;
public sealed class HttpSaveBackendProvider : ISaveBackendProvider
{
public string Id => "http";
public string DisplayName => "HTTP (JSON)";
// Probe by reflection rather than referencing the assembly, so
// the project still compiles when the dependency is absent.
public bool IsAvailable => System.Type.GetType("MyStudio.Net.HttpSaveClient, MyStudio.Net") != null;
public string UnavailableReason => IsAvailable ? null : "MyStudio.Net is not installed.";
public ISaveBackend CreateBackend(SaveBackendSettings settings, out string reason)
{
if (!IsAvailable)
{
reason = UnavailableReason;
return null;
}
reason = null;
return new HttpSaveBackend(settings.SaveSlot);
}
public string DescribeTarget(SaveBackendSettings settings) => "https://saves.example/" + settings.SaveSlot;
}
// Register before anything resolves a backend.
SaveBackendRegistry.Register(new HttpSaveBackendProvider());using ZOA.Persistence.Core.Diff;
using ZOA.Persistence.Core.SaveData;
var before = SaveSnapshot.FromSaveRoot(previousRoot, previousRoot.packageSections);
var after = SaveSnapshot.FromSaveRoot(currentRoot, currentRoot.packageSections);
var report = new SaveDiffCalculator().ComputeDiff(before, after);
if (!report.HasChanges)
return;
foreach (var entry in report.GetEntriesByOperation(SaveDiffOperation.Modified))
{
// entry.Path reads "section:com.zoa.inventory"; OldValue and
// NewValue are the raw payloads for that package.
Debug.Log(entry.PackageId + " changed at " + entry.Path);
}Tooling
Editor tools
Persistence Workbench module
Tools/ZOA/Workbench, Persistence
Four capabilities in one surface: Create Assets for the settings asset and the scene save system, Backend Configuration for selecting and configuring the active backend, Save / Load Test for triggering save, load, and delete without writing code, and Scene Setup for finding objects that still need a PersistentGuidComponent.
Create Save Backend Settings Asset
Tools/ZOA/Advanced/Define/World/Persistence/Create Save Backend Settings Asset
Creates the settings asset at the conventional Resources path so SaveBackendSettings.Instance resolves at runtime instead of falling back to a legacy local-file default.
Create Save System
Tools/ZOA/Advanced/Build/World/Persistence/Create Save System
Installs a ZOASaveSystem into the active scene under a ZOA SaveSystem root, through the idempotent ZOAPersistenceSceneInstaller.
Add Persistent GUID
Tools/ZOA/Advanced/Build/World/Persistence/Add Persistent GUID
Adds a PersistentGuidComponent to the selected object so save and load can map back to it reliably.
Wipe Save Data
Tools/ZOA/Advanced/Maintain/Persistence/Wipe Save Data (Current Slot) and Wipe All Local Save Data
Deletes the current slot or every local artefact found by ZOASaveArtifactLocator, which enumerates both save files in the resolved local directory and the PlayerPrefs blob. The distinction matters: switching backends leaves the other backend's data behind.
Read this
Notes and caveats
See also