05 — Save¶
Goal¶
Learn how in-flight crafting jobs survive a load, and meet the problem underneath it: Crafting is the only system in the framework that cannot identify its own data.
What This Scene Demonstrates¶
The participant flow:
Queued / running / paused jobs → owner via StableId, recipe via a convention you choose → one labelled section → coordinator → save payload
CraftingSaveParticipant adapts CraftingService.SaveActiveJobs / RestoreJobs to the coordinator. Those two already made the caller inject resolvers, and this scene is largely about why they had to.
The Identity Problem¶
Owners resolve through StableId, like every other participant. Recipes have nothing equivalent.
RecipeCore exposes a display name, a description and a station tag — nothing that survives being written to a file. ItemDefinition carries a guid for exactly this purpose; recipes do not.
So the participant makes you choose. The convention the framework settled on, and the one this scene uses, is that the recipe asset's name is its identity. Hand it the recipes your game can craft and it builds the maps.
The cost is real and worth saying plainly: renaming a recipe asset invalidates every save that references it, and nothing detects the rename. Press Load A Save Naming A Recipe That Is Not Here to watch what happens next.
The job is dropped, and the two layers involved behave very differently about it.
Crafting's own restore path is silent. A snapshot whose owner or recipe will not resolve is skipped by a bare continue — no warning, nothing in the Console — and RestoreJobs returns void, so a host driving it directly has no way at all to find out.
The participant is not. It supplies both resolvers, which makes it the one place that can count the drops, so it counts them before it touches live state and then refuses the section with RevSavePartialRestoreException. The coordinator files that as PartiallyApplied, which makes RevSaveReport.Success false, so the result reads RevSave: completed with problems and names the count, the cause, and the fact that the inputs and currency those jobs consumed were spent before the save was written and are not refunded.
So the loss is real, and the report is exactly where you learn about it. The Console is the thing that stays quiet.
Use the resolver constructor instead if your project already has a durable identity — a content database, an addressable key, an authored id.
Two Behaviours That Are Unique To This Participant¶
It replaces rather than applies on top. A restore clears the live job list first, because a load is a rewind: the snapshot is the whole truth about what is in flight, and keeping the live jobs would leave the player with both sets.
It writes a section even when nothing is crafting. That is the one place this participant departs from the convention that an empty capture writes nothing, and it has to. Since a restore replaces, the section is the only thing that can carry "you had nothing crafting" across a load. Cancel every job, capture, and read the payload — the section is there, holding an empty list.
Returning null for that made the behaviour depend on what the save happened to contain: quickload a save taken mid-craft and your jobs were replaced, quickload one taken while idle and they kept running, with nothing to explain the difference.
Why It Restores Late — And Why This Scene Only Explains It¶
CraftingSaveParticipant is the only participant that declares a restore order, and the only one 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. Inventory's restore clears the container first and Currency's writes absolute balances, so either one running afterwards erases what this produced — with no error, because nothing failed.
It therefore declares RevSaveOrder.Late through IRevSaveOrdered, and the coordinator moves it past them whatever order you pass. You cannot get this wrong any more, which is the point: a caller could not reasonably be expected to know it.
In this scene that is explained rather than demonstrated, on purpose. The adapters here are FakeInventoryAdapter and FakeCurrencyAdapter, so there is no real Inventory or Currency participant to be ordered against. The panel shows the declared order and states the reason; do not go looking for a button that proves it. Proving it needs a project with all three systems and all three participants, which is a game rather than a sample.
Scene Setup¶
Player, an otherwise empty GameObject carryingStableId— assigned as the panel's Owner. Jobs belong to an owner, not to the service, and the panel shows the owner's id so you can see the requirement is metCraftingServicewithFakeInventoryAdapterandFakeCurrencyAdapter, kept from the base sceneFakeInventoryCurrencyPanel, kept deliberately — see belowCraftingSavePanelwith all fourRecipeCoreassets assigned
The fake inventory starts empty. Until you press Add on FakeInventoryCurrencyPanel to give yourself materials, every craft is refused for missing inputs. That is not a bug in either panel; it is the fake behaving like a real container.
Only four assets in Samples/Systems/Crafting/Recipes/ are actually RecipeCore, and three that are named like it are not — RecipeCore_MintHerb, RecipeCore_IngotToBlade and RecipeCore_IronOre are a different type and the field will not accept them. The four that work are RecipeCore_CopperOre, RecipeCore_WoodToPlank, RecipeCore_WoodToPlank_Offline and RecipeCore_WoodToSword_RealAdapterScene.
RecipeCore_WoodToPlank_Offline has a 110-second craft time, which is what makes capturing a job mid-flight comfortable.
What To Look For¶
- The Recipes You Hand It lists every assigned recipe by asset name, and flags two sharing a name — saved jobs for one would restore as the other, so it is reported rather than quietly resolved
- Build A Participant With No Recipes shows the constructor refusing an empty list outright. Every saved job would be dropped on restore — reported, but with the inputs and currency already spent and unrefunded. Refusing here says so while it is still a wiring mistake rather than a player's loss
- Start Some Crafts lists jobs with their state and progress, and shows the owner's
StableIdabove them - Capture And Restore — capture with jobs running, cancel them all, capture again, and read both payloads
- What A Renamed Recipe Looks Like doctors a real capture, so the demo cannot quietly stop demonstrating anything if the payload shape changes
- Why It Restores Late prints the declared order against
RevSaveOrder.DefaultandEarly
Sample Scope¶
This scene focuses only on:
- Capturing and restoring queued, running and paused jobs
- Recipe identity, the asset-name convention, and what a rename costs
- Replace-not-merge, and the section written when idle
- The one ordering rule in the framework, stated
This scene does NOT cover:
- Writing the payload to disk — nothing here does any file I/O, on purpose
- Real Inventory or Currency delivery — the adapters are fakes
- Demonstrating the Late ordering against real participants
- Save slots, versioning beyond the participant's own guard, or networking
Authority Note¶
This scene may include a permissive sample authority setup. If no authority is resolved, mutations are allowed for demonstration purposes.
Networking Reminder¶
No networking is included. Multiplayer authority and synchronization are the developer's responsibility.
How To Use¶
- On
FakeInventoryCurrencyPanel, press Add to give yourself materials - Press Start A Craft once or twice.
RecipeCore_WoodToPlank_Offlineruns for 110 seconds, so there is no rush - Press Capture, and read the jobs in the payload
- Press Cancel All, then Capture again. The crafting section is still there, holding an empty list
- Start another craft, then Restore the first payload. The live job list is replaced, not merged
- Press Load A Save Naming A Recipe That Is Not Here. The job is dropped, the Console says nothing at all, and the restore reports completed with problems — read Last Result, which names what was lost and why
- Press Build A Participant With No Recipes to see the constructor refuse
- Read Last Result after each action
Step 4 is the one that surprises people. Step 6 is the one worth a slide.
Failure Behaviour¶
Failures are reported through RevSaveReport rather than thrown.
Common patterns:
-
Every craft is refused → the fake inventory has no materials → Press Add on
FakeInventoryCurrencyPanelfirst -
The participant refuses to be constructed → the recipe list is empty → Assign recipes. An empty list would drop every saved job on restore
-
A job is dropped and the section is reported
PartiallyApplied→ its recipe id resolves to nothing → A renamed asset, or a recipe missing from the panel's list. This is the documented cost of the asset-name convention. Nothing detects the rename; the participant detects the damage, at load, and fails the section for it. Do not carry aPartiallyAppliedsection into a later save — part of it is live state already, so writing it back would overwrite the jobs that did load -
Two recipes share a name → the first is used and the second is reported → Rename one. Saved jobs for the second would restore as the first
-
Restoring wiped jobs you expected to keep → working as designed; a restore replaces →
clearExisting: falseonRestoreJobsis the merge path, for a mid-session restore rather than a load
Behind The Scenes¶
This panel uses public save and crafting APIs only:
CraftingSaveParticipantRevSaveCoordinator.Capture(...)/RevSaveCoordinator.Restore(...)RevSaveReport,RevSaveEnvelope,RevSaveSectionIRevSaveOrdered/RevSaveOrderStableIdCraftingService.EnqueueOne(...)/GetJobs(...)/CancelJob(...)CraftJob,CraftJobSnapshot,RecipeCore
A restore also clears Crafting's completion history, because a load is a rewind and that history is not part of the save. A host calling RestoreJobs directly rather than through the coordinator wants ClearAppliedCompletions() for the same reason.
Key Takeaway¶
Every other participant identifies its data for you. This one asks you to choose, because the framework genuinely cannot: a recipe has no id that outlives the asset it lives in.
Asset names are a workable answer and they are not a free one. Whatever you pick, the two functions have to round-trip — whatever the selector writes, the resolver has to accept — and once a save exists, the identity is frozen.
Related¶
- 04 • Offline Progress & Persistence — this scene was built from it. That one is the layer below:
SaveActiveJobs/RestoreJobsdriven directly, with the resolvers supplied by hand.