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
- 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 | ✔ The order you supply the participants — not 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 |
| 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 + UTC timestamp, diagnostic only |
| Metadata in logic | ❌ Nothing branches on framework version or timestamp |
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.Resetruns onAddComponent, in play mode as well as edit mode, andAwakecovers the build.Instantiatenever generates one.OnValidatedoes not fire onInstantiate. 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 fromAwake, 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 |
| 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
isDeadcome 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 |
| 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 |
| Unresolvable items | Governed by InventorySnapshotOptions.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 |
| 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 |
| Restore order against Inventory / Currency | ❌ Not enforced — put them first. See note |
| Every job dropped, nothing was in flight | Section recorded Failed — carryable |
| Anything else dropped | Section recorded PartiallyApplied — not carryable |
Put Inventory and Currency before Crafting in your participant list
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 a list with Crafting ahead of them erases exactly what Crafting just produced:
// Wrong — the offline craft's outputs are cleared away moments after they land
new IRevSaveParticipant[] { craftingParticipant, inventoryParticipant };
// Right
new IRevSaveParticipant[] { inventoryParticipant, currencyParticipant, craftingParticipant };
Nothing detects it. No participant failed, no section is marked, and the report says the load was clean. The player is simply short an offline craft. This is the one cross-participant ordering dependency inside the framework; the rest are between your own participants and are yours.
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:
TimedStatusEffect.ApplysetsTimeRemaining = Duration.- 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.Progress01, 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 caught; a refused duplicate is not
StatusEffectController.ApplyStatus returns void and silently does nothing when authority is denied or the target is immune. The participant checks whether the controller holds the status afterwards and reports the ones that did not land.
That check cannot tell a refusal from a redundancy when the id was already active before the restore. Restoring into a controller still holding live statuses is your decision — call ClearAll() first if the save should be the whole truth.
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
- ❌ Scene loading or scene management of any kind
- ❌ Save slots, autosave scheduling, or any save UI
- ❌ File I/O, paths, or platform storage decisions
- ❌ 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 —
HealthSnapshotis 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 asProgress01 - ❌ 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
StableIdvalues - ❌ State held on prefab assets rather than scene objects
Order matters, and is yours to decide
Participants are captured and restored in the order you supply them. Where two participants touch the same state — a shield status and Health's shield — the outcome depends on that order. The coordinator will not detect or resolve the interaction for you.
One such pair is inside the framework and has a right answer: Inventory and Currency before Crafting. Crafting's restore delivers offline craft outputs into the inventory and refunds currency; Inventory's clears the container first and Currency's writes absolute balances. Getting it backwards loses the offline craft, silently. 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 | ✔ | ||
| Restore of a single participant | ✔ | ||
| Cross-participant atomicity | ❌ | ||
| Partial-restore rollback | ❌ | ||
| Cross-participant ordering safety | ❌ | ||
| 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 save-game framework
- A serialisation library
- A scene manager
It composes what your systems already know how to save, and routes it back. Everything outside that boundary stays yours, because baking storage, slots or scene flow into it would make it opinionated about your project's structure in ways that age badly.