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 |
|---|---|
| Engine | Unity 6.3 LTS (6000.3) · C# (Roslyn, C# 9+ records/init) |
| Domain | Core / Scripting |
| Knowledge Risk | LOW — 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 Consulted | docs/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 Used | None. |
| 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 On | ADR-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) |
| Enables | ADR-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) |
| Blocks | Every [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 Note | Third 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
- ADR-0001's stance: purity is a compile-time fact via
noEngineReferences: true, never a convention. - ADR-0002's bans:
Executesynchronous and non-re-entrant; no Unity send/spawn inside; enforcement lives here. - Every GDD's purity mandate: BB AC purity ("plain-data snapshots —
ITerrainQuery, registry as a plain collection, player positions as a passed-in list — never liveTilemap/Physics2D/NGO queries; adapters are thin and NOT unit-tested"); combat "F1–F5 pure/DI-testable"; harvesting "Rule 6, F1–F4 pure"; crafting "F1–F4 pure"; enemy-ai composition "pure function of(template, variationSeed)"; LDUlayout"byte-identical placement across runs, machines, and reloads". - BB ghost preview evaluates F1 every frame with no per-frame allocation (TR-bb-003).
- Coding standards: DI over singletons; data-driven values; 80 % coverage on pure logic.
- Solo part-time developer: the asmdef count must stay small enough to maintain.
Requirements
- A reference graph where no lower assembly references a higher one and
Tide,Data,Rulesreference no Unity assembly. - One conversion utility, allocation-free, owned by Runtime.
- Binding shapes for
Cell,Vector2(in Rules),HostContext,ReconcileContext,LandInstance,HitEvent, plus a worked concrete command. gridCellSizepinned (BB OQ6) so snap arithmetic and AC-8/9/14b/14c/14d/14e can run.- Automated enforcement of every purity/re-entrancy ban that can be checked by reflection, in EditMode CI.
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.Tide | true | false | — | ADR-0001 (fixed node) |
Driftlands.Data | true | false | Driftlands.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.Rules | true | false | Driftlands.Tide, Driftlands.Data | Every 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.Runtime | false | false | Tide, Data, Rules, Unity.Netcode.Runtime, Unity.Addressables, Unity.InputSystem, Unity.ResourceManager | Every 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.Presentation | false | false | Data, Rules (read-only views), Runtime, UnityEngine.UIElementsModule, URP | HUD + screens, TideTintDriver, feedback presenters. Runtime never references Presentation (the one direction that is easy to get wrong). |
Driftlands.Tests.EditMode | false (test asm) | — | Tide, Data, Rules, NUnit, UnityEngine.TestRunner | every [UNIT] criterion; the enforcement tests (§6) |
Driftlands.Tests.PlayMode | false (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
Vector2in Rules isSystem.Numerics.Vector2(BCL, float, no Unity reference). Gameplay geometry (arc tests, ranges, snap, distances) is float; the tide alone islong/double(ADR-0001).Rulesnever seesUnityEngine.Vector2/Vector3/Vector2Int.Cellisreadonly record struct Cell(int X, int Y)inDriftlands.Data, withCell.FromWorld(Vector2 p) = (floor(p.X), floor(p.Y))andCell.Center → Vector2(X + 0.5f, Y + 0.5f)— becausegridCellSize = 1 world unit(BB OQ6 resolved, ruled 2026-08-28): one cell = one 32 px terrain tile at PPU 32 (the camera's 64 px/u is a 2× display scale of that same unit — camera Rule 1 — not a second PPU; do not "fix" the cell to 2 u), so every GDD range is already in cells (placementReach6,stationRange3,socketNoBuildRadius4) and BB AC-14b's fixture constraint (gridCellSize ≤ 3 u) holds with margin. Footprints are cell rectangles anchored at their min corner; snap =Cell.FromWorldof the cursor plus BB Rule 4's nudge toward adjacent footprint edges, computed in Rules (ghostSnapResult). Socket-exclusion distance (BB F4, AC-6/7 at fractional distances like 3.5 u) is measuredCell.Centerto socket position in floats — the one place aCellbecomes a float inside a gate.- Client-evaluated pure functions return
Cell/bool, never a float a host later compares (TD-ADR C8).ghostSnapResult(client preview and host commit) andFindCorrectionCell(runs on the correcting client — BB EC21, an owner-rail action, never inside anICommand; TD-ADR C11) both produce integer cells, soSystem.Numericsfloat divergence across machines cannot produce a host/client disagreement.Rulescode is callable from both rails; only host-rail mutations go through commands. UnityInterop(Driftlands.Runtime,static class, the only place conversion lives):ToNumerics(this UnityEngine.Vector2/Vector3),ToUnity(this System.Numerics.Vector2),ToUnityInt(this Cell),ToCell(this Vector3Int),ToUnityBounds(...). All extension methods, all field copies, zero allocation. A test asserts no other type in Runtime/Presentation declares a method whose signature mixes aSystem.Numericsand aUnityEnginevector type.- Adapters are thin and untested:
TilemapTerrainQuery : ITerrainQuerywrapsTilemap.GetTileper cell intoIsBuildable/InPlaceableRegion;RegistrySnapshotexposesIStructureRegistryover the live list;HostPositions : IHostPositionsreadsNetworkTransformstate. Rules receive these interfaces (or plain snapshots) and never the Unity objects — and may never cache them across commands (ADR-0002).
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
- EditMode (
Tide,Data,Rulesonly): every[UNIT]AC in every GDD; fixtures are plain records or JSON underTests/EditMode/Fixtures/; fakes for everyHostContextinterface live inTests/EditMode/Fakes/(one per interface, hand-written, no mocking framework — YAGNI). - PlayMode (
Runtime,Presentation):[INTEGRATION]ACs; where Multiplayer Play Mode cannot automate (ADR-0002 Verification 5), the test is a documented playtest percoding-standards.md's Integration row. - Adapters (
TilemapTerrainQuery,HostPositions,UnityInterop) are not unit-tested (BB purity mandate) — they are exercised by PlayMode.
6. Enforcement (EditMode, run every CI pass — the floor until a Roslyn analyzer exists)
| Test | Asserts |
|---|---|
Asm_PureAssembliesHaveNoUnityReferences | Driftlands.Tide, .Data, .Rules reference no assembly whose name starts with UnityEngine, UnityEditor, or Unity. |
Asm_NoUpwardReferences | Runtime 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_NoCodeOutsideAsmdefs | Assembly-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_NoStaticMutableFields | no non-const, non-readonly static field in Tide/Data/Rules (ADR-0001 static_cache_in_pure_assembly) |
Commands_ExecuteIsSynchronous | no 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_NoReentrantCallsInRules | Vacuous 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_OnlyChannelDeclaresServerRpc | exactly 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_SingleConversionSite | no 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 / QueuedCommand | record struct is C# 10 (CS8773); Unity 6.3 compiles C# 9.0 | readonly 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> SoftClosedSockets | IReadOnlySet<T> is .NET 5+; 0 hits in Unity's NetStandard/ref/2.1.0/netstandard.dll | IReadOnlyCollection<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)
- Description: Keep ADR-0001's tide assembly; put rules, adapters, and presentation in one engine-referencing assembly; enforce purity of rules by review.
- Pros: Minimal setup; no interop layer; one project to navigate.
- Cons:
UnityEnginein scope for every formula — aMathf.Round(banker's rounding, contradicting the pinnedAwayFromZero) or aPhysics2D.OverlapCircleinside a "pure" predicate compiles fine; EditMode tests drag NGO into the test domain; every GDD's purity mandate becomes unenforceable; BB's "adapters are NOT unit-tested" has no boundary to attach to. - Rejection Reason: Discards the one mechanism (compile-time isolation) that makes the
[UNIT]labels honest.
Alternative C: Four assemblies (merge Data into Rules; merge Presentation into Runtime)
- Description: Same purity boundary, fewer files.
- Pros: Two fewer asmdefs for a solo developer.
- Cons:
Presentationmerged intoRuntimeloses the "Runtime never references Presentation" guarantee — HUD code can reach into NetworkBehaviours and vice versa;Datamerged intoRulesmakes GameData's load/validation code live beside formulas and lets Presentation (which needs*Defs for display) pull in all of Rules. - Rejection Reason: The two merges each remove a boundary the design relies on (presentation is pull-only; data is shared by Rules and Presentation without either seeing the other). The maintenance cost of two extra asmdefs is minutes.
Alternative D (vector type): custom Driftlands.Vec2 struct
- Rejection Reason:
System.Numerics.Vector2is in the BCL, float like Unity's, alloc-free to convert, and needs no operator boilerplate; a custom type buys only a future fixed-point option nobody has asked for (YAGNI). Revisit only if cross-platform float non-determinism in gameplay geometry ever becomes observable — the tide, where determinism is load-bearing, is alreadylong/double.
Alternative E (grid): 0.5 u or 2 u cells
- Rejection Reason: 1 u = one 32 px tile = one Tilemap cell keeps every GDD range integral in cells and every footprint a tile rectangle; 0.5 u doubles registry rows and misaligns walls with tiles; 2 u breaks
stationRange3 u into 1.5 cells.
Consequences
Positive
- Purity is a build error and a CI test, not a discipline; every
[UNIT]AC across five GDDs has an assembly to live in. - One conversion site; one
HostContext; one command template — the LP's "six programmers, six utilities" risk is closed. - BB OQ6 resolved: snap, ghost, AC-8/9/14b–e become runnable.
- Presentation cannot reach into Runtime internals (reference direction enforced).
Negative
- Six asmdefs to maintain; new code must be placed deliberately (a type in the wrong assembly is a compile error, which is the point but also a speed bump).
- A conversion at every Rules↔Runtime boundary (field copies — free at runtime, a few characters of noise in code).
- Hand-written fakes for ~12
HostContextinterfaces in the test project. - Float geometry in Rules means gameplay distances are not bit-deterministic across CPU architectures in theory (
System.Numericsmay use FMA on one CPU and not another); in practice every gameplay decision is host-authoritative (combat Rule 7) and the two client-evaluated pure functions return integerCells (§2 invariant). NoteSystem.Numerics.Vector2is scalar under Mono and IL2CPP — no SIMD path — chosen for shape and zero-alloc conversion, never for speed (specialist).
Risks
- A "helpful" refactor moves a formula into Runtime to reach a Unity API →
Asm_*tests fail; review checklist item. System.Numericsnot present in a stripped IL2CPP build → Verification 2; it is part of .NET Standard 2.1 which Unity 6 targets.HostContextgrows into a god object → each interface is owned by one system ADR; the record's member list is reviewed at each new ADR; a command may only use the interfaces its GDD names.- Reflection tests slow CI → they run in milliseconds against loaded assemblies; measured, not assumed.
GDD Requirements Addressed
| GDD System | Requirement | How This ADR Addresses It |
|---|---|---|
| base-building | AC purity mandate: pipeline consumes plain-data snapshots via ITerrainQuery, plain registry, passed-in positions; adapters thin and untested | Rules.Build consumes ITerrainQuery/IStructureRegistry/IHostPositions interfaces; TilemapTerrainQuery etc. are Runtime adapters excluded from EditMode |
| base-building | OQ6 gridCellSize / snap constants — "pin before AC-8 can run" | gridCellSize = 1 u; Cell(int,int); Cell.FromWorld; snap in Rules |
| base-building | AC-14b fixture constraint gridCellSize ≤ 3 u; AC-14c FindCorrectionCell pure over ITerrainQuery + plain registry | holds at 1 u; FindCorrectionCell lives in Rules.Build with exactly that signature |
| base-building | AC-2 / AC-24 / AC-44 reflection allow-lists (StructureDef fields; StructureInstance anonymity; exactly two station predicates) | Schema_* enforcement tests |
| combat | F1–F5 pure/DI-testable; AwayFromZero rounding; durability events SwingLanded/DamageAbsorbed need payloads | Rules.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 |
| combat | AC-9 bit-identical F1/F2 across 1,000 calls | pure functions over ints; no static state (Purity_NoStaticMutableFields) |
| harvesting | Rule 6 gates + F1–F4 pure; referent choice at windup start | Rules.Harvest; HarvestResolveCommand (ADR-0002 worked example) follows the §4 template |
| harvesting | AC-25 no home-island nodes (schema-negative) | Schema_* test over TemplateDef/home content |
| crafting | F1 legality pure; AC-12 no craft-count field; Rule 4 opaque InstanceTag | Rules.Craft; Schema_*; ItemStack.Tag in Data |
| inventory | Rule 2 stack interface as plain data; Rule 6 transaction shape | ItemStack record in Data; IInventoryTx interface in Rules, implemented in Runtime |
| land-dock-undock | Rule 2 layout() pure, byte-identical (TR-ldu-002); Rule 7 traversalProfile | LandInstance shape; Rules.Land.Layout(template, seed) engine-free |
| enemy-ai | Rule 8 composition pure function of (template, variationSeed) | Rules.Enemy.Compose in the pure assembly; SpawnPoint in LandInstance |
| player-movement | AC schema-negative: no MoveSpeedModifier/stamina/encumbrance | Schema_* test |
| save-persistence | Rule 5 stages invoke owners' predicates; Save has no reconcile semantics | ReconcileContext with stage-populated members; ISaveable in Data |
| netcode-session (ADR-0002) | ICommand.Execute synchronous; no re-entrant Unity API; one host-rail RPC object | Commands_ and Net_ enforcement tests |
| tide-scheduler (ADR-0001) | engine-free assembly; no static caches | inherited as a fixed node; Purity_NoStaticMutableFields extended to Data/Rules |
| coding-standards | DI over singletons; 80 % coverage on pure logic | HostContext is the injection seam; EditMode coverage measured over Tide/Data/Rules only |
Performance Implications
- CPU: none at runtime beyond field-copy conversions (inlined). Reflection tests run in EditMode only.
- Memory: records for contexts are built once per drain / once per load; commands are small records (pooling is ADR-0002's per-kind choice); no per-frame allocation in the BB ghost path because
Rules.Buildtakes structs andIReadOnlyListsnapshots that Runtime reuses. - Load Time: asmdefs slightly improve domain-reload and compile times by scoping recompilation.
- Network: none.
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
- All enforcement assertions in §6 pass in EditMode CI and fail when deliberately violated (each has a companion negative test on a throwaway assembly).
- Count corrected 2026-09-02 (
/create-stories foundation-bootstrap, QL-STORY-READY gate). This line previously read "all nine", which counted §6's table rows, not tests. The table has 9 rows, butCommands_NoReentrantCallsInRulesstates in its own row that it is vacuous by construction and therefore NOT a separate test, andSchema_*is a wildcard covering seven distinct schema-negatives. The real figure is 7 mechanism assertions + 7 schema-negatives = 14, each with a companion negative → 28 test methods. Nothing about the decision changed; the arithmetic was simply wrong and would have under-scoped the work. - The 7 schema-negatives are NOT implemented in
foundation-bootstrap. A schema-negative asserting "type X has no field Y" is vacuously green when type X does not exist yet — and no companion negative can detect that, because the companion is written against a fabricated stand-in rather than the real type. Each assertion therefore belongs to the epic that introduces its target type, written in the same change that creates it:MoveSpeedModifier/stamina/encumbrance → movement;StructureInstance+StructureDefallow-lists and the two station predicates → base-building/crafting; craft-count field → crafting; home-island node defs → harvesting; save merge/import path → save-persistence. Driftlands.Tests.EditModeruns with no scene and noNetworkManagerin the domain.PlaceStructureCommandpasses BB AC-13/14/17/18/19 against fakes with zero Unity references in the test.- BB AC-8/9 (snap purity, edge arithmetic) run at
gridCellSize = 1. - A Vector2/Cell conversion in a hot path (BB ghost F1 per frame) shows 0 B allocation in the Profiler.
- Coverage:
com.unity.testtools.codecoveragewith assembly filter+Driftlands.Tide,+Driftlands.Data,+Driftlands.Rulesreports ≥ 80 % (technical-preferences) — Runtime/Presentation/tests excluded by filter, not by omission. - Each enforcement test has a companion negative on a throwaway assembly proving it fails when violated.
/architecture-reviewshows QQ-09 and QQ-12 closed and TR-bb-003/004, TR-combat-005, TR-harv-004/005, TR-ldu-002, TR-ai-004, TR-craft-009, TR-move-006 covered.
Related Decisions
- ADR-0001 (fixed engine-free node;
static_cache_in_pure_assembly), ADR-0002 (ICommand,HostContextminimum surface, bans enforced here) docs/architecture/architecture.md— Assembly layout, API Boundaries, QQ-09, QQ-12, Principle 3- Future: ADR-0004 (
ReconcileContextconsumer), ADR-0005 (LandInstanceproducer), ADR-0006 (Driftlands.Datacontents), ADR-0009–0012 (system commands) - Design: base-building Acceptance Criteria purity mandate + OQ6; combat Formulas rounding pin; harvesting Rule 6; land-dock-undock Rule 2; enemy-ai Rule 8; save-persistence Rule 5
Amendments — 2026-09-07 (architecture-review-2026-09-07.md)
- C1 §6
Asm_NoUpwardReferencesrow corrected to the amendedData → Tidegraph (it still read "nothing but BCL" after the 2026-09-03 §1 correction); the row now also names theDriftlands.Editorprohibition. - C4 (HIGH) §3
ReconcileContext.LogDropped : Action<string>→Report : Action<ReconcileFinding>withFindingKind { Dropped, KeptOrphan, Stale }. Evidence:InventorySaveTables.cs:78,117were logging KEPT rows through a channel named Dropped. Follow-up story migrates the three call sites (HarvestDiffTable,InventorySaveTables×2). - C5 the
Driftlands.Editorprohibition is now a stated fence; follow-up story adds theAssemblyRulescase + companion negative. - #2 shared-formula rule recorded under §1.
- Open:
HostContextmember count is stated as 18 here and 17 inHostContextServices.cs:4— settle by compiler at the next edit (review U5).
- 2026-09-08, review-fixes story-006 (finding #19 / U5). The
HostContextmember count is no longer prose:Tests/EditMode/HostContextMemberCountTestsassertstypeof(HostContext).GetConstructors().Single().GetParameters().Length == 18. The toolchain-corrections table above already said 18;Rules/Commands/HostContextServices.cs:4said 17 and is corrected. A member added without updating the constant fails the test, not a reader.
Amendments — 2026-09-11 (ADR-0008 netcode wire)
Driftlands.Runtime.asmdefreferences gainUnity.Networking.Transport,Unity.Services.Multiplayer,Unity.Services.Core,Unity.Services.Authentication,Unity.Services.Relay— used only byRelaySessionLauncher : ISessionLauncher(fenceNet_SessionLauncherIsTheOnlyTransportToucher). Manifest pins: NGO 2.13.2, Multiplayer Services 2.2.1, Multiplayer Tools 2.2.9 (transport 2.7.3 transitively).- §6 fence
Net_OnlyChannelDeclaresServerRpcis replaced byNet_RpcSurfaceIsClosed(exactly three[Rpc]methods in Runtime ∪ Presentation:HostCommitChannel.SubmitRpc,HostCommitChannel.OutcomeRpc,SessionNet.SnapshotChunkRpc) and joined byNet_NoObsoleteRpcFlags,Net_TopologyIsClientServer,Net_NoOwnerWrittenNetworkVariables,Net_CodecsFitTheCap,Net_NoRelevancyFilter,Net_RejectionCarriesNoState,Net_SessionLauncherIsTheOnlyTransportToucher(ADR-0008 §9). Rules.Commands.CommandKindgainsSessionReady;ReasonCodegainsNotReady,ConfigVersion.
Amendments — 2026-09-11 (ADR-0009 player transform authority)
- New engine-free namespace
Driftlands.Rules.Movement:Locomotion.Step,Sweep.Move,Facing.Octant,LocomotionMode,DisplacementRequest,ISweepQuery,IDisplacementSink;Driftlands.Rules.CamerasgainsIPlayerSnapper. Runtime adapters:Physics2DSweep : ISweepQuery,KinematicMover2D(the only writer oftransform.positionfor players and enemies),PlayerMotor,PlayerTransform : NetworkTransform,PlayerSnapService,SnapSource : ISnapSource,HostPositionsfinalised withIHostPositionsunchanged (HostKnown(ulong clientId),All() → (ulong, Vector2);clientIdis a within-tick lookup handle, never a row key).HostContextgains no member. Runtime asmdef referencesUnityEngine.Physics2DModule,UnityEngine.TilemapModule. - §6 fences gain ADR-0009 §9's eight (
Move_PrefabAuthority,Move_NoRigidbodyMotion,Move_TeleportOnlyFromSnap,Move_NoCollisionCallbacks,Move_QueriesStartOutside,Move_LayerMatrix,Schema_NoMoveSpeedModifier,Move_SnapReasonClosed).
Amendments — 2026-09-11 (ADR-0010 inventory replication)
HostContextgainsAction<DespawnRequest> QueueDespawn— 19 members;Tests/EditMode/HostContextMemberCountTests' constant moves 18 → 19 in the same story (the §Risks per-ADR review, exercised).SpawnRequest.SharedStorage(instance, origin, slots)in §4's worked command is now a real factory.- New types:
Rules.InventoryCarryFold,CarryDelta,EquipCommand/UnequipCommand,EquipmentChanged,CarrySync,EquipmentSlot;Runtime.InventoryStorageNet,StorageReplicator(the only writer ofRows),CarryView,StorageView,ItemIndexTable,TagCodec;Runtime.HarvestNodeListSeeder : INodeSeeder,NodeNetRow. §6 fences gain ADR-0010 §7's nineInv_*.
Amendments — 2026-09-11 (ADR-0011 structure registry replication)
- The placeholders in
HostContextServices.csget their shapes:IStructureRegistry { All; AnyCovers(Cell); TryGet(uint, out); Add(StructureId, Cell) → uint; Remove(uint) },IStationPresenceProvider { StationPresentExact; StationPresentAtLeast }(AC-44),ITerrainQuery { IsBuildable; InPlaceableRegion }.Rules.Builddeclared (Footprint,BuildRulesF1/F2/snap/FindCorrectionCell,StationPresence, the two outcomes,DemolishCommand);Data.StructureId.Relicsentinel. Runtime:StructureRegistry,BaseNet,RegistryReplicator(the only live writer ofStructures),ClientRegistry,StructureView,StructureIndexTable,StationPresence.HostContextgains no member (still 19). §6 fences gain ADR-0011 §8'sBld_*.