DRIFTLANDS co-op survival on drifting tides — studio progress

ADR-0001: Tide Scheduler as a Pure Engine-Free Assembly

Status

Accepted

Accepted 2026-08-28 — architecture-review-2026-08-28.md (verdict CONCERNS; review amendments applied before acceptance).

Date

2026-08-27

Engine Compatibility

Field Value
EngineUnity 6.3 LTS (6000.3)
DomainCore / Scripting (plus one Networking adapter at the boundary)
Knowledge RiskLOW for the assembly itself (plain C#, System.Math, NUnit EditMode — all pre-cutoff and unchanged). HIGH for the boundary adapter that replicates t over NGO 2.x — scoped out to ADR-0008 (netcode wire) and flagged below.
References Consulteddocs/engine-reference/unity/VERSION.md, breaking-changes.md (Scripting: [SerializeField] fields-only), deprecated-apis.md, current-best-practices.md (Scripting: "keep the tide scheduler engine-free pure C# … unit-testable in EditMode"; Testing: EditMode for pure logic), modules/networking.md (NetworkVariable patterns — 1.x-era examples, treated as unverified), prototypes/tide-scheduler-spike-2026-08-25/SPIKE-NOTE.md
Post-Cutoff APIs UsedNone inside Driftlands.Tide. The Runtime adapter uses NetworkVariable<long> with server write permission — a 1.x-era API whose 2.x surface is NEEDS VERIFICATION (ADR-0008).
Verification Required(1) noEngineReferences: true asmdef compiles under 6000.3 and an engine-referencing EditMode test asmdef can reference it; (2) GC.GetAllocatedBytesForCurrentThread() reports 0 across 10,000 warmed calls in the Unity Editor profile (spike ran on dotnet 10); (3) AC-2 fixture identical on one IL2CPP player build; (4) NetworkVariable<long> write-permission API on NGO 2.x — modules/networking.md is a 1.x-idiom source and must be refreshed (WebFetch against the NGO 2.x docs) before ADR-0008; the ADR-0008 dependency is one scalar, one direction.

ADR Dependencies

Field Value
Depends OnNone — first ADR; the spike (9/9 contract tests) is the only prerequisite and it is done
EnablesADR-0003 (assembly graph — inherits Driftlands.Tide as a fixed, engine-free node; ADR-0003 owns every other asmdef and all reference edges), ADR-0002 (host commit model — SoftClose-first needs ITideClock), ADR-0004 (save — lastKnownGoodT + EpochState clock guard; the reconcile predicate), ADR-0005 (land lifecycle — PreloadDue/DockStarted/SoftClose), ADR-0006 (game data — defines TideConfig's shape and validator that GameData loads), ADR-0013 (tide-tint — TintFloats), ADR-0008 (netcode wire — t replication is one of its rows)
BlocksThe Tide Scheduler epic; transitively every epic that reads Docked (Harvesting, Combat soft-close, Land Dock/Undock, Tide-Tint)
Ordering NoteWrite and Accept this ADR first. Its interface (ITideClock) is consumed by five other Foundation/Core ADRs; none of them can be drafted against a moving target. Scope boundary with ADR-0003 (TD-ADR C1): this ADR decides only that Driftlands.Tide exists and is engine-free; which assemblies reference it, and the rest of the six-assembly graph, are ADR-0003's. The ADR-0008 dependency is one scalar, one direction (long t into Advance) — no wire-shape surprise can invalidate anything inside Driftlands.Tide.

Context

Problem Statement

Driftlands has no server. The world's past, present and future are DockedLands(seed, t, config) — a pure function every machine evaluates identically (tide GDD Core Rule 3, concept Pillar 1). If that function ever reads engine time, a random source, a float, or any mutable field, the host and a client can disagree on whether a land is docked, harvest diffs can key against the wrong cycle, and the "nothing happens while nobody plays" promise breaks silently. The tide GDD makes purity a rule (AC-3, AC-4, AC-49) and the spike proved the math; this ADR decides how the Unity project makes that purity a compile-time fact rather than a discipline, and where the one impure input — wall-clock UTC — enters.

Constraints

Requirements

Decision

Alternative A: a dedicated Driftlands.Tide assembly with noEngineReferences: true, consumed through ITideClock; a thin TideClockAdapter in Driftlands.Runtime owns the only impure input (host UTC) and its replication.

Architecture Diagram

Driftlands.Tide (asmdef, noEngineReferences)          Driftlands.Runtime
┌───────────────────────────────────────────┐          ┌──────────────────────────────────┐
│ Math: StableHash64 · FloorDiv/Mod · FY    │          │ TideClockAdapter : NetworkBehaviour│
│ Config: TideConfig · TideConfigValidator  │          │  host: UtcNow → clock.Advance(t)  │
│ Schedule: TideSchedule (F1–F8, keyed cache)│◄─────────│        t → NetworkVariable<long> ⚠️│
│ Clock: TideClock : ITideClock             │          │  client: replicated t + local     │
│   Advance(hostUtcNow) · Restore · events   │          │        elapsed → presentation clock│
└───────────────┬───────────────────────────┘          │  timer at NextBoundary()          │
                │ ITideClock (derived values only)      └──────────────────────────────────┘
   ┌────────────┼─────────────┬───────────────┬──────────────┐
   ▼            ▼             ▼               ▼              ▼
 Harvesting   Combat      LandLifecycle   SavePersistence  TideTint / UIHUD
 (CycleIndex, (Evaluate    (PreloadDue,   (LastKnownGoodT, (TintFloats,
  Evaluate)    at resolve)  DockStarted,   Restore)          Evaluate.docked)
                            SoftClose)

Key Interfaces

namespace Driftlands.Tide
{
    public readonly record struct TemplateId(int Value);

    public sealed record TideConfig(
        long W, long G, int N, long PreloadLeadTime, long EvictionGrace,
        double ArrivalWindowFrac, double DriftOutWindowFrac,
        TemplateId[] Pool, int ConfigVersion)   // ConfigVersion is POPULATED FROM manifest.configVersion by the tide reader — one value, one writer (ADR-0006); never authored in tide.json
    { public long C => W + G; }

    public static class TideConfigValidator {          // tide EC 15–18, AC-36..43
        public static ValidationResult Validate(TideConfig c);  // errors: W=0, W>=C, N!C, deckSize<=2M, frac sum>1; warning: N>=2 && W<C/N
    }

    public readonly record struct TideState(long LastKnownGoodT, int EpochIndex, long EpochActivationT);

    public interface ITideClock {
        (bool docked, double progress) Evaluate(int socket);   // progress only meaningful when docked
        long  CycleIndex(int socket);
        int   EpochIndex { get; }
        ulong VariationSeed(int socket, long cycleIndex);
        TemplateId TemplateFor(int socket, long cycleIndex);
        long  NextBoundary();                                   // > t, <= t + C
        (float amber, float violet) TintFloats(int socket);     // (0,1) held during EvictionGrace
        bool  IsDockingLive(int epochIndex, int socket, long cycleIndex);  // the reconcile validity predicate the scheduler OWNS (tide Interactions row / save AC): true iff that epoch-qualified docking is the one currently live at t
        event Action<int, TemplateId> PreloadDue;
        event Action<int> DockStarted;
        event Action<int> SoftClose;
    }

    public sealed class TideClock : ITideClock {
        public TideClock(ulong seed, TideConfig config, TideClockRole role);   // role: Authority | Presentation
        public void Restore(TideState state);                                  // Save → clock: LastKnownGoodT, EpochIndex, EpochActivationT
        public void Advance(long hostUtcNow);                                  // t = max(lastKnownGoodT, hostUtcNow); raises edge events (Authority only); ±5 s tolerance
        public void AdvanceEpoch(TideConfig newConfig, long activationT);      // the only method that INCREMENTS EpochIndex; Restore(TideState) ASSIGNS the persisted value (epoch is an input, not a derivation — §2). Throws unless activationT == t (ADR-0006 §5 TD-ADR C2b, enforced). Caller: GameData/Save on an N change
        public void ApplyPoolChange(in TideConfig newConfig);                  // tide EC13: pool change at the next round boundary. Refuses an N change (→ AdvanceEpoch) and a W/G change (not a pool change). Declared 2026-09-07 (review C6)
        // Reached only through the seams below — TideClock implements both explicitly, so neither is callable off the concrete type:
        //   public interface IEpochAdvancer { long T { get; } void AdvanceEpoch(TideConfig newConfig, long activationT); }   (Driftlands.Tide)
        //   public interface ISaveClock : IEpochAdvancer { TideState State { get; } void Restore(TideState s); void Advance(long hostUtcNow); }
        public TideState State { get; }                                        // clock → Save
        // Never exposes t, φ, C, W (AC-27). Recurrence bounds (F8) via TideSchedule.RecurrenceBounds(config) for tooling only.
    }
}

Invariants callers must respect: never cache Evaluate() across ticks (re-sample at resolution — architecture principle 4); never construct a second Authority clock; Advance is called only by the adapter. Guarantees to callers: identical outputs for identical (seed, config, t) on every platform (AC-1/2/3/49); zero allocation on the hot path after warm-up (AC-45); events fire exactly once per transition (AC-26).

Alternatives Considered

Alternative B: Pure static class inside Driftlands.Runtime

Alternative C: External class library (.csproj → DLL in Plugins/)

Consequences

Positive

Negative

Risks

GDD Requirements Addressed

GDD System Requirement How This ADR Addresses It
tide-scheduler.mdCore Rule 3 / AC-3: DockedLands is a pure function, no stored schedule stateTideSchedule takes (seed, config) + t; keyed cache is output-invisible
tide-scheduler.mdAC-4: no System.Random, GetHashCode, Mathf, float, DateTime.NownoEngineReferences asmdef + AC-4 reflection test over the assembly
tide-scheduler.mdRule 1 / EC9 / AC-50b: host-authoritative t, client evaluation presentation-onlyTideClockRole split; adapter replicates t; a Presentation-role clock throws if a gameplay consumer subscribes to its events (the real guard, not labelling)
tide-scheduler.mdRule 2 / EC4–5 / AC-32: monotonic clock guard, future value winsTideClock.Advance = max(lastKnownGoodT, hostUtcNow); Restore
tide-scheduler.mdF6 / AC-25–26: NextBoundary schedule-one-wake-up, no pollingadapter timer at NextBoundary(); Advance diffs previous vs current state
tide-scheduler.mdEC3 / AC-33: never iterate elapsed cyclesAdvance is a single evaluation at the new t
tide-scheduler.mdEC 13–14 / AC-34–35b: pool change at round boundary; N change = hard epoch with epochIndexConfigVersion in TideConfig (round-boundary pool change); TideState.EpochIndex + EpochActivationT restored from Save and passed as pure inputs; AdvanceEpoch the only writer
tide-scheduler.mdEC 15–18 / AC-36–43: load-time config validationTideConfigValidator with named errors and the AC-42b warning
tide-scheduler.mdAC-27: no raw t/φ/C/W on the UI/render surfaceITideClock exposes derived values only
tide-scheduler.mdAC-44/45: ≤ 50 µs, 0 alloc (warmed)EditMode benchmark tests, hard-gated — measured in the Editor (Mono/CoreCLR); IL2CPP player behaviour assumed identical for a boxing-free pure assembly, verified by one smoke build (Validation)
tide-scheduler.mdAC-46/47/48/50/50b (integration: frame cost, host/client bit-identity, silent reconcile, join, extrapolation bound)deferred to ADR-0008's PlayMode suite — the adapter is theirs; not addressed here
tide-scheduler.mdInteractions row "Scheduler owns … reconcile predicate"; save-persistence AC "each stage invokes the owning system's predicate — Save contains no reconcile semantics"ITideClock.IsDockingLive(epoch, socket, cycle) — Save's droppable stage calls it; Harvesting never re-derives it
tide-scheduler.mdRule 4 / lines 181–182: transitions fire host-side with ±5 s toleranceAdvance at NextBoundary(); Validation asserts every transition lands within ±5 s of its instant
save-persistenceRule 2/5: lastKnownGoodT persisted; clock guard step 2 of the load pipeline; droppable reconcile via the tide predicateTideState property + Restore; IsDockingLive
land-dock-undockRule 1: subscribe to tide edges, preload at NextBoundary − PreloadLeadTimePreloadDue / DockStarted / SoftClose events
harvesting / combatcycleIndex asked never recomputed; Docked re-sampled at resolutionCycleIndex, Evaluate on ITideClock; no other path exists
tide-tint (art bible §8.B.3)amber/violet floats, violet held through EvictionGraceTintFloats
netcode-sessionRule 2: the tide is on neither rail — computed identically everywhereonly t crosses the wire; the schedule does not

Performance Implications

Migration Plan

No existing production code. The spike (prototypes/tide-scheduler-spike-2026-08-25/Program.cs) is the reference for algorithms and the AC-2 fixture generator; per its own note it is never refactored into production. Steps: (1) create the asmdef + namespaces, (2) port F0–F8 against the GDD text (not the spike's placeholder constants), (3) generate the AC-2 fixture from the spike with production constants, (4) land the EditMode suite, (5) write the adapter stub with a fake t source; NGO replication lands with ADR-0008.

Validation Criteria

Amendments

Related Decisions

Amendment notes

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