ADR-0004: Save File Format and Reconcile Pipeline — Atomic Write, TideState, ISaveable Two-Class Rule, Durability Semantics
Status
Accepted
Accepted 2026-08-28 —
architecture-review-2026-08-28.md(verdict CONCERNS; review amendments applied before acceptance).
Date
2026-08-28
Engine Compatibility
| Field | Value |
|---|---|
| Engine | Unity 6.3 LTS (6000.3) · C# / .NET Standard 2.1 profile |
| Domain | Core / Save-Load (System.IO) |
| Knowledge Risk | LOW — System.IO.File.Replace, FileStream flush semantics, Application.persistentDataPath, and background I/O are pre-cutoff and unchanged. The JSON-library question was verified 2026-08-28 (unity-specialist, live docs): System.Text.Json is not shipped with Unity 6.3 (vendoring required, transitive-dependency gaps), its source generator conflicts with Unity's pinned Roslyn 4.3, and it has no confirmed IL2CPP report → com.unity.nuget.newtonsoft-json (Unity-distributed, IL2CPP-proven) is primary (wording per architecture review E7). The ISaveWriter/ISaveReader seam (ADR-0003) keeps that swappable. |
| References Consulted | docs/engine-reference/unity/VERSION.md, breaking-changes.md, deprecated-apis.md, current-best-practices.md (Scripting: prefer Awaitable for async flows); ADR-0001 (TideState, IsDockingLive), ADR-0002 (drain step 5, Persist dirty-mark, per-drain durability routed here — QQ-11), ADR-0003 (ISaveable, SaveClass, ReconcileContext stages, ISaveWriter/ISaveReader in Driftlands.Data); design/quick-specs/save-persistence-2026-08-26.md; the reconcile rules in tide (EC1/5/13/14, AC-30–35b), harvesting (EC19–20), crafting (EC7–9, 19–20), base-building (EC13–16), inventory (Rule 8). |
| Post-Cutoff APIs Used | None required. Awaitable.BackgroundThreadAsync/MainThreadAsync (Unity 6.0+, pre-cutoff) is the flush hand-off — Unity's documented idiom for real thread-pool work with same-frame main-thread resumption. Task.Run + UnitySynchronizationContext is a fallback only, and its continuations resume on the next Update tick, which shifts the report() timing in §3 by a frame (specialist note). |
| Verification Required | (1) ~~JSON library availability~~ closed 2026-08-28 — Newtonsoft primary (see Knowledge Risk); STJ + source-gen remains a documented future swap behind the seam, gated on a vendored build passing the round-trip test on IL2CPP. (2) ~~File.Replace atomicity~~ closed — Windows ReplaceFile(); macOS/Linux POSIX rename(), atomic on one volume (the backup step is best-effort cross-volume — irrelevant, same folder). (3) flushDeadlineMs default 250 ms is a conservative engineering default, not yet a measured number — the standalone-build integration test in Validation Criteria measures fsync/F_FULLFSYNC on SSD and on an HDD with antivirus and retunes it. (4) macOS: FileStream.Flush(true) maps to fsync(), which does not flush the drive cache on macOS — the worker issues fcntl(fd, F_FULLFSYNC) via P/Invoke under UNITY_STANDALONE_OSX (ruled 2026-08-28); verify the P/Invoke on an Apple-silicon and an Intel build. (5) The background flush never touches a UnityEngine object (it serialises a plain-data snapshot taken on the main thread). |
ADR Dependencies
| Field | Value |
|---|---|
| Depends On | ADR-0001 (TideState shape; IsDockingLive predicate for stage 5), ADR-0002 (drain boundary; Persist dirty-marks; broadcast-before-flush window is this ADR's to rule), ADR-0003 (ISaveable/SaveClass/ISaveWriter/ISaveReader in Driftlands.Data; ReconcileContext stage semantics; CarrySnapshot) |
| Enables | ADR-0005 (KnownLandTemplates persistence + stage-4 union), ADR-0006 (configVersion is one of the two gate inputs), ADR-0008 (the late-join snapshot serialises the reconciled tables through the same writers), ADR-0010 / ADR-0011 (CARRY, SharedStorage, StructureRegistry table serialisation), ADR-0015 (nothing — combat state is never saved; stated) |
| Blocks | The Save epic; every system's ISaveable implementation story; the Tide epic's Restore(TideState) story |
| Ordering Note | Fourth ADR. The two-class rule and stage order are locked by the quick-spec and merely mechanised here; the three genuine decisions are format (JSON), I/O placement (off the drain, bounded), and the durability statement (per-drain, broadcast-first). Save-anyone-can-host stays Tier 1 first-to-slip and is deliberately not designed. |
Context
Problem Statement
The world is a save file plus a pure function (Principle 1). The quick-spec fixed what persists, the eight-stage reconcile order, atomic writes, version gating, and the two-class rule — but left three things a programmer would otherwise invent: the on-disk format, where file I/O runs relative to a host frame that is also a player's frame, and what "confirmed → durable" means once ADR-0002 established that a drain broadcasts outcomes before any byte reaches disk. The LP review named the last one as the collision between TR-net-007 (non-yielding critical section) and TR-save-003 (write-through on every confirmed action); the TD's ADR-0002 review required this ADR to state the resulting durability window honestly rather than claim Rule 3 as written.
Constraints
- Forbidden:
save_io_inside_drain(ADR-0002) — no serialisation or file swap insideExecuteorDrain. - Save Rule 1: one host-owned file per world; no merge/import path may ever exist (schema-negative, enforced by ADR-0003's
Schema_*). - Save Rule 4: unknown
saveFormatVersion/configVersion→ refuse loudly, never partial-load, never silent migration. - Save Rule 5 order and Rule 6 two-class rule — locked;
ReconcileContext(ADR-0003) is the vehicle. - Save Rule 7: load restores full HP, clears Downed, places per tide EC1.
- Tide Rule 2 / EC4–5:
t = max(lastKnownGoodT, hostUtcNow); future value wins;TideStatealso carriesepochIndex/epochActivationT(ADR-0001 C2). - Host frame budget 16.6 ms; the host is a player (technical-preferences).
- Friends-only: no anti-tamper, no encryption; a human-readable file is a feature.
- Tables are small: low hundreds of rows lifetime (crafting EC21, BB EC18); a whole-file rewrite is cheap.
Requirements
- Per-drain durability with a bounded flush window; honest statement of what a host crash loses.
- Atomic file replacement; a torn write can never load; the previous good file or a backup wins (save AC).
- Rolling backups (
backupCopies3). - Every table serialised by its owner through
ISaveWriter/ISaveReader; Save contains no reconcile semantics (save AC "verified by inspection"). saveFormatVersion+configVersiongate;worldSeed; bothinstanceIdcounters;TideState.- Load pipeline in the fixed order, invoking
ISaveable.Reconcile(ReconcileContext)per stage; stage 8 as orchestration. - Loading triggers no gameplay events (save AC "reconcile is silent by construction").
- Full save → load round-trip byte-identical for every table (save AC).
Decision
Alternative A: one JSON file per world (Application.persistentDataPath/Driftlands/worlds/<worldId>/save.json) with three rolling backups; owners serialise their tables through ISaveWriter/ISaveReader; each drain's dirty set is snapshotted on the main thread and flushed on a background thread as a write-temp-then-File.Replace whole-file rewrite, in drain order, under a flushDeadlineMs bound; durability is per-drain and broadcast-first, stated plainly.
1. File and format
- Path:
persistentDataPath/Driftlands/worlds/<worldId>/containingsave.json,save.1.json,save.2.json,save.3.json(rolling: on each successful flush the previoussave.jsonbecomes.1,.1→.2,.2→.3,.3dropped), andsave.json.tmptransiently.<worldId>is a GUID minted at world creation and stored inside the file (a copied folder is still one world — no merge). - Format: JSON, UTF-8, one document: ```json { "saveFormatVersion": 1, "configVersion": 1, "worldId": "…", "worldSeed": 123456789, "tide": { "lastKnownGoodT": 1780000000, "epochIndex": 0, "epochActivationT": 1779900000, "socketCount": 1 }, // socketCount: the N this save last ran under — a load-time comparison input for the config epoch, NOT part of TideState (amended by ADR-0006, 2026-08-28) "counters": { "gearInstanceId": 41, "structureInstanceId": 57 }, "tables": { "knownLandTemplates": {...}, "knownRecipes": {...}, "harvestDiffs": {...}, "structures": {...}, "instanceMetadata": {...}, "carries": {...}, "sharedStorage": {...}, "players": {...} } } ``` Each
tables.*value is whatever its owner writes throughISaveWriter— Save never interprets it (theharvestDiffskey shape(epochIndex, socket, cycleIndex, nodeId)is Harvesting's, ADR-0010's to fix — this ADR asserts only ordering; TD-ADR C4).carriesincludes each player's equipped instances (weapon/armor slots with theirinstanceTagand durability — save Rule 2 "per-player CARRY + equipped instances", crafting EC15);playersholds per-player last position for stage 8. - Library:
com.unity.nuget.newtonsoft-json(Unity-distributed — the package repackages Json.NET 13.0.x — IL2CPP-proven; ruled 2026-08-28 after verification showedSystem.Text.Jsonis not shipped with Unity 6.3 (vendoring needed, with transitiveSystem.Memory/System.Buffersgaps) and its source generator fights Unity's Roslyn 4.3 pin; wording corrected by the architecture review, E7). It is used only through theISaveWriter/ISaveReaderseam viaJsonTextWriter/JObjectcalls — never reflection-serialising gameplay types (noJsonConvert.SerializeObject(someRecord)), so IL2CPP stripping and allocation stay bounded and a gameplay-type change never silently changes the file. Owners map to/from small explicit DTO writes insideSerialize/Deserialize. Key order is deterministic (owners emit keys in a fixed order; never iterate aDictionaryinto the file) — that is what makes the byte-identical round-trip AC true (TD-ADR C7).System.Text.Json+ source generation stays the documented upgrade path behind the same seam, gated on a vendored build passing the round-trip test on IL2CPP. - Unknown keys are an error at every level (TD-ADR C8): within a supported
saveFormatVersionno unknown key can legitimately exist; the version gate (Rule 4) refuses newer files outright, so preserving unknown keys would only round-trip hand edits and complicate the writer. Dropped.
2. The seam (in Driftlands.Data, fixed by ADR-0003; semantics fixed here)
public enum SaveClass : byte { Permanent, Droppable } // exactly one Droppable table exists: harvestDiffs (save Rule 6)
public interface ISaveable {
string TableId { get; } // the "tables.*" key
SaveClass Class { get; }
void Serialize(ISaveWriter w); // called on the MAIN thread against a snapshot; must not touch live Unity objects
void Deserialize(ISaveReader r); // main thread, load only; must not raise gameplay events
}
public interface ISaveWriter { void Int(string key, long v); void Str(string key, string v); void Bool(string key, bool v); IArrayWriter Array(string key); IObjectWriter Object(string key); }
public interface ISaveReader { bool TryInt(string key, out long v); /* … symmetric … */ IEnumerable<ISaveReader> Array(string key); ISaveReader? Object(string key); }
Reconcile is not on ISaveable (2026-09-07, review #10). ISaveable (Driftlands.Data) carries TableId / Class / Serialize / Deserialize only. The reconcile half is Driftlands.Rules.Reconcile.IReconcilable : ISaveable { void Reconcile(ReconcileContext ctx); } — ReconcileContext names Rules service interfaces (IKnownRecipes, IStructureRegistry, IInstanceMetadata, ISocketLayout) and Data may not reference Rules (ADR-0003 §1), so the shape above was uncompilable as written. HostContext.Persist keeps taking the Data-level ISaveable (dirty-marking needs no reconcile); SaveRegistry.Register takes IReconcilable. Shipped in foundation-save-persistence story-001.
Owners register once at boot (SaveRegistry.Register(IReconcilable)); the set is closed at StartHost. Adding a table = implementing ISaveable and declaring its class in the owning GDD/ADR (save Rule 6's "must declare its class").
3. Write path — dirty set, snapshot, background flush (resolves QQ-11)
host tick N: Drain() … cmd.Execute → ctx.Persist(table) → table.MarkDirty() (main thread, O(1), inside the critical section)
Drain() returns → SavePersistence.EndOfDrain(N):
dirty := swap the dirty set (main thread)
if dirty ≠ ∅: snapshot := for each dirty table: table.Serialize(writer) → JSON fragment (main thread, plain data, no Unity objects)
fullDoc := merge(lastFullDoc, snapshot) (main thread; unchanged tables reuse their last fragment)
flushQueue.Enqueue(FlushJob{ drain: N, doc: fullDoc }) (main thread → background)
background: for job in flushQueue (drain order, one at a time):
write job.doc → save.json.tmp; FileStream.Flush(flushToDisk: true); File.Replace(tmp → save.json, backup → save.1.json); rotate .1→.2→.3
report(job.drain, elapsed) (background → main via Awaitable.MainThreadAsync / SynchronizationContext)
main thread: if elapsed > flushDeadlineMs → loud log "save flush late: drain N took X ms" (never a gameplay effect); lastDurableDrain := N
- Serialisation is on the main thread (it reads owner state; owners are not thread-safe by design — ADR-0002's single-threaded world) but reads a snapshot: owners serialise from their own tables at end-of-drain, which is a consistent point (no command is mid-execution). Cost is proportional to dirty tables only; unchanged tables reuse their last fragment. At MVP sizes this is tens of microseconds per dirty table.
- File I/O is on a background thread (
Awaitable.BackgroundThreadAsync), one job at a time, in drain order; the queue coalesces: if drain N+1's job is enqueued while N is still writing, N+1's doc supersedes N's unstarted job — the queue holds at most one in-flight and one pending job. Coalescing is legal only because every job is a complete document (a newer full document contains every older one's state). A future partial-rewrite optimisation must make jobs non-coalescing or merge them explicitly — it is not pre-approved by this ADR (TD-ADR C2). Coalescing bounds the queue, not the loss (TD-ADR C1): on a slow disk many drains fold into the one pending job; see §4 for the loss bound. - The tide table has its own dirty path (TD-ADR C3):
lastKnownGoodTis written byTideClockAdapter.Advance, not by a command, so the adapter callsSavePersistence.Persist(tideTable)after everyAdvancethat changest(andAdvanceEpoch); the nextEndOfDrainpicks it up. Without this, a clock-set-back-then-crash could ratchet the tide backward — exactly the case tide EC4/AC-32 exists for. Consequence (architecture review C1, 2026-08-28): withAdvancecalled once per host tick (ADR-0001 §4 as amended),tchanges once per second, so the tide table dirties — and a flush job runs — about once per second even with no gameplay. Accepted at MVP (a few-KB file); if it shows in the profiler,Persist(tideTable)is rate-limited to boundary transitions plus atideCheckpointSecsknob — never removed. - No lock is held across the boundary: the background job owns an immutable string/byte[]; the main thread never waits on it.
4. Durability semantics — stated, not implied (TD-ADR C5 on ADR-0002)
- "Confirmed" (to clients) = broadcast, which happens inside the drain (ADR-0002 step 4). "Durable" = flushed, which happens on the background thread afterwards. The two are not the same instant, and this ADR does not pretend they are.
- What a host crash loses: every command in every drain newer than
lastDurableDrain. The bound is wall-clock, not queue depth (TD-ADR C1): with a healthy SSD that is the current drain plus at most one more (~40 ms at 50 Hz); with a slow or AV-scanned disk it is every drain that coalesced into the pending job — up toflushDeadlineMs(250 ms ≈ 12 drains at 50 Hz) before the loud log fires, and in principle longer if the disk stays slow (the log is the only signal; there is no gameplay throttle). Clients may have seen broadcasts the reloaded save does not contain (e.g. a placed wall vanishes on reload). Accepted for a friends-only game — a session ending is "a save-file fact, not a failure state" (netcode Rule 5), and the alternative (broadcast-after-flush) adds a disk round-trip to every hit. - "Durable" holds on all three OSes only with
F_FULLFSYNCon macOS (ruled 2026-08-28): .NET'sFlush(flushToDisk: true)isFlushFileBufferson Windows andfsyncon Linux/macOS, and macOSfsyncdoes not flush the drive cache; the worker P/Invokesfcntl(fd, F_FULLFSYNC)underUNITY_STANDALONE_OSXbeforeFile.Replace. The atomic-rename torn-write guarantee is independent of this and holds everywhere. - Save Rule 3 is restated: "Once the host confirms a mutation it is durable within
flushDeadlineMs; a crash loses at most the drains still in the flush queue — never a partial file, never a torn table." The quick-spec's "at most the in-flight action" was written before the drain existed; the unit of loss is the drain. - Session end flushes synchronously (the one place a blocking wait is allowed) on both exit paths:
StopHostawaitsFlushAllAsyncbefore returning to menu, andApplication.wantsToQuit(a synchronousFunc<bool>) is handled with an explicit latch: if the flush queue is empty returntrue; otherwise set_quitPending, returnfalse, run a bounded blocking wait on the background worker (≤ 2 ×flushDeadlineMs— the one blocking wait this ADR permits; anAwaitablecontinuation may never resume while the player loop tears down), then re-issueApplication.Quit(); the second call sees the latch and returnstrue(no loop).Application.quittingfires too late to block. In the EditorwantsToQuitdoes not fire on Play-mode exit —EditorApplication.playModeStateChanged == ExitingPlayModeruns the same bounded flush (amended 2026-08-28, architecture review E4).NetworkManager.Shutdown()is synchronous in NGO 2.x with deferred teardown, so ordering relative to the flush is free. saveDebounceMs(quick-spec knob, default 0) changes meaning — stated, not implied (TD-ADR C10): the quick-spec described 0 as "every confirmed action writes through"; under this ADR 0 means a flush job per dirty drain (already not per-action), and > 0 waits that long after a dirty drain before enqueuing, merging further drains. The knob keeps its name and range inassets/data/save.json; its description in the quick-spec is superseded alongside this ADR.
5. Load pipeline (mechanising save Rule 5; owners' rules only)
SavePersistence.Load(worldFolder):
0. pick file: save.json if it parses and passes the gate; else save.1.json … save.3.json in order (torn/corrupt current → newest good backup, loudly logged)
1. version gate: saveFormatVersion == SUPPORTED && configVersion == GameData.ConfigVersion → else refuse with a named message; NO partial state constructed
2. clock guard: TideClock.Restore(TideState{lastKnownGoodT, epochIndex, epochActivationT}); TideClock.Advance(hostUtcNow) (future value wins — tide EC5)
3. epoch check (amended by ADR-0006): if GameData.Configs.Tide.N ≠ save.tide.socketCount → TideClock.AdvanceEpoch(newConfig, activationT = clock.T) — the only caller, and it runs AFTER step 2's Advance so the activation instant honours the monotonic guard (tide EC5); else nothing. The cycle is still derived purely; the epoch is restored, not derived (ADR-0001 C2)
4. union: knownLandTemplates.Reconcile(ctx) → union with seeded starting templates (LDU Rule 3); knownRecipes.Reconcile(ctx) → union with shipped defaults (crafting EC19)
5. droppable: harvestDiffs.Reconcile(ctx) → keep iff ctx.Tide.IsDockingLive(epoch, socket, cycle) AND nodeId ∈ current template (harvesting EC19–20); dropped rows → ctx.Report(FindingKind.Dropped)
6. permanent: structures.Reconcile(ctx) → unknown structureId → inert relic (BB EC13); no cost snapshot (EC14)
instanceMetadata.Reconcile(ctx) → row with zero referencing stacks → Report(FindingKind.Dropped) (crafting EC8); removed itemId → inert curiosity (EC20)
7. inventories: carries / sharedStorage.Reconcile(ctx) → tag-without-row → mystery gear, Report(FindingKind.KeptOrphan) (crafting EC9); missing container list → Inventory's orphan rule, Report(FindingKind.KeptOrphan) (BB EC15)
8. player placement (orchestration, not an ISaveable): for each player: saved position on a socket where ctx.Tide.Evaluate(socket).docked == false → depositPoint (tide EC1); HP := full; Downed := none (save Rule 7); reads ctx.Carries ONLY to assert bit-identity against the loaded stacks (save AC) — never mutates them (TD-ADR C5)
9. counters restored; SaveRegistry closed; then NetworkManager.StartHost (late-joiners receive the reconciled tables — ADR-0008)
Save owns steps 0–2 and 8–9 and the ordering of 4–7; every keep/drop/union decision in 4–7 is the owner's Reconcile — the save AC's "verified by inspection: no drop/keep decision logic lives in Save code" is a code-review checklist item plus a test that SavePersistence references no owner type's members other than ISaveable.
6. What is deliberately not persisted
Combat state (HP, Downed, stagger, aggro), enemy state, in-flight commands, the tide schedule (derived), client presentation clocks, cosmetic scatter. Note the late-join snapshot (ADR-0008) is a superset of the save: it carries session-live state the save never contains (per-player HP/Downed/bleed-out retained by netcode Rule 5) — TD-ADR C9. The two-class rule's second half — nothing else is ever stale-dropped — is enforced by SaveRegistry asserting at close (StartHost), not per-Register, that exactly one Droppable table exists and that its TableId == "harvestDiffs" — identity, not cardinality: a registry whose one Droppable happened to be structures would silently invert the highest-blast-radius rule in the design (TD-ADR C6).
Architecture Diagram
Drain N (main thread) SavePersistence (main) Flush worker (background)
───────────────────── ────────────────────── ─────────────────────────
cmd.Execute → ctx.Persist(tbl) ─► tbl.Dirty
… Drain returns ─────────────────────────► EndOfDrain(N):
dirty ← swap set
fragments ← tbl.Serialize(writer) for dirty (snapshot, plain data)
doc ← merge(lastDoc, fragments)
flushQueue ◄─ FlushJob(N, doc) (coalesce: supersede unstarted) ─► write tmp → fsync → File.Replace → rotate .1/.2/.3
lastDurableDrain ← N ◄──────────────────────────────────── report(N, elapsed) [> flushDeadlineMs → loud log]
StopHost ────────────────────────────────► await flushQueue.Empty (the one synchronous wait)
Load: pick file → gate → Restore+Advance tide → Reconcile 4..7 via ReconcileContext → placement → counters → StartHost
Key Interfaces
See §2 (ISaveable, writers/readers) and ADR-0003's ReconcileContext. Additionally fixed here:
public sealed class SavePersistence {
public void Register(IReconcilable table); // IReconcilable : ISaveable (§2 amendment 2026-09-07) // boot only; closed at StartHost, which asserts exactly one Droppable AND that it is "harvestDiffs"
public void Persist(ISaveable table); // dirty-mark; called by HostContext.Persist (commands) and by TideClockAdapter after Advance/AdvanceEpoch
public void EndOfDrain(long drainIndex); // called by HostCommitQueue after Drain (ADR-0002 step 5)
public long LastDurableDrain { get; }
public Awaitable FlushAllAsync(); // StopHost / quit
public LoadResult Load(string worldFolder, long hostUtcNow, IGameData data); // LoadResult: Ok | RefusedVersion(msg) | RefusedCorrupt(msg)
}
public readonly record struct SaveHeader(int SaveFormatVersion, int ConfigVersion, Guid WorldId, ulong WorldSeed);
Alternatives Considered
Alternative B: Binary (custom BinaryWriter per table)
- Pros: smallest file, fastest serialisation, no library question.
- Cons: opaque; every schema change needs a versioned reader; a corrupt save is a hex dump; the friends-only debugging story ("send me your save") gets worse; the speed is irrelevant at low-hundreds of rows.
- Rejection Reason: Optimises the wrong thing; human-readable saves are a feature for this project.
Alternative C: JSON, synchronous flush inside/at the end of the drain (Save Rule 3 literally)
- Pros: per-action durability with no window; simplest ordering.
- Cons: serialize + fsync +
File.Replaceon the host main thread every tick that had a command — 2–20 ms on SSD, unbounded on HDD/AV — the LP review's frame-hitch collision; the host is a player. - Rejection Reason: Violates the 16.6 ms budget non-deterministically on every hit.
Alternative D: Broadcast only after flush
- Pros: clients never see an unsaved outcome; durability strictly per action.
- Cons: adds a disk round-trip to the latency of every hit, harvest, craft and placement, on top of RTT + tick; makes combat feel depend on the host's disk.
- Rejection Reason: The tension is resolved on the side of feel; the window is small, bounded, logged, and its worst case (a wall vanishes on reload after a crash) is acceptable for a friends-only game.
Alternative E: System.Text.Json with source generation as the primary library
- Pros: No runtime reflection, IL2CPP-friendly in principle, faster.
- Cons (verified 2026-08-28): not in Unity 6.3's bundled BCL — must be vendored via NuGet/UnityNuGet with documented transitive-dependency gaps (
System.Memory/System.Buffers) and reports of Unity silently stripping the package; its source generator conflicts with Unity's pinned Roslyn 4.3; no confirmed IL2CPP-working report. - Rejection Reason: The original draft had this as primary; verification inverted it. Retained as the documented upgrade behind the seam. Newtonsoft's reflection cost is avoided by using it only through
JsonTextWriter/JObject— never reflection-serialising gameplay types.
Alternative F: Per-table files / append-only journal
- Rejection Reason: Per-table needs a manifest and two-phase swap to stay atomic across tables; a journal is the strongest durability story but is machinery a low-hundreds-of-rows world does not need (YAGNI).
Consequences
Positive
- Zero file I/O on the host main thread during play; the drain stays within budget.
- One atomic whole-file replace per flush; a torn write can never load; backups are automatic.
- Owners own their bytes and their reconcile rules; Save is orchestration only — the quick-spec's "no reconcile semantics in Save" is testable.
- Human-readable saves; the JSON library is swappable behind the seam.
- The durability window is bounded, measured, and logged — never silent.
Negative
- Durability is per-drain and broadcast-first; a crash can lose the last ≤ 2 drains and leave clients having seen something the save lacks. Stated in the file's own header comment and in save Rule 3's restatement.
- Whole-file rewrite per dirty drain — trivially cheap now; if tables ever grow to thousands of rows, per-table fragments are already the unit of serialisation and a partial rewrite can be added without changing the seam.
- Two JSON DTO layers (owner DTOs + document) — deliberate insulation of the file from gameplay-type churn.
System.Text.Jsonavailability is a verification item, not a certainty.
Risks
- Flush falls behind on a slow disk → queue coalesces to depth ≤ 2;
flushDeadlineMsbreach is logged loudly; the window grows but stays bounded by disk speed, never by queue growth. - A background write races a
StopHostflush →FlushAllAsyncawaits the worker; the worker is single-consumer. File.Replacesemantics differ per OS → Verification 2; fallback toFile.Movewith overwrite + explicit backup copy on platforms without atomic replace.- An owner's
Serializetouches a Unity object → the snapshot rule is a review-checklist item;Serializeruns on the main thread so it would not crash, only leak a non-snapshot read — theInterop/purity tests do not catch this; named. - Format drift →
saveFormatVersionbump is a deliberate decision with a loud gate; no silent migration ever (save Rule 4). - Domain reload disabled (Enter Play Mode Options) → a static flush worker/queue would survive Play sessions and Unity does not kill background threads on stop;
SavePersistenceis an instance owned by the session (not static) and the worker is cancelled + the queue cleared onEditorApplication.playModeStateChanged == ExitingPlayModein the Editor (specialist note; consistent with ADR-0001's no-static-cache stance). flushDeadlineMsuntuned → 250 ms is a default; the integration test measures realfsync/F_FULLFSYNClatency and the value is retuned before Alpha — stated so the number is not quoted as validated.
GDD Requirements Addressed
| GDD System | Requirement | How This ADR Addresses It |
|---|---|---|
| save-persistence | Rule 1 one file, host-owned, never merged | one save.json per world folder keyed by worldId; no import path (ADR-0003 Schema_*) |
| save-persistence | Rule 2 persisted tables incl. both instanceId counters, TideState, per-player CARRY + equipped instances | document layout §1; counters, tide, carries (equipment slots inside), players blocks |
| save-persistence | Rule 3 per-confirmed-action durability | restated honestly: durable within flushDeadlineMs on a healthy disk; the loss unit is every drain since lastDurableDrain (wall-clock bound, not queue depth); torn writes impossible; macOS needs F_FULLFSYNC |
| save-persistence | Rule 4 version gate, no partial load, no silent migration | step 1; LoadResult.RefusedVersion |
| save-persistence | Rule 5 fixed pipeline; each stage invokes the owner's predicate | steps 2–8 via ReconcileContext; inspection test |
| save-persistence | Rule 6 two-class rule; only HarvestDiff droppable | SaveClass; SaveRegistry asserts exactly one Droppable |
| save-persistence | Rule 7 load: full HP, no Downed, position per tide EC1 | step 8 orchestration |
| save-persistence | AC torn-write → previous good file; corrupt current + intact backup → backup loads | step 0 file pick + File.Replace + rotation |
| save-persistence | AC loading triggers no gameplay events | Deserialize/Reconcile contracts; StartHost only after step 9 |
| tide-scheduler (ADR-0001) | Rule 2 / EC4–5 / AC-32 monotonic guard; TideState incl. epoch (EC14) | step 2 Restore + Advance before any table reconciles |
| harvesting | EC19–20 stale/orphan diffs dropped and logged (ordering + predicate call only — the F4 key schema is Harvesting's / ADR-0010's) | step 5 via ctx.Tide.IsDockingLive + template membership; LogDropped |
| crafting | EC7 monotonic instanceId persisted; EC8–9 orphan/mystery gear; EC19 union; EC20 inert curiosity | counters block; steps 6–7 |
| base-building | EC13 inert relic, EC15 missing container list, EC16 counter | step 6; counters block. (EC14 current-cost refund is satisfied by StructureInstance carrying no cost field — BB's schema decision, ADR-0011, noted so it is not re-litigated here — TD-ADR C4) |
| land-dock-undock | Rule 3 KnownLandTemplates permanent, union, seeded | step 4 |
| inventory | Rule 8 stacks persist via Save; per-player CARRY + per-container STORAGE | step 7; carries/sharedStorage tables |
| netcode-session | Rule 5 "session ending is a save-file fact"; AC host-quit durability | FlushAllAsync on StopHost |
Performance Implications
- CPU (main thread):
MarkDirtyO(1) per command; end-of-drain serialisation tens of µs per dirty table at MVP sizes; no fsync on the main thread except atStopHost. - CPU (background): one whole-file write per dirty drain (coalesced), single worker.
- Memory: last document fragments retained per table (a few KB); one in-flight doc string.
- Load Time: parse + gate + reconcile — milliseconds at MVP sizes.
- Network: none (the late-join snapshot is ADR-0008's, built from the same reconciled tables).
Migration Plan
No production code. Order: (1) ISaveWriter/ISaveReader JSON implementation behind the seam (Verification 1 decides the library); (2) SavePersistence with Register/EndOfDrain/FlushAllAsync/Load and the file-pick/rotate logic, EditMode-tested against an in-memory ISaveWriter; (3) the flush worker + torn-write simulation test; (4) first real tables: tide (ADR-0001) and knownLandTemplates (ADR-0005); (5) remaining owners as their ADRs land.
Validation Criteria
- EditMode: full save → load round-trip byte-identical for every registered table (save AC); unknown version →
RefusedVersion, no state constructed; torn temp file at swap instant → previous good file loads; corrupt current + intact backup → backup loads; exactly oneDroppabletable registered. - EditMode: reconcile order matches Rule 5; a fake
ISaveablerecords theReconcileContextmembers non-null at its stage exactly per ADR-0003's table;SavePersistencereferences no owner member other thanISaveable(reflection). - EditMode: stale
HarvestDiffdropped, stale-looking permanent rows never dropped (two-class rule); saved position on an Absent socket →depositPoint, CARRY bit-identical. - Integration (documented playtest / standalone build): kill the host between two drains → reload loses at most the drains after
lastDurableDrain; a flush exceedingflushDeadlineMsproduces the loud log and no gameplay effect; noFileStreamcall on the main thread during play (profiler marker). /architecture-reviewshows TR-save-001..006, TR-tide-004, TR-harv-003, TR-craft-002/004, TR-ldu-003, TR-inv-005, TR-bb-005 covered and QQ-07, QQ-11 closed.
Related Decisions
- ADR-0001 (
TideState,IsDockingLive), ADR-0002 (drain step 5, durability routed here), ADR-0003 (ISaveable,ReconcileContext) docs/architecture/architecture.md§3.3 save/load path, QQ-07, QQ-11- Future: ADR-0005 (templates table), ADR-0006 (
configVersion), ADR-0008 (late-join snapshot), ADR-0010/0011 (inventory/structure tables) - Design: save-persistence quick-spec; tide EC1/4/5/13/14; harvesting EC19–20; crafting EC7–9/19–20; base-building EC13–16; inventory Rule 8; netcode Rule 5
Amendments — 2026-09-07 (architecture-review-2026-09-07.md)
- #10 (HIGH) §2:
Reconcilemoved off the Data-levelISaveableontoRules.Reconcile.IReconcilable : ISaveable— the §2 shape could not compile against the accepted assembly graph. - C4 (HIGH) §5 steps 5–7:
ctx.LogDropped→ctx.Report(FindingKind …)(typed findings, ADR-0003 §3). Kept orphans (steps 6–7) now reportKeptOrphan, never masquerade as drops.
Amendments — 2026-09-11 (ADR-0008 netcode wire)
- The late-join snapshot (
SnapshotDocument, ADR-0008 §6) is the Enables clause made literal: the host'slastFullDocminus other players' CARRY, plus asessionActorssection (per-PlayerKeyHP/State/bleed-out/OnLand, connected or not — the §6 "superset" clause) written throughISaveWriterbyCombatStatebut never registered withSaveRegistry. The joiner runs deserialisation only: load steps 0–2 are replaced by the connection, 3 ridesepochState, 4–7 are skipped (host-reconciled), 8 (placement) runs inSessionReadyCommand.Execute.configVersionmismatch → disconnect with reasonConfigVersion.
Amendments — 2026-09-11 (ADR-0010 inventory replication)
- The
carriestable's reservedequipmentarray becomes live: twoinstanceTagstrings (weapon, armor) — the equipped stacks moved out of the bag (ADR-0010 §5). Reconcile posture for a tag with no metadata row is unchanged (KeptOrphan, tag kept); on the wire such a row encodes as untagged (InstanceId 0) — lossy for orphans, harmless because views never write back.ContainerId.Valueis"s" + structureInstanceId(ADR-0010 §1) so the two decimal id spaces stay distinguishable in findings.
Amendments — 2026-09-11 (ADR-0011 structure registry replication)
- The
structurestable writes afootprintcolumn (w,h) per row from the def at save time — a writer column, not aStructureInstancefield (BB AC-24 stays literal) — so stage 6's inert relic keeps its full silhouette and collision after a patch removes its def (user decision 2026-09-11). Reconcile: live rows re-derive from the def (data wins); relic rows keep the column; a missing column (no such save exists yet) reads as1×1withKeptOrphan.