ADR-0005: Land Lifecycle on Addressables — Per-Template Groups, Preload/Activate/Teardown on Tide Edges, the LandNet Object, and the Per-Template Memory Budget
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) · Addressables 2.x (com.unity.addressables) · NGO 2.x · C# |
| Domain | Core / Assets (Addressables) — with one Networking touch-point (the LandNet object) |
| Knowledge Risk | MEDIUM — Addressables 2.x async/Awaitable patterns and release discipline are post-cutoff-adjacent (architecture §Engine Knowledge Gap). The NGO half is deliberately shaped so that no addressable prefab is ever an NGO network prefab — the one interaction whose 2.x behaviour is least documented (specialist-verified: NGO 2.x's NetworkPrefabHandler docs warn against relying on its spawn timeout (NetworkConfig.SpawnTimeout — the "~1 s" figure came from a 1.x doc page; the value is irrelevant to this design — review E5, 2026-08-28) for asset readiness; there is no supported primitive for gating a spawn on a client-side async load). |
| References Consulted | docs/engine-reference/unity/VERSION.md, plugins/addressables.md, breaking-changes.md, deprecated-apis.md, current-best-practices.md, modules/networking.md; live: Addressables 2.0 manual (InitializeAsync, BuildLayoutReport), NGO 2.6 NetworkVariable manual, NGO 2.10 NetworkPrefabHandler; ADR-0001/0002/0003/0004/0006; design/quick-specs/land-dock-undock-2026-08-27.md; design/art/art-bible.md §8.A.5 / §8.B.1–2/6 |
| Post-Cutoff APIs Used | Addressables.InstantiateAsync / ReleaseInstance / Release / LoadResourceLocationsAsync / InitializeAsync (2.x); Awaitable (6.x) for timers and for awaiting AsyncOperationHandle.Task. Specialist-confirmed: NGO 2.x delivers initial NetworkVariable values before OnNetworkSpawn (manual 2.6), so LandNet.OnNetworkSpawn may read its identity synchronously. The same is UNVERIFIED for NetworkList (no 2.x manual page states spawn-payload atomicity for lists) — ADR-0010's NodesReady gate is therefore a required fallback, not optional (architecture review E5, 2026-08-28). Addressables auto-initialises on first API call; InitializeAsync is optional and idempotent (manual 2.0). |
| Verification Required | (1) await handle.Task from an Awaitable method — Unity documents no interop contract between the two (both resume on the main thread next tick, so no hazard is expected); the PlayMode round-trip test exercises await handle.Task as the pattern under test, with handle.Completed → Awaitable.MainThreadAsync as the fallback if it misbehaves. (2) Release: the InstantiateAsync handle is released by exactly one call — Verification decides whether that call is ReleaseInstance(GameObject) or Release(handle) (the manual names ReleaseInstance as the path for instantiated objects); the Addressables Profiler must show the Land_<id> bundle unloaded afterwards. (3) LoadResourceLocationsAsync on an unknown key returns an empty list, not an exception. (4) Addressables init timing vs SubsystemRegistration is undocumented — the catalog check is a Runtime gate after InitializeAsync (§4), not a boot-hook row. (5) Spawning the non-addressable LandNet from NetworkPrefabsList while each peer instantiates an addressable visual locally needs no NGO registration (expected: none). (6) Build layout report: in Addressables 2.0.x the file is Library/com.unity.addressables/buildlayout (extensionless, legacy text) and written only if "Debug Build Layout" is enabled; 2.7+ writes buildlayout.json. The CI gate must pin the installed sub-version, check the setting in, and fail if the file is absent — never pass vacuously. (7) TilemapCollider2D/CompositeCollider2D enable cost at φ is unmeasured — the PlayMode activate test asserts a frame-time budget for the collider-enable step (Validation). |
Documents Amended by This ADR
| Document | Amendment |
|---|---|
| ADR-0006 | TemplateDef gains AddressableKey and per-band CandidateCells; stage 7 gains two pure rows (AddressableKey == $"land/{Id}"; CandidateCells[band].Count ≥ NodeCounts[band]). The catalog-presence check is not a stage — see §4. |
| ADR-0003 | IKnownLandTemplates is declared in Driftlands.Data (it is a HostContext member; Rules cannot name a Runtime type). |
design/art/art-bible.md §8.A.5 / §8.B.1 | "one shared 'core' atlas … packed at Addressables build time" → "the home atlas is a SpriteAtlas referenced directly by the home scene (always resident, not addressable); the UI atlas stays separate and non-addressable; only land-template atlases are packed by Addressables, one per template." |
docs/architecture/architecture.md | §3.2 event table + §3.3 dock scenario (SoftCloseDeposit, EvictionExpired, LandNet); API-Boundaries invariant 4 gains its one registered exception (§3 carve-out); QQ-06 resolved. |
ADR Dependencies
| Field | Value |
|---|---|
| Depends On | ADR-0001 (PreloadDue/DockStarted/SoftClose on the Authority clock; NextBoundary(), TemplateFor, VariationSeed; TideClockRole.Presentation; this ADR adds the narrow IPreloadSchedule interface TideClock implements in both roles), ADR-0002 (drain step 3 teardown release — consumed here; the tide-edge carve-out below is the one named exception to its one-channel rule), ADR-0003 (LandInstance in Data; LandLifecycle in Runtime, layout() in Rules), ADR-0004 (KnownLandTemplates permanent-class, stage-4 union), ADR-0006 (TemplateDef, manifest.startingTemplates, stage 7 — amended, see above) |
| Enables | ADR-0010 (node replication: NetworkList<NodeState> lives on LandNet; ADR-0010 must guarantee the list is fully populated before Spawn() — if NGO 2.x cannot deliver initial NetworkList contents atomically with the spawn, ADR-0010 adds a NodesReady NetworkVariable gate and client LandActivated waits for it), ADR-0012 (spawn contract called from host LandActivated), ADR-0013 (LandTornDown ends the held violet), ADR-0009 (SoftCloseDeposit and EvictionExpired are host→owner teleports ADR-0009 must provide), ADR-0008 (late-join: joiner sees LandNet spawned, loads visuals with no preload window; KnownLandTemplates replication is one ADR-0008 row) |
| Blocks | Land-template asset pipeline story (group/label naming); first LandLifecycle round-trip story; Harvesting's node-instance story |
| Ordering Note | Architecture Required-ADR row 5 = ADR-0005 (numbered by row). Written after ADR-0006 because TemplateDef and the stage it amends had to exist first. Accept after ADR-0001/0002/0003/0006. |
Context
Problem Statement
The tide is a pure function that decides what docks and when; nothing yet says how a land comes to physically exist on every machine, how it stops existing, and how that is kept inside 2 GB. The land-dock-undock quick-spec fixes the semantics and hands three things to an ADR: the Addressables group/label scheme, the per-template MB budget's owner and number (TR-ldu-007, TD-C4), and registry replication. ADR-0002 hands over a fourth: what "teardown release at drain step 3" means to the thing that despawns. ADR-0003 fixed LandInstance but not who produces it or when. This ADR closes all four and answers the question none of them asked — what is the land's network identity — because clients need one authoritative "gameplay exists" edge and the tide events fire on the host clock only.
Constraints
- Forbidden Patterns: no offline simulation; no tide-compensation; presentation clock never drives gameplay (ADR-0001 — the Presentation clock throws on a gameplay subscriber); host-rail mutations ride the one channel (ADR-0002) except as named here;
layout()pure inRules,LandLifecyclea Runtime adapter (ADR-0003);Validation.Runis pure and Addressables-free (ADR-0006). - Tide Rule 8: arrival cosmetic only until exactly
φ. Rule 9: atφ+Wevery player on the land is deposited atdepositPoint(tone-safe). EC11: the AFK backstop at grace expiry is a hard teleport. - Harvesting EC6/AC-31: teardown after any in-flight
< φ+Wresolution — ADR-0002 step 3. - Art bible §8: one atlas per land template, 2048² cap, BC3 (RGBA32 on artifact), PPU 32, Point, no mips; home atlas BC7 (4096 permitted) persistent; UI atlas separate; draw calls < 100.
- 2 GB ceiling; Addressables approved for land load/unload; not for startup assets (engine reference).
- MVP 1 socket, 2–3 templates; Tier 1 3–4 sockets, 6–8 templates — a teardown can overlap the next preload, and one template can be docked on two sockets.
Requirements
- Preload at
NextBoundary − PreloadLeadTimeon every machine; activation atφis a host-authoritative edge. - Despawn + unload at
φ+W+EvictionGrace, never earlier, only after drain step 3 released the socket; deposit atφ+Wprecedes teardown. - Home + 1 docked + 1 preloading ≤ 2 GB and ≤ a per-template budget CI enforces without vacuous passes.
LandInstancebyte-identical per docking key (TR-ldu-002).KnownLandTemplatesappends at firstφon the host only; permanent; stage-4 union; seeded.- Missing/unloadable content is loud, never a silent Absent socket; the co-op blast radius is stated.
Decision
Alternative A: one local Addressables group per land template (visuals only), instantiated offshore as cosmetic at the preload instant, activated at φ by the host spawning a small non-addressable LandNet NetworkObject that is every peer's authoritative "gameplay exists" edge, and released at φ+W+EvictionGrace once drain step 3 has released the socket; LandLifecycle (Runtime) orchestrates and owns a 64 MB per-template budget enforced in CI from the Addressables build layout.
1. What is in the addressable, and what is not
| Concern | Lives in | Why |
|---|---|---|
Tilemap, TilemapCollider2D, SpriteAtlas, prop prefabs, scatter sprites, ambience clip | Addressable LandVisualPrefab — one per template | The only thing that loads and unloads |
Node counts per band, spawn points, socket layout, traversalProfile, per-band CandidateCells, AddressableKey | TemplateDef JSON (Driftlands.Data, ADR-0006) | layout() is pure over plain data; a land's gameplay shape never depends on an asset load |
Node state (swingsRemaining) | LandNet (NetworkList<NodeState>, ADR-0010) — written by the host before Spawn() so the spawn message carries it | Host-rail state |
| Enemies | Enemy NetworkObjects (ADR-0012) | Host-only simulation |
| Node/scatter visuals | Instantiated locally from LandInstance on every peer | Same seed → same placement |
Walkability is the prefab's TilemapCollider2D — local on every machine, identical because the asset is identical; movement is owner-authoritative so a client-local collider is right. An EditMode asset test asserts every CandidateCell and SpawnPoint lies on a walkable tile of its prefab. CandidateCells authoring: cells are exported from a designated Candidates_<band> Tilemap layer in the template prefab by an editor tool (owner: tools-programmer, first template story) — the same layer the walkability test reads; never hand-typed. CandidateCells order is load-bearing for determinism: the reader preserves authored array order (ADR-0006 explicit reads); a set/dictionary must never back this member.
2. Groups, labels, keys
- One local packed group per template:
Land_<templateId>— Pack Together, local build/load paths, LZ4. Contents: theLandVisualPrefaband itsSpriteAtlasin the same group; the atlas is referenced by exactly one addressable (this prefab), which is what makes the setup free of the duplicate-texture pitfall — necessary (same group) and sufficient (single consumer), both by construction. - Address key
land/<templateId>=TemplateDef.AddressableKey(stage 7 asserts the equality — a pure string check). Labellandon every template prefab. - No
coregroup. Home island, player, hats: direct references; the home atlas is a scene-referencedSpriteAtlas(BC7, ≤ 4096²); the UI atlas is separate and non-addressable. Nothing else is addressable at MVP; a future remote/DLC group is a new ADR (no Required-ADR row is reserved for it). LandNetis not addressable — a plain prefab inNetworkPrefabsList.
3. Lifecycle — the four edges
The tide-edge carve-out (named, bounded). LandNet spawn/despawn and IKnownLandTemplates.Append are host-rail mutations that do not ride HostCommitQueue. An ICommand models a player intent with an owner and a client sequence; a tide edge has neither, and the queue's arrival-order and soft-close-drop semantics are meaningless for it. Bound: exactly these operations, all driven by ITideClock events on the Authority clock, host-only, reading no player state. Ordering: they run inside the tide adapter's Advance, which ADR-0002 step 1 already places before that tick's drain — a φ activation is visible to every command drained in the same tick. A third tide-driven host-rail mutation requires an amendment. Registered as tide_edge_carve_out; architecture invariant 4 gains this as its one exception.
The presentation-clock carve-out (named, type-checked). ADR-0001's Presentation clock throws on any gameplay subscriber. Client preload must nevertheless start before φ, and an asset load is not a gameplay decision. Mechanism: TideClock implements a second, narrow interface in both roles —
public interface IPreloadSchedule { long NextPreloadInstant(int socket); TemplateId NextTemplate(int socket); long NextEvictionInstant(int socket); } // Driftlands.Tide
int socket, not SocketId; NextEvictionInstant on the pull surface (2026-09-07, review U1 / #13). SocketId is a Driftlands.Data type and Driftlands.Tide references nothing, so the shape as first written could not compile — the same exception as ITideClock (review F2); callers unwrap with SocketId.Value. NextEvictionInstant (= φ + W + EvictionGrace, an instant, never raw t) joins the interface because LandLifecycle's teardown timer needs it and may neither re-derive it (ADR-0001) nor reach the concrete TideClock through this seam; it shipped on TideClock (foundation-tide-scheduler story-009) and a follow-up story moves it onto the interface.
— which LandLifecycle.SchedulePreload reads by pull. LandLifecycle never subscribes to a Presentation clock's PreloadDue/DockStarted/SoftClose, and nothing reached through IPreloadSchedule may enable colliders, raise LandActivated, spawn, or gate a command. The ITideClock gameplay-subscriber guard is unchanged. Registry stance presentation_clock_driving_gameplay is amended to admit exactly IPreloadSchedule.
LandLifecycle : MonoBehaviour (Runtime, one instance per machine, injected — never a static Instance) holds one LandSlot per socket:
LandSlot { SocketId; TemplateId?; long CycleIndex; AsyncOperationHandle<GameObject> Visual; GameObject VisualRoot; LandNet Net;
LandInstance Instance; bool DrainReleased; bool GraceExpired; SlotState State }
SlotState: Empty → Preloading → Arriving → Active → SoftClosed → TearingDown → Empty
| Edge | Instant | Host | Client |
|---|---|---|---|
| Preload | NextBoundary() − PreloadLeadTime | PreloadDue event (Authority clock) | IPreloadSchedule pull timer (carve-out above) |
| action | Visual = Addressables.InstantiateAsync(key, offshorePose); await; colliders disabled, tint driver attached, drift-in tween toward the socket. State Arriving. Not enterable (tide Rule 8). Retry on Failed with backoff until φ − 30 s (§4). | same | |
| Activate | φ | on DockStarted(socket), in this order: (1) Instance = LandContent.Build(def, VariationSeed(socket, cycle), socket, cycle, epoch); (2) Harvesting.SeedNodeState(Instance) — host writes swingsRemaining incl. live HarvestDiffs into the list backing the not-yet-spawned LandNet; (3) spawn LandNet(socket, template, cycle, epoch); (4) enable colliders (tween snapped to the socket pose); (5) IKnownLandTemplates.Append(template); (6) raise LandActivated(Instance, VisualRoot) → Harvesting node views, IEnemySpawner.Spawn(template, seed, Instance.SpawnPoints, ctx). | on LandNet.OnNetworkSpawn (identity NetworkVariables already delivered): compute the same Instance locally (pure); if the visual is still loading, await it; enable colliders; raise LandActivated → node views from the replicated list. Gameplay existence on a client is the LandNet spawn, never its own clock. |
| Soft-close | φ+W | SoftClose(socket) → State SoftClosed; LandNet.SoftClosed = true; raise SoftCloseDeposit(socket) → host deposits every player whose host-known position is on this land at depositPoint (tide Rule 9 — the ordinary, tone-safe deposit; EC8's mid-transition case resolves from the same snapshot; mechanism ADR-0009); drain step 3 later sets DrainReleased = true; start the grace timer. | LandNet.SoftClosed flips → State SoftClosed; violet held at 1.0 (tide F7/B2). |
| Teardown | φ+W+EvictionGrace | GraceExpired = true. Teardown runs when DrainReleased && GraceExpired (either order — a stalled drain can never race the deposit, harvesting AC-31). Sequence: raise EvictionExpired(socket) → EC11 backstop: hard-relocate anyone who returned or was never successfully moved (not the primary deposit); despawn LandNet; raise LandTornDown(socket) (tint stops, ADR-0013); release the slot (§5); Empty. | on LandNet.OnNetworkDespawn: raise LandTornDown, release the slot, Empty. |
Release is slot-driven, not solely despawn-driven. LandLifecycle releases a slot's handle on any of: LandNet despawn; a preload whose docking is superseded ((TemplateId, CycleIndex) no longer matches IPreloadSchedule/TemplateFor at the next boundary — the "preloaded but never activated" case); session end (StopHost/disconnect/OnDestroy → ReleaseAll() over every slot). A slot never reaches Empty without its release having run.
Invariants: (a) LandNet is gameplay existence — spawned at φ, despawned at grace expiry; every consumer keys off it or the events it drives, never off a clock. (b) On any machine that completed preload, the φ edge costs colliders-on + LandNet spawn and does no I/O; a client that has not (slow disk, late join) pays the load at activation and its LandActivated fires late — the honest degradation in §4. (c) The four identity variables on LandNet are set once by the host before Spawn() and never mutated; only SoftClosed changes during its life.
Preload budget in time: PreloadLeadTime 600 s vs a local LZ4 bundle ≤ 64 MB — three orders of magnitude of slack; no hitch at φ is the design, not a hope.
4. Failure behaviour — fail loudly, never an empty socket
- Boot catalog check — a Runtime gate, not a validation stage.
Validation.Run(ADR-0006) stays pure and Addressables-free. AfterGameDataLoadersucceeds andAddressables.InitializeAsync()completes — before the main menu, afterSubsystemRegistration—LandCatalogCheck.RunAsync(IGameData)(Runtime) callsLoadResourceLocationsAsync(def.AddressableKey)for every pooled template; zero locations → the same fatal-config screen, findings inValidationFindingshape (Stage = -1,Rule = "ldu.catalog") so one screen renders both sources. This is the common failure (renamed key, template not in a group) and it never reaches a docking.NetworkManagerstarts only after both gates pass. - Runtime load failure — asymmetric (ruled 2026-08-28, TD-ADR C7). A local bundle that fails to load is a corrupt install. Host: the world's authority cannot proceed → fatal-config screen, the session ends for every connected player — the stated co-op blast radius: one corrupt file on the host ends the group's session, and this is accepted over the alternative of a silently Absent socket, which the tide's contract forbids. Client: that client alone is disconnected with a named error ("your install is missing content for
<template>"); the session continues for everyone else; repairing the install and rejoining is the existing late-join path (netcode Rule 4). The tide is never delayed, faked, or compensated for either case. LandNetspawn before the client's visual is resident (slow disk, late join): activation waits for the load; the player sees water for its duration; intents targeting that land are legal (host truth). Accepted; measured at the playtest.
5. Memory budget — owner and number (TR-ldu-007, TD-C4)
- Owner:
LandLifecycle. Number:landTemplateBudgetMB = 64per template (knob inland-dock.json, 32–128), covering the packed bundle and its resident footprint. Worst case MVP: home + 1 docked + 1 preloading = 128 MB of land content; Tier 1 ≈ 320 MB — trivial against 2 GB, so the budget is an authoring discipline gate. - CI gate (non-vacuous): the installed
com.unity.addressablessub-version is pinned inpackages-lock.json; "Debug Build Layout" and the JSON report format cannot be checked in — Addressables 2.9 persistsProjectConfigDatatoLibrary/AddressablesConfig.dat, binary and outsideAssets/— so they are asserted and corrected programmatically byDriftlands.Editor.AddressablesSetup.Ensure(), which every content build runs first (review #9); the EditMode test parses the layout file for that pinned version (2.0.x: extensionlessLibrary/com.unity.addressables/buildlayout; 2.7+:buildlayout.json) and fails if the file is absent. EveryLand_*bundle ≤landTemplateBudgetMB; failing template named. - Import-settings gate (three cases, the art-bible hard-limits table as code): land atlases ≤ 2048², BC3 or RGBA32, Point, no mips; home atlas ≤ 4096², BC7 or RGBA32, Point, no mips; UI atlas separate, sources ≤ 128², Point.
- Release discipline: exactly one handle per slot — the
InstantiateAsyncresult — released by exactly one call (Verification 2 decides which). Atlas and tilemap ride the prefab's dependencies. No other module may load againstland/keys or the atlas: Harvesting and EnemyAI receiveGameObjects/LandInstance, never keys; a reflection test restrictsAddressables.LoadAsync/InstantiateAsynccall sites onland/toLandLifecycle(the "second handle" leak the refcount test could not otherwise see). - Same template on two sockets (Tier 1): Addressables refcounts per key; each slot's release decrements and only the last drops the bundle. AC-U1's "refcount returns to 0" is asserted per key with exactly one holding slot; the two-socket case has its own Tier-1 PlayMode row.
6. KnownLandTemplates
IKnownLandTemplates : ISaveable is declared in Driftlands.Data (a HostContext member — Rules cannot name a Runtime type; ADR-0003 amended), implemented by LandContent (Core, owner), with Append called by LandLifecycle at φ on the host — a clean one-way, idempotent call from the only module that knows when φ happened. Persist: permanent class, stage-4 union with manifest.startingTemplates. Removal: no API exists (schema-negative). Replication is ADR-0008's row; this ADR fixes only the authority (host appends, clients read) and requires a single mechanism — a live mirror makes a snapshot row redundant, and both is a drift source.
Architecture Diagram
host clock (Authority) client clock (Presentation)
PreloadDue ─────────────────────────┐ IPreloadSchedule.NextPreloadInstant (pull) ──┐
InstantiateAsync("land/<id>") offshore, colliders OFF, drift-in (same)
│ φ: DockStarted │
Build(Instance) → SeedNodeState → spawn LandNet ═══(NGO)═══► LandNet.OnNetworkSpawn → Build(Instance)
colliders ON → Append → LandActivated ──► Harvesting, IEnemySpawner colliders ON → LandActivated → node views
│ φ+W: SoftClose → SoftClosedNV=true → SoftCloseDeposit (ADR-0009) SoftClosed → violet held
drain step 3 → DrainReleased grace timer → GraceExpired
│ both: EvictionExpired (EC11 backstop) → despawn LandNet ═══════► OnNetworkDespawn
LandTornDown → release slot LandTornDown → release slot
Key Interfaces
namespace Driftlands.Tide
{
/// The ONLY Presentation-role surface a non-tint/HUD consumer may read; asset scheduling only (ADR-0001 amended).
public interface IPreloadSchedule { long NextPreloadInstant(int socket); TemplateId NextTemplate(int socket); long NextEvictionInstant(int socket); }
}
namespace Driftlands.Data
{
// ADR-0006 TemplateDef — AMENDED (two added members)
public sealed record TemplateDef(
TemplateId Id,
string AddressableKey, // == $"land/{Id}" — stage 7, pure
IReadOnlyDictionary<Band, int> NodeCounts,
IReadOnlyDictionary<Band, IReadOnlyList<Cell>> CandidateCells, // NEW — authored order is load-bearing; Count ≥ NodeCounts[band] (stage 7)
IReadOnlyList<SpawnPoint> SpawnPoints,
SocketLayout Sockets,
TraversalProfile Traversal);
/// Declared here so HostContext (Rules) can name it; implemented by LandContent (Runtime); Append called by LandLifecycle at φ on the host.
public interface IKnownLandTemplates : ISaveable
{
bool Contains(TemplateId t);
void Append(TemplateId t); // idempotent; no Remove exists
IReadOnlyCollection<TemplateId> All { get; }
}
}
namespace Driftlands.Rules.Land
{
public static class Layout
{
/// Pure. Same (def, seed, socket, cycle, epoch) → byte-identical LandInstance everywhere (TR-ldu-002).
/// Picks NodeCounts[band] cells from CandidateCells[band] by seeded Fisher–Yates over a splitmix stream from variationSeed;
/// scatter likewise; SpawnPoints and Traversal copied verbatim. All generated members are Cell/int/ulong (ADR-0003 C9 intact).
public static LandInstance Build(TemplateDef def, ulong variationSeed, SocketId socket, long cycleIndex, int epochIndex);
}
}
namespace Driftlands.Runtime.Land
{
/// Runtime-only (carries GameObject) — never a HostContext member.
public interface ILandLifecycle
{
event Action<LandInstance, GameObject> LandActivated; // each machine, at its own activation instant
event Action<SocketId> SoftCloseDeposit; // host only, at φ+W — tide Rule 9 deposit (ADR-0009 performs it)
event Action<SocketId> EvictionExpired; // host only, at grace expiry — EC11 backstop
event Action<SocketId> LandTornDown; // after LandNet despawn, before release
SlotState StateOf(SocketId socket);
LandInstance? InstanceOf(SocketId socket);
}
/// The land's network identity. Non-addressable prefab in NetworkPrefabsList. Spawned at φ, despawned at grace expiry.
/// Resolves ILandLifecycle by injection (scene-registered instance), never a static.
public sealed class LandNet : NetworkBehaviour
{
public NetworkVariable<int> Socket, Template, EpochIndex; // write-once by the host before Spawn()
public NetworkVariable<long> CycleIndex; // write-once
public NetworkVariable<bool> SoftClosed; // the only mutable member
// ADR-0010 adds: NetworkList<NodeState> Nodes — populated before Spawn()
public override void OnNetworkSpawn() => _lifecycle.OnLandNetSpawned(this);
public override void OnNetworkDespawn() => _lifecycle.OnLandNetDespawned(this);
}
}
Data-flow classification (architecture §3.2): tide → LandLifecycle is a host C# event / client pull via IPreloadSchedule; LandNet spawn is NGO shared state; LandActivated/LandTornDown local C# events; Addressables loads are main-thread async — no Driftlands code crosses a thread boundary.
Alternatives Considered
Alternative B: Each template as an additive Addressable scene
- Description:
LoadSceneAsync("land/<id>", Additive)at preload;UnloadSceneAsyncat teardown. - Pros: Scene-level isolation; per-land lighting/audio in-scene; one unload call.
- Cons: A scene loads in place — the cosmetic drift-in needs the root re-parented after load; the Tier-1 same-template-on-two-sockets case needs two instances of one scene, which additive loading handles awkwardly; scene loading couples to NGO's
NetworkSceneManager, which must not manage land visuals. - Rejection Reason: Drift-in and same-template-twice fit prefab instantiation naturally; nothing a scene offers is needed.
Alternative C: All templates always resident — no Addressables at MVP
- Description: Direct references, prefabs disabled in the home scene, enable/disable at the edges.
- Pros: Zero Addressables surface; no catalog check.
- Cons: Tier 1's 6–8 templates and the atlas-per-template rule exist because of unload; the lifecycle edges and
LandNetare identical in both designs, so deferring buys nothing but a later migration under pressure. - Rejection Reason: Addressables adds one load call and one release to a state machine that must exist anyway.
Alternative D: The addressable land prefab is itself the NetworkObject
- Description: The template prefab carries
NetworkObject; host loads andSpawn()s it; clients substitute viaNetworkPrefabHandler. - Pros: One object, one lifecycle; NGO's spawn is the activation edge.
- Cons: Specialist-verified against NGO 2.x's
NetworkPrefabHandlerdocs: the nearest built-in mechanism explicitly warns against relying on its ~1 s spawn-message timeout for asset readiness ("assets should be expected to be loaded before they're needed") — NGO 2.x has no supported primitive for gating a spawn on a client-side async load, which is precisely the coupling this needs; every client's disk speed lands on the host'sφedge. - Rejection Reason: A 200-byte non-addressable
LandNetgives the same authoritative edge with zero NGO/Addressables interaction; unready clients activate late, honestly.
Alternative E: No NetworkObject for the land
- Rejection Reason: Clients would activate gameplay from their Presentation clock —
presentation_clock_driving_gameplayby definition; nodes need a replication parent anyway (ADR-0010).
Consequences
Positive
- One authority for gameplay existence (
LandNet), one owner of timing (LandLifecycle), one unit of memory (the bundle); the TD split-watch's two concerns are two types in two assemblies. - No addressable prefab is ever an NGO prefab; no Addressables call is ever inside pure validation.
- Preload has 600 s to load ≤ 64 MB locally; the
φedge does no I/O on any machine that preloaded. - Both carve-outs (tide-edge, presentation-clock) are named, bounded, and registered — the rules stay checkable.
- The budget is a CI number with an owner, and the CI gate cannot pass vacuously.
Negative
TemplateDefgrows two members; templates need aCandidates_<band>layer and an export tool.- A slow client can stand on a land it cannot see for a moment.
- Two clocks drive two things (Authority → gameplay; Presentation → visuals via
IPreloadScheduleonly); the boundary lives in two interfaces and must stay that way. - A host-side corrupt bundle ends the group's session (ruled, stated).
Risks
- Refcount leak from a second handle outside
LandLifecycle→ bundle never unloads. Mitigation: the call-site reflection test (§5) + the refcount-to-0 test. Awaitable/Taskinterop undocumented by Unity. Mitigation: it is the pattern under test in AC-U1;Completed→MainThreadAsyncfallback.- Build-layout report absent (setting off, version drift) → vacuous CI pass. Mitigation: pinned sub-version,
Ensure()-asserted setting, fail-on-absent. - NGO initial
NetworkListdelivery may not be atomic with spawn. Mitigation: ADR-0010'sNodesReadygate clause (Enables). - Collider-enable cost unmeasured. Mitigation: frame-time assertion in the PlayMode activate test.
- Drift-in tween vs
φ: activation is event-driven; the tween is cosmetic and snapped at activation.
GDD Requirements Addressed
| GDD System | Requirement | How This ADR Addresses It |
|---|---|---|
| land-dock-undock | Rule 1: tide-edge subscription; preload at NextBoundary − PreloadLeadTime; gameplay at φ; unload at +EvictionGrace never earlier | §3; teardown = DrainReleased && GraceExpired |
| land-dock-undock | Rule 2: layout() pure, byte-identical | Rules.Land.Layout.Build over ordered CandidateCells |
| land-dock-undock | Rule 3: KnownLandTemplates at first φ, seeded, permanent, union | §6; Append in the DockStarted handler (tide-edge carve-out); ADR-0004 stage 4 |
| land-dock-undock | Rule 4 sole-source warning | not this ADR — discharged by ADR-0006 stage 7; noted so /architecture-review records no LDU gap |
| land-dock-undock | Rule 5: enemy instantiation routes through here | IEnemySpawner.Spawn from host LandActivated |
| land-dock-undock | Rule 6: MVP "changed on return" = variation, not revision | no mechanism needed — VariationSeed per cycle delivers it |
| land-dock-undock | Rule 8: socket layout; Tier-1 3–4-socket re-check (BB OQ2) | TemplateDef.Sockets (stage 7); re-check owned in "Deferred" below |
| land-dock-undock | Rule 9 / TR-ldu-007: memory discipline, budget owner + number | §5 |
| land-dock-undock | INTEGRATION: round-trip; teardown never races the deposit | §3; SoftCloseDeposit at φ+W precedes LandTornDown |
| tide-scheduler | Rule 8 arrival cosmetic; Rule 9 deposit at φ+W; EC11 backstop; F6 no polling; EC9 client evaluation presentation-only | colliders off until φ; SoftCloseDeposit; EvictionExpired; timers at NextBoundary(); IPreloadSchedule carve-out + LandNet |
| harvesting | EC6/AC-31 teardown after in-flight resolution; AC-40 absent-node diff dropped | DrainReleased; LandInstance.Nodes |
| enemy-ai | Rule 7 spawn at activation, ephemeral | host LandActivated; nothing of the land saved but KnownLandTemplates |
| crafting | Era gate reads KnownLandTemplates; EC17/18 | Contains; no removal API |
| base-building | Socket layout / depositPoint (AC-63b) | TemplateDef.Sockets; deposit target |
| art bible §8.A.5 / §8.B.1–2/6 | Atlas classes, caps, compression, import settings | §2 (amended wording); §5 three-case import gate |
| netcode-session | Rule 2 registries host-rail; Rule 4 Known* in late-join | §6 authority; representation → ADR-0008, single mechanism |
Deferred, with owners
| Item | Owner | Trigger |
|---|---|---|
Tier-1 3–4-socket layout + socketNoBuildRadius re-check (LDU Rule 8, BB OQ2) | LandContent / base-building | first Tier-1 multi-socket template |
CandidateCells export tool | tools-programmer | first real template story |
| Remote/DLC Addressables group | new ADR (no Required-ADR row reserved) | if ever |
KnownLandTemplates replication representation | ADR-0008 | playtest |
NetworkList<NodeState>-before-spawn guarantee | ADR-0010 | node replication ADR |
Performance Implications
- CPU: preload async on Addressables' worker + one main-thread instantiate;
φedge: colliders-on (budget asserted, unmeasured) +LandNetspawn +Layout.Build(≤ 60 picks — microseconds). No per-frame cost. - Memory: ≤ 64 MB per resident template; MVP worst case 128 MB of land content + home; Tier 1 ≈ 320 MB. Ceiling 2 GB.
- Load Time: catalog check = one
LoadResourceLocationsAsyncper template at boot (ms); land load hidden inPreloadLeadTime. - Network:
LandNet≈ 30 bytes of identity per docking + the node list (ADR-0010);KnownLandTemplates≤ a few dozen ints.
Migration Plan
No production code. (1) ADR-0006 amendment: TemplateDef members + two pure stage-7 rows; IKnownLandTemplates moved to Data (ADR-0003 note); (2) IPreloadSchedule on TideClock (ADR-0001 note); (3) Rules.Land.Layout.Build + purity tests; (4) Addressables settings: Land_<id> groups, land label, local paths, Debug Build Layout on, sub-version pinned; CI layout + import-settings tests; (5) LandCatalogCheck Runtime gate; (6) LandNet prefab + LandLifecycle slot machine + ReleaseAll, PlayMode round-trip; (7) KnownLandTemplates registered with Save; (8) art-bible §8.A.5/§8.B.1 wording edit in the same change; (9) consumers as Harvesting/EnemyAI/ADR-0009 stories land.
Validation Criteria
- UNIT:
Layout.Buildbyte-identical across 1,000 runs and two machines; differentcycleIndex→ different placement, same counts/types. - UNIT:
KnownLandTemplatesappends atφonly; preloaded-never-activated absent; noRemove(reflection); fresh world containsstartingTemplates. - UNIT (CI): layout file present for the pinned version (absent = fail); every
Land_*bundle ≤landTemplateBudgetMB; three atlas-class import cases. - UNIT (asset): every
CandidateCell/SpawnPointwalkable on the prefab tilemap;CandidateCellsread back in authored order. - UNIT (reflection):
Addressables.Load*Async/InstantiateAsynconland/keys only inLandLifecycle. - AC-U1 PlayMode: preload → activate → soft-close → teardown; refcount for
land/<id>returns to 0 (one holding slot);LandNetexists exactly on[φ, φ+W+EvictionGrace); collider-enable step within its frame budget. - PlayMode: a player on the land at
φ+WreceivesSoftCloseDepositbeforeLandTornDown; one still on it at grace expiry receivesEvictionExpiredbeforeLandNetdespawns. - PlayMode: a client present at
φseesswingsRemainingmatching the host's post-diff values, not template defaults. - PlayMode: preload, then end the session before
φ→ refcount returns to 0 (slot-driven release). - PlayMode (Tier 1): same template on two sockets — teardown of slot A leaves slot B intact and the bundle resident.
- PlayMode: pool template with a missing key → fatal-config screen naming it; client load failure → that client disconnected with the named error, session continues.
- Playtest: drift-in reads as arrival; slow-client "water then land" measured.
Related Decisions
- ADR-0001 (events,
NextBoundary, clock roles —IPreloadScheduleadded), ADR-0002 (drain step 3; one-channel rule — tide-edge carve-out named), ADR-0003 (LandInstance;IKnownLandTemplatesin Data), ADR-0004 (stage 4), ADR-0006 (TemplateDefamended), ADR-0008/0009/0010/0012/0013 (enabled) design/quick-specs/land-dock-undock-2026-08-27.md,design/gdd/tide-scheduler.md,design/gdd/harvesting-system.md,design/quick-specs/enemy-ai-2026-08-27.md,design/art/art-bible.md§8 (amended)docs/architecture/architecture.md§2, §3.2/3.3, invariant 4, Required-ADR row 5, QQ-06
Amendments — 2026-09-07 (architecture-review-2026-09-07.md)
- U1
IPreloadScheduletakesint socket(both declarations) — theSocketIdform could not compile inDriftlands.Tide. - #13
NextEvictionInstant(int)added toIPreloadSchedule; follow-up story moves it off the concreteTideClock. - #9 §5: Debug Build Layout is
Ensure()-asserted, not checked in (impossible on Addressables 2.9).
Amendments — 2026-09-11 (ADR-0009 player transform authority)
SoftCloseDeposit(socket)andEvictionExpired(socket)have their consumer: the hostPlayerSnapService(ADR-0009 §4) deposits every player whose host-known position is on that land atdepositPoint(SnapReason.TideDeposit); EC11's backstop is the same call at grace expiry. The host commands the owner (PlayerSnappedoutcome) and overrides host truth immediately — it never moves a transform itself.
Amendments — 2026-09-11 (ADR-0010 inventory replication)
- The
INodeSeederseam is implemented byNodeListSeeder:LandNet.Nodes : NetworkList<NodeNetRow>(index-aligned withLandInstance.Nodes) is written fromNodeTable(kept diff rows already applied) beforeILandNetSpawner.Spawn— activation step 2 → 3 as this ADR ordered. TheNodesReadycontingency is closed: NGO 2.13.2's spawn-time sync sends full list contents (WriteFieldSynchronization→WriteField, source-confirmed); story 009 keeps anOnNetworkSpawncount assertion instead of a gate.
Amendments — 2026-09-11 (ADR-0012 enemy simulation model)
IEnemySpawneris implemented byEnemySpawner:LandActivated→ pureComposition.Choose(template, variationSeed, spawnPoints, …)→QueueSpawn(Enemy)buffered from tick step 1 (ADR-0002 amended) → oneNetworkObjectper enemy after the drain. Teardown: the enemy sweep subscribes toEvictionExpired(raised before theLandNetdespawn) and drops pending death lingers;LandTornDownremoves the combat rows.EnemyScalingContextstays empty (enemy-ai Rule 11).