Technical Q&A Advanced
Crash & durability
Are my saves safe if the game crashes in the middle of saving?
Yes. On native platforms (desktop, mobile, console) the file-based managers never write over the live save in place: a save is written to a temporary file first, then swapped in with an atomic rename, so an interrupted write leaves either the complete old save or the complete new one, never a half-written file. WebGL has no atomic rename, so there the write goes straight to the file; but the durable copy only updates when the write succeeds and is then flushed to the browser's IndexedDB. A failed write is never flushed, so the persisted save still cannot tear, an interrupted save simply keeps the last good one. This crash-safety is a hard requirement the package documents for custom managers too.
What about power loss, or the OS killing the process?
Same guarantee: the atomic swap is the last step, so the on-disk save is always one whole version or the other. On top of that, the Local File manager can keep a backup (on by default): a second copy written alongside the primary. On load, if the primary is locked, errored, unreadable, or read fine but its content no longer decodes (a torn or hand-mangled file), the manager transparently falls back to the backup and logs that it did so.
Can a corrupt or unreadable save brick the game?
No. A read that finds nothing usable (missing or corrupt) is reported as Invalid:
the object keeps its defaults and the next save overwrites the bad data, the same as a normal
first run. A read that is only temporarily unavailable (a locked file, a server down)
is reported as Failure, which leaves the object not ready so a save can never
overwrite data a retry might still recover. That Invalid-versus-Failure distinction (see
Core Concepts) is the core safety mechanism: corrupt
data degrades to a fresh start, a transient glitch never costs you a recoverable save.
What happens to in-flight saves when the game quits or play mode exits?
A shutdown drain runs: with asynchronous saves still in flight, the quit
waits for them to finish before going through, so a save is not cut off merely because the
game is closing, it gets up to its timeout to land. The wait is bounded both by each manager's
SaveTimeout and by a project-wide drain ceiling, and whichever elapses first wins,
so with several managers, or a ceiling lower than a manager's SaveTimeout, an
individual save can still be cut short. The hooks to show a "saving..." screen or
force-quit, and those settings, are in Saving on quit.
Editor safety
Does entering and leaving play mode change my authored asset values?
No. At the start of a play session the package captures an in-memory snapshot of each persistent object's authored values, and restores them when the object unloads at play-exit. It also guards against the object being written to disk during play. The result: your committed defaults stay exactly as you authored them, and version control does not fill with play-session noise, even though the save itself is written and reloads next launch.
What if the editor crashes while I am playing?
Your authored asset values are safe. While you are playing, the package vetoes any write of a
persistent object's .asset file to disk, even a manual Ctrl+S or a tool calling
SaveAssets, so runtime values are never baked into the asset. A crash mid-play
therefore leaves your committed asset exactly as you authored it. The player-facing save,
meanwhile, was written to its own location with the same atomic, crash-safe guarantees as in a
build.
Do editor play sessions overwrite the saves of a real build installed on my machine?
No. In the editor, the Prototype and file managers redirect their saves into the project's UserSettings folder instead of the platform persistent-data path, so playing in the editor never reads or clobbers the saves of an installed build of the same game.
Does it work with "Enter Play Mode without Domain Reload"?
Yes. Static state that would otherwise leak between play sessions (the drain flag, the global slot, runtime auth hooks, the in-memory Session data) is reset as each session begins, so a reload-disabled editor behaves like a cold build launch.
Async & concurrency
What if I trigger a save while another operation is already running?
Each manager runs its operations one at a time, never two at once. What happens to the new one depends on the call and the operation:
- An asynchronous call waits its turn rather than failing: operations queue in arrival order, and a queued operation is never dropped in favor of another one you also asked for (a
Slots.SaveToqueued behind a save runs after it, and so on). - A queued request that means the same work as one already queued merges with it: saves to the same slot coalesce (you do not get redundant writes) and loads of the same slot join the pending one.
- A clear supersedes what it makes moot: it cancels the in-flight operation's effect on the object and drops what was queued before it (their results say so; nothing fails silently).
- A forced-synchronous call (
SaveSync) that cannot run because async work is in flight is declined and reportsIsBusy, so you can retry once idle or awaitWhenReady.
How do you stop a stale async result from clobbering newer state?
Every operation is bound to the scope it started in. When an asynchronous result comes back,
it is only applied if the manager is still in the same scope and slot it began in. If the
object left scope or changed slot meanwhile, the late result is discarded (reported as
Cancelled) instead of being written into an object that has moved on.
Is any work done off the main thread? Is it thread-safe?
For managers with a true asynchronous path (Local File, the remote managers), the heavy parts, file IO, compression, encryption, formatting, run on a background thread. The target object itself is only ever touched on the main thread: serialization reads it on the main thread, and a loaded payload is deserialized back into it on the main thread. Manager logging is thread-safe so async code can log from any thread. As a rule for custom code: do not touch the target from a background thread.
How are operation timeouts handled?
Asynchronous operations are bounded by per-manager timeouts (LoadTimeout,
SaveTimeout, ClearTimeout). When one overruns, its
CancellationToken is cancelled and the operation is reported as
Cancelled. A manager that guarantees its own bounds can opt out and self-bound
(the remote managers do this when their offline cache is on, to avoid double-bounding the same
wait).
Honoring that token is your responsibility in custom async code. Ignoring it never corrupts anything, a late result is still discarded if the manager has moved on, but the work cannot be force-stopped, so it keeps running detached. On a self-bounding manager (one the core does not time out), an async operation that ignores its token and never returns therefore never completes at all: that operation is stuck as a no-op forever, and it also holds up the shutdown drain on quit. Always honor the cancellation token.
Multi-device conflicts (remote)
Two devices saved the same player. How is the conflict resolved?
It depends on the remote manager's cache mode (the three modes are described there). With Server + Cache, the relevant mode for multiple devices, a load prefers the server and a disagreement is settled by the most recent save, with the save time kept alongside the data, so resolution relies on the devices' clocks.
Saves are read back verbatim, byte for byte, and a save written with no cache carries no timestamp (so it reads as the oldest possible save when a cache is later enabled). The reverse direction is safe too: a save written with a cache on still loads after the cache is turned off. Note that turning the cache off abandons offline changes still awaiting push on players' devices, which is why Cache Mode counts as a breaking field for the manager lock.
Failures & exceptions
What if my code throws inside a callback or an optional-interface method?
It is contained. All user-supplied code, event subscribers, optional-interface methods, the
serializer, runs behind a single containment layer, so a throw or a returned null cannot
corrupt the system or break the operation. One throwing event subscriber neither stops the
other subscribers nor fails the save; the exception is logged. Where it is part of an
operation result, it surfaces as a result whose type is Exception.
What if the serializer cannot handle my object?
The package fails safe rather than silently losing data. A serializer must not fail on valid data; if an object cannot be encoded as it enters scope (no serializer, or one that throws or returns null), persistence is disabled for that object: every load, save and clear is refused, and a clear one-time error is logged and shown in the inspector. The intent is that a broken setup is loud and harmless, never a quiet corruption.
Security
What is the threat model?
Local save protection is client-side cheat deterrence, not a guarantee. It protects data at rest (the file on disk), and it is only as secret as the key behind it. Anyone who can extract that key from your build, or read the game's live memory while it runs, can defeat it. The goal is to make casual and copy-paste cheating impractical; for anything competitive, the only real answer is a server-authoritative design where the client is never trusted.
How is the protection actually built?
The Local File manager offers two levels, both keyed off the same derivation:
- Append Hash: the data stays readable but carries an HMAC-SHA256 authentication tag, so any edit is detected (integrity and authenticity, no secrecy). Detection is tamper-evident, not a rejection: the edited save still loads, and every save of that lineage from then on re-writes an authenticated tamper mark into the tag line, so the mark survives sessions and cannot be edited out (see
FilePersistenceManager.IsTampered). - Encrypt: the data is encrypted with AES-256-CBC (PKCS7 padding, a fresh random IV per save), then authenticated with HMAC-SHA256 over the ciphertext (encrypt-then-MAC). The tag is verified before any decryption, so tampering is caught cleanly.
Key handling:
- A 32-byte master key is derived as the SHA-256 of length-prefixed (framed) inputs: a package constant, your project secret, and, when the matching lock is set, the device id (Target Machine) and the runtime secret (Runtime Secret). The File Location lock is folded into the MAC instead of the key (see below). Framing the inputs means two different combinations can never collide into the same key.
- The cipher and the MAC each get their own sub-key (a separate SHA-256 of the master key plus a distinct label), so the same bytes are never handed to both AES and HMAC.
- The Append Hash tamper mark is authenticated under a third sub-key of its own: a marked and an unmarked tag can never validate each other's data, so stripping the mark (or forging one onto a clean save) invalidates the tag and the file reads as freshly tampered.
- Tag verification uses a constant-time comparison, so a forged tag cannot be reconstructed byte by byte through timing.
What does each attack actually face?
| Edit the save in a hex or text editor | The HMAC tag no longer matches. Under Encrypt the load returns Invalid and the save is treated as absent: blocked. Under Append Hash the edited save loads but is permanently marked as tampered (IsTampered / OnTamperedLoaded); the mark is authenticated and re-written into every later save, so it cannot be laundered by re-saving, editing, or slot-copying. Restoring a byte-for-byte pristine copy the player kept from before the edit is the one way back: that is rollback, which no client-side scheme prevents. |
| Forge a fresh save | Needs the MAC sub-key, which only the secret can produce; without it, no valid tag. Blocked as long as the key stays secret. |
| Read an encrypted save | AES-256 protects the contents at rest; the cipher sub-key is required. Blocked. |
| Copy a save to another device (Target Machine lock) | The device id is mixed into the key, so the save never verifies on any other machine. Under Encrypt it is unreadable there: blocked; under Append Hash it loads marked as tampered (a failed check is a failed check: the manager cannot tell an edit from a foreign machine). |
| Move, rename or replay save files (File Location lock) | The path is folded into the MAC (never written into the file), so a save only verifies at the exact path it was written to. Under Encrypt: blocked; under Append Hash the moved file loads marked as tampered. Copies and renames through the Slots API still work, they re-protect the data for its new path (carrying an existing tamper mark with it). |
| Reverse-engineer the build to extract the project secret | The project secret ships inside the player, so a determined reverser can recover it from the build, then forge or decrypt: not blocked on its own. The Runtime Secret lock changes that, the deciding secret comes from your server at runtime and never ships in the build, so a reverser without it is blocked. |
| RAM dump or live memory editor (Cheat Engine and the like) | Once loaded, your values are ordinary fields in memory; encryption guards disk, not RAM. Not blocked, the hard ceiling of any client-side scheme, where only server authority defends. |
Identity & portability
How is a save matched to the right object?
Each manager carries a stable unique id, and its storage defaults to a name derived from that id (a file name, prefs key, server id or Cloud Save key). Managers that surface that name, Local File's File Name, the HTTP Id, the Cloud Save Key, let you override it. Slots are encoded into safe path or key segments, so any slot name, including player-controlled ones, resolves to valid, collision-free storage.
What stops two managers from saving to the same place?
The defaults cannot collide: every storage name derives from the manager's unique id, and a duplicated asset receives a fresh one. Overriding the name (Local File's File Name, the HTTP URL and Id, the Cloud Save Key) is where a duplicate can slip in, and two managers writing to one location would silently overwrite each other's saves. The editor treats that as a configuration error: an error box in both managers' inspectors, an error in the console, and the build fails at its very start, until one of the locations is changed.
Can two managers report the same location while saving to different places?
Technically yes. The check above compares locations as plain text, the same
GetStorageLocation() strings the import logic trusts when deciding whether an old
save can safely be deleted, and a location string only carries what its manager puts in it.
Among the built-ins this cannot happen, their strings are mutually exclusive by construction: a
file path always ends with the save extension, which a Cloud Save key cannot even contain, a
Player Prefs key starts with its package prefix, and an HTTP address carries its URL. A custom
manager, though, could report a string another storage also produces; the conflict is then
reported all the same, and the fix is to shape its StorageLocation so that only
its storage can produce it (see Extending).
Do saves work across platforms and operating systems?
Yes. Files are written as UTF-8 with a fixed newline, and reads are tolerant of differing
newline styles and a stray byte-order mark (so a file touched by cloud sync or a text editor
still loads). With no compression or encryption, a save is human-readable text: pristine
serializer output, except that an object using save versioning (a non-zero
SaveVersion) carries a short version prefix on the payload (still plain
text). WebGL flushes writes to the browser's persistent
storage; consoles, which gate saving behind
their platform SDK, are served by a small custom manager (see Extending).
Performance
Will saving cause frame hitches?
The package is built to avoid them. The automatic saves that must complete before the object
goes away (on unload, focus loss, pause) run synchronously by necessity, but the
regular timed save and the automatic load retry prefer the asynchronous path so they
never hitch a frame, and on capable managers the file work runs on a background thread. You
also control the cadence (RegularSaveDelay, and AutoLoadRetryDelay for
the load retry) and can drive saves manually at your own safe points.
Does it save even when nothing changed?
Only if you let it. Implement IDirtyTracked and a save is skipped while the
object is not dirty (the flag is cleared after a successful save), so an idle object costs
nothing. Without it, the regular timer saves on schedule regardless.
How big can a save get?
There is no hard limit in the package, but the manager has practical ones. Player Prefs is for
small values, not large blobs, use Local File for anything substantial. Cloud Save caps a
value's size, so turn on Compression for larger saves, and a remote save costs
a network round trip, so keep it lean. The serializer matters too: a deep object graph is slower
to encode, so flatten runtime-only caches out of your persisted fields (with
ISerializationCallbacks) rather than saving them.
Testing & quality
How is the package tested?
Persistent Asset ships an extensive automated test suite, 1600+ Edit-mode and Play-mode NUnit tests covering the operations, the readiness contract, serialization, slots, security and the No-Code modules, and it is manually verified on every Unity 6 minor release: 6.0, 6.1, 6.2, 6.3, 6.4, 6.5 and 6.6.
Can I stress-test my own save code?
Yes, with the built-in Test persistence manager (last in the Built-Ins group of the manager dropdown). Every operation can be forced to any outcome (Success, Invalid, Failure, Exception, Cancelled), with injectable async delays, so you can verify your game handles a failed load, a slow save, a cancelled operation or the shutdown drain, all in memory, with no real storage involved.