DRIFTLANDS co-op survival on drifting tides — studio progress

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
EngineUnity 6.3 LTS (6000.3) · C# / .NET Standard 2.1 profile
DomainCore / Save-Load (System.IO)
Knowledge RiskLOW — 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 Consulteddocs/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 UsedNone 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 OnADR-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)
EnablesADR-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)
BlocksThe Save epic; every system's ISaveable implementation story; the Tide epic's Restore(TideState) story
Ordering NoteFourth 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

Requirements

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

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

4. Durability semantics — stated, not implied (TD-ADR C5 on ADR-0002)

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)

Alternative C: JSON, synchronous flush inside/at the end of the drain (Save Rule 3 literally)

Alternative D: Broadcast only after flush

Alternative E: System.Text.Json with source generation as the primary library

Alternative F: Per-table files / append-only journal

Consequences

Positive

Negative

Risks

GDD Requirements Addressed

GDD System Requirement How This ADR Addresses It
save-persistenceRule 1 one file, host-owned, never mergedone save.json per world folder keyed by worldId; no import path (ADR-0003 Schema_*)
save-persistenceRule 2 persisted tables incl. both instanceId counters, TideState, per-player CARRY + equipped instancesdocument layout §1; counters, tide, carries (equipment slots inside), players blocks
save-persistenceRule 3 per-confirmed-action durabilityrestated 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-persistenceRule 4 version gate, no partial load, no silent migrationstep 1; LoadResult.RefusedVersion
save-persistenceRule 5 fixed pipeline; each stage invokes the owner's predicatesteps 2–8 via ReconcileContext; inspection test
save-persistenceRule 6 two-class rule; only HarvestDiff droppableSaveClass; SaveRegistry asserts exactly one Droppable
save-persistenceRule 7 load: full HP, no Downed, position per tide EC1step 8 orchestration
save-persistenceAC torn-write → previous good file; corrupt current + intact backup → backup loadsstep 0 file pick + File.Replace + rotation
save-persistenceAC loading triggers no gameplay eventsDeserialize/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
harvestingEC19–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
craftingEC7 monotonic instanceId persisted; EC8–9 orphan/mystery gear; EC19 union; EC20 inert curiositycounters block; steps 6–7
base-buildingEC13 inert relic, EC15 missing container list, EC16 counterstep 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-undockRule 3 KnownLandTemplates permanent, union, seededstep 4
inventoryRule 8 stacks persist via Save; per-player CARRY + per-container STORAGEstep 7; carries/sharedStorage tables
netcode-sessionRule 5 "session ending is a save-file fact"; AC host-quit durabilityFlushAllAsync on StopHost

Performance Implications

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

Related Decisions

Amendments — 2026-09-07 (architecture-review-2026-09-07.md)

Amendments — 2026-09-11 (ADR-0008 netcode wire)

Amendments — 2026-09-11 (ADR-0010 inventory replication)

Amendments — 2026-09-11 (ADR-0011 structure registry replication)