Skip to content

04 — Save

Goal

Learn what a save actually stores about an active status effect — an id and a time, and nothing about what the effect does — and the one line of your own code that decides whether restoring it works.


What This Scene Demonstrates

The participant flow:

Active effects → StableId lookup → id + remaining time + attribution → one labelled section → coordinator → save payload

Two characters carry a StatusEffectController and a StableId. StatusEffectsSaveParticipant finds both on its own.

On load it hands each saved id and its remaining time to a factory you supply, and everything about what comes back comes out of that factory rather than out of the save. Status effects are polymorphic objects rather than data — which is why ApplyOrRefresh takes a Func<IStatusEffect> in the first place — so no participant could rebuild one for you.


The Scene Is Set Up To Make One Mistake Visible

TimedStatusEffect.Apply assigns TimeRemaining = Duration, and the controller then calls Refresh(Duration × DurationScale). Both read Duration. So the saved time survives only when it is the duration you build with:

new PoisonStatus(duration: remaining, damagePerSecond: 10f)   // correct

Passing the authored duration instead — the natural thing, because that is what sits in a config table — restores every status at full time and reports success. Nothing fails. Nothing warns.

The panel's Factory toggle runs both, so you can watch the difference rather than take it on trust.


And One Thing People Do Not Believe Until They See It

Enemy has poison resistance of 0.5. Player has 1, meaning none.

The duration scaling in step two above is not skipped for a restore, so an IStatusResistance provider shortens the saved time again on the way back in. Poison the pair, capture, restore: the Enemy comes back at half the number sitting in the payload, while the Player comes back at exactly what was saved.

And it compounds — but once per save/load cycle, not once per restore. Capture, restore, capture again, restore: the second payload holds the already-halved time, and the resistance halves that too. Roughly 20 → 10 → 5.

Restoring the same payload three times in a row is not that experiment, and it is the one people reach for first. Poison is a Replace status, so each restore discards the live instance and rebuilds it from the payload's fixed remaining — which never changed. The Enemy lands on the same number every time. The compounding lives in the capture, and that is the point worth taking away: it is not the load that shrinks the buff twice, it is saving a value that was already shrunk.

That is the whole reason the two characters are configured differently. Watching one number decay against a control is the only way this lands.


Why The Totem Was Removed

02_Potency_Auras — the scene this was built from — has a Totem carrying a StatusAuraZone. It is deliberately not here.

An aura re-applies from OnTriggerStay, and its duration-refresh policy calls Refresh(Duration × scale), which sets the time rather than topping it up. Left in, a restore is unreadable: you cannot tell whether a status came back from the payload or from the totem. Worse, the totem refreshes at the authored duration — exactly what the broken factory produces — so it would mask the one demo this scene exists for.


What To Look For

  • Who Gets Saved lists both controllers with their active effects and remaining times, live. Watch them tick down before you capture — a save taken at full duration cannot show you anything, because full is also what the broken restore produces
  • Capture produces the payload. An entry is ownerId, statusId, remaining, and flattened attribution
  • SourceDef is absent and is not restored. It is a ScriptableObject reference and there is no asset registry to resolve it through — the same gap recipes have. Bake it into the factory if a status needs it
  • Magnitude is absent too, for a better reason: the controller calls SetMagnitudeScale from the target's potency providers on every apply, so a restored effect picks up the buffs present at load. The member is write-only, so no participant could read a scale to save one even if it wanted to
  • The factory log under section 4 prints what the factory was asked for and what it built, per status. That is the panel showing its working rather than asserting a result
  • MovementSpeedScaler on both characters is the potency consumer — it is what magnitude is being read for

Scene Setup

  • Two characters, Enemy and Player, each with StatusEffectController and StableId
  • DemoStatusResistance on both — poison: 0.5 on the Enemy, all 1 on the Player
  • StatusAuthorityBinder, kept from the base scene
  • StatusSavePanel in place of StatusPotencyAurasPanel
  • No aura zone, see above

Sample Scope

This scene focuses only on:

  • Capturing active effects across every controller through one participant
  • The factory contract, and both the correct and the natural-but-wrong implementation
  • Why NormalizedRemaining reads full immediately after a correct load
  • Resistance re-scaling a restored duration, and where the compounding actually comes from — a capture between loads, not a repeated restore
  • What the payload does and does not carry

This scene does NOT cover:

  • Writing the payload to disk — nothing here does any file I/O, on purpose
  • Restoring SourceDef, or any asset reference an effect holds
  • Saving your own duration so NormalizedRemaining reads correctly after a load — that needs a participant of your own
  • 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

  1. Press Apply poison and Apply haste, then wait a few seconds. Watch the times fall in section 1
  2. Press Capture, and read the remaining times in the payload
  3. Press Clear All, then Restore. With the factory on remaining-as-duration, the effects come back where they were
  4. Read the factory log: saved 12.4s → built with duration 12.4s
  5. Toggle Factory to authored-duration. Clear All, Restore again. Everything comes back at 20s and the load reports success
  6. Set the factory back. Capture, Clear All, Restore: the Enemy comes back at half the time the payload holds, the Player at all of it. Now Capture again, Clear All, Restore again — the new payload carries the already-halved number and the Enemy halves from there. One halving per save/load cycle, roughly 20 → 10 → 5. Restoring the same payload twice without recapturing does not do this: poison is a Replace status, so the second restore rebuilds from the same fixed remaining and lands where the first one did. The compounding is in the capture, not the load
  7. Read section 5 after any restore: Duration now equals the time that was left, so NormalizedRemaining reads full

Step 5 is the one to record. Step 6 is the one people argue about.


Failure Behaviour

Failures are reported through RevSaveReport rather than thrown.

Common patterns:

  • No eligible controllers → nothing has both StatusEffectController and StableId → Add a StableId; the capture would otherwise write no status section at all

  • A status is dropped and the load still succeeds → the factory returned null → That is supported and deliberate: return null for a status you no longer support and it is dropped rather than failing the load. The factory log says which

  • Everything comes back at full duration → the factory passed the authored duration → Not a failure the framework can report. It is the failure this scene exists to make visible

  • A restored status is shorter than the payload says → an IStatusResistance provider scaled it → Working as designed. The multiplier is clamped to 0..1, so it only ever shortens. It compounds across save/load cycles — capture the shortened time and the next restore shortens that — but restoring one payload repeatedly gives the same answer every time

  • The section reports a partial → some owners restored and others refused → That section is no longer a description of anything. Do not carry it into a later save


Behind The Scenes

This panel uses public save and status APIs only:

  • StatusEffectsSaveParticipant
  • RevSaveCoordinator.Capture(...) / RevSaveCoordinator.Restore(...)
  • RevSaveReport
  • IRevSaveParticipant
  • StableId / StableIdOwners.Find<T>(...)
  • StatusRegistry.TryBuild(...)
  • StatusEffectController.ApplyOrRefresh(...) / .Active / .ClearAll()
  • StatusContext.FromAbility(...)
  • IStatusEffect.Id / .Duration / .TimeRemaining / .NormalizedRemaining

Both factory branches are written out in the panel's own source rather than one being commented as wrong, because the wrong one has to be runnable for the difference to be visible.


Key Takeaway

A save can only carry what a save can carry: an id, a time, and who caused it. Rebuilding the effect is your code's job, and the framework cannot check that you did it right.

The one line that matters is the duration you construct with. Get it wrong and nothing tells you — the load succeeds, the report is green, and every status your player had is quietly back at full.