DRIFTLANDS co-op survival on drifting tides — studio progress

ADR-0011: Structure Registry Replication — BaseNet List, Client Registry Copy, Wall Colliders, and the Chest Binding

Status

Accepted

Accepted 2026-09-11. Gate 0 confirmed GO the same day by the two-machine friend playtest (production/playtests/gate-0-2026-09-11/, record production/gate-checks/gate-0-provisional-pass-2026-09-11.md); the provisional note and revisit clause this ADR was written under are discharged. Playtest observation carried to the register: occasional remote stutter at ≈ 150–180 ms RTT (interpolation tuning, ADR-0009 §6).

Date

2026-09-11

Engine Compatibility

Field Value
EngineUnity 6.3 LTS (6000.3.18f1) · NGO 2.13.2 (NetworkList<T>, in-scene NetworkObject) · Unity 2D Physics (BoxCollider2D, layer matrix)
DomainNetworking (Core data replication) + Physics 2D (static blockers)
Knowledge RiskHIGH for NGO list semantics — but every fact this ADR relies on was read from the 2.13.2 source for ADR-0010 (2026-09-11): NetworkList<T> constraint unmanaged, IEquatable<T>; spawn-time full-contents sync via WriteFieldSynchronization → WriteField; Add/Remove/indexer emit one event + one delta each; NativeList grows on demand. LOW for 2D static colliders (unchanged for years). New in this ADR and verification-required: in-scene placed NetworkObjects and their variables at scene synchronisation for a late joiner (V1).
References Consulteddocs/engine-reference/unity/VERSION.md, breaking-changes.md, deprecated-apis.md, modules/physics.md (layer matrix); ADR-0008 (§3 HostCommitChannel is itself an in-scene object, §5 rule, §6 snapshot, NetworkObject.InScenePlaced), ADR-0009 §3 (Structure layer, sweep, Physics2D.queriesStartInColliders = false, matrix fence), ADR-0010 (StorageNet, SpawnRequest.SharedStorage, ContainerId = "s"+instance, ItemIndexTable pattern, CarryFold over StructurePlaced/Demolished), ADR-0003 (worked PlaceStructureCommand, RegistrySnapshot, TilemapTerrainQuery, Schema_* AC-24 allow-list), ADR-0004 (stage 6 permanent reconcile, structures table, EC13/15/16), ADR-0002 (deferred effects), ADR-0006 (structures.json validation, IGameData.Structures); design/gdd/base-building.md Rules 1/4/6/8/9/12/13, F1/F2, EC13/15/16/18/21/22, AC-24/27/39b/44; design/ux/build-mode.md (picker, ghost, reason strings, demolish preview); shipped code: Rules/Commands/HostContextServices.cs (IStructureRegistry, IStationPresenceProvider, ITerrainQuery placeholders; ISocketLayout), Data/Tables/Ids.cs (StructureId is a string), Data/Tables/DefMarkers.cs (StructureDef marker), Rules/BuildOverlay/ReachGridMesh.cs (build overlay already reads placementReach)
Post-Cutoff APIs UsedNetworkList<StructureNetRow> server-write on an in-scene NetworkObject (InScenePlaced, ADR-0008); NetworkListEvent<T>.EventType.{Add, Remove, RemoveAt} + Index/Value (src, ADR-0010); NetworkObject.Spawn()/Despawn(true) for StorageNet (ADR-0010). No new NGO API.
Verification Required(V1) Confirmed from source (specialist, 2026-09-11): a late joiner receives every spawned object's variables — in-scene included — through NetworkSceneManager.SynchronizeNetworkObjects (SceneEventType.Synchronize, ~1978/2320) → NetworkBehaviour.WriteNetworkVariableData → WriteFieldSynchronization (full contents, ~1378–1411); NetworkConfig.EnableSceneManagement defaults to true (src 109) and ADR-0008 leaves it. Precondition (CHANGELOG #3177): the scene holding BaseNet must be loaded and the list filled before StartHost — the in-scene object table is populated at manager start. Story 1 keeps the Structures.Count assertion in the joiner's OnNetworkSpawn. (V2) Confirmed with a caveat (specialist): Remove(T) builds NetworkListEvent{Type = Remove, Value = item} and never assigns Index (src NetworkList.cs 498–522) — the field is 0/meaningless on that event, and m_List.RemoveAt shifts every later element. Clients therefore key exclusively on Value.InstanceId and must never read Index on a Remove; fenced (§8 Bld_NoIndexOnRemove). (V3) A BoxCollider2D created and positioned in the Add handler (main thread, NGO message processing — outside FixedUpdate) is queryable by Rigidbody2D.Cast in the same frame only once the physics world has synced the new shape: StructureView calls Physics2D.SyncTransforms() right after creating the collider (specialist note; Physics2D.autoSyncTransforms' default is an engine-global setting this ADR does not rely on — the explicit call is the guarantee). PlayMode asserts: place → next Sweep.Move stops; a companion negative without the sync call shows the frame gap. (V4) Row ↔ StorageNet order independence: a storage row arriving before or after its StorageNet spawn binds correctly either way (two objects, no ordering relation — §4). (V5) ~100 rows (1.4 KB) in one scene-sync message stay well under MaxPayloadSize (6 144 B); asserted so growth past EC18's "low hundreds" is noticed.

Revisit clause (Gate 0)

No section is feel-dependent. The one-tick state lag (ADR-0008 §2) means a client may receive StructurePlaced one tick before the row that creates the collider; §3 makes presenters act on the outcome and movement on the collider, so the lag is invisible except as ≤ 20 ms of "the thunk before the wall".

Documents Amended by This ADR

Document Amendment
ADR-0008§5 structure-registry row decided: NetworkList<StructureNetRow> on the in-scene BaseNet; §6 SnapshotDocument drops the structures table (live list is authoritative — same reasoning as storage/nodes); the snapshot header's itemIndexHash becomes dataHash covering the item and structure index tables; OutcomeKind gains StructurePlaced, StructureDemolished with the fields in §5.
ADR-0010The ContainerId = "s" + structureInstanceId constraint is accepted; StorageNet.StructureInstance is the join key to the registry row (§4). CarryFold reads StructureDef.Cost for StructurePlaced and the refund (100 % of current cost, zero for relics) for StructureDemolished — the outcome carries RefundIsZero so the fold needs no relic lookup.
ADR-0009§3's blocker set is instantiated here: one static BoxCollider2D per registry row on Structure; ADR-0009's EC21 trigger fires from the list Add event (the collider's appearance), never from the StructurePlaced outcome (§6).
ADR-0003IStructureRegistry (placeholder) gets its shape (§Key Interfaces): All, AnyCovers, TryGet, Add, Remove; IStationPresenceProvider gets exactly the two predicates (AC-44); ITerrainQuery gets IsBuildable/InPlaceableRegion; new Runtime types BaseNet, RegistryReplicator, ClientRegistry, StructureView, StructureIndexTable, StationPresence; Rules.Build namespace declared (F1–F4, Footprint, FindCorrectionCell — the pure functions ADR-0003 already assigned there). HostContext gains no member.
ADR-0004Stage 6's inert relic is made concrete: the structures table writes a footprint column (w, h) per row from the def at save time — a writer column, never a StructureInstance field (AC-24 literal) — so a relic keeps its full silhouette, collision and noOverlap after its def is removed (user decision 2026-09-11). Reconcile: a row whose def is missing keeps its column values; a row whose def exists is re-derived from the def (data wins for live structures).
design/gdd/base-building.mdEC13: "footprint preserved" is honoured by a save-table column, not an instance field (AC-24 untouched); EC16's ⚑ closes (host-monotonic counter persisted; replicated as the row's InstanceId); Rule 12's "component / NetworkObject / replicated record" question closes: a registry row + a StorageNet object (ADR-0010).
docs/registry/architecture.yamlwire_id_codecs revised: itemIndexHash → dataHash (items, structures, appended tables); entries added per Registry candidates.
architecture.mdRequired-ADR row 11 ✅; §2 BaseBuilding row loses both ⚠️; §3.5 wall-placement scenario's "registry replication ⚠️(ADR)" → BaseNet.Structures.
UnityProject/Assets/Driftlands/Data/Configs/GameConfigs.cs / DefMarkers.csBuildConfig and StructureDef placeholders get their fields (Rule 1 + knobs) in the BaseBuilding epic's Data story; structures.json joins RequiredFiles (+ both TestJson fixtures).

ADR Dependencies

Field Value
Depends OnADR-0008 (rule, outcomes, snapshot, in-scene objects), ADR-0009 (Structure layer, sweep, EC21 CorrectTo), ADR-0010 (StorageNet, SpawnRequest, ContainerId, index-table pattern, fold), ADR-0002 (commands, deferred effects), ADR-0003 (HostContext members, purity), ADR-0004 (permanent table), ADR-0006 (structures.json)
EnablesADR-0012 (nothing structural — enemies never enter the home island at MVP; the Structure layer blocks them by the same matrix), the BaseBuilding epic (every story), presentation-ui-hud-screens build picker / placement (reads ClientRegistry + StructureIndexTable), crafting runtime (IStationPresenceProvider on the host; advisory reads on the client)
BlocksBaseBuilding epic; HUD build/placement screens; crafting's station gate; the BB EC21 story in the PlayerMovement epic
Ordering NoteFourth of the five. ADR-0012 is independent of it.

Context

Problem Statement

StructureInstance = {instanceId, structureId, position} "is the whole registry" (BB), host-authoritative, permanent in the save, low hundreds of rows at most (EC18). Every client needs a copy: a wall's entire function is collision (Rule 8, AC-27) and it must block "the instant its host-confirmed placement lands"; the ghost's provisional F1 and crafting's advisory stationPresent read a client copy that is "legal for display, never for gating" (Rule 13, AC-39b); a storage structure is also a StorageNet (ADR-0010) that must bind to its row; a late joiner needs the whole base before it can walk into a wall. StructureId is a string and list rows must be unmanaged. Row 11 of the architecture asks: list vs NetworkObject per structure, snapshot-vs-in-flight ordering, static collider instantiation, the layer matrix.

Constraints

Requirements

TR-bb-002 (registry replication representation), TR-bb-005 (demolish, refund to CARRY, storage empty-first, relic reconcile — the wire half), TR-bb-006 (two station predicates; storage placement instantiates a container), TR-bb-007 (walls are static 2D colliders; EC21 write mode — decided by ADR-0009, trigger decided here), TR-move-003 (blocker set), TR-craft-003's station gate input.

Decision

**Alternative A: one in-scene BaseNet NetworkObject carries NetworkList<StructureNetRow{uint InstanceId, ushort StructureIndex, short X, short Y, byte W, byte H}> mirrored from the host StructureRegistry after every drain by one writer; every machine (host included) builds a ClientRegistry from the list's events and instantiates one plain StructureView per row — a static BoxCollider2D on the Structure layer sized to the def's footprint plus the sprite — created on Add, destroyed on Remove; storage rows bind lazily to the StorageNet whose StructureInstance matches; StructureId crosses as an index from a StructureIndexTable hashed into the snapshot header; relics are rows whose def is absent on the receiving machine and render as the fallback visual at the row's own footprint; stationPresent* is evaluated on the host over the live registry and on clients over the copy for display only; the snapshot drops the structures table.**

1. The row and the codec

StructureNetRow { uint InstanceId; ushort StructureIndex; short X; short Y; byte W; byte H; } — 14 B, unmanaged, IEquatable. W, H are the footprint as it was when the row was written (from the def at placement; from the save column on load) — carried so a relic keeps its silhouette after its def is gone (user decision 2026-09-11). StructureIndex comes from StructureIndexTable (Runtime): the ordinal-sorted key order of IGameData.Structures at data load — the ItemIndexTable pattern (ADR-0010 §1); its hash is folded with the item table's into the snapshot header's dataHash (ADR-0008 amended) — dataHash = FNV-1a over the concatenation of each index table's own hash in a fixed table order: items, structures, then any table a later ADR adds, appended, never reordered (ADR-0012 appends its enemy/template table rather than defining a second header field; TD C8) so a client whose structures.json differs disconnects with ConfigVersion instead of mis-mapping a wall to a chest. X, Y are the footprint's anchor cell (Cell, min corner — ADR-0003 §2). Relic rows (BB EC13): a structureId absent from current data has no index — the host's StructureIndexTable returns 0xFFFF and the string stays only in the host's save table; every machine renders 0xFFFF as the generic relic visual at the row's own W × H, and the relic blocks noOverlap, movement and FindCorrectionCell over that whole footprint — EC13's "position/footprint preserved" holds as written. The footprint survives the patch because the structures save table writes a footprint column per row (ADR-0004 stage 6; a table column, written from the def at save time, not a field of StructureInstance — AC-24's allow-list stays literal). Live rows carry W, H too (redundant with data, self-describing on the wire, 2 B).

2. Host truth and the mirror

3. The client copy, colliders and sprites — one code path on every machine

4. Chests — a registry row and a StorageNet, bound lazily

A storage-category placement produces two replicated things from one command: the registry row (this ADR) and the StorageNet spawned via QueueSpawn(SpawnRequest.SharedStorage(instance, origin, slots)) (ADR-0010) — both deferred effects of the same drain, on two NetworkObjects with no ordering relation. StructureView for a storage row therefore binds lazily: it looks up StorageNet by StructureInstance (a static dictionary StorageNet registers itself into on OnNetworkSpawn) and, if absent, subscribes to a StorageNet.Spawned event; StorageView (ADR-0010) binds the other way for the UI. Demolish: DemolishCommand (gated on ChestNotEmpty) removes the row and QueueDespawns the object in the same drain; a client handles either order (the view's OnDestroy unsubscribes; a StorageNet despawn with no view is a no-op). A client with the chest panel open when the row is removed closes it on the Remove event (TD C5): StructureView's teardown raises StructureRemoved(InstanceId) and PanelManager (ADR-0014) closes any panel bound to that instance; StructureDemolished stays the presenter edge — the close is keyed on the row, never the outcome (§3's rule). PlayMode asserts: open panel → demolish → panel closed, no StorageNet reference survives. ContainerId = "s" + InstanceId (ADR-0010's constraint, accepted) is the one string form both sides derive from the same uint.

5. Outcomes

Outcome (OutcomeRpc, ADR-0008) Fields Consumers
StructurePlaceduint Instance, ushort StructureIndex, short X, short Y, PlayerKey Placer (≈ 25 B)presenters (thunk, ghost clear), HUD picker affordability, CarryFold (−StructureDef.Cost for Placer), EC21 not (see §6)
StructureDemolisheduint Instance, ushort StructureIndex, short X, short Y, PlayerKey Demolisher, bool RefundIsZero (≈ 26 B)presenters, CarryFold (+100 % of the current StructureDef.Cost for Demolisher, or nothing when RefundIsZero — relics, EC13/14)
CommandRejected(ClientSeq, ReasonCode) (ADR-0008)—the ghost's reason string on a refused commit (F1 gate order = ReasonCode precedence: TooFar → OutOfBounds → Overlap → Occupied → NoWayOut → BadTerrain → SocketExclusion → NotEnoughMaterials; demolish: AlreadyGone, ChestNotEmpty, NoRoomForRefund)

Both Structure* outcomes name the actor so the fold (ADR-0010) can apply cost/refund from data; the row itself carries no actor (AC-24 — anonymity is a registry fact, not a wire fact; an outcome is an edge that happens to have an author, exactly like SwingLanded).

6. BB Edge Case 21 — trigger and write mode

ADR-0009 §5 decided the write (owner-side CorrectTo, interpolated for observers). This ADR decides the trigger: on the list Add event (the same instant the collider appears — never on the outcome, which can precede the collider by a tick), the local ClientRegistry checks whether the local player's cell is inside the new footprint; if so it calls Rules.Build.FindCorrectionCell(seed, terrain, registry, placeable) — the GDD's reference implementation, pure, reading no player positions — and PlayerMotor.CorrectTo(cell.Center); null (enclosed) is EC22, socially resolved. Only the owner corrects itself (owner rail); remote bodies under a wall are their own owner's problem on their own machine. The GDD's three pins carry over (TD C6): (ii) re-check immediately before the write — the player may have walked clear between the event and the correction; if its cell is no longer covered, no write; (iii) at most one correction in flight, re-evaluated fresh at the post-correction cell (AC-14e) — a second wall landing during the first correction queues one re-check, never a second concurrent write; and FindCorrectionCell's N, E, S, W order is the binding tie-break (AC-14c; fenced Bld_CorrectionOrderIsNESW against the GDD's fixtures).

7. stationPresent* — two contexts, one function

Rules.Build.StationPresence.Exact(tier, asker, registry, defs, stationRange) / .AtLeast(...) — pure over IStructureRegistry + IGameData.Structures. Host: StationPresence : IStationPresenceProvider over HostContext.Structures, evaluated at the commit instant inside CraftCommitCommand.Execute, never cached (Rule 13); asker is ctx.Positions.HostKnown(OwnerClientId) — the same source and the same ≤ 1 RTT + 1 tick staleness as F1's noPlayerOccupancy (ADR-0009 §7). ADR-0008 §7's RangeSlackUnits does not apply (TD C7, ruled): stationRange is a design knob, not an anti-cheat reach gate; widening it would change crafting's reachability rule. Client: the same function over ClientRegistry for greying/hints — display only; the client never skips the host re-sample (AC-39b). Exactly two predicates are exposed (AC-44; fenced by ADR-0003's Schema_*).

8. Fences (EditMode unless noted)

Fence Asserts
Bld_RowIsUnmanagedStructureNetRow is 14 B blittable, IEquatable, no reference field
Bld_StructureIndexIsSortedOrdinalStructureIndexTable = ordinal-sorted Structures.Keys; relic lookup returns 0xFFFF; hash stable
Bld_RegionFitsShortthe home island's placeable Region bounds fit short (X, Y) — validated at data load (ADR-0006 row), loud reject otherwise (the bagSlots ≤ 14 posture)
Bld_ListSingleWriterStructures.Add(/.Remove(/.Clear(/Structures[ appear only in RegistryReplicator and BaseNet.Initialise (raw-text)
Bld_RegistryWrittenOnlyByCommandsIStructureRegistry.Add/Remove are called only from PlaceStructureCommand.Execute, DemolishCommand.Execute and StructureRegistry.Deserialize (raw-text over Rules + Runtime)
Bld_NoIndexOnRemoveno handler of NetworkListEvent<StructureNetRow> reads .Index when Type == Remove (raw-text over Runtime: the Remove branch references Value only); StructureView creation is followed by Physics2D.SyncTransforms() (raw-text)
Bld_CorrectionOrderIsNESWFindCorrectionCell returns the GDD reference block's answer on its fixtures — N before E before S before W; a fixture with two clear neighbours picks the earlier
Bld_RegistryAndCollidersNeverComparedreview-checklist item: no method reads both HostContext.Structures (or IStructureRegistry) and a Collider2D/Physics2D query (raw-text over Runtime.Build + Runtime.Enemy)
Bld_ClientRegistryIsAdvisoryno type in Driftlands.Rules references ClientRegistry (vacuous by assembly — asserted anyway) and no HostContext member is a ClientRegistry (reflection)
Bld_ViewPerRow (PlayMode, host)after an Add event a BoxCollider2D exists on layer Structure with the footprint's size at the anchor; after Remove it is gone; a relic row yields a collider of the row's W × H
Bld_TwoStationPredicatesADR-0003's AC-44 allow-list (exists) — referenced
Schema_StructureInstanceADR-0003's AC-24 allow-list (exists) — the wire row is a different type and is allowed its four fields

Architecture Diagram

HOST (drain)                                                                    EVERY MACHINE (host included)
PlaceStructureCommand.Execute: 8 gates over HostContext.Structures/Terrain/Sockets/Positions → Inventory.Remove(cost) → Structures.Add → Persist → Broadcast(StructurePlaced) → [storage] QueueSpawn(SharedStorage)
DemolishCommand.Execute: exists ∧ (not storage ∨ empty) ∧ CanAdd(refund) → Inventory.Add → Structures.Remove → Persist → Broadcast(StructureDemolished) → [storage] QueueDespawn
   │ phase 4: OutcomeRpc ─────────────────────────────────────────────────────► presenters (thunk) · CarryFold (cost/refund from data) · HUD
   │ after Drain: RegistryReplicator → BaseNet.Structures.Add/Remove(row) ═══ list delta ═══► ClientRegistry: StructureView (BoxCollider2D on Structure + sprite) · EC21 check (owner) · advisory F1 / stationPresent
   │               StorageNet spawn/despawn (ADR-0010) ═══ separate object ═══► StructureView ⇄ StorageNet bound lazily by InstanceId
boot: Save.Load → StructureRegistry (relics kept) → BaseNet.Structures filled → StartHost; late joiner: BaseNet scene sync carries the whole base (V1); snapshot has no structures table

Key Interfaces

namespace Driftlands.Rules.Commands                      // placeholders shipped in HostContextServices.cs get their shape
{
    public interface IStructureRegistry {                // HostContext.Structures — host truth; ClientRegistry implements the READ half only
        IReadOnlyList<StructureInstance> All { get; }
        bool AnyCovers(Cell c);                          // any footprint covers the cell (data for live rows, the stored column for relics)
        bool TryGet(uint instanceId, out StructureInstance s);
        uint Add(StructureId id, Cell origin);           // host only, inside PlaceStructureCommand.Execute; returns the monotonic instanceId
        bool Remove(uint instanceId);                    // host only, inside DemolishCommand.Execute
    }
    public interface IStationPresenceProvider {          // exactly two (BB AC-44)
        bool StationPresentExact(StationTier tier, Vector2 asker);
        bool StationPresentAtLeast(StationTier tier, Vector2 asker);
    }
    public interface ITerrainQuery { bool IsBuildable(Cell c); bool InPlaceableRegion(Cell c); }
}

namespace Driftlands.Data { public sealed record StructureInstance(uint InstanceId, StructureId Structure, Cell Position); }   // exactly three (AC-24, fenced)

namespace Driftlands.Rules.Build                         // engine-free (ADR-0003 already assigns F1–F4, snap, FindCorrectionCell here)
{
    public readonly record struct Footprint(Cell Origin, int W, int H) { public bool Covers(Cell c); public Cell Center { get; } }
    public static class BuildRules { public static PlacementVerdict PlacementLegality(/* ADR-0003 §4's argument list */); public static DemolishVerdict DemolishLegality(/* structure, storage-empty, CanAdd(refund) */); public static Cell? FindCorrectionCell(Cell seed, ITerrainQuery terrain, IReadOnlyList<Footprint> registry, Region placeable); }
    public static class StationPresence { public static bool Exact(StationTier t, Vector2 asker, IStructureRegistry r, IReadOnlyDictionary<StructureId, StructureDef> defs, float range); public static bool AtLeast(/* same */); }
    public sealed record StructurePlaced(uint Instance, StructureId Structure, Cell Origin, PlayerKey Placer) : IOutcome;                              // wire: StructureIndex ushort
    public sealed record StructureDemolished(uint Instance, StructureId Structure, Cell Origin, PlayerKey Demolisher, bool RefundIsZero) : IOutcome;
    public sealed record DemolishCommand(ulong OwnerClientId, uint ClientSeq, PlayerKey Player, uint Instance) : ICommand;                              // PlaceStructureCommand: ADR-0003 §4
}

namespace Driftlands.Runtime.Build
{
    public struct StructureNetRow : IEquatable<StructureNetRow> { public uint InstanceId; public ushort StructureIndex; public short X; public short Y; public byte W; public byte H; }   // 14 B; 0xFFFF = relic (renders at W×H)
    public sealed class StructureIndexTable { public ushort Of(StructureId id); public StructureId? At(ushort i); public uint Hash { get; } public static StructureIndexTable Build(IGameData data); }
    public sealed class StructureRegistry : IStructureRegistry, IReconcilable { /* structures table + instanceId counter; relics kept with their string id */ }
    public sealed class BaseNet : NetworkBehaviour {                                    // in-scene, always spawned, server-owned; bulk state off the command channel
        public NetworkList<StructureNetRow> Structures = new(writePerm: NetworkVariableWritePermission.Server);
        internal void Initialise(IStructureRegistry host, StructureIndexTable idx);    // host, before StartHost
    }
    public sealed class RegistryReplicator { public void AfterDrain(IReadOnlyList<StructureInstance> added, IReadOnlyList<uint> removed); }   // the ONLY runtime writer of Structures
    public sealed class ClientRegistry : IStructureRegistry /* read half; Add/Remove throw */ { /* OnListChanged → StructureView create/destroy; EC21 check for the local player */ }
    public sealed class StructureView : MonoBehaviour { /* BoxCollider2D on Structure + SpriteRenderer; lazy StorageNet binding for storage rows */ }
    public sealed class StationPresence : IStationPresenceProvider { /* host: over HostContext.Structures at commit instant; client instance over ClientRegistry for display */ }
}

Alternatives Considered

Alternative B: a NetworkObject per structure

Alternative C: outcome-driven registry + snapshot

Alternative D: wall Tilemap + TilemapCollider2D + CompositeCollider2D

Alternative E: store the relic's footprint in StructureInstance

Alternative E′: narrow relics to a 1×1 anchor cell

Alternative F: EC21 triggered from the StructurePlaced outcome

Consequences

Positive

Negative

Risks

GDD Requirements Addressed

GDD / spec Requirement How This ADR Addresses It
base-building Rule 6, F1 (TR-bb-004)one host critical section over the registryIStructureRegistry.Add inside PlaceStructureCommand.Execute; the mirror follows
base-building Rule 8, AC-27 (TR-bb-007, TR-move-003)a wall's function is collision; blocks the instant placement lands§3 StructureView collider on Add; ADR-0009's sweep
base-building Rule 9, F2, EC13 (TR-bb-005)demolish; total refund; relics refund zero; storage empty-firstDemolishCommand + StructureDemolished{RefundIsZero}; ChestNotEmpty before QueueDespawn
base-building Rule 12 (TR-bb-006)storage placement instantiates a containerrow + StorageNet (ADR-0010), bound lazily (§4)
base-building Rule 13, AC-39b, AC-44 (TR-bb-006, TR-craft-003)stationPresent host-authoritative at commit; client copy advisory; exactly two predicates§7
base-building AC-24 (TR-bb-002)StructureInstance exactly three fieldsunchanged; the wire row is a separate type; relic footprint not stored (§1)
base-building EC15/16missing container list is Inventory's; instanceId never reusedADR-0010 orphan rule; counter persisted, replicated as InstanceId
base-building EC21/22correction trigger; enclosure social§6
save Rule 5 stage 6 (TR-save-005)permanent reconcile, inert relicsStructureRegistry : IReconcilable; relics in the list as 0xFFFF
ux build-mode (picker, ghost, reasons, demolish preview)data the UI needsClientRegistry + StructureIndexTable + CommandRejected reasons in F1 order

Performance Implications

Migration Plan

Validation Criteria

Related Decisions