Skip to content

Save — System Guarantees Matrix

The behavioural contract

This page defines the behavioural contract of the save coordinator and its participants.

No marketing. No implication. Just guarantees — and explicit non-guarantees.


Quick Navigation

  • Coordinator
  • Payload & Envelope
  • Versioning
  • Failure Isolation
  • Missing Systems
  • Identity
  • Health Participant
  • Currency Participant
  • Inventory Participant
  • Crafting Participant
  • Status Effects Participant
  • Manager & Storage
  • Non-Guarantees
  • Final Summary

1. Coordinator

RevSaveCoordinator

Aspect Guarantee
Type Static, stateless
Threading Synchronous. ❌ Call from the Unity main thread — see note
Capture output JSON string; one section per participant that returned a payload
Empty payloads Participant returning null/empty records no section
Capture order The order you supply the participants
Restore order ✔ Your list order, stably sorted by IRevSaveOrdered — a participant that declares a position moves to it; everything else keeps the order you supplied. Never the order in the file
Participant with no section in the save ✔ Not called; records no outcome
File I/O ❌ None, by design — caller owns the destination
Sequential calls ✔ Independent — the coordinator holds nothing between them
Overlapping calls (coroutine, or a save started inside a participant) ⚠ Share the scene-scan scope — see note
Scene scans per operation One, shared across every participant — see note
Objects spawned mid-operation ❌ Not seen by later participants unless StableIdOwners.Invalidate() is called
Save timestamp ✔ Stamped into the envelope on capture and parsed back on restore, reported on RevSaveReport.SavedAtUtc. One UtcNow per capture, so file and report agree; null when the file carried none
Result Always returns a RevSaveReport, never null
Exceptions to caller ✔ None for bad input — reported, not thrown

The main-thread requirement comes from the participants, not the router

Every framework participant resolves owners through StableIdOwners.Find<T>(), which calls Object.FindObjectsByType. Unity restricts that to the main thread and throws if called elsewhere — so an off-thread Capture or Restore fails loudly rather than corrupting anything.

The coordinator itself holds no state and touches nothing thread-bound beyond serialising the envelope. That distinction matters if you write a participant over pure data: the constraint is inherited from what a participant reads, not intrinsic to the routing.

If serialisation cost is the concern, the string is the boundary — write it to disk on a background thread once you have it.

One scene scan per operation, not one per participant

That same FindObjectsByType walk is the entire cost of resolving owners — the per-type filtering afterwards is a component lookup each. Every participant did its own, so a capture paid for four walks and a restore for six, all returning the same objects, on a path a game may run from an autosave timer.

The coordinator now wraps each operation in StableIdOwners.BeginScope(), so the walk happens once and every participant reads it. Custom participants get this for free as long as they resolve owners through StableIdOwners rather than calling FindObjectsByType themselves.

The cost is that the scan is a snapshot. An object spawned partway through a save is invisible to the participants that follow it. Nothing in the framework spawns during a save, but if yours does, call StableIdOwners.Invalidate() afterwards. Destroyed objects need no such care — results are null-checked on every use, so an object destroyed mid-operation simply stops appearing.

Outside a scope nothing is cached and every call scans, exactly as before. The optimisation cannot reach a caller who did not ask for it.

The scope is static, so "stateless coordinator" is not the same as "independent operations". The coordinator itself holds nothing between calls, but the scan cache lives in StableIdOwners and is shared by whatever is inside a scope. An operation that runs start to finish inside one call — which is every operation the framework performs — cannot overlap another. A save spread across a coroutine, or one started from inside a participant, can: the inner one reads the outer one's scan. The frame stamp bounds how stale that can get, and Invalidate() clears it deliberately.

Use StableIdOwners.Batch(() => { ... }) rather than BeginScope in your own code. The scope form has to be disposed, and forgetting is silent — no throw, no log, the depth simply never returns to zero — so one missing using would mean every lookup for the rest of the session answering from a scene snapshot that only gets older. A cached scan is therefore also stamped with the frame it was taken in and ignored after it, which bounds that mistake to a single frame instead of the session. A save runs inside one frame, so the intended use pays nothing for the guard.


2. Payload & Envelope

Aspect Guarantee
Payload opacity ✔ Coordinator never parses a participant's payload
Section contents Participant key, payload version, payload string
Format ownership Each participant owns its own format entirely
Serialiser JsonUtility — no external JSON dependency in Core
Envelope metadata Framework version (diagnostic only) + UTC timestamp
Timestamp reachable ✔ Parsed onto RevSaveReport.SavedAtUtc on capture and restore; null when the file carried none
Metadata in logic The coordinator branches on neither. Your game may branch on the reported timestamp — that is what it is handed back for

3. Versioning

Aspect Guarantee
Envelope version Independent of participant versions
Participant version Recorded per section
Version delivered to Restore ✔ The version the payload was written with
Newer envelope version ✔ Still read; recognised sections restore
Missing / zero envelope version ❌ Refused as fatal — the payload is not a RevSave envelope
Newer participant version Participant's decision — framework participants refuse
Migration hooks ✔ Present (version reaches Restore)
Migration tooling ❌ Not provided

4. Failure Isolation

Aspect Guarantee
Participant throws on capture ✔ Recorded Failed; other participants still capture
Participant throws on restore ✔ Recorded Failed; other sections still restore
Corrupt / unparseable payload ✔ Reported via FatalError; does not throw
Null / empty payload ✔ Reported via FatalError; does not throw
Valid JSON that is not a save ✔ Reported via FatalError — no envelope version means not ours
Null entries in participant list ✔ Ignored
Participant with null/empty key ✔ Recorded Failed
Participant whose Key getter throws ✔ Recorded Failed; isolated on capture and restore
Participant whose Version getter throws ✔ Recorded Failed; no section written
Duplicate keys among participants ✔ First registration wins; clash reported
Two sections under one key in a payload ✔ First used; clash reported — a capture cannot produce this
Order-dependent routing ❌ Never — a section goes to the first participant claiming its key
Participant applied nothing before failing ✔ Recorded Failed; section retained in report.Unapplied
Participant applied part before failing ✔ Recorded PartiallyApplied; retained in report.PartiallyApplied

A half-applied section is not a refused one, and must not be carried over

Currency, Inventory and Status Effects all restore owner by owner, so any of them can apply three wallets and refuse the fourth. Filing that alongside a genuine refusal would invite you to pull the lever in §5 — omit the participant from the next capture so its section survives — which writes the whole section back, returning the three wallets that did load to what they held before, and discarding everything the player has done since.

So the two are separate lists. Unapplied is safe to carry because nothing in it was applied. PartiallyApplied is kept for diagnostics and backup only: it is the last intact record of state that is now half-overwritten, and recovering from it means reloading, because a partial restore has no undo.

Writing your own participant: throw an ordinary exception if you applied nothing — a version guard should run before you touch anything, which is what makes that true. Throw RevSavePartialRestoreException if you mutated something first.

Isolation covers the properties, not just the methods

Key and Version are participant code as much as Capture and Restore are, and a key read from a serialized reference — an unassigned config asset, a scriptable object dropped on scene unload — throws from the getter rather than from a method body. Both are read inside the same isolation as everything else, so a participant that cannot name itself is recorded Failed and the rest of the operation continues.

It matters most on the load side: a Restore that throws is how a game ends up stuck on its main menu, which is the outcome the report-first design exists to prevent.


5. Missing Systems

Aspect Guarantee
Section with no participant ✔ Recorded Unrecognised
Effect on report.Success ✔ None — supported situation, not a failure
Data retained ✔ Returned verbatim in report.Unrecognised
Carried into next capture ✔ When passed as carryOver
Automatic carry-over ❌ Caller must pass it — omitting it discards that data
Live participant vs carried section Live participant wins — when it actually wrote a section
Participant that declared the key and wrote nothing ✔ Carried section is written; the key survives the save
Two carried sections under one key First written; reported as a passenger clash, not as current state winning
Any such collision Reported as Displaced, never silent, never as Skipped
The displaced section itself ✔ Handed back verbatim in report.Displaced — the last copy there is
Effect of a displacement on report.Success ❌ None — the save produced is complete and valid; see note
Section a participant refused outright ✔ Retained verbatim in report.Unapplied
Refused section preserved automatically ❌ Its participant is live and wins the key — see note
Section a participant applied part of ✔ Retained in report.PartiallyApplied — ❌ not carryable

A refused section is kept, but keeping it is not the same as preserving it

Unrecognised and Unapplied are deliberately separate lists. An unrecognised section belongs to a system this build does not have, and carrying it over always works. An unapplied one belongs to a system that is installed and refused the data — nearly always a payload from a newer build, after a version downgrade.

Carrying an unapplied section into the next capture will not resurrect it, because there is one section per key and the participant that refused it is still live and still captures. Current state winning is the right default: the alternative discards the session the player just finished in favour of data this build cannot read. The collision is reported, so the loss is never silent.

The lever, when you would rather keep the newer data: leave that participant out of the next capture. Nothing then claims the key, the section carries over as an ordinary unrecognised passenger, and it survives intact for whenever that build comes back. The cost is that the system does not save for the rest of the session — which is the honest trade, and yours to make.

The lever is safe because Unapplied only ever holds sections that were applied in full or not at all. A section that got halfway is deliberately kept out of it — see §4.

Only a section that exists can displace a passenger

Current state beating a carried section assumes there is current state in the save to prefer. A participant that threw, or that had nothing to save, declared its key and wrote nothing — so the carried section is written and the key survives.

The alternative loses the data twice over: the envelope would carry neither the live state nor the carried copy, having reported nothing worse than a Failed or Skipped participant. And the route in is ordinary rather than exotic — Inventory returns nothing when the scene holds no characters, Currency when no owner has a wallet, so an autosave in a hub scene is enough to trigger it.

Whether carried data is stale is yours to weigh; whether it still exists is not.

A displaced section is handed back, because that copy is the last one

When a passenger does lose its key, the section is returned in report.Displaced rather than only mentioned in a message. For one carried out of Unapplied that copy is the only one left: it is not in the save being written, and the save it came from is about to be overwritten by it. Stash it before you commit the file if it matters — recovering it afterwards is not possible.

It has its own status for the same reason. Skipped and Displaced were once one value, and they are opposites — a participant with nothing to write is the commonest non-event in a capture, while a displacement is data ceasing to exist. Reported alike, the only notice of a real loss read exactly like a no-op.

It does not fail the capture. One section per key is the rule rather than a fault, and the save produced is complete and valid — marking it failed would report correct work as broken and train you to ignore the flag. ToString() names the count in its header so a logged summary cannot read as a bare "OK".


6. Identity

Aspect Guarantee
Owner identity StableId.Id
Owners without StableId ❌ Not saved, not restored — silently omitted by design
Scene scope Loaded scenes only
Prefab assets ❌ Excluded
Saved owner absent at restore ✔ Skipped, not an error
Objects authored into a scene ✔ Stable across sessions — the id is serialized into the scene
Objects spawned at runtime Not stable across sessions unless you call StableId.AssignId
Duplicated objects Share an id; not auto-corrected
Prefab assets given an id ❌ Never — the field stays blank on the asset
Prefab instances ✔ Each mints its own id on becoming a scene object
Id already on a prefab from an older version ✔ Left alone, never cleared
Duplicate detection ✔ Reported once per scan, and by Tools ▸ RevGaming ▸ RevFramework ▸ Validate ▸ Duplicate Stable Ids
Duplicates across different component types ✔ Detected — the scan is checked before any type filtering
Which duplicate wins ✔ Deterministic: lowest hierarchy path and sibling index. Capture and restore agree
Automatic repair of a duplicate ❌ Never — see below
AddComponent<StableId>() at runtime ✔ Gets an id in the editor and in a build
Instantiate of a prefab that has an id Clone shares it — editor and build alike
Instantiate of a prefab whose id is blank ⚠ Editor and build disagree — see note

Spawned objects need an id you choose

In a player build a spawned instance has no serialized id, so one is generated in Awake — a different one every launch. It is unique within the session, which is enough to capture, and useless afterwards, because nothing next session claims that id. Anything saved against it is silently unrestorable.

Call AssignId with something your game derives from durable data — a spawn table entry, a room and slot index, a quest id, a server key — before the object's state is captured.

One spawn path behaves differently in the editor than in a build

Id generation runs from Reset and OnValidate in the editor, and from Awake in a build — two separate branches. They agree everywhere except one case, and both behaviours are measured rather than assumed:

  • AddComponent<StableId>() at runtime gets an id in both. Reset runs on AddComponent, in play mode as well as edit mode, and Awake covers the build.
  • Instantiate never generates one. OnValidate does not fire on Instantiate. So a prefab whose id is blank produces a clone with no id in the editor — invisible to every participant, saved and restored as though it were not there — while the same spawn in a build gets a throwaway id from Awake, and is captured under an id nothing will ever claim again.

Neither outcome is useful, and testing in the editor will not show you the build's version of it. In practice this is a corner: a prefab only stays blank if nobody ever selected it in the Inspector, because doing so stamps an id into the asset — after which every instance shares that one id instead, which is the collision below and much the likelier problem.

Either way the fix is the same — AssignId at spawn time.

Duplicating an object copies its id

Unity's duplicate command copies serialized fields, and the automatic generation only fills an empty value. Two objects then claim one identity, and one will be restored with the other's state.

This is not self-corrected, deliberately: nothing can tell which copy was the original, and regenerating the wrong one breaks every save that referenced it. The validate gate reports collisions and names the objects; which one changes is your call.

What is guaranteed is that the choice does not move. The scan runs unordered — FindObjectsSortMode.None is what makes it fast — so "the first one wins" used to mean "whichever the scan happened to reach first", and a capture could take one object's state while the restore that followed applied it to the other. The winner is now the object whose hierarchy path and sibling index sort first: authored data, stable across sessions in a way an instance id is not, and computed only when a collision actually exists so the normal path pays nothing for it.

The sibling index is doing real work there. Unity lets siblings share a name, so a path of names alone can be identical for two different objects — and a row of prefab instances all called "Enemy" under one parent is both a normal thing to have and a common route to a duplicate id. Equal keys would mean neither sorts first, the scan order would decide again, and the guarantee would quietly evaporate in exactly the case it was written for.

The collision is also reported over the whole scan rather than inside each per-type lookup. Previously the type filter ran first, so a "hero" carrying Health and a second "hero" carrying Inventory never met inside one query and neither was reported — while both saves were ambiguous.

Prefabs no longer walk into that collision by default

Id generation runs from OnValidate, which fires on a prefab asset the moment anyone selects it or opens it in Prefab Mode. That used to write an id into the asset — and because generation only fills an empty value, every instance then carried that same id and none regenerated. A single inspection of a prefab was enough to make every copy of that enemy claim one identity.

A prefab asset is now never given an id. It is not a save owner in the first place — only scene objects are — so the value did nothing except propagate. The field stays blank on the asset and each instance fills it in on becoming a scene object.

An id already written to a prefab by an earlier version is left exactly as it is, because saves may reference it. The change declines to create ids; it never removes one. Existing prefabs in that state still show up in the validate gate.


7. Health Participant

Aspect Guarantee
Captures Current, max, dead state per HealthSystem
Shields (including OverhealShield) Not captured, not restored
Regen timers ❌ Not captured, not restored
Rule state / damage lock ❌ Not captured, not restored
Restore path HealthSystem.RestoreSnapshot
Authority consulted on restore NoRestoreSnapshot writes current, max and dead with no CanMutate, and says so at the method. A denying IHealthAuthority refuses live damage and does not refuse a load: a restore is a rewind, not a mutation
Events on restore ❌ None — no damage, heal, death or revive events fire
Dead state Restored as saved; not recomputed from current health
Restoring Dead = true onto a live object ⚠ Dead to the health system only — nothing death normally does runs
Restoring Dead = false onto a dead object ⚠ Alive to the health system only — nothing death did is undone
Death handler / Died listeners on restore ❌ Never invoked in either direction — see note
Construction requirements ✔ None
Newer payload version Refused (throws; coordinator records Failed)
Per-owner isolation ✔ Each owner restored independently
Snapshot with Max ≤ 0, or Current outside 0..Max ✔ Refused and reported; that object is left alone
Entry with no snapshot at all (truncated save) ✔ Refused — reads as all-zero, which no capture can write
Dead versus Current ❌ Not policed — a live system can legitimately hold either pairing
Saved owner not in the scene Skipped — a save outlives the objects it was taken from
No saved owner in the scene ✔ Reported; section recorded Failed — carryable
Every entry refused Section recorded Failed — carryable
Some objects restored Section recorded PartiallyApplied — not carryable

A section that matched nothing is reported, not counted as a clean load

One saved owner missing is ordinary and stays silent: a save outlives the objects it was taken from, and a level that no longer contains an enemy should not fail to load because of it.

All of them missing is a different event. The section applied to nothing, every other signal said the load succeeded, and the symptom the developer sees is a game full of default state with nothing anywhere to explain it. The usual causes are a scene that is not the one the save was taken in, and ids that have changed since — a runtime-spawned object gets a fresh id every launch unless StableId.AssignId is called with one of your own.

Reported as a plain refusal rather than a partial one, because nothing was applied: the section still describes exactly what the save held, so it stays in Unapplied and stays carryable.

Health, Currency, Inventory and Status Effects all do this. Crafting reports the equivalent per job — see §10.

HealthSnapshot is three fields, and that is the whole guarantee

Max, Current, Dead. Nothing else. A character saved mid-fight behind a shield comes back with the right health and no shield — silently, and reported as a successful load, because from the save system's point of view nothing failed.

This is Health's contract, not a save-system limitation: HealthSystem.RestoreSnapshot documents that it does not restore shields, regen timers or rule state. The participant cannot widen it without inventing a second, competing definition of health state.

If shields matter to your game, restore them through whatever granted them — a status effect via the Status Effects participant, or your own participant. See §11.

A snapshot that cannot be true is refused rather than clamped

RestoreSnapshot takes what it is given and clamps it, and documents that supplying something consistent is the caller's job. This participant is that caller.

It matters because of how a truncated file reads: an entry that lost its snapshot deserialises to all-zero, which clamps to Max = 1, Current = 0 — a character left alive on one hit point, with the load reporting success and nothing anywhere saying why. Entries whose max is zero or less, or whose current health falls outside 0..Max, are therefore reported and skipped instead. No capture can produce either, so one in a payload means the save was edited or truncated.

Dead is not checked against Current. The pairing looks inconsistent at zero health and is not: whether an object at zero is dead belongs to its death rules, which RestoreSnapshot restores none of. Rejecting a combination the live system can legitimately hold would refuse real saves in order to catch tampered ones.

Restoring dead state changes the model, and nothing else on the object

The two rows above are the same fact from both sides, and it is the one that bites an in-place restore — a quickload that does not reload the scene, which is what this participant is built for.

RestoreSnapshot writes maxHealth, currentHealth, isDead and the internal death-handled flag directly. It does not go through the kill or revive paths, so Died never fires and the configured death handler never runs. Both directions are silent:

  • Died, then quickloaded to before the fight. Health and isDead come back correct. The ragdoll is still ragdolled, the AI is still switched off, the collider is still disabled and the loot is still on the floor. The character is alive and behaving like a corpse.
  • Saved while dead, restored onto a live object. It becomes dead to the health system without any of that ever happening — no death VFX, no loot, still walking around.

This is Health's documented contract rather than a save-system defect: a silent restore is the right primitive, because firing death events during a load would spawn VFX and drop loot every time a save was read. The save system just cannot supply the other half for you, because what death means to an object is your game's.

Reload the scene, or reconcile after restoring — read IsDead once the load finishes and put the object into the matching presentation state yourself. The framework does the same thing at a different layer: this is why RestoreSnapshot says "callers are responsible for providing a consistent snapshot".


8. Currency Participant

Aspect Guarantee
Captures The CurrencyIds you supply plus everything the wallet actually holds
Currency you forgot to configure ✔ Captured anyway, when the service implements ICurrencyWalletQuery
Configured currency the wallet never held ✔ Written at zero, so a restore can zero it
Objects that never held currency ✔ Skipped, when the service implements ICurrencyWalletQuery
Service without that capability Every StableId owner captured, configured currencies only
Currency present in the wallet but not in the save ❌ Left alone by a restore — see note
Owner holding a wallet not in the save Governed by UnsavedWalletPolicy; never silent either way
— under Zero (default) ✔ Every balance the wallet holds is zeroed
— under Leave Left as it is, and warned about
— service that cannot enumerate ❌ Cannot be zeroed; warned about rather than passed over
Saved owner not in the scene Skipped — a save outlives the objects it was taken from
No saved owner in the scene ✔ Reported; see §7
Empty currency list Rejected at construction
Invalid CurrencyId in the list Rejected at construction
Restore path CurrencyPersistence.Restore
Events on restore Raised — each line is applied with SetBalance, which raises OnWalletChanged and its UnityEvent mirror whenever a balance actually changes
Refusal by caps / authority / escrow ✔ Reported, collected across owners
Unreadable saved balance (blank or whitespace id / negative) ✔ Skipped and reported
One unreadable line in a wallet ✔ Costs that line only, never the wallet's other balances
Failure with no wallet written Section recorded Failed — carryable
Failure after some wallets written Section recorded PartiallyApplied — not carryable
Partial application within one wallet ❌ Not guaranteed (see Currency's own matrix) — counts as written
Audit attribution ✔ Reason string passed through

A currency you forget to configure used to be free money

Balances are addressed by id, so a caller has always had to name the currencies to save. Naming only some of them was the problem: an unconfigured currency was neither written to the save nor set by a load, so its balance simply survived a quickload. Spend 500 gems on a sword, load an earlier save, and the sword is gone but the gems are back. Nothing failed, nothing logged, Success was true — and forgetting one is easy in the way that matters, because a premium currency added in a patch is configured somewhere other than where the participant is constructed.

A capture now writes the configured list plus whatever each wallet actually holds, asked through ICurrencyWalletQuery.TryGetHeldCurrencies. The configured list is still a floor rather than a fallback: a wallet has no key for a currency it has never touched, so only the list can produce a line at zero for one — and that line is what lets a restore put a wallet back to zero.

A service that cannot enumerate keeps the old behaviour, configured currencies only. It answers "cannot tell" rather than "holds nothing", because a caller reading an empty list would write no balances at all. If you use a custom ICurrencyService, implementing ICurrencyWalletQuery is what closes this for your project.

A restore sets what the save contains, and removes nothing

The gap above is closed on the capture side. The other direction is deliberate and framework-wide: a participant applies onto existing state rather than clearing it first, so a currency the save never mentioned keeps whatever balance it has now.

In practice that means a currency first acquired after the save was written survives loading that save. If your game needs a load to be the whole truth for a wallet, zero it yourself before restoring — the same call ClearAll() makes for status effects.

The same hole exists one level up, at owner rather than currency

A capture skips an owner with no wallet, which is right — writing zero lines for every door and pickup creates the wallets they never had. But an owner paid after the save was written had no wallet when it was taken, so the save has no entry for it, so a restore never reaches it.

Transfer 500 to a companion, quickload: the player's wallet is restored and the companion keeps the 500. The money now exists twice. Nothing failed, nothing logged, Success was true — the same shape as the currency-level exploit above, one level up, and reached through the filter that closed the phantom-wallet problem.

A game cannot close this for itself. The workaround for the currency-level version — zero it before restoring — needs a list of the wallets to zero, and ICurrencyWalletQuery answers per owner. There is no enumeration to walk.

So the participant does it. Every restore compares the wallets in the scene against the owners the save names, and UnsavedWalletPolicy decides what happens to the difference:

Policy What a restore does
Zero (default) Zeroes every balance those wallets hold
Leave Nothing, and logs a warning naming the owners

This is the one place a participant does not apply onto existing state, and the exception is deliberate. That rule is right when the alternative is destroying data; here the alternative is minting it, and a duplication exploit no report mentions is worse than a load that clears more than you expected.

Zero zeroes what the wallet holds, asked through TryGetHeldCurrencies, not the configured list: zeroing the configured list would add keys at zero to wallets that never had them, which is the phantom-wallet problem arriving from the other direction. A service that answers HasWallet but not TryGetHeldCurrencies cannot be zeroed, and says so rather than passing over it quietly.

An object spawned since the save is zeroed too — nothing distinguishes it from one that was always there and has since been paid. That object should not have survived the load either; the save system does not destroy objects, so this is the half of the problem it can reach.

// Opt out where wallets legitimately outlive a load — a persistent world, a shop
// economy outside the player's save, a partial or mid-session restore.
new CurrencySaveParticipant(
    service, currencies, unsavedWallets: UnsavedWalletPolicy.Leave);

Leave still warns and names the owners, because the balance it preserves is indistinguishable from one arriving out of nowhere and no game can tell the two apart on its own.

A balance that cannot be applied does not pass quietly

Currency refuses a snapshot line carrying a blank id or a negative amount, so such a line cannot be restored. Leaving it out is correct; leaving it out silently is not — the player's wallet comes back lighter than they saved it and the load still reports success.

Those lines are counted and reported with the refusals, which marks the section Failed. Because invalid currency ids are rejected at construction, a line like this in a real payload means the save was edited or corrupted, not that the participant was set up wrong.

Blankness is judged the way Currency judges it. A CurrencyId normalises whitespace to empty, so " " is blank to Currency and non-empty to a naive string check. The line is tested through the id it will become, because the cost of the disagreement is not one dropped line: CurrencyPersistence.Restore validates a whole snapshot before applying any of it, so a bad line that gets that far refuses every balance in the same wallet — silently, and without being counted as unreadable at all.


9. Inventory Participant

Aspect Guarantee
Captures Container contents, plus equipment when present on the same object
Equipment absent ✔ Supported — normal case
Construction requirements ItemDatabase (rejected at construction if null)
Item resolution By GUID through the supplied database
Events on restore Raised — the container and equipment raise their change events as they are written. Unlike Health, an Inventory restore is not silent
Authority consulted on restore NoInventorySnapshots.ApplyJson writes containers directly, not through the gated service API. A denying IInventoryAuthority refuses GiveExact and does not refuse a load
Unresolvable items Governed by InventorySnapshotOptions.policy, a MissingItemPolicy
Unparseable / too-new snapshot ✔ Refused before mutating; character keeps existing state
Saved character not in the scene Skipped — a save outlives the objects it was taken from
No saved character in the scene ✔ Reported; section recorded Failed — carryable. See §7
Every character refused Section recorded Failed — carryable
Some characters refused Section recorded PartiallyApplied — not carryable
Schema versioning ✔ Inventory's own version, separate from participant version
Multiple databases per character ❌ Not supported (single shared database)

10. Crafting Participant

Aspect Guarantee
Captures Queued, running and paused jobs
Completed jobs ❌ Not captured — nothing left to resume
Nothing in flight ✔ A section is still written — "idle" is a state. See note
Existing jobs cleared first Yes — the only participant that replaces rather than adds
Authority consulted on restore NoRestoreJobs clears and re-creates with no ICraftingAuthority call, so a load discards a job the authority refuses to let anyone cancel and re-creates one it refuses to let anyone enqueue. Drive it only from a trusted load path
Owner identity StableId
Recipe identity Asset name, or your own resolver
Recipe renamed since save ❌ Job dropped — and ✔ reported
Unresolvable owner or recipe Per-job skip, not a failed load — ✔ and counted in the report
Inputs / currency of a dropped job ❌ Not refunded — they were spent before the save was written
Job whose owner has no StableId ❌ Unrestorable; warned about at capture, where the cause is
Duplicate recipe asset names First wins; warning logged
Offline progress ✔ Reconciled by Crafting on restore — a job may complete immediately
Events on restore Raised — an offline completion emits XP and completion, delivers into Inventory and refunds Currency, so it raises those systems' events too
Downstream refusals during offline reconciliation ⚠ A currency refund the Currency stack refuses — a fail-closed authority during a load is the ordinary case — is a development-only log line, not a report outcome. The section is still reported Ok
Restore order against Inventory / Currency ✔ Enforced by default — CraftingSaveParticipant declares RevSaveOrder.Late, so it restores after them wherever you listed it
Every job dropped, nothing was in flight Section recorded Failed — carryable
Anything else dropped Section recorded PartiallyApplied — not carryable

Crafting orders itself after Inventory and Currency — you no longer have to

Crafting is the only participant whose restore writes into another system's state. Offline reconcile runs during the load: a job whose timer elapsed while the game was shut delivers its outputs into the inventory, refunds currency when that delivery fails, and emits XP. Inventory's restore calls ClearAll() on the container before applying its snapshot, and Currency's writes absolute balances — so Crafting running ahead of them would see its own work erased moments after it landed.

That ordering used to be yours to get right, by convention. CraftingSaveParticipant now declares RevSaveOrder.Late through IRevSaveOrdered, so the coordinator moves it after Inventory and Currency whatever order you pass — the code answers the question the old version of this box asked you to remember.

A custom participant of your own that writes into another system's state should do the same: implement IRevSaveOrdered rather than relying on list position.

Why it had to move into the code. Getting the order wrong was undetectable: no participant failed, no section was marked, and the report said the load was clean while the player was simply short an offline craft. A rule with no failure signal is one a caller eventually gets wrong, which is why it is now declared rather than documented.

This is the one cross-participant ordering dependency inside the framework. The rest are between your own participants and remain yours — the coordinator still will not detect or resolve an interaction nobody declared.

An idle crafting save writes a section anyway, and that is deliberate

Everywhere else, an empty capture writes nothing: a scene with no HealthSystem records no health section, because "no such objects here" is not state worth restoring. Crafting is the exception, because its restore replaces the live job list rather than applying on top of it.

Under the old rule the two halves disagreed. Quickload a save taken mid-craft and your current jobs were replaced; quickload one taken while idle and they kept running, because no section meant the participant was never called. Same operation, opposite outcome, nothing to explain the difference.

Writing the empty section makes an idle save clear. The cost is a few bytes in every save of a project that never crafts.

A dropped job is a real loss, so it reaches the report

CraftingService.RestoreJobs returns void and drops a job it cannot identify with a log line — so before this, an asset rename cost the player an in-flight craft in complete silence, with the load reporting success.

The loss is not just the job. A craft consumes its inputs and its currency when it starts, so those were already spent when the save was written, and Crafting does not refund a job it drops.

Both resolvers belong to the participant, which is what lets it count the drops and put them in the report. The section is marked PartiallyApplied rather than Failed, because the live job list was already replaced by the time anything could be dropped — the one exception is a restore that found nothing in flight to clear and landed nothing, which is genuinely a no-op.


11. Status Effects Participant

Aspect Guarantee
Captures Each active effect instance's id, remaining time and attribution — nothing else
Stacks ✔ Per-instance, so stacking rebuilds naturally
Effect reconstruction Your factory — required
Remaining time ⚠ Passed to the factory, but survives only one way — see note
The effect's authored Duration ❌ Not captured. Only remaining time is
IStatusResistance providers ⚠ Re-applied on restore, shortening the saved time again — see note
Magnitude / potency scale Recomputed from providers present at load, not restored from the save
Per-effect internal accumulators ❌ Reset — e.g. PoisonStatus's part-finished damage tick
Factory returns null ✔ That status is dropped, not a failed load
Side effects on restore Apply runs — statuses re-establish themselves
Double application ❌ Not prevented where another participant restores the same state
Refused by authority or immunity ✔ Detected and reported
Saved owner not in the scene Skipped — a save outlives the objects it was taken from
No saved status found its controller ✔ Reported; section recorded Failed — carryable. See §7
Every status refused Section recorded Failed — carryable
Some statuses applied Section recorded PartiallyApplied — not carryable
Refusal of a status already active ❌ Not detectable — see note
Existing statuses cleared first ❌ No — restore applies on top of whatever is active
Instigator attribution ✔ Restored via StableId
StatusContext.SourceDef ❌ Not restored — no asset registry to resolve it

The saved remaining time survives only if your factory makes it the effect's Duration

Restoring goes through the normal apply path, and applying a status writes its remaining time twice, both times from Duration:

  1. TimedStatusEffect.Apply sets TimeRemaining = Duration.
  2. The controller then calls Refresh(Duration × DurationScale) so duration providers can act.

Whatever TimeRemaining your factory hands back is overwritten by both. So the only construction that preserves a saved time is one where the saved time is the duration:

// Correct: the saved remaining time becomes this instance's duration.
new PoisonStatus(duration: remaining, damagePerSecond: 10f)

// Wrong, and silent: the status comes back at full time, every load.
new PoisonStatus(duration: 6f, damagePerSecond: 10f)

The second is the natural thing to write — the authored duration is what sits in your config table, and the remaining argument looks like it is there for information. Nothing warns you, and a quickload is exactly where a player would notice their poison lasting longer than it should.

The cost of doing it correctly is that the instance's Duration now equals the time left rather than the time it started with, so anything deriving progress from it — IStatusEffect.NormalizedRemaining, a radial cooldown fill — reads full immediately after a load. The framework's own StatusIconView reads TimeRemaining directly and is unaffected. Save your own duration alongside if you need it; the payload is your factory's to interpret.

Resistance is applied again on every load, and it compounds

Step 2 above is not skipped for a restore. TimeRemaining ends up Duration × DurationScale, and that scale comes from the IStatusResistance providers on the target. So a poison saved with 20 seconds left, restored onto a target with 0.5× poison resistance, comes back with 10. Save and load again and it is 5.

Correct for a fresh application, wrong for a restore — the saved time already had the resistance in it when it was first applied. The multiplier is clamped to 0..1, so this only ever shortens: a save scummer walks their own debuffs toward zero rather than inflating them, which is the harmless direction but still not what the save said.

There is no hook to suppress it — the scaling happens inside the controller, past the point the participant hands the effect over. Divide by the resistance in your factory if it matters, or restore statuses before whatever grants it.

Magnitude is not lost — it is re-derived, which is usually better

Five of the shipped statuses implement IAdjustableMagnitude, and a scaled Poison or Slow is not captured. It does not need to be: SetMagnitudeScale is called by the controller on every apply, from the potency providers on the target. A restored status therefore reflects the buffs and auras present at load, which for provider-driven potency is the right answer.

It is also the only available one — SetMagnitudeScale is write-only, with no getter anywhere on the interface, so a participant could not read a scale to save it even if it wanted to. Bake it into the factory if your game sets magnitude from something other than a provider.

Shields: the interaction is the reverse of what it looks like

§7 makes this concrete, and it is worth stating from this side too. HealthSnapshot does not carry shields, so a shield granted by a status is never double-applied on load — re-applying it here is the only thing that brings it back.

Dropping shield statuses from the save to avoid a clash therefore loses the shield entirely. The real double-application risk is a status that overlaps a participant which does restore the same state — your own, or Currency if a status grants balance.

A refused apply is reported, with its reason

StatusEffectController.ApplyStatus returns a StatusApplyResult, so the participant reads the answer to its own call rather than inferring one. Entries the controller refuses are collected and reported with the reason it gave — NoAuthority, Immune, NoEffect or BuildFailed — and an entry whose apply threw is listed separately as (threw).

Since 1.3.0 this no longer has a duplicate caveat. It previously detected landing by checking whether the controller held the id afterwards, which could not tell a refusal from a redundancy when the id was already active before the restore. A return value belongs to its call, so restoring into a controller that still holds live statuses is now answered correctly either way. Calling ClearAll() first is still your decision, for the ordinary reason that the save should be the whole truth.


12. Manager & Storage

The coordinator is a router and stays one. RevSaveManager is that router driven, and IRevSaveStore is where the string ends up. Both ship in RevFramework.Core, in every package, with no define guard.

RevSaveManager

Aspect Guarantee
Type MonoBehaviour, [DisallowMultipleComponent]RevFramework ▸ Core ▸ Save Manager
Anything on Awake, quit, or a timer None. Adding it to a scene starts nothing
Participant discovery ❌ None — you register each one. See §1's note on the dependency graph
Registering the same instance twice ✔ Refused, returns false. Two different participants sharing a key is still a coordinator-reported clash
Save / Load return value ✔ Never null
Store failure on save ✔ Reported through report.FatalError — never thrown
Missing or unreadable slot on load ✔ Reported through report.FatalError; nothing applied, and CarriedOver is left untouched
Carry-over, wired ✔ A load keeps Unrecognised plus Unapplied; the next save writes them through
PartiallyApplied carried ❌ Deliberately excluded — part of one is live state now
Carry-over across loads Replaced, not accumulated. Loading slot B does not carry slot A's passengers
Carry-over across saves Kept. A save does not consume it; only a load replaces it and only DiscardCarriedOver empties it
SaveCompleted / LoadCompleted ✔ Raised whether or not the report succeeded — check Success before acting
A throwing event subscriber ✔ Logged and isolated; the other subscribers still run and nothing reaches the caller
Events raised during a restore Depends on the participant — two of six are silent, four are not. See the box below, and reconcile presentation from LoadCompleted either way

Restores are not uniformly silent, and this row used to say they were

The guarantee here read "Participants write state directly — no Died, no Revived, no HealthChanged", which is Health's contract stated as if it were the framework's. It is true of two participants and false of the other four, because a participant restores through whatever API its system exposes and most of those APIs raise on a write:

Participant Raises during a restore?
Health ❌ No — current, max and dead are field assignments
Attributes ❌ No — values are written directly
Currency ✔ Yes — restore calls SetBalance per line, which raises OnWalletChanged and its UnityEvent mirror whenever a balance actually changes
Inventory ✔ Yes — the container and equipment raise their change events as they are written
Status Effects ✔ Yes — statuses come back through ApplyStatus, so StatusApplied / StatusRefreshed, the …Ctx variants, the UnityEvent mirrors and refresh FX all run
Crafting ✔ Yes — offline reconciliation completes jobs during the load: it emits XP and completion, delivers into Inventory and refunds Currency, so it also raises their events

The advice does not change: reconcile from LoadCompleted. What changes is what you may assume in the meantime. A listener that treated "no event" as proof no load was happening was relying on a guarantee that only Health and Attributes ever offered — and the reason the row gave for the silence, that firing death events during a load would spawn VFX, is exactly what a Status Effects restore does today.

It is not a defect in the four. Restoring through the public API is what makes a restored status re-establish itself, a restored wallet respect its caps, and a restored container refuse what it cannot hold. Suppressing those events would mean a second write path per system, with its own bugs. What a listener can read instead is RevSaveRestore.InProgress, which is true for anything a restoring participant raises synchronously — see the note below for what it does and does not cover.

Telling a load apart from play, when you need to

RevSaveRestore.InProgress answers the case that matters: whether this call stack is inside some participant's Restore. The scope wraps participant.Restore itself rather than that participant's own bookkeeping, so anything a restoring participant raises synchronously runs inside it. A listener on a container change, a balance change or a status event can therefore tell that the change it is hearing about is a load applying rather than something the player did — without knowing anything about the save system, and without its own participant having been called at all.

What it cannot tell you is that a load is running while nothing is currently restoring: before the first participant, between two of them, after the last, for work a system defers to a later frame instead of raising inline, or when the loaded save had no sections to apply. RevSaveManager raises LoadCompleted and has no counterpart for the start of a load.

For those, a flag around the call covers strictly more, and your game is always the caller:

public static class GameLoad { public static bool InProgress; }

GameLoad.InProgress = true;
try     { manager.Load(slot); }
finally { GameLoad.InProgress = false; }

A flag you set yourself does not depend on any participant being reached, which is the gap a participant-side flag alone leaves: a save with no section for your participant never calls its Restore, so nothing participant-side is ever armed. CountsWhatHappened documents that hole from the inside and guards with RevSaveRestore.InProgress beside its own flag, which closes it for anything raised while another participant restores — but not for a load in which nothing restores at all. Reconciling from RevSaveManager.LoadCompleted remains the route for anything that can wait until the load has finished; both of the above are for code that has to run during one.

IRevSaveStore and FileSaveStore

Aspect Guarantee
Default store FileSaveStore under Application.persistentDataPath/<saveFolderName>, built lazily on first access to Store
Replacing it ✔ Assign RevSaveManager.Store. Assigning null restores the default
Anything on the interface throwing ❌ Never — every operation reports success or failure
Slot name validation ✔ Refused, not sanitised: not empty, not . or .., no separators, no characters the platform rejects
A refused slot ✔ Logged, and nothing is read or written
A folder name that cannot be one ✔ Falls back to Saves with a logged error, rather than throwing out of a property getter
An absolute path passed as a folder name ✔ Refused — use FileSaveStore.AtAbsolutePath when you mean one
TryListSlots ✔ Clears results; true means the store could answer, including with an empty list
An absent save directory ✔ "No saves", not a failure
Files that are not slots ✔ Ignored — the listing matches <slot>.json only
Interrupted write ✔ Previous save left readable — the payload goes to a temporary file first
Interrupted swap ⚠ Slot left empty; payload survives as <slot>.json.tmp and is recoverable by hand. See the box below
Write atomicity Not guaranteed

What a failed write does and does not promise

FileSaveStore.TryWrite writes to a sibling <slot>.json.tmp, deletes the existing <slot>.json, and moves the temporary into place.

What that buys: the write itself is the long part, and the one a player force-quitting during an autosave is most likely to land in. An interruption there leaves the previous save readable rather than truncated — the worst case becomes losing the newest progress rather than all of it.

What it does not: the delete-then-move swap is a window this cannot cover. A failure inside it leaves the slot holding nothing. The window is far smaller than writing the payload; File.Replace was not used instead because it requires the destination to already exist — which it does not for a first save — and adds a backup file as a third artefact.

What survives it: the payload, as <slot>.json.tmp. At that point the temporary is complete and valid, so TryWrite keeps it rather than cleaning it up and logs the path to rename. It is not a slot — TryListSlots, Exists and TryRead all match *.json and none of them see it — so recovering is a deliberate rename, and the next write to that slot overwrites it. A store that read back the file its own swap had just rejected would be hiding the fault rather than reporting it.

So Save reporting a fatal write error means nothing was saved, which is the outcome that matters to the caller. It does not mean the previous save survived. A title needing more than that — console certification, cloud sync — should implement IRevSaveStore over whatever atomicity its platform actually offers. That is what the interface is for.

Slot and folder names are validated rather than made safe

A slot becomes a file name and the folder name becomes a directory, so both arrive from game code and reach a real path. Quietly rewriting "../../ProjectSettings" into something harmless would mean the caller's slot and the file no longer correspond, which surfaces much later as a load that silently finds nothing. Rejecting is the honest failure.

The folder name is the one exception, and for a specific reason: RevSaveManager builds its default store from a serialized inspector field, so an exception out of a property getter on the first save would be a far worse failure than saves landing in Saves with an error explaining why.


Non-Guarantees

The save coordinator does not guarantee

  • Atomicity across participants — if section 3 fails, sections 1 and 2 have already applied
  • Rollback of a partial restore — there is no undo
  • ❌ Ordering safety between participants that touch the same state and neither declares a position through IRevSaveOrdered — nothing detects or resolves that for you
  • ❌ Dependency ordering between two participants that both declare the same position
  • ❌ Scene loading or scene management of any kind
  • ❌ Autosave scheduling, save timing, or any save UI — RevSaveManager addresses slots, but nothing in the framework decides when to save
  • ❌ An atomic write — FileSaveStore survives an interrupted write, not an interrupted swap; see §12
  • ❌ Encryption, compression, or tamper resistance
  • ❌ Thread safety or async/background saving
  • ❌ Network replication or server authority
  • ❌ Migration tooling (the version hook exists; the tooling does not)
  • ❌ Health shields, regen timers or rule state — HealthSnapshot is current, max and dead only
  • ❌ Presentation matching restored dead state — ragdoll, AI, colliders and loot are yours to reconcile
  • ❌ A status effect's authored Duration, or anything derived from it such as IStatusEffect.NormalizedRemaining
  • ❌ Suppressing resistance scaling on a restore — saved time is shortened again by any live provider
  • ❌ Per-effect internal accumulators — a part-finished damage tick restarts
  • ❌ Clearing existing state before a restore — participants apply onto what is already there, with two deliberate exceptions: Crafting, whose restore replaces the live job list, and Currency, which zeroes wallets the save does not mention
  • ❌ Identity for objects without a StableId
  • ❌ Cross-session identity for runtime-spawned objects without an explicit AssignId
  • ❌ Automatic correction of duplicated StableId values
  • ❌ State held on prefab assets rather than scene objects

Order matters, and is yours to decide — unless a participant decides for itself

Participants are captured and restored in the order you supply them, stably sorted by IRevSaveOrdered for any participant that declares a position. Where two participants touch the same state — a shield status and Health's shield — the outcome depends on that order, and the coordinator will not detect or resolve the interaction for you.

The one such pair inside the framework now answers itself: CraftingSaveParticipant declares RevSaveOrder.Late, so it restores after Inventory and Currency wherever you listed it. A participant of your own that writes into another system's state should declare its position the same way. See §10.

Restore reads your list, not the file. That is what makes this an actual lever: the order inside a save was fixed by whichever build wrote it, from a participant list that may no longer exist, with carried-over sections appended after the live ones. Driving restore off the file would leave you reordering your list to no effect and no way to discover why. A section whose key no participant claims is still reported — routing does not depend on order, only sequencing does.


Final Summary

Layer Strong Guarantee Best Effort Not Guaranteed
Section routing by key
Participant failure isolation
Version delivered as written
Unrecognised data retained
Corrupt payload reported, not thrown
Per-owner restore
Declared restore order honoured (IRevSaveOrdered)
Carry-over across a load and the next save (RevSaveManager)
Restore of a single participant
Slot write surviving an interrupted write
Slot write surviving an interrupted swap
Cross-participant atomicity
Partial-restore rollback
Ordering safety where nothing declared a position
Encryption / tamper resistance
Multiplayer replication

System Philosophy

The save coordinator is

  • Explicit
  • Composable
  • Report-first, never throw-first
  • Opinion-free about storage

It is deliberately not

  • A serialisation library
  • A scene manager
  • A scheduler

It composes what your systems already know how to save, and routes it back. Baking storage, slots or scene flow into it would make the router opinionated about your project's structure in ways that age badly.

Those opinions still had to live somewhere, so they live one layer up and stay replaceable: RevSaveManager for slots, participants and carry-over, and IRevSaveStore for the bytes. Take the string and stop, or take the defaults and swap the parts that do not suit you. What never happens is the coordinator learning about files.