DRIFTLANDS co-op survival on drifting tides — studio progress

ADR-0003: Assembly and Purity Boundaries — asmdef Graph, Engine-Free Rules, UnityInterop, HostContext and Command Shapes

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# (Roslyn, C# 9+ records/init)
DomainCore / Scripting
Knowledge RiskLOW — Assembly Definition files, noEngineReferences, System.Numerics, NUnit EditMode/PlayMode test assemblies, and reflection are all pre-cutoff and unchanged through 6.3. The only 6.3 scripting change ([SerializeField] fields-only) cannot occur in engine-free assemblies and is a compile error elsewhere.
References Consulteddocs/engine-reference/unity/VERSION.md, breaking-changes.md (Scripting), deprecated-apis.md, current-best-practices.md (Scripting: engine-free tide; Testing: EditMode for pure logic, PlayMode for integration); ADR-0001 (asmdef mechanism, TideState, ITideClock); ADR-0002 (ICommand, QueuedCommand, HostContext minimum surface, no-re-entrancy ban); docs/architecture/architecture.md (Assembly layout, API Boundaries, QQ-09, QQ-12).
Post-Cutoff APIs UsedNone.
Verification Required(1) An asmdef with noEngineReferences: true can reference another noEngineReferences asmdef (Rules → Tide, Rules → Data) and be referenced by a normal engine test asmdef; (2) System.Numerics.Vector2 is available under the project's .NET profile (.NET Standard 2.1 / .NET Framework API compat) in both Mono and IL2CPP builds; (3) the reflection-based enforcement tests run in EditMode against the compiled assemblies (AppDomain assembly enumeration works under the Editor's domain).

ADR Dependencies

Field Value
Depends OnADR-0001 (Driftlands.Tide is a fixed engine-free node this graph inherits; TideState, ITideClock), ADR-0002 (ICommand.Execute(HostContext) → CommandResult, QueuedCommand, the re-entrancy and async bans this ADR enforces, HostContext's minimum surface)
EnablesADR-0004 (ReconcileContext shape; ISaveable lives in Driftlands.Data), ADR-0005 (LandInstance shape), ADR-0006 (Driftlands.Data is the assembly it fills), ADR-0008 (the payload types it must encode — Cell, ItemStack, SwingLanded/DamageAbsorbed, command records; TD-ADR C12), ADR-0009 / ADR-0010 / ADR-0011 / ADR-0012 (their command records and pure predicates have a home and a template), ADR-0013 / ADR-0014 (Presentation assembly boundary), ADR-0015 Combat model (new Required-ADR row: owner of ICombatState — TD-ADR C7)
BlocksEvery [UNIT] suite outside Driftlands.Tide (ADR-0001 lands its own assembly and suite independently); ADR-0002's Migration step 1 (ICommand/fake HostContext need Driftlands.Rules to exist); the first implementation story of any non-tide system
Ordering NoteThird ADR, immediately after 0001/0002; write before any code. Resolves architecture QQ-09 (gridCellSize) and QQ-12 (five type shapes + UnityInterop + worked command). The shapes here are minimum binding surfaces; a system ADR may add members to its own types but may not remove or retype what is fixed here.

Context

Problem Statement

The approved architecture promises that rules are engine-free (Principle 3) and lists six assemblies, but a list is not a decision: reference edges, what counts as "plain data" at the boundary, who pays the Driftlands↔Unity conversion, and the shapes of five load-bearing types (HostContext, ReconcileContext, Cell, LandInstance, HitEvent) were all left open (LP review items 2–3, QQ-12). Base Building's OQ6 (gridCellSize) blocks its AC-8 outright. Without this ADR, six programmers invent six conversion utilities and six HostContexts.

Constraints

Requirements

Decision

Alternative A: six assemblies; Tide, Data, Rules engine-free; System.Numerics.Vector2 and an int Cell in Rules; one UnityInterop in Runtime; HostContext/ReconcileContext as sealed records of interfaces; commands as sealed records; reflection tests as the enforcement floor.

1. Assembly graph

Assembly (asmdef) noEngineReferences autoReferenced References Contains
Driftlands.Tidetruefalse—ADR-0001 (fixed node)
Driftlands.DatatruefalseDriftlands.Tide (amended 2026-09-03 — see Toolchain corrections)Def records (ItemDef, RecipeDef, StructureDef, EnemyDef, TemplateDef, all Config), IGameData (read-only lookups), the fail-loud validation rules (ADR-0006 fills them), ISaveable/SaveClass/ISaveWriter/ISaveReader (format-agnostic — ADR-0004 implements), shared primitives: Cell, ItemId, NodeId, InstanceId, TemplateId, ContainerId, ItemStack, InstanceTag
Driftlands.RulestruefalseDriftlands.Tide, Driftlands.DataEvery pure formula, predicate, state machine, seeded generator, and command record: Rules.Combat (F1–F5, player state machine, unarmed baseline, referent choice), Rules.Harvest (three gates, yield), Rules.Craft (F1 legality, durability F3), Rules.Build (F1/F2/F3/F4, snap, FindCorrectionCell, CI gates), Rules.Inventory (stack ops, IInventoryTx check half), Rules.Land (layout, stripTimeEstimate), Rules.Enemy (composition, density check), Rules.Commands (ICommand, CommandResult, QueuedCommand, HostContext, all concrete commands), Rules.Reconcile (ReconcileContext)
Driftlands.RuntimefalsefalseTide, Data, Rules, Unity.Netcode.Runtime, Unity.Addressables, Unity.InputSystem, Unity.ResourceManagerEvery MonoBehaviour/NetworkBehaviour: TideClockAdapter, HostCommitChannel + HostCommitQueue, session state, Save I/O + reconcile orchestration, LandLifecycle, movement, camera, UnityInterop, the thin adapters (TilemapTerrainQuery : ITerrainQuery, RegistrySnapshot, HostPositions), replication components
Driftlands.PresentationfalsefalseData, Rules (read-only views), Runtime, UnityEngine.UIElementsModule, URPHUD + screens, TideTintDriver, feedback presenters. Runtime never references Presentation (the one direction that is easy to get wrong).
Driftlands.Tests.EditModefalse (test asm)—Tide, Data, Rules, NUnit, UnityEngine.TestRunnerevery [UNIT] criterion; the enforcement tests (§6)
Driftlands.Tests.PlayModefalse (test asm)—all of the above + Runtime, Presentation[INTEGRATION] criteria (documented playtests / standalone builds where MPPM cannot automate — ADR-0002 Verification 5)

Folder layout mirrors the graph: Assets/Driftlands/{Tide,Data,Rules,Runtime,Presentation}/ each with its asmdef at the root; Assets/Driftlands/Tests/{EditMode,PlayMode}/. No code lives outside an asmdef — Assembly-CSharp is normally absent (Unity emits it only when a loose script exists); Asm_NoCodeOutsideAsmdefs asserts that. autoReferenced: false on all five runtime asmdefs is load-bearing for that fence, not cosmetic: with it true, a stray script outside every asmdef folder would silently compile against them (specialist). Both test asmdefs carry "defineConstraints": ["UNITY_INCLUDE_TESTS"] and reference UnityEngine.TestRunner/nunit.framework so NUnit never ships in the Steam player build; PlayMode additionally pulls NGO/Addressables/Input/URP transitively and can never be engine-free.

2. Plain data at the boundary

3. The five shapes (QQ-12) — binding minimum surfaces

namespace Driftlands.Rules.Commands
{
    /// Everything a command may read or mutate. Built by Runtime once per HOST TICK and passed to CombatState.Tick → EnemyAI.Tick → Drain (ADR-0015 host step order); never held across ticks.
    public sealed record HostContext(
        ITideClock            Tide,           // Evaluate / CycleIndex / SoftClosedSockets source — re-sample, never cache
        long                  NowTick,        // host network tick; ResolveTick comparisons use this, never wall clock
        IReadOnlySet<SocketId> SoftClosedSockets,   // kept because ADR-0002's minimum surface names it; ILandState.SoftCloseTick is the authoritative instant — commands read the set, the drain reads the tick (TD-ADR C7)
        IGameData             Data,
        IHostPositions        Positions,      // exact for the host's own player, one RTT (+≤1 tick) stale for remotes
        IInventoryTx          Inventory,      // CanRemove/Remove over CARRY ∪ STORAGE-in-range; CanAdd/Add
        ILandState            Lands,          // SocketOf(node), Node(id), Decrement(id), SoftCloseTick(socket), HarvestDiffs — live docked-land state (no socket layout: that is Sockets)
        IStructureRegistry    Structures,
        IStationPresenceProvider Stations,
        ITerrainQuery         Terrain,        // BB F1 validTerrain / placerHasExit / FindCorrectionCell input (TD-ADR C1 — was used by §4 but undeclared)
        ISocketLayout         Sockets,        // socket positions, depositPoint, noBuildRadius (BB F4) — LandContent-owned static data
        ICombatState          Combat,         // HP/state per actor; stagger meters; ApplyDamage; Downed/Dead transitions. OWNER: a Combat model ADR — a gap in the Required-ADR list, added as row 15 (TD-ADR C7)
        IInstanceMetadata     Gear,          // IInstanceMetadata EXPOSES IGearHooks (DamageModifier/DamageReduction/HarvestModifier by InstanceId) — a type relationship, not a comment (ADR-0015 TD-ADR C1)           // marks, durability, IGearHooks
        IKnownRecipes         Recipes, IKnownLandTemplates Templates,   // IKnownLandTemplates is declared in Driftlands.Data (ADR-0005 §6): a Rules type cannot name a Runtime interface
        Action<ISaveable>     Persist,        // dirty-mark only (ADR-0002/0004)
        Action<IOutcome>      Broadcast,      // buffered; flushed after the envelope loop
        Action<SpawnRequest>  QueueSpawn);    // buffered; flushed after the envelope loop

    public interface ICommand { ulong OwnerClientId { get; } uint ClientSeq { get; } CommandResult Execute(HostContext ctx); }   // ADR-0002
    public readonly record struct CommandResult(bool Success, ReasonCode Reason) { public static CommandResult Ok() ... ; public static CommandResult Fail(ReasonCode r) ...; }
    public readonly record struct QueuedCommand(ICommand Command, uint? ArrivalSeq);                                            // ADR-0002
    public enum ReasonCode : byte { None, LandDeparted, Depleted, BagFull, NotEnoughMaterials, NoStation, WorldNotOffered, TooFar, OutOfBounds, Overlap, Occupied, NoWayOut, BadTerrain, SocketExclusion, AlreadyGone, ChestNotEmpty, NoRoomForRefund, ActorDead, Whiff, NotDowned, AlreadyBeingRevived, ReviverIneligible /* +3 by ADR-0015 */ }
}

namespace Driftlands.Rules.Reconcile
{
    /// Passed to every ISaveable.Reconcile in the fixed load order (save Rule 5: 1 version gate · 2 clock guard · 3 tide derives
    /// · 4 union Known* · 5 droppable HarvestDiff · 6 permanent Structures/InstanceMetadata · 7 inventories · 8 player placement).
    /// A member is non-null only once the stage that PRODUCES it has COMPLETED (TD-ADR C2).
    /// INVARIANT (TD-ADR C3): Tide is the single Authority-role clock, already Restore(TideState)'d and Advance(loadUtc)'d by
    /// stages 2–3 before stage 4 begins — stage 5's IsDockingLive(epoch, socket, cycle) is silently wrong on an un-advanced clock.
    public sealed record ReconcileContext(
        ITideClock            Tide,
        IGameData             Data,           // current content: item/structure/template/recipe ids
        IKnownLandTemplates?  Templates,      // after stage 4
        IKnownRecipes?        Recipes,        // after stage 4
        IReadOnlyList<HarvestDiff>? HarvestDiffs,     // after stage 5
        IStructureRegistry?   Structures,     // after stage 6
        IInstanceMetadata?    Gear,           // after stage 6 — stage 7's tag-without-row → mystery gear reads it
        IReadOnlyDictionary<ulong, CarrySnapshot>? Carries,   // after stage 7 — stage 8 (player placement) reads it
        ISocketLayout         Sockets,        // static — stage 8 decides "saved position on a now-Absent land" via Tide.Evaluate(socket) + depositPoint
        Action<ReconcileFinding> Report);     // every dropped / kept-orphan / stale row is reported as a TYPED finding, never a prose string (review 2026-09-07 C4)
    // Stage 8 (player placement) is Save orchestration over Tide + Sockets + Carries, not an ISaveable — stated so it is not silently unaddressed.

    public enum FindingKind : byte { Dropped, KeptOrphan, Stale }
    /// One reconcile outcome. Kind is the load-bearing field: a Dropped row is gone, a KeptOrphan row survived with a warning, a Stale row was superseded.
    /// (2026-09-07, review C4: the former Action<string> LogDropped was carrying rows that were KEPT — a channel named Dropped reporting keeps.)
    public readonly struct ReconcileFinding { public string TableId; public string RowKey; public FindingKind Kind; public string Detail; }
}

namespace Driftlands.Data
{
    public readonly record struct Cell(int X, int Y);
    public readonly record struct SocketId(int Value);   // one socket representation everywhere (TD-ADR C9) — replaces bare int in LandInstance, HostContext.SoftClosedSockets, ISocketLayout
    public readonly record struct ItemStack(ItemId Id, int Count, InstanceTag? Tag);
    // Combat's TWO host-confirmed events are two records with distinct payloads (TD-ADR C4 — one merged record would make every
    // subscriber guess which half is meaningful, the "silent wrong mode" shape BB AC-44 bans). Neither carries victim HP, max HP,
    // or meter values — combat's "no enemy HP bars at MVP" rule; DamageDealt/DamageTaken are the only numbers presenters may show.
    public readonly record struct SwingLanded(                     // per confirmed hit — Crafting F3 drains the WEAPON; Presentation shows DamageDealt
        ulong AttackerClientId, ActorId Victim, int DamageDealt, InstanceId? WeaponInstance /* null = unarmed */,
        System.Numerics.Vector2 HitPoint, bool StaggerFired, bool Lethal);
    public readonly record struct DamageAbsorbed(                  // per confirmed damage-taken — Crafting F3 drains the ARMOR; Presentation shows DamageTaken
        ActorId Victim, ulong? AttackerClientId /* null = enemy attacker */, int DamageTaken, InstanceId? ArmorInstance /* null = no armor */,
        bool Downed, bool Dead);
    // Crafting F3's third drain source — tool @Tier 1 = harvest swing — is NOT a combat event: HarvestResolveCommand's HarvestResolved
    // outcome (ADR-0002) carries the ToolInstance? field when tools exist (Tier 1). Shape reserved here; no MVP emitter.
}

namespace Driftlands.Rules.Land
{
    /// Output of layout(template, variationSeed): plain data, byte-identical everywhere (TR-ldu-002). Runtime instantiates visuals from it.
    public sealed record LandInstance(
        TemplateId Template, SocketId Socket, long CycleIndex, int EpochIndex, ulong VariationSeed,
        IReadOnlyList<NodePlacement> Nodes,          // NodePlacement(NodeId Id, ItemId Item, Band Band, Cell Cell) — counts/types fixed per template, positions vary
        IReadOnlyList<SpawnPoint>    SpawnPoints,    // SpawnPoint(int Index, Band Band, Cell Cell)
        IReadOnlyList<ScatterItem>   Scatter,        // cosmetic only
        TraversalProfile             Traversal);     // (shoreToMidSecs, shoreToDeepSecs) floats — COPIED VERBATIM from TemplateDef; participates in no generated geometry, so the byte-identity claim covers every generated member (all Cell/int/ulong) (TD-ADR C9)
}

Rules for these shapes: (a) records are immutable — a command never mutates a context field, only calls its interface methods; (b) every member is an interface or a plain struct — no MonoBehaviour, no NetworkObject, no Transform; (c) adding a member is a system ADR's right; removing or retyping one is a revision of this ADR.

4. Worked concrete command (the template every command follows)

namespace Driftlands.Rules.Commands
{
    /// Intent-time data ONLY in the record; everything else is read from ctx inside Execute.
    public sealed record PlaceStructureCommand(ulong OwnerClientId, uint ClientSeq,
        StructureId Structure, Cell Origin) : ICommand
    {
        public CommandResult Execute(HostContext ctx)
        {
            var def = ctx.Data.Structures[Structure];
            var footprint = Footprint.At(def, Origin);
            var placer = ctx.Positions.HostKnown(OwnerClientId);                       // fresh (one RTT stale for remotes — BB F1)
            var verdict = BuildRules.PlacementLegality(                               // pure: all eight conjuncts, plain data in
                footprint, placer, ctx.Data.Configs.Build, ctx.Structures, ctx.Positions.All(),
                ctx.Sockets, ctx.Terrain, cost: def.Cost, ctx.Inventory, ctx.Inventory.Carry(OwnerClientId), ctx.Inventory.StorageWithin(ctx.Data.Configs.Craft.StationRange, footprint.Center));
            if (!verdict.CanPlace) return CommandResult.Fail(verdict.FirstFailingGate);   // reason-string precedence = F1 gate order
            ctx.Inventory.Remove(def.Cost, ctx.Inventory.Carry(OwnerClientId), ctx.Inventory.StorageWithin(...));   // one Inventory-Rule-6 transaction
            var instance = ctx.Structures.Add(Structure, Origin);                      // host-monotonic instanceId
            if (def.Category == StructureCategory.Storage) ctx.QueueSpawn(SpawnRequest.SharedStorage(instance, def.StorageSlots!.Value));   // buffered
            ctx.Persist(ctx.Structures);                                               // dirty-mark
            ctx.Broadcast(new StructurePlaced(instance, Structure, Origin));            // buffered
            return CommandResult.Ok();
        }
    }
}

Intent-time: Structure, Origin. Everything else — positions, registry, inventory, terrain, socket layout — is read from ctx at execution. No await, no Unity API, no captured state.

5. Test assemblies and what is tested where

6. Enforcement (EditMode, run every CI pass — the floor until a Roslyn analyzer exists)

Test Asserts
Asm_PureAssembliesHaveNoUnityReferencesDriftlands.Tide, .Data, .Rules reference no assembly whose name starts with UnityEngine, UnityEditor, or Unity.
Asm_NoUpwardReferencesRuntime does not reference Presentation; Rules does not reference Runtime; Data references nothing but the BCL and Driftlands.Tide (§1 graph correction, 2026-09-03); no production assembly references Driftlands.Editor
Asm_NoCodeOutsideAsmdefsAssembly-CSharp is absent from the loaded assemblies (Unity does not emit it when no loose script exists — the normal case, PASS) or, if present, contains zero types (FAIL otherwise). A test that treats "not found" as an error, or catches it silently, tests the wrong thing (specialist B1)
Purity_NoStaticMutableFieldsno non-const, non-readonly static field in Tide/Data/Rules (ADR-0001 static_cache_in_pure_assembly)
Commands_ExecuteIsSynchronousno ICommand.Execute implementation carries AsyncStateMachineAttribute and none returns Task/ValueTask/Awaitable/IEnumerator — both checks, since a hand-written iterator has no attribute (ADR-0002 async_or_reentrant_execute)
Commands_NoReentrantCallsInRulesVacuous by construction in Rules (a noEngineReferences assembly cannot name Instantiate/Spawn/an RPC) and therefore NOT a separate test. ADR-0002's ban on re-entrant calls inside Execute bodies is method-body (IL) inspection, which reflection cannot do — it is the first rule the Roslyn analyzer must carry; until then it is a review-checklist item (TD-ADR C5, honestly stated)
Net_OnlyChannelDeclaresServerRpcexactly one method in Runtime carries Unity.Netcode.RpcAttribute whose constructor argument is SendTo.Server (read via CustomAttributeData.ConstructorArguments — [Rpc] is one attribute type parameterised by SendTo; presence alone also matches the permitted SendTo.ClientsAndHost broadcasts), and it is on HostCommitChannel (ADR-0002; specialist B3)
Interop_SingleConversionSiteno method outside UnityInterop has both a System.Numerics and a UnityEngine vector type in its signature — bounds the declared boundary only; an inline new Numerics.Vector2(v.x, v.y) passes (analyzer territory; TD-ADR C5)
Schema_*the schema-negatives every GDD requires: no MoveSpeedModifier/stamina/encumbrance (movement AC); StructureInstance = exactly {instanceId, structureId, position} (BB AC-24 — an allow-list, not merely a builder-field ban); no craft-count field (crafting AC-12); no home-island node defs (harvesting AC-25 — the harvesting criterion; BB's own AC-25 is a different, /ux-design-deferred UI-contract check, listed as deferred not omitted); StructureDef allow-list (BB AC-2); exactly two station predicates (BB AC-44); no merge/import path for a second save file (save-persistence schema-negative AC) — TD-ADR C6

A Roslyn analyzer package reproducing the Commands_ and Interop_ rules as compile-time diagnostics is the intended upgrade (tracked as a Technical Setup nice-to-have); the reflection tests remain as the CI gate either way.

Architecture Diagram

                Driftlands.Presentation  (HUD, tint driver, presenters)          UIElements, URP
                        │ reads views                │
                        ▼                            ▼
                Driftlands.Runtime  (adapters, NetworkBehaviours, Save I/O,     NGO, Addressables, Input, Physics2D
                  HostCommitQueue, LandLifecycle, movement, camera,
                  UnityInterop  ◄── the ONLY Numerics↔Unity conversion site)
                        │ passes plain data / interfaces down
                        ▼
                Driftlands.Rules  ── noEngineReferences ──  System.Numerics.Vector2, Cell
                  formulas · predicates · state machines · layout · composition
                  Commands: ICommand, HostContext, PlaceStructureCommand, HarvestResolveCommand, …
                  Reconcile: ReconcileContext
                   │                          │
                   ▼                          ▼
        Driftlands.Tide (ADR-0001)     Driftlands.Data ── noEngineReferences
          pure scheduler                 *Def records, IGameData, ISaveable, Cell, ItemStack, HitEvent, ids

   Tests.EditMode → Tide, Data, Rules          Tests.PlayMode → everything

Key Interfaces

See §3 (the five shapes) and §4 (the command template). Additional boundary interfaces fixed by this ADR's graph: ITerrainQuery { bool IsBuildable(Cell); bool InPlaceableRegion(Cell); }, IHostPositions { Vector2 HostKnown(ulong clientId); IReadOnlyList<(ulong, Vector2)> All(); }, ILandState, ICombatState, IOutcome (marker for buffered broadcasts), SpawnRequest (buffered spawn intent) — all in Driftlands.Rules as interfaces, implemented in Driftlands.Runtime.

Toolchain corrections to §3 (2026-09-03, /dev-story foundation-bootstrap/story-004)

Three facts about the pinned toolchain (Unity 6000.3.18f1 · C# 9.0 · .NET Standard 2.1) that §3's shapes did not account for. None changes the decision; each changes how a shape is spelled. Found by compiling the shapes, which is the check that should precede accepting any ADR that specifies code.

§3 as written Pinned toolchain Shipped as
readonly record struct CommandResult / QueuedCommandrecord struct is C# 10 (CS8773); Unity 6.3 compiles C# 9.0readonly struct with hand-written IEquatable<T>, same positional shape
sealed record HostContext (positional, init setters)C# 9 records need System.Runtime.CompilerServices.IsExternalInit, which is .NET 5+ and absent from netstandard 2.1 (CS0518)Unchanged — plus an internal static class IsExternalInit {} polyfill per assembly that declares records
IReadOnlySet<SocketId> SoftClosedSocketsIReadOnlySet<T> is .NET 5+; 0 hits in Unity's NetStandard/ref/2.1.0/netstandard.dllIReadOnlyCollection<SocketId> — read-only, HashSet<T> implements it, LINQ Contains stays O(1)

Also: HostContext has 18 positional members, not 17 as one story draft said — pinned by Tests/EditMode/HostContextMemberCountTests (2026-09-08, review-fixes story-006), which reads the compiled constructor; HostContextServices.cs's header said 17 until then.

Graph correction (2026-09-03, /dev-story foundation-game-data/story-003). §1 gave Driftlands.Data no references. That is contradicted by two other Accepted ADRs: ADR-0006's GameConfigs.Tide places a TideConfig (a Driftlands.Tide type, per ADR-0001) inside Data, and ADR-0005 §6 places IKnownLandTemplates — which names TemplateId, also a Tide type — inside Data. Data therefore must reference Tide. The edge is downward (Tide is the fixed, reference-free root; ADR-0001), creates no cycle, and changes nothing about purity (both assemblies are noEngineReferences: true). §1 now reads Data → Tide; Asm_NoUpwardReferences allows BCL + Driftlands.Tide for Data and still forbids Rules/Runtime/Presentation. Found by implementation, like the toolchain items above: the shapes were never compiled together.

Shared formulas (2026-09-07, review #2). A pure formula needed by both a Data-stage validation rule and a Rules consumer lives in Driftlands.Data, and Rules calls in (Rules already references Data). Until a second Rules consumer exists, a duplicated arithmetic pinned by a cross-implementation agreement test (Data_stage10_and_Rules_SupplyVsHaul_agree_on_the_shipped_numbers, mutation-verified) is the accepted interim; the third consumer forces the move.

Editor-only assembly (2026-09-03, /dev-story foundation-game-data/story-007). Driftlands.Editor (includePlatforms: ["Editor"], references Runtime + Data, autoReferenced: false) holds editor tooling that needs UnityEditor — first occupant: the IPreprocessBuildWithReport step that mirrors assets/data/ into StreamingAssets/data/. It is outside the seven-assembly production graph and no production asmdef may reference it; the logic it wraps (DataMirror) lives in Runtime so it stays testable in EditMode. Enforced (2026-09-07, review C5): Asm_NoUpwardReferences asserts that no assembly in {Tide, Data, Rules, Runtime, Presentation} lists Driftlands.Editor; test assemblies may (EditMode does; PlayMode reaches editor setup by reflection and must not). Arithmetic, stated once: five production runtime assemblies + two test assemblies + one Editor-only assembly.

Alternatives Considered

Alternative B: Two assemblies (Tide pure; everything else in Runtime)

Alternative C: Four assemblies (merge Data into Rules; merge Presentation into Runtime)

Alternative D (vector type): custom Driftlands.Vec2 struct

Alternative E (grid): 0.5 u or 2 u cells

Consequences

Positive

Negative

Risks

GDD Requirements Addressed

GDD System Requirement How This ADR Addresses It
base-buildingAC purity mandate: pipeline consumes plain-data snapshots via ITerrainQuery, plain registry, passed-in positions; adapters thin and untestedRules.Build consumes ITerrainQuery/IStructureRegistry/IHostPositions interfaces; TilemapTerrainQuery etc. are Runtime adapters excluded from EditMode
base-buildingOQ6 gridCellSize / snap constants — "pin before AC-8 can run"gridCellSize = 1 u; Cell(int,int); Cell.FromWorld; snap in Rules
base-buildingAC-14b fixture constraint gridCellSize ≤ 3 u; AC-14c FindCorrectionCell pure over ITerrainQuery + plain registryholds at 1 u; FindCorrectionCell lives in Rules.Build with exactly that signature
base-buildingAC-2 / AC-24 / AC-44 reflection allow-lists (StructureDef fields; StructureInstance anonymity; exactly two station predicates)Schema_* enforcement tests
combatF1–F5 pure/DI-testable; AwayFromZero rounding; durability events SwingLanded/DamageAbsorbed need payloadsRules.Combat engine-free (no Mathf.Round); two records SwingLanded (weapon instance, DamageDealt) and DamageAbsorbed (armor instance, DamageTaken) — crafting F3's weapon and armor drains; the Tier 1 tool drain rides HarvestResolved, not a combat event
combatAC-9 bit-identical F1/F2 across 1,000 callspure functions over ints; no static state (Purity_NoStaticMutableFields)
harvestingRule 6 gates + F1–F4 pure; referent choice at windup startRules.Harvest; HarvestResolveCommand (ADR-0002 worked example) follows the §4 template
harvestingAC-25 no home-island nodes (schema-negative)Schema_* test over TemplateDef/home content
craftingF1 legality pure; AC-12 no craft-count field; Rule 4 opaque InstanceTagRules.Craft; Schema_*; ItemStack.Tag in Data
inventoryRule 2 stack interface as plain data; Rule 6 transaction shapeItemStack record in Data; IInventoryTx interface in Rules, implemented in Runtime
land-dock-undockRule 2 layout() pure, byte-identical (TR-ldu-002); Rule 7 traversalProfileLandInstance shape; Rules.Land.Layout(template, seed) engine-free
enemy-aiRule 8 composition pure function of (template, variationSeed)Rules.Enemy.Compose in the pure assembly; SpawnPoint in LandInstance
player-movementAC schema-negative: no MoveSpeedModifier/stamina/encumbranceSchema_* test
save-persistenceRule 5 stages invoke owners' predicates; Save has no reconcile semanticsReconcileContext with stage-populated members; ISaveable in Data
netcode-session (ADR-0002)ICommand.Execute synchronous; no re-entrant Unity API; one host-rail RPC objectCommands_ and Net_ enforcement tests
tide-scheduler (ADR-0001)engine-free assembly; no static cachesinherited as a fixed node; Purity_NoStaticMutableFields extended to Data/Rules
coding-standardsDI over singletons; 80 % coverage on pure logicHostContext is the injection seam; EditMode coverage measured over Tide/Data/Rules only

Performance Implications

Migration Plan

No production code exists. Order: (1) create the seven asmdefs and folders with their reference lists and an empty type each, plus Assembly-CSharp empty; (2) land the Asm_ and Purity_ tests (they pass on empty assemblies — the fence goes up first); (3) add Data primitives (Cell, ids, ItemStack, HitEvent, ISaveable); (4) add Rules.Commands (ICommand, CommandResult, QueuedCommand, HostContext, ReasonCode) and Rules.Reconcile.ReconcileContext; (5) UnityInterop; (6) PlaceStructureCommand + its EditMode test against fakes as the reference implementation; (7) systems fill in per their ADRs.

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-0009 player transform authority)

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

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