A save that survives a crash mid-write¶
Keep the previous save. When a write is interrupted, the run is still there.
Recipe
Systems required: Save, which lives in Core. Package: any — Core ships in all of them, so this is the one recipe every package can run. Shape: one class you drop into a project that already exists. No prefab, no bespoke scene, and not a MonoBehaviour — it wraps the store your RevSaveManager already has. Public API only. It assumes: a RevSaveManager in the scene, and a store underneath it (the default is a FileSaveStore). Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.
The part that is not obvious¶
This Cookbook has now found the same thing four times.
| The thing you want to vary | Where it turned out to already be | Recipe |
|---|---|---|
| The price | An argument to IShopService.Buy | A shopkeeper who charges you more when you are cursed |
| The money | An argument to IShopService.Buy | A shop that takes payment in blood |
| The destination | An argument to Buy, Sell and Craft | A shop that hands over the goods when the bag is full |
| Where saves live | RevSaveManager.Store | this one |
IRevSaveStore is a five-method interface with one shipped implementation, and the manager holds it in a settable property. So "saves in the cloud", "saves encrypted" and "saves with a safety net" are all the same answer: a different store.
Before proposing a seam, check whether the thing you want to vary is already a parameter
Four for four. It is now the first question worth asking, not the last.
The wiring is one line, and it keeps whatever was already underneath rather than deciding for the project where saves belong:
manager.Store = new RollingBackupStore(manager.Store);
What it protects against, stated narrowly¶
A write that fails, is interrupted, or leaves a truncated file. Power loss, a crash, a full disk. That is the classic way a run disappears, and it is what this fixes.
It does not protect against a save that is readable but wrong
A store takes and returns a string and has no opinion about what is in it — IRevSaveStore's own remarks are explicit that the coordinator keeps it that way, and this recipe does not break that to peek at the envelope. Saving a semantically broken payload rotates the good one out like any other save.
For that failure the answer is RevSaveReport, not the store.
Rotate before writing, not after¶
The window this exists for is the one where the new payload is half-written. So the previous save has to already be somewhere else before that window opens.
Rotating afterwards destroys the thing it exists to preserve
And it does so in precisely the case it was added for. This is not a stylistic ordering: with the rotation moved after the write, a probe over this recipe fails eight assertions, including the central one — after a failed save, the backup holds the save from two writes ago rather than the one that was live.
A failed backup does not cancel the save
If the rotation cannot be written, this warns and saves anyway. Refusing would protect the old progress by throwing away the new, which is the wrong trade for a player who just pressed Save.
A decorator has to re-answer every method in its own terms¶
Forwarding the ones you are not deliberately changing is the instinct, and here it is wrong three times over. FloorStore reached the same rule from Economy; this is what it looks like in Core.
Exists — must count a slot whose backup survived
Otherwise RevSaveManager.HasSave says no for a slot that TryRead would have loaded perfectly well, and the load menu hides a save that is still there.
TryDelete — must remove both, and not with &&
Short-circuiting on an absent primary leaves the backup behind, and the next read serves it. A save the player deleted comes back.
TryListSlots — must hide backups and put back the backup-only ones
Hiding them is the obvious half: nobody wants save1 and save1__backup side by side in the menu. The second half is the one that gets missed — a slot whose primary is gone but whose backup survives is still readable through this store, so leaving it out makes the menu disagree with both Exists and TryRead.
TryListSlots distinguishes 'none' from 'could not tell', and so must the wrapper
The interface says so in as many words, and gives the reason: showing "no saves" for a store that failed to answer is how a player concludes their progress is gone. A refusal is propagated rather than flattened into an empty list.
Falling back is never silent¶
Loading an older save without saying so is how an hour disappears
And the player blames the game, not the disk. A read served from the backup warns and names the slot on LastSlotReadFromBackup, which a load screen can check straight after RevSaveManager.Load — before the player carries on and overwrites the only remaining copy.
An empty payload counts as a failed read
Even where the inner store called it a success. A zero-length file is the classic shape of an interrupted write, and treating it as a valid save is how you load an empty game over a real one.
The suffix is reserved
Operations on a slot whose name already ends with it are refused rather than quietly aliased onto another slot's backup. Keep it to letters, digits and underscores: the derived name has to be a legal slot for the inner store, and FileSaveStore rejects anything that is not a plain file name.
Drop it in¶
using System;
using System.Collections.Generic;
using RevGaming.RevFramework.Core.Save;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.BackupSaveStore
{
/// <summary>
/// A save store that keeps the previous save, so a write that is interrupted halfway does not
/// take the run with it.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Save</b> only, which lives in Core — so this is the one recipe that runs on <b>every
/// package</b>. Public API only. It is not a <c>MonoBehaviour</c>: it wraps whatever store your
/// <c>RevSaveManager</c> already has.</para>
///
/// <para><b>The store is an argument, and that is now the fourth time.</b> The Cookbook has
/// already found that Economy's <i>price</i> is an argument (<c>StatusPricedShop</c>), its
/// <i>money</i> is an argument (<c>BloodLedger</c>) and its <i>destination</i> is an argument
/// (<c>FloorStore</c>). <see cref="RevSaveManager.Store"/> is the same shape one system over:
/// where saves live was never fixed, so "saves somewhere else" and "saves with a safety net" are
/// both just a different <see cref="IRevSaveStore"/>. <b>Before proposing a seam, check whether
/// the thing you want to vary is already a parameter.</b></para>
///
/// <para><b>What it actually protects against, stated narrowly.</b> A write that fails, is
/// interrupted, or leaves a truncated file — power loss, a crash, a full disk. It does <b>not</b>
/// protect against a payload that is readable but wrong, because a store takes and returns a
/// string and has no opinion about what is in it; <see cref="IRevSaveStore"/>'s own remarks are
/// explicit that the coordinator keeps it that way. Saving a semantically broken payload rotates
/// the good one out like any other. For that failure, read
/// <c>RevSaveReport</c>.</para>
///
/// <para><b>Rotate before writing, not after.</b> The window this exists for is the one where the
/// new payload is half-written, so the previous save has to already be somewhere else by the time
/// that window opens. Rotating afterwards would destroy the thing it exists to preserve, in
/// exactly the case it exists for.</para>
///
/// <para><b>A failed backup does not cancel the save.</b> If the rotation cannot be written, this
/// warns and saves anyway: refusing would protect the old progress by throwing away the new,
/// which is the wrong trade for a player who just pressed Save.</para>
///
/// <para><b>A decorator has to re-answer every method in its own terms.</b> Forwarding the ones
/// you are not deliberately changing is the instinct and it is wrong here three times over.
/// <see cref="Exists"/> must count a slot whose backup survived, or <c>HasSave</c> says no while
/// <see cref="TryRead"/> would have succeeded. <see cref="TryDelete"/> must remove both — and not
/// with <c>&&</c>, because short-circuiting on an absent primary leaves an orphaned backup
/// that the next read resurrects as a save the player deleted. <see cref="TryListSlots"/> must
/// hide backup names <i>and</i> put back any slot that now exists only as one, or the save menu
/// disagrees with both of the others. <c>FloorStore</c> reached the same rule from Economy.</para>
///
/// <para><b><see cref="TryListSlots"/> distinguishes "none" from "could not tell", and so must
/// this.</b> The interface says so in as many words, and the reason is that showing "no saves" for
/// a store that failed to answer is how a player concludes their progress is gone. A refusal is
/// propagated rather than turned into an empty list.</para>
///
/// <para><b>Falling back is never silent.</b> Loading an older save without saying so is how an
/// hour disappears and the player blames the game. A read served from the backup warns and names
/// the slot on <see cref="LastSlotReadFromBackup"/>, which a load screen can check straight after
/// <c>RevSaveManager.Load</c>.</para>
///
/// <para><b>The suffix is reserved.</b> Operations on a slot whose name already ends with it are
/// refused rather than quietly aliased onto some other slot's backup. Keep it to letters,
/// digits and underscores: the derived name has to be a legal slot for the <i>inner</i> store, and
/// <c>FileSaveStore</c> rejects anything that is not a plain file name.</para>
/// </remarks>
public sealed class RollingBackupStore : IRevSaveStore
{
/// <summary>Suffix used when no other is supplied.</summary>
public const string DefaultSuffix = "__backup";
private readonly IRevSaveStore inner;
private readonly string suffix;
/// <summary>
/// Wraps an existing store.
/// </summary>
/// <remarks>
/// The usual wiring is <c>manager.Store = new RollingBackupStore(manager.Store);</c>, which
/// keeps whatever the manager was already using underneath rather than deciding where saves
/// live on its behalf.
/// </remarks>
/// <param name="inner">Store that does the real reading and writing.</param>
/// <param name="backupSuffix">
/// Appended to a slot name to derive its backup. Must be a plain name fragment.
/// </param>
public RollingBackupStore(IRevSaveStore inner, string backupSuffix = DefaultSuffix)
{
this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
if (string.IsNullOrWhiteSpace(backupSuffix))
throw new ArgumentException("A backup suffix is required.", nameof(backupSuffix));
suffix = backupSuffix;
}
/// <summary>
/// Slot whose most recent read was served from its backup, or <c>null</c> when the last read
/// came from the primary.
/// </summary>
/// <remarks>
/// Reset by every read, so check it immediately after the load it belongs to. It is the hook
/// for telling the player their newest save could not be read and an earlier one was used —
/// which they need to know before they play on and overwrite it.
/// </remarks>
public string LastSlotReadFromBackup { get; private set; }
/// <summary>The slot this store keeps <paramref name="slot"/>'s previous payload in.</summary>
/// <remarks>
/// Public so a project can inspect or clear a backup itself. The name is derived rather than
/// tracked, so it stays correct across a run that never wrote one.
/// </remarks>
public string BackupSlotFor(string slot) => slot + suffix;
/// <summary>
/// Reads a slot, falling back to its backup when the primary cannot be read.
/// </summary>
/// <param name="slot">Slot name.</param>
/// <param name="payload">Receives the payload when one was read.</param>
/// <returns><c>true</c> when either the primary or the backup produced a payload.</returns>
public bool TryRead(string slot, out string payload)
{
payload = null;
LastSlotReadFromBackup = null;
if (!IsUsableSlot(slot))
return false;
if (inner.TryRead(slot, out payload) && !string.IsNullOrEmpty(payload))
return true;
// An empty payload counts as a failure here even where the inner store called it a
// success: a zero-length file is the classic shape of an interrupted write, and it is
// exactly what this store exists to survive.
if (!inner.TryRead(BackupSlotFor(slot), out payload) || string.IsNullOrEmpty(payload))
{
payload = null;
return false;
}
LastSlotReadFromBackup = slot;
Debug.LogWarning(
$"[{nameof(RollingBackupStore)}] Save slot '{slot}' could not be read, so its backup " +
"was loaded instead. That is an older save: tell the player before they play on and " +
"overwrite it.");
return true;
}
/// <summary>
/// Rotates the current payload into the backup, then writes the new one.
/// </summary>
/// <param name="slot">Slot name.</param>
/// <param name="payload">Payload to store.</param>
/// <returns><c>true</c> when the new payload was stored.</returns>
public bool TryWrite(string slot, string payload)
{
if (!IsUsableSlot(slot))
return false;
// Read first, because the rotation is a copy and the inner store has no copy operation.
// A slot with nothing in it has nothing to preserve, and writing an empty backup over a
// good one would be worse than not rotating at all.
if (inner.TryRead(slot, out var previous) && !string.IsNullOrEmpty(previous))
{
if (!inner.TryWrite(BackupSlotFor(slot), previous))
{
Debug.LogWarning(
$"[{nameof(RollingBackupStore)}] Could not back up save slot '{slot}', so this " +
"save is being written without a safety net. The previous backup, if any, is " +
"now older than one save.");
}
}
return inner.TryWrite(slot, payload);
}
/// <summary>
/// Whether the slot holds a payload this store could read — from either copy.
/// </summary>
/// <remarks>
/// Not a pass-through. <see cref="TryRead"/> serves a slot whose primary is gone but whose
/// backup survives, so reporting <c>false</c> for it would make <c>RevSaveManager.HasSave</c>
/// deny a save that loads perfectly well.
/// </remarks>
public bool Exists(string slot)
=> IsUsableSlot(slot) && (inner.Exists(slot) || inner.Exists(BackupSlotFor(slot)));
/// <summary>
/// Removes both copies of a slot.
/// </summary>
/// <returns><c>true</c> when either copy was removed.</returns>
public bool TryDelete(string slot)
{
if (!IsUsableSlot(slot))
return false;
// Both calls, deliberately not `a && b`. Short-circuiting on an absent primary leaves the
// backup behind, and the next read serves it -- a deleted save that comes back.
bool primary = inner.TryDelete(slot);
bool backup = inner.TryDelete(BackupSlotFor(slot));
return primary || backup;
}
/// <summary>
/// Lists the slots a player would recognise: backups hidden, backup-only slots restored.
/// </summary>
/// <remarks>
/// A refusal from the inner store is propagated rather than reported as an empty list, which
/// is the distinction <see cref="IRevSaveStore.TryListSlots"/> exists to preserve.
/// </remarks>
public bool TryListSlots(List<string> results)
{
if (results == null)
return false;
if (!inner.TryListSlots(results))
return false;
// Collected before the removal, because the removal is what destroys the evidence that a
// backup-only slot is there at all.
List<string> recovered = null;
for (int i = 0; i < results.Count; i++)
{
var name = results[i];
if (name == null || !name.EndsWith(suffix, StringComparison.Ordinal))
continue;
var real = name.Substring(0, name.Length - suffix.Length);
if (real.Length == 0 || results.Contains(real))
continue;
recovered ??= new List<string>();
if (!recovered.Contains(real))
recovered.Add(real);
}
results.RemoveAll(n => n != null && n.EndsWith(suffix, StringComparison.Ordinal));
if (recovered != null)
results.AddRange(recovered);
return true;
}
/// <summary>
/// Refuses a slot name that is empty or already ends with the reserved suffix.
/// </summary>
/// <remarks>
/// Without this a slot literally called <c>"save1__backup"</c> would share storage with
/// <c>"save1"</c>'s backup, and each would silently overwrite the other. Refusing is the honest
/// answer: the name is reserved by this store, and nothing else can be done with it that is
/// not a surprise.
/// </remarks>
private bool IsUsableSlot(string slot)
{
if (string.IsNullOrEmpty(slot))
return false;
if (!slot.EndsWith(suffix, StringComparison.Ordinal))
return true;
Debug.LogWarning(
$"[{nameof(RollingBackupStore)}] '{slot}' ends with the reserved backup suffix " +
$"'{suffix}', so it cannot be used as a slot name. Nothing was read or written.");
return false;
}
}
}
Wiring it up¶
- Wrap the manager's existing store, once, before the first save or load:
manager.Store = new RollingBackupStore(manager.Store); - Leave the suffix alone unless you have a reason. If you change it, change it before any saves exist — the old backups become unreachable orphans under the new name.
- After
RevSaveManager.Load, checkLastSlotReadFromBackup. If it is set, tell the player their most recent save could not be read and an earlier one was used. Do it before they play on. - Nothing else changes. Participants, sections, reports and slot names all behave exactly as before; this sits underneath all of it.
What it deliberately does not do¶
It does not keep more than one generation. One backup, rotated. A depth of N is a list of suffixed slots and a loop, and it is a different recipe with a different cost — every save becomes N writes.
It does not validate the payload. See above: a store has no opinion about what a save contains, and giving this one an opinion would make it a save format checker wearing a store's interface.
It does not compress, encrypt or move anything. Those are also stores, and they compose — wrap this one, or wrap it in one, and the ordering is yours to choose.
It does not repair a corrupt primary. A fallback read leaves the broken file exactly where it is; the next successful save overwrites it. If you want the bad payload kept for a bug report, copy it out in your fallback handler before saving again.
It does not make writing atomic. A store that wrote to a temporary file and renamed it would be a stronger answer on platforms where rename is atomic, and a worse one where it is not. This is the portable version of the same idea.
Related¶
- Save — the coordinator, participants, sections and the restore report.
- A shop that remembers — the participant side of the same system, and the refusal contract in detail.
- Buffs that run down while the game is closed — the other end again: ordering between participants, and a participant that reaches into another system.
- A shop that hands over the goods when the bag is full — the same re-answer-every-method rule, in Economy, where forwarding a preflight was the bug.