Save — One File, Every System¶
This folder contains the save participants: one per system, each translating that system's own snapshot API into the shared contract the save coordinator understands.
The coordinator itself lives in Runtime/Core/Save/ and ships in every package. These participants are optional and compile only when their system is present.
Why This Exists¶
Every system already knew how to save itself. None of them knew how to save together.
Inventory speaks JSON strings. Currency returns objects. Health returns a struct. Crafting wants identity resolvers injected. Status Effects had no persistence surface at all. Combining them into one save file was left to you.
The coordinator composes them, and none of the six systems changed to make it work. Each participant is a translator in a define-gated assembly, exactly like the rest of Integrations/ — so deleting a system still compiles.
The Shortest Version¶
using RevGaming.RevFramework.Core.Save;
var participants = new IRevSaveParticipant[]
{
new HealthSaveParticipant(),
new InventorySaveParticipant(itemDatabase),
};
// Save
string json = RevSaveCoordinator.Capture(participants);
File.WriteAllText(path, json);
// Load
var report = RevSaveCoordinator.Restore(File.ReadAllText(path), participants);
if (!report.Success) Debug.LogWarning(report);
The coordinator never touches the filesystem, deliberately. It takes and returns a string, so where a save lives is entirely yours: Application.persistentDataPath, PlayerPrefs, cloud save, inside a larger save file of your own, or encrypted. The router holds no opinion about platforms.
If you would rather not own that end, RevSaveManager does. Add the component, register these participants, and call Save("slot1") — it writes through a replaceable IRevSaveStore, defaulting to one file per slot under Application.persistentDataPath/Saves, and it carries forward the sections a load could not place:
manager.Register(new HealthSaveParticipant());
manager.Register(new InventorySaveParticipant(itemDatabase));
manager.Register(new CraftingSaveParticipant(craftingService, recipes));
var report = manager.Save("slot1");
Registration order does not matter — see Trades You Should Know About, below. Documentation/Save/ covers the manager and the store in full, including exactly what a failed write does and does not promise.
The One Thing That Will Catch You¶
Every object whose state you want saved needs a StableId component.
A GameObject reference means nothing across sessions, so participants identify owners by StableId.Id. An object without one is invisible to every participant — it will not be saved, and it will not be restored. That is deliberate: saving it would write state that could never be matched back to anything.
Add RevFramework ▸ Core ▸ Stable Id to your player, your enemies, your chests — anything whose state should survive a save.
Participants¶
| Participant | What it needs | What it saves |
|---|---|---|
HealthSaveParticipant | nothing | Current, max and dead state per owner |
CurrencySaveParticipant | service + currency ids | The currencies you name, plus whatever each wallet actually holds |
InventorySaveParticipant | ItemDatabase | Container contents, and equipment where present |
CraftingSaveParticipant | service + recipes | Queued, running and paused jobs |
StatusEffectsSaveParticipant | effect factory | Active statuses, with their remaining time |
AttributesSaveParticipant | nothing | Base attribute values per owner — never effective values |
Health and Attributes are the only ones that need nothing. A participant is something your game assembles and hands over — not something discovered by reflection — which is why the others take constructor arguments.
For Crafting and Status Effects the reason is the same: the thing has no durable identity of its own.
- Crafting recipes have no id at all.
ItemDefinitioncarries aguidfor exactly this purpose; recipes have no equivalent. You hand the participant your recipes and it identifies them by asset name, matching the convention the teaching panels already use. - Status Effects are polymorphic objects rather than data, so only your game can rebuild one from an id. You supply a factory, and it receives the saved remaining time.
Currency is a different case, and its list means something narrower than it used to. Balances are addressed by id, so CurrencyPersistence.Capture has always had to be told which ids to write — and relying on that list alone is what made a forgotten currency dangerous: it was neither saved nor set by a load, so its balance survived a quickload and the player kept whatever they had spent it on.
A service implementing ICurrencyWalletQuery can now be asked which currencies a wallet actually holds, and a capture asks. So what gets written is your list plus everything the wallet holds. The list still earns its place: a currency the wallet has never touched has no key to enumerate, and a line at zero for it is the only thing that lets a restore put that balance back to zero. A service that cannot enumerate falls back to your list alone, which is the behaviour this always had.
Trades You Should Know About¶
These are decisions, not defects. Each is pinned by a test so it stays honest.
Crafting restores after Inventory and Currency, and declares that itself. It is the only participant whose restore writes into another system's state: Crafting reconciles offline progress as it restores, so a job whose timer elapsed while the game was shut delivers its outputs into the inventory and refunds currency when that delivery fails — during the load. Inventory's restore clears the container before applying its snapshot and Currency's writes absolute balances, so either one running afterwards would erase what Crafting just produced.
CraftingSaveParticipant implements IRevSaveOrdered and returns RevSaveOrder.Late, so the coordinator moves it after them whatever order you register or supply them in:
var participants = new IRevSaveParticipant[]
{
new CraftingSaveParticipant(craftingService, recipes), // declared Late; restored last anyway
new InventorySaveParticipant(itemDatabase),
new CurrencySaveParticipant(currencyService, currencies),
};
Listing it last as well is harmless and reads clearly — it is simply no longer what makes it correct. A deliberate order of your own still comes back untouched: the sort is stable and everything that does not implement the interface shares RevSaveOrder.Default, so only a participant that explicitly asks to move, moves. Capture ignores the interface entirely.
Why it had to move into the code. Getting the order wrong was undetectable — no participant failed, no section was marked, the report read clean, and the player was simply short an offline craft. A rule with no failure signal is one a caller eventually gets wrong. If you write a participant whose restore writes into another system's state, declare its position the same way rather than documenting a rule for the next caller to remember.
Crafting's restore replaces the live job list rather than adding to it, which also makes it the only participant that does not apply on top of existing state — adding would duplicate every job on a second load. So its capture writes a section even when nothing is in flight, because "you had nothing crafting" is a state a load has to be able to restore.
Renaming a recipe asset invalidates saves that reference it. Asset name is the recipe's identity, because nothing better exists. The job is dropped on load and reported — the save still loads, that job is simply gone, and the inputs and currency it consumed were spent when it started and are not refunded. Use the resolver constructor if your project has durable recipe identity of its own.
A restore zeroes wallets the save does not mention — the one place a participant clears rather than applying on top. A capture skips an owner with no wallet, so an owner paid after the save was written has no entry in it: transfer 500 to a companion, quickload, and without the sweep the player's wallet is restored while the companion keeps the 500. That is the currency-level exploit one level up, and the rule about not clearing is there to stop data being destroyed, not to protect money being minted. Opt out where wallets legitimately outlive a load:
new CurrencySaveParticipant(service, currencies, unsavedWallets: UnsavedWalletPolicy.Leave);
Restoring a status runs its Apply. Statuses re-establish themselves properly, which is right for most. But where a status owns state another participant also restores — a shield status alongside Health's shield — you can get it twice. Either have the factory build a version that skips re-application, or leave those statuses out and let Health's snapshot own the result.
StatusContext.SourceDef is not restored. It is a ScriptableObject reference and there is no asset registry to resolve it through. Instigator, source id, slot and note all survive. Bake the definition into your factory if a status needs it.
Economy and Pickups have no participant. Economy orchestrates Currency and Inventory rather than owning state, and Pickups are world objects. Nothing is missing — there is nothing there to save.
Loading On A Build That Lacks A System¶
Supported, and handled deliberately. A section no participant claims is reported as Unrecognised rather than failing the load, and handed back so you can preserve it:
var report = RevSaveCoordinator.Restore(json, participants);
// Later, when saving again — keep data for systems this build does not have
string json2 = RevSaveCoordinator.Capture(participants, out _, report.Unrecognised);
Without that second argument, saving on a build with a system removed silently destroys that system's data. Since RevFramework is sold in SKUs, that is a normal situation rather than an edge case.
Your Own Data In The Same File¶
Framework systems are not privileged. Implement IRevSaveParticipant and your quest flags, unlocked levels or settings sit in the same file alongside them:
public sealed class QuestSaveParticipant : IRevSaveParticipant
{
public string Key => "mygame.quests"; // stable forever; renaming it orphans existing saves
public int Version => 1;
public string Capture() => JsonUtility.ToJson(QuestLog.Current);
public void Restore(string payload, int version)
{
if (version > Version) throw new NotSupportedException("Save is from a newer build.");
QuestLog.Apply(JsonUtility.FromJson<QuestState>(payload));
}
}
Use your own key prefix and it can never collide with a framework one. Throwing from Restore is a legitimate way to refuse a payload — the coordinator isolates it, records that section as failed, and carries on with the others.
Failure Handling¶
Nothing here throws at you. RevSaveReport tells you what happened per section:
- A participant that throws is isolated and recorded; the rest still run. A half-loaded save that names the broken section is more useful than an exception that abandons everything.
- Corrupt or truncated payloads report through
FatalErrorrather than throwing. A load that throws is how a game ends up stuck on its main menu. - Duplicate keys are reported rather than resolved by enumeration order.
- A section that matched no owners at all is reported rather than passing as a clean load. Some owners missing stays silent — a save outlives the objects it was taken from — but none of them found means the section applied to nothing, and the only symptom otherwise is a game full of default state. Usually the wrong scene is loaded, or ids have changed since.
report.Success is deliberately not tripped by unrecognised or skipped sections — those are supported situations, not failures.
Versioning¶
Two independent version numbers, at different layers:
- Envelope version — the shape of the save container itself.
- Participant version — the shape of one section's payload.
The version a payload was written with is what reaches Restore, not your current one. That is what makes migration possible: without it, a participant could never tell old data from new, and every format change would silently corrupt existing saves.
A save from a newer envelope version is still read rather than refused, because sections are self-describing and refusing outright would throw away data that could have been restored.
Safe To Delete¶
If you are not using the save coordinator, this entire folder can be deleted. No system depends on it.