Crash safety and durability

Can a crash or a power loss tear a save file?

No, on every platform with a real file system. The file managers stage each write under {file}.tmp, flush it to stable storage with FileStream.Flush(true), then swap it in with File.Replace. The swap is the last step, so the file on disk is always one complete generation or the other, and the flush before it means the new directory entry never points at data that never reached the disk.

Some destinations refuse File.Replace, such as several network shares and some removable media. There the previous file is moved to {file}.old, the staged one takes its place, and the parked copy is deleted. That path is still never torn, but it is no longer atomic: an interruption between the two moves leaves the save missing, which is what the backup covers.

WebGL has no atomic rename, and the browser file system lives in memory. The write goes straight to the file, and the package asks the browser to persist it to IndexedDB once the write completes, so a tab closed mid-write keeps the last persisted generation.

What does the Local File backup protect against?

The backup is a second byte copy of the primary, written after it, and on by default. A load falls back to it when the primary is missing, locked, unreadable, or reads fine but no longer decodes (a torn or hand-edited file), and logs that it did. A clean first run, with neither copy present, checks the backup silently. One case skips the backup: a primary written by a newer version of your game. Loading the older backup there would let the next save replace the player's newer progress with it.

Can a corrupt save brick the game?

No. A save that won't decode is first retried from the manager's alternate copy, such as the Local File backup. When that fails too, the load settles like a first run: the asset keeps its current values, and the next save overwrites the bad data. Storage that can't be read at all, such as a locked file or a server that is down, is handled the opposite way: IsReady stays false, so nothing overwrites a save a retry might still recover. For the result states, refer to When a save doesn't load, and for the whole load as a diagram, to What a load leaves you with.

What happens to a save still in flight when the game quits?

A shutdown drain holds the exit open until it lands. In the Editor it forces Play mode back on, and in a player it refuses Application.wantsToQuit, so the player loop keeps pumping while the in-flight asynchronous saves and any queued save or clear complete. Each save stays bounded by its manager's SaveTimeout, and the whole drain by a project-wide ceiling. A remote manager with a local cache skips its in-line push during the drain: the cache write already made the save durable, and the push resumes on the next launch. For the settings and the "saving..." hooks, refer to Save on quit, and for the sequence as a diagram, to Quitting mid-save.

Note: A process the OS kills gets no drain. Only the saves that already completed are on disk.

What does an older build do with a save a newer build wrote?

It protects it. The version envelope tells the loader the save is newer than the running code's SaveVersion. When the old code still decodes it, IUpgradable.Downgrade(fromVersion) decides: false keeps the data loaded but blocks every save, so the newer file is never rewritten in the older format. When the old code can't decode it, saving is always blocked, since there is no loaded state to judge. A slot change or a clear lifts the block. For the interface, refer to Version your data, and for the version check as a diagram, to Versioning and migration.

Security

What does each attack face?

The anchors below are the flags of the Local File manager's Anchor Save To setting.

AttackResult
Edit the fileThe HMAC-SHA256 tag no longer matches. Encrypt refuses the save. Append Hash loads it with a permanent tampered mark, which re-saving, editing or copying the slot can't remove. Restoring a pristine copy the player kept is a rollback, which no client-side scheme prevents.
Forge a fresh saveNeeds the key derived from your project secret. Blocked while the secret stays secret.
Read an encrypted saveAES-256 protects the contents, and needs the same key. Blocked.
Copy a save to another machineWith Anchor Save To set to Target Machine, the device id is part of the key, so the check fails on any other machine: refused under Encrypt, marked under Append Hash. A platform that reports no device identifier derives the same key everywhere, and the package warns about it.
Move, rename or swap save filesWith File Location, the check fails at any other path. Copies and renames through Slots still work, since they re-protect the data for its new path.
Extract the secret from the buildThe project secret ships in the player, so a reverser can recover it. Runtime Secret closes this: the deciding secret comes from your server at run time and never ships.
Edit memory at run timeNot blocked by save protection, which covers the disk, not RAM. Memory obfuscation tools raise the cost of this, and server authority is the only complete defense.

What happens if the project secret changes?

Every protected save already written stops opening: Encrypt refuses them, and Append Hash loads them marked as tampered. The secret is generated once per project and stored in ProjectSettings/PersistentAssetSettings.asset, so commit that file and keep it identical on every machine and in every build. A machine that builds without it generates a new secret, and ships a game that can't read your players' saves. An empty secret falls back to a key every project using the package shares.

Warning: Anyone who reads that file can forge and decrypt your saves. Keep it out of public repositories.

Is a remote save protected in transit and on the server?

In transit, the Server (HTTP) manager uses the scheme its URL names, and the Inspector window warns when a release URL uses plain http://, since the save and the Authorization header would then travel unencrypted. Cloud Save goes through the Unity Gaming Services SDK and Steam Cloud through the Steam client, each over its own secured connection.

On the server, the manager's Encryption applies to what leaves the device, so with Encrypt the server stores only ciphertext it can't read. Cache Encryption protects the copy the offline cache keeps on the device, separately. Append Hash and the Target Machine anchor are refused on the remote save, since it has to open on the player's other devices. The server still trusts any request carrying valid credentials, so validate on the server whatever must not be forged. For the setting-by-setting table, refer to Secure a remote save.

Editor safety

Can Play mode write runtime values into my assets?

No. Each persistent asset's authored values are captured in memory as it comes into scope, and restored into the object when it unloads at the end of the session. While a manager is in scope, an AssetModificationProcessor drops its target's path from every OnWillSaveAssets call, so neither Ctrl+S nor a tool calling AssetDatabase.SaveAssets can write the loaded save into the .asset file. That also covers an Editor crash during Play mode: the files were never written, so they reopen with their authored values.

Do Editor sessions touch the saves of an installed build?

No. In the Editor, the file-based managers write under the project's UserSettings/Persistent Asset folder instead of Application.persistentDataPath, and the standard Unity ignore lists already exclude that folder from version control. The exception is a Local File manager with an explicit Save Folder: it writes there in the Editor too, so it shares its saves with an installed build.

Does it support Enter Play Mode with Domain Reload disabled?

Yes. The package never relies on a domain reload to reset its statics. Each one is reset explicitly as Play mode begins, through [InitializeOnEnterPlayMode] or a play-mode-state hook, so a session with Domain Reload disabled starts like a cold build launch.

Where are manager locks stored?

In ProjectSettings/PersistentAssetSettings.asset, under LockedManagerIDs, not in the manager asset. Commit that file so the whole team shares the locks. If a merge conflicts there, keep the entries from both sides: an extra one is harmless, while a lost one unlocks a shipped manager. Locks of deleted managers stay until you clear them.

Concurrency and threading

What happens when I save while another operation is running?

Each manager runs its storage operations one at a time, through a per-manager gate. What happens to the new request depends on the call:

  • An asynchronous or automatic request queues in arrival order.
  • A request for the same work as one already queued joins it: saves of the same slot coalesce into one, and a load joins an in-flight load of the same slot and scope.
  • A clear of the current slot wins. It invalidates the in-flight operation's write to the target, asks it to cancel, makes everything queued before it stale, and runs once that operation settles, so its delete lands after any superseded save's write. Saving then stays paused until the next load.
  • An explicit synchronous request, such as SaveSync(), never degrades to asynchronous. While asynchronous work is in flight it fails fast as busy: retry once idle, or await WhenReady.

Slot storage operations (Slots.Delete, Copy, Rename) ride the same queue, but they touch another slot's storage, never the target, so they never join, never supersede, and survive a slot change. For every pairing as a table, refer to Overlapping operations.

In what order do my calls take effect?

In the order you make them, per manager, with one refinement: a request that joins another is served where that other request sits in the queue. A joined save still captures the asset when it runs, so it holds every change made before it.

A clear never joins another clear. A load made between the two would then run after the second clear and leave the asset loaded, although the last call cleared it. So a clear made during another clear replaces it.

Once queued, a request is dropped only by a later clear, a slot change, the asset unloading or the shutdown drain, and still answers Ignored, so an awaited call never hangs.

There is no order between managers: each has its own queue. When two assets must be written in a set order, await the first save before starting the second.

Is a late async result ever applied to the wrong slot?

No. When the slot changes or the asset leaves scope while a load is in flight, the load's result is discarded when it lands, whatever it returned, and reports Cancelled. Operations still queued for the old slot no longer apply to anything, so they are dropped without running. With automatic saving on, the old slot's own save is not one of them: it captures the data before the switch, runs on its own path, and is never dropped. For the full sequence as a diagram, refer to Slot change.

Which work runs off the main thread?

The target is only ever touched on the main thread. A save encodes the target synchronously, before its first await, so the captured state is atomic with respect to game code. After that, a manager with an asynchronous path runs compression, encryption, envelope work and I/O on the thread pool, then returns to the main thread before decoding into the target, updating the session or raising an event. A custom manager that resumes off the main thread (ConfigureAwait(false)) is brought back automatically. On WebGL, which has no threads, the background work runs on the main thread.

Does the package allocate every frame?

No. Between operations, the package's own per-frame work (the automatic save timers, the operation, drain and conflict listeners, and the slot list) allocates nothing. Memory is allocated when a load or a save runs, not while the game idles.

One component can: a Variable Binder set to Two Way reads its target back on every poll. A value-type member is boxed on each read, and a list or map member boxes its enumerator and every element. It polls every Binder Poll Delay seconds, set in the No-Code project settings. Raise that delay, or set the binder to Variable To Target when the variable alone drives the member.

How are timeouts enforced?

Each asynchronous operation is bounded by its manager's LoadTimeout, SaveTimeout or ClearTimeout. On expiry the operation's CancellationToken is cancelled, the result reports Cancelled with IsTimedOut, and a warning reaches the console, in release builds too.

The next operation starts only when the timed-out task actually ends, so two storage operations never overlap. A manager that overrides SelfBoundsAsyncOperations to true takes over its own bounds, and the core then never cancels it.

Warning: In a custom manager, stop the work when the token is cancelled. An operation that ignores it keeps running after its timeout, and every load, save and clear queued behind it on that manager waits. One that never ends blocks the manager for the rest of the session, and holds the shutdown drain until its ceiling.

Multi-device conflicts

How does a remote manager detect a conflict?

With a hash, not with clocks. Each cache entry records a base marker: a hash (half of a SHA-256) of the stored form of the last server payload it diverged from, or of the last merged result. When a load finds a pending local change and a server save:

  • If the server still holds the base, this device is simply ahead: the local save wins and is pushed, whatever the clocks say.
  • If the server moved, both devices changed the same object from the same starting point: a real conflict, settled by IMergeable, then the Ask policy, then the most recent save.
  • An entry with no base, such as one written by an older build or a pending deletion, falls back to the most recent save.

The push side classifies the same way, and never pushes over a server save this device has not seen. A change made during an open conflict is held, and Status reports it, until the next load settles it. For the policies and the merge contract, refer to Resolve a conflict between two devices.

Why must Merge be commutative and idempotent?

Because each device folds the other's save independently, and the merged result is saved and pushed again. If the result depended on the order, two devices would converge on different data. If folding the same save twice changed anything, each device would keep producing a new save for the other to fold, and they would push back and forth forever. A maximum or a set union meets both rules. A sum pays the player twice.

Error handling

What happens when my code throws inside a callback?

The package contains it. Every call into code you write (an event handler, an optional interface method, a manager override, a serializer) goes through one containment layer, so a throw, a returned null or a hang can't corrupt the package's state. The exception is logged with the asset as context, the other subscribers of the same event still run, and the operation completes. When the fault is the operation's own work, it surfaces as a result of type Exception.

What happens when the serializer can't handle my asset?

Persistence is disabled for that asset. As the asset comes into scope, the manager checks it has a serializer and that the serializer can encode the target. If not, it refuses every load, save and clear, including the automatic ones, logs a single error and shows it in the Inspector window. Readiness.Reason reads PersistenceDisabled. Storage is never written through a serializer that has already failed on the data.

Can an edited save make a serializer build arbitrary types?

No. The package includes protection against type injection, the attack where a modified save names a framework type whose construction does real work, such as starting a process or writing a file. Each serializer that reads a type name from a save checks it:

SerializerWhat a save may name
Unity JSONOnly the type of a [SerializeReference] value, and Unity builds only a type assignable to that field's declared type. A field declared as your own class or interface can't receive a framework type.
Newtonsoft JSON, with polymorphic type names onAn allowlist. Any type from a non-framework assembly (yours, the engine's, a plugin's), but from the framework assemblies (mscorlib, System.*, Microsoft.*) only primitives, enums, strings, the System.Collections families, tuples, Nullable<T> and a few value types such as Guid and DateTime. Generic arguments and array elements are checked recursively. The check is by assembly, not namespace, so a fake System.Collections.Generic in another assembly doesn't pass.
OdinA denylist, since Odin names a type for every node, including ordinary data. The construction gadget families are refused: System.IO, System.Reflection, System.Net, System.Data, processes, remoting, interop handles, and a few more.
MemoryPackNothing. The root type comes from the target, and unions are closed sets declared at compile time.

A refused name fails the load and logs the name once. Odin's denylist sets a floor, not a guarantee: for a save that can arrive from off the device, set Encryption to Encrypt, which refuses a modified save before any serializer reads it. Append Hash doesn't: it still loads a modified save, marked as tampered.

Identity and portability

How does a save find its asset?

Through the manager, not the asset's path or name. Each manager carries a unique id, and its storage name (a file name, a prefs key, a server id, a Cloud Save key) defaults to one derived from that id. Duplicating a manager gives the copy a fresh id. Renaming or moving the asset changes nothing.

The slot is encoded into its path or key segment injectively: every slot, player-typed names included, produces a valid name, and two different slots never produce the same one. Letters pass through, and only the characters a path can't hold, plus the shapes Windows mishandles (reserved device names, a trailing dot or space), are escaped. On a case-insensitive file system, two slots that differ only in case still share a file.

How does a scene object keep its identity between sessions?

Through an id, not its name or place in the hierarchy. Each Persistent Scene Object carries an id minted in the Editor and saved into the scene, and its records are keyed by scene and id, so renaming or moving it in the hierarchy loses nothing. A prefab asset carries a prefab id, stamped when it is imported, and no object id of its own, so every instance mints one. An instance created at run time mints its id as it is created, which is what marks it as spawned: its record keeps the prefab id, and a load instantiates it again from that prefab. A build fails when a scene holds a Persistent Scene Object with no id, which a scene not reopened since a prefab change can.

What stops two managers from writing to the same place?

Default names can't collide, since each derives from a unique id. An overridden name can, so the Editor compares every manager's storage location as plain text, reports a match in both Inspector windows and in the console, and fails a build at its start until the locations differ. A custom manager should shape its StorageLocation so only its own storage can produce that string.

Is it safe under IL2CPP and managed code stripping?

Yes, with no AOT code generation step of its own. The package stays off the generic and reflection paths the linker can't see: No-Code collections, enums and asset variables run through non-generic code, and MemoryPack generates its formatters at compile time. What only serialized data references is preserved by link.xml files the package writes at the start of each build and deletes at the end: every serializer and settings section class, and every member a Variable Binder reaches by reflection, recorded as you author your scenes and prefabs.

A few cases are yours to handle:

  • Odin: generate Odin's AOT support before an IL2CPP build. For more information, refer to Odin.
  • Properties reached by reflection: Newtonsoft JSON reads and writes properties through their accessors, and Scene Objects restores object references held in properties the same way. At a Managed Stripping Level above Minimal, preserve those accessors with [Preserve] or a link.xml entry.
  • A Binder on a generic collection: IL2CPP only compiles a generic instantiation your code uses somewhere. When a Binder drives a List<T> or Dictionary<TKey, TValue> of a type nothing else declares, it reports that it can't create it: declare a field of that type in your own code.

Can a save move between machines and operating systems?

Yes. Files are UTF-8 with a fixed \n newline, and a read accepts \r\n, \r and a leading byte order mark, so a file that passed through cloud sync or a text editor still loads. The package writes no byte order mark itself, and never lets a binary payload that happens to start with one be mistaken for it. An unprotected, uncompressed save is exactly the serializer's output, plus the envelope prefix when it is versioned.

Testing

How is the package tested?

With about 3,000 NUnit test cases, in Edit mode and Play mode. Tests make up about 40% of the codebase, which is above average for a Unity package. They cover the operation gate and its concurrency rules, readiness, every built-in manager and serializer, the offline cache and conflict resolution, security, slots, migration, and the No-Code and Scene Objects modules. The suite runs in every Unity minor from 6.0 to 6.6.

To rehearse failures in your own game, refer to Simulate any save outcome.