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 |
|---|---|
| Engine | Unity 6.3 LTS (6000.3) |
| Domain | Core / Scripting (plus one Networking adapter at the boundary) |
| Knowledge Risk | LOW 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 Consulted | docs/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 Used | None 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 On | None — first ADR; the spike (9/9 contract tests) is the only prerequisite and it is done |
| Enables | ADR-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) |
| Blocks | The Tide Scheduler epic; transitively every epic that reads Docked (Harvesting, Combat soft-close, Land Dock/Undock, Tide-Tint) |
| Ordering Note | Write 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
- Tide GDD Formulas:
System.Math,longUTC seconds,doubleintensities only; banned:System.Random,GetHashCode(),UnityEngine.Mathf,float, transcendental functions,DateTime.Nowinside schedule math (AC-4). Hash algorithm and salt constants are save-breaking if changed (F0). Locked tint fractions sum to 0.40 (0.15 + 0.25, AC-43 / Tuning Knobs — the GDD's Edge Case 18 still says 0.35, a stale figure from before the M4 raise; corrected in the GDD alongside this ADR). - Tide GDD AC-27: no signature on the UI/render-facing surface may expose raw
t,φ,C,W. - Tide GDD AC-44/45 (hard gates, TD ruling 2026-08-27): ≤ 50 µs average per combined
DockedLands+NextBoundarycall atN ≤ 8, 0 bytes allocated per call; AC-46 (advisory): ≤ 0.1 ms per frame. - Technical preferences: 80 % coverage on pure-logic systems, tide scheduler is "the first mandatory unit-test target";
[SerializeField]fields-only (6.3). - Forbidden Patterns: no offline simulation — the implementation must never iterate elapsed cycles (tide EC3, AC-33).
- Architecture principle 3 (Rules are engine-free) and the approved assembly layout (
Driftlands.Tidehas no Unity references).
Requirements
- Must implement tide F0–F8 exactly as specified (StableHash64 splitmix, F1 offsets, F2 docked/cycle/progress, F3 dockingIndex, F4 global deck + seam rules, F5 variationSeed, F6 NextBoundary, F7 tint floats, F8 recurrence bounds).
- Must expose the
ITideClockboundary from the architecture document unchanged. - Must be evaluable in EditMode NUnit with no scene, no
NetworkManager, noMonoBehaviour. - Must accept
TideConfigas an explicit parameter (data-driven, versioned, load-validated per tide EC 15–18). - Must provide
tto clients from the host only; client-local evaluation is presentation-only (tide Rule 1, EC9, AC-50b). - Must persist and restore
lastKnownGoodTthrough Save (tide Rule 2, AC-32).
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.
- Assembly
Assets/Driftlands/Tide/Driftlands.Tide.asmdef—"noEngineReferences": true,"references": [],"autoReferenced": false. Because the asmdef excludesUnityEngine, anyMathf/Time/Randomreference is a compile error, not a code-review catch. Who references it (Driftlands.Rules,Driftlands.Runtime,Driftlands.Tests.EditMode) is ADR-0003's decision; this ADR only fixes the node. Note thatnoEngineReferencesis a property of the referenced assembly and does not propagate: the EditMode test asmdef is an ordinary engine-referencing test assembly (NUnit +UnityEngine.TestRunner) that referencesDriftlands.Tide. - Namespace layout inside the assembly:
Driftlands.Tide.Math—StableHash64,FloorDiv,FloorMod,SeededFisherYates(static, pure,long/ulong/doubleonly).Driftlands.Tide.Config—TideConfigrecord (W,G,C = W + G,N,PreloadLeadTime,EvictionGrace,ArrivalWindowFrac,DriftOutWindowFrac,Pool: TemplateId[],ConfigVersion) +TideConfigValidator(EC 15–18 →ValidationResultwith named errors/warnings; AC-36..43).Driftlands.Tide.Schedule—TideSchedule(the pure evaluator: F1–F8) taking(seed, TideConfig)at construction andtper call. It precomputes the per-socket offsets and the current round's deck lazily; both are cached by key ((seed, config)for offsets,roundIndexfor a deck) so repeated evaluation allocates nothing (AC-45) while remaining a pure function of its inputs (AC-3: same inputs → same outputs; the cache is an implementation detail invisible to callers).Driftlands.Tide.Clock—TideClock : ITideClockwrapping aTideScheduleplus the monotonic guard:Advance(hostUtcNow)setst = max(lastKnownGoodT, hostUtcNow)and fires edge events by comparing the previous and current evaluation;Restore(TideState);Statefor Save.NextBoundary()drives the wake-up.- Epoch state is an input, not a derivation (TD-ADR C2). Tide EC14 defines
epochIndexas a counter "incremented at every config epoch, never reset" and rebasesdockingIndex/roundIndexto the config-activation timestamp; neither is computable from(seed, config, t). They travel inTideState { LastKnownGoodT, EpochIndex, EpochActivationT }, persisted by Save (ADR-0004), restored into the clock, and passed toTideScheduleas pure inputs.TideClock.AdvanceEpoch(newConfig, activationT)is the only writer (called by GameData/Save on a config epoch —Nchange), and the schedule stays a pure function of(seed, config, state, t).architecture.md's "derives epoch/cycle purely (nothing stored)" is true only in this sense: the cycle is derived, the epoch is restored. - Cache invariants: the keyed caches are instance-scoped, never
static(a disabled domain reload would otherwise carry stale state across Play Mode sessions), evicted whenroundIndexadvances (at most two rounds resident — current and the seam neighbour), and the clock is main-thread only (AC-49's thread-affinity clause). - The only impure input is a
long hostUtcNowpassed intoAdvance. Nothing in the assembly reads a clock. TheTideClockAdapter(Driftlands.Runtime, aNetworkBehaviour) samplesDateTimeOffset.UtcNow.ToUnixTimeSeconds()on the host, callsAdvance, and replicatestas a host-writtenNetworkVariable<long>⚠️; clients construct their ownTideClockfrom the replicated(seed, config, t)and callAdvance(lastReceivedT + monotonicLocalElapsed)for presentation only — theirTideClockinstance is taggedPresentationand its events are never wired to gameplay (tide Rule 1, EC9). Wire details (write permission API, send cadence, join-time delivery) belong to ADR-0008. - Events (
DockStarted,SoftClose,PreloadDue) are plain C# events raised fromAdvanceon the host clock only.Advancehas exactly one caller (amended 2026-08-28 — architecture review C1, exercising the ADR-0008 override clause early):HostTickOrchestratorstep 1, once perNetworkTickSystem.Tick(ADR-0015). Direct call order guarantees SoftClose-first for that tick's drain (ADR-0002), the ±5 s tolerance holds trivially (one tick), and a boundary tick can never callAdvancetwice — which matters becauseLandNetspawn/despawn hang off these events (ADR-0005). TheAwaitable.WaitForSecondsAsynctimer (the 6.3-idiomatic default percurrent-best-practices.md; neverInvoke/coroutines) is retained only for the Presentation-role clock andIPreloadSchedulewake-ups atNextBoundary()/NextBoundary() − PreloadLeadTime(clients have no host tick) and never calls the Authority clock'sAdvance; it is a hint, not the truth — every wake re-samples UTC, so whetherWaitForSecondsAsynchonoursTime.timeScale(UNVERIFIED) cannot matter. Per-tickAdvanceis one ≤ 50 µs evaluation, not schedule polling. Suspend/wake recovers by callingAdvancewith the fresh UTC — a single evaluation, never a replay (AC-33). - Config source:
TideConfigis loaded by GameData (ADR-0006) fromassets/data/tide.jsonand validated before the clock is constructed; the tide assembly never touches files.ConfigVersionparticipates in Save's version gate (ADR-0004); a version change mid-save follows tide EC 13/14 (round-boundary pool change; hard epoch onNchange) — implemented insideTideScheduleas theepochIndexterm of the diff key. - Tests:
Driftlands.Tests.EditMode/Tide/holds tide AC-1..45 and AC-49 as NUnit tests over the pure types. The AC-2 fixed-vector table is a checked-in JSON fixture (≥ 20 rows spanning MVP and Tier 1 configs) generated once from the production implementation with production constants, hand-checked against the GDD's worked examples (AC-8:φ=0, t=600,000 → docked, cycle 2, progress ≈ 0.515; AC-25:min(648,000, 676,800); AC-17/18 recurrence bounds) and then pinned — the spike cannot produce production-constant vectors without modification and is throwaway by its own note. AC-44/45 run as benchmark tests withGC.GetAllocatedBytesForCurrentThread()and a stopwatch, gated hard in CI (Editor profile). AC-46/47/48/50/50b are PlayMode tests against the adapter and belong to ADR-0008's suite.
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
- Description: Same math and interface, but living in the main game assembly; purity enforced by code review and a reflection test (AC-4's static-analysis check).
- Pros: No asmdef setup; one fewer assembly to reference from tests.
- Cons:
UnityEngineis in scope — aMathf.FloorToIntorTime.timecompiles fine and passes until AC-4's reflection test catches it (if it does); EditMode tests must reference the whole Runtime assembly, pulling NGO into the test domain; contradicts architecture principle 3 and the spike's explicit instruction. - Rejection Reason: Turns a compile-time guarantee into a review discipline for the single most determinism-critical code in the project.
Alternative C: External class library (.csproj → DLL in Plugins/)
- Description: Build the scheduler as a standalone .NET library (like the spike), drop the DLL into the Unity project, share it with CI tooling / a future companion site.
- Pros: Strongest isolation; reusable outside Unity; the spike is already this shape.
- Cons: A second build pipeline for a solo part-time developer; Unity's Mono/IL2CPP target profile can differ from the dotnet build (the 0-alloc guarantee must be measured in-editor anyway); debugging through a prebuilt DLL is worse; asmdef gives the same compile-time isolation without leaving Unity.
- Rejection Reason: The isolation benefit is fully captured by
noEngineReferences: true; the extra pipeline is YAGNI until a non-Unity consumer exists (revisit if a companion tool is ever scoped).
Consequences
Positive
- Purity is a compile error, not a convention —
UnityEnginecannot be referenced from the scheduler. - The entire tide AC suite (AC-1..45, 49) runs in EditMode in seconds with no scene; it becomes the project's pre-commit smoke gate.
- One impure input (
hostUtcNow) at one call site (Advance) — auditable in a single file. - Consumers program against
ITideClockand never see raw time (AC-27 by construction).
Negative
- Two clock instances per client (the host's authority clock, replicated
t; the client's presentation clock) — a small duplication that must be kept clearly labelled. - A keyed cache inside
TideScheduleis state; it must be provably invisible to outputs (covered by AC-3's 1,000× identical-call test and a dedicated cache-invalidation test on config/seed change). - Round-deck computation on a cold cache allocates once per round; the 0-alloc gate applies to warmed calls (as the GDD's AC-45 already says).
Risks
doubleresults differ between Mono (Editor/host dev build) and an IL2CPP player build — the real cross-machine risk in host-and-play → mitigated by asserting the AC-2 fixture on at least one IL2CPP standalone player build, not two Mono editors; any drift fails AC-2 first.- IL2CPP stripping of a pure assembly nothing visibly calls → reachable only via
ITideClockfrom Runtime; normal call-graph analysis keeps it, nolink.xmlexpected — verified by the IL2CPP smoke build below. - 0-alloc measured under dotnet, not Mono/IL2CPP → verification item; AC-45 runs in the Unity EditMode profile and in the CI batch run.
- Presentation clock drives a gameplay decision by mistake →
TideClockRole.Presentationclocks throw if a gameplay consumer subscribes to their events (guard), and AC-50b bounds their lead. Amended by ADR-0005 (2026-08-28):TideClockadditionally implements the narrowIPreloadSchedule { NextPreloadInstant(socket); NextTemplate(socket) }in both roles — the one Presentation-role surface a non-tint/HUD consumer (LandLifecycle.SchedulePreload, asset loading only) may read by pull; the event guard is unchanged. NetworkVariable<long>API differs on NGO 2.x → adapter is ~30 lines; the API name is an ADR-0008 verification item, not a design risk.
GDD Requirements Addressed
| GDD System | Requirement | How This ADR Addresses It |
|---|---|---|
| tide-scheduler.md | Core Rule 3 / AC-3: DockedLands is a pure function, no stored schedule state | TideSchedule takes (seed, config) + t; keyed cache is output-invisible |
| tide-scheduler.md | AC-4: no System.Random, GetHashCode, Mathf, float, DateTime.Now | noEngineReferences asmdef + AC-4 reflection test over the assembly |
| tide-scheduler.md | Rule 1 / EC9 / AC-50b: host-authoritative t, client evaluation presentation-only | TideClockRole split; adapter replicates t; a Presentation-role clock throws if a gameplay consumer subscribes to its events (the real guard, not labelling) |
| tide-scheduler.md | Rule 2 / EC4–5 / AC-32: monotonic clock guard, future value wins | TideClock.Advance = max(lastKnownGoodT, hostUtcNow); Restore |
| tide-scheduler.md | F6 / AC-25–26: NextBoundary schedule-one-wake-up, no polling | adapter timer at NextBoundary(); Advance diffs previous vs current state |
| tide-scheduler.md | EC3 / AC-33: never iterate elapsed cycles | Advance is a single evaluation at the new t |
| tide-scheduler.md | EC 13–14 / AC-34–35b: pool change at round boundary; N change = hard epoch with epochIndex | ConfigVersion in TideConfig (round-boundary pool change); TideState.EpochIndex + EpochActivationT restored from Save and passed as pure inputs; AdvanceEpoch the only writer |
| tide-scheduler.md | EC 15–18 / AC-36–43: load-time config validation | TideConfigValidator with named errors and the AC-42b warning |
| tide-scheduler.md | AC-27: no raw t/φ/C/W on the UI/render surface | ITideClock exposes derived values only |
| tide-scheduler.md | AC-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.md | AC-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.md | Interactions 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.md | Rule 4 / lines 181–182: transitions fire host-side with ±5 s tolerance | Advance at NextBoundary(); Validation asserts every transition lands within ±5 s of its instant |
| save-persistence | Rule 2/5: lastKnownGoodT persisted; clock guard step 2 of the load pipeline; droppable reconcile via the tide predicate | TideState property + Restore; IsDockingLive |
| land-dock-undock | Rule 1: subscribe to tide edges, preload at NextBoundary − PreloadLeadTime | PreloadDue / DockStarted / SoftClose events |
| harvesting / combat | cycleIndex asked never recomputed; Docked re-sampled at resolution | CycleIndex, Evaluate on ITideClock; no other path exists |
| tide-tint (art bible §8.B.3) | amber/violet floats, violet held through EvictionGrace | TintFloats |
| netcode-session | Rule 2: the tide is on neither rail — computed identically everywhere | only t crosses the wire; the schedule does not |
Performance Implications
- CPU: ≤ 50 µs per combined evaluation at
N ≤ 8(hard gate); evaluation happens on state change and per-frame only for tint floats (a few multiplies). - Memory: one
TideScheduleper clock (two per client); offsets array (Nlongs) + one deck array per cached round (deckSizeints); no per-call allocation after warm-up. - Load Time: negligible — config parse + validation + one deck shuffle.
- Network: one
longreplicated on change/join (ADR-0008 owns cadence).
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
- All tide
[UNIT]criteria (AC-1..45, 49) pass in EditMode CI; the AC-2 fixture is byte-identical on a Mono Editor run and on one IL2CPP standalone player build. - AC-44/45 pass in the Unity Editor profile (not only dotnet); 0-alloc is an Editor-measured claim — one IL2CPP standalone build exercises
ITideClock.Evaluatebefore Alpha (stripping + profile smoke). - Every Absent↔Docked transition fires within ±5 s of its computed instant over a simulated cycle (AC-26 + the GDD tolerance).
- Cache invariants hold: no
staticcache in the assembly (reflection test); at most two rounds resident; config/seed change invalidates. - A deliberate
using UnityEngine;in the tide assembly fails to compile. - A presentation-role clock rejects gameplay subscribers.
/architecture-reviewshows TR-tide-001..010 covered by this ADR.
Amendments
- 2026-08-28, ADR-0005 —
IPreloadSchedule.TideClockimplements the narrowIPreloadSchedule { NextPreloadInstant(socket); NextTemplate(socket) }in both roles — the one Presentation-role surface a non-tint/HUD consumer (LandLifecycle.SchedulePreload, asset loading only) may read by pull. The gameplay-subscriber guard is unchanged (see Risks). - 2026-08-28, architecture review C1 (via ADR-0015) — single
Advancecaller. Decision item 4 amended: the Authority clock'sAdvanceis called only byHostTickOrchestratorstep 1, once perNetworkTickSystem.Tick; theAwaitabletimer serves only Presentation-role /IPreloadSchedulewake-ups. Consequence recorded in ADR-0004 §3 (tide table dirties once per second). Verification added: (5) the orchestrator is the soleAdvancecall site (reflection test, with ADR-0015's sole-Tick-subscriber test).
Related Decisions
docs/architecture/architecture.md— Layer Map (Foundation: TideScheduler), Assembly layout, API Boundaries (ITideClock), Required ADRs #1- Future: ADR-0002 (host commit model), ADR-0004 (save), ADR-0005 (land lifecycle), ADR-0008 (netcode wire —
treplication), ADR-0013 (tide tint) design/gdd/tide-scheduler.md(Reviewed) ·prototypes/tide-scheduler-spike-2026-08-25/SPIKE-NOTE.md
Amendment notes
- 2026-09-03 (foundation-game-data story-009) —
IEpochAdvancer. Folded 2026-09-07 (review C6):IEpochAdvancerandISaveClockare now declared in §Key Interfaces;TideClockexists and implements both explicitly.TideConfigshipped as areadonly struct, not arecord(story-003 ruling — equally immutable, no polyfill in Tide), with validation inTideConfigValidatorrather than the constructor. - 2026-09-07 (review C6) — "only writer" corrected.
AdvanceEpochis the only method that incrementsEpochIndex;Restore(TideState)assigns the persisted value.ApplyPoolChange(tide EC13) is a declared clock method with its own pending-schedule switch, not "implemented insideTideSchedule" as §5 previously implied. - 2026-09-07 (review #12) — risk registered. Deck chaining (story-002's seam anchor) is
O(r − anchor)on a cold cache. Not an EC3 violation (rounds, not elapsed cycles;Advancestays a single evaluation) but unbounded in principle for a far-future resume. Owed: a cold-cache benchmark att = now + 10 yinside AC-44's 50 µs, or a chain cap.
Amendments — 2026-09-11 (ADR-0008 netcode wire)
treplication decided:TideClockAdapterholds oneNetworkVariable<long> TwithNetworkVariableWritePermission.Server(API verified against NGO 2.13.2 source), default update traits —tchanges once per second by construction. Onlyttravels; every derived value is recomputed locally (netcode quick-spec Rule 4 and tide Rule 1 wording aligned). The ADR-0008 override clause on the timer mechanism is exercised as already recorded (2026-08-28:Advanceruns fromHostTickOrchestratorat 50 Hz).