ADR-0010: Inventory Replication — CARRY from Outcomes, SharedStorage and Node Lists on the Wire, Equipment, and the Id Codecs
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/, recordproduction/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 |
|---|---|
| Engine | Unity 6.3 LTS (6000.3.18f1) · NGO 2.13.2 (NetworkList<T>, NetworkVariable<T>, spawn-time field sync) |
| Domain | Networking (Core data replication) |
| Knowledge Risk | HIGH for NetworkList semantics (initial sync with the spawn message, indexed set, event kinds, capacity) — read from the 2.13.2 source 2026-09-11 (NetworkVariable/Collections/NetworkList.cs, Core/NetworkBehaviour.cs), facts marked (src). LOW for everything else (plain C# tables that already ship). |
| References Consulted | docs/engine-reference/unity/VERSION.md, breaking-changes.md, deprecated-apis.md; ADR-0008 §4–§6 (representation rule, encoding, snapshot), ADR-0002 (InventoryTransfer, ctx.Persist/Broadcast/QueueSpawn), ADR-0003 (IInventoryTx, ItemStack, worked PlaceStructureCommand with SpawnRequest.SharedStorage), ADR-0004 (carries/sharedStorage/harvestDiffs tables, stage 7, tag-without-row), ADR-0005 (LandNet, list-before-spawn, INodeSeeder seam), ADR-0015 (PlayerActor.Equipped, IGearHooks); design/quick-specs/inventory-hauling-2026-08-26.md, harvesting-system.md (Rule 4, EC1, EC16, replication note), crafting-system.md (Rules 4/5/7/9, EC21), base-building.md (Rule 12, storageEmpty, demolish gate); shipped code: Rules/Inventory/ (ICarryInventory, ISharedStorage, StackBag, InventoryTransferCommand), Runtime/Inventory/ (CarryInventory, SharedStorage, InventoryTx, CarriesSaveTable), Runtime/Harvest/* (NodeTable, LandStateAdapter), Rules/Harvest/HarvestResolveCommand.cs, Runtime/Land/LandSeams.cs (INodeSeeder), Data/Tables/Ids.cs (ItemId/ContainerId are strings, NodeId is an int) |
| Post-Cutoff APIs Used | NetworkList<T>(IEnumerable<T> values, readPerm, writePerm) where T : unmanaged, IEquatable<T> (src 14/39); backing NativeList<T>(64, Persistent) — grows (src 16); OnListChanged + NetworkListEvent<T>{EventType, Index, Value} (src 23–28, 197…); Add, Remove, Clear, Contains, IndexOf, Count, indexer set (src 433–534); WriteField/ReadField (full contents — the spawn-time sync path, src 150/160) and WriteDelta/ReadDelta (src 85/171); NetworkBehaviour.NetworkVariableFields initialised on spawn (src NetworkBehaviour.cs 838); NetworkVariable<uint> server-write (ADR-0008) |
| Verification Required | (V1) Confirmed from source 2026-09-11 (specialist): spawn-time sync calls WriteFieldSynchronization → WriteField for every NetworkVariableFields entry (NetworkBehaviour.cs ~1384–1400, NetworkVariableBase.cs ~496), so a NetworkList populated before Spawn() arrives in full with the spawn message and is readable in OnNetworkSpawn. ADR-0005's NodesReady contingency is not needed; story 1 keeps a PlayMode assertion (Nodes.Count == instance.Nodes.Count in OnNetworkSpawn) so a future NGO change is caught, not a runtime gate. (V2) The indexer set Rows[i] = row on the host emits one Value event and one delta; a client's OnListChanged sees Index == i. (V3) Confirmed from source (specialist): NetworkList<T>'s constraint is exactly where T : unmanaged, IEquatable<T> (NetworkList.cs:14); INetworkSerializeByMemcpy belongs to NetworkVariable<T>/RPC serializer selection (FallbackSerializer.cs) and is inert for lists — the row structs may declare it (harmless) but need not. Indexer set routes to Set(int, T) → one Value event + one delta (NetworkList.cs ~617–659) — V2 also confirmed; NativeList grows on demand (no hard cap). (V4) A list of storageSlots (40; ≤ 80) rows and a land's ~40 node rows stay under one MaxPayloadSize (6 144 B) in the spawn message: 40 × 8 B = 320 B — trivially; asserted so growth is noticed. (V5) CarrySync (≤ 90 B) fits the raised 128 B outcome payload and reaches only the owner's view (others ignore by OwnerClientId). |
Revisit clause (Gate 0)
No section is at risk from a feel verdict. The two provisional items are ADR-0008's: the one-tick state lag (affects when a StorageNet row visibly changes relative to its InventoryTransferred outcome — presenters act on the outcome, §2) and the snapshot chunk size.
Documents Amended by This ADR
| Document | Amendment |
|---|---|
| ADR-0008 | §4 OutcomeEnvelope payload becomes 128 B (fixed byte Payload[128], MaxOutcomePayloadBytes ≤ 128) so CarrySync (10 slots + 2 equipment rows ≈ 90 B) fits; OutcomeKind gains CarrySync (owner-addressed), EquipmentChanged, StorageSpawned/StorageDespawned are not outcomes (spawn/despawn is state); §6 SnapshotDocument drops the joiner's own CARRY and the sharedStorage and harvestDiffs tables — CARRY arrives as CarrySync from SessionReadyCommand.Execute; storage and nodes have live replicated forms; §5 table rows for CARRY, SharedStorage, NodeState are now decided (this ADR); the snapshot header gains itemIndexHash (§1). |
| ADR-0002 | CommandKind gains Equip, Unequip (§5) — extended by ADR as ADR-0002 requires; SpawnRequest (a placeholder struct in Rules.Commands) gets its shape (§3): { SpawnKind Kind; uint StructureInstance; Cell Origin; int Slots } plus a DespawnRequest for demolish. |
| ADR-0003 | HostContext gains QueueDespawn(DespawnRequest) — 19 members; HostContextMemberCountTests 18 → 19 in the same story; IInventoryTx unchanged; SpawnRequest.SharedStorage(instance, slots) in the worked PlaceStructureCommand is now a real factory; new Runtime types StorageNet, StorageReplicator, CarryView, ItemIndexTable, TagCodec, NodeListSeeder : INodeSeeder; Rules.Inventory gains EquipCommand/UnequipCommand, EquipmentSlot, CarryDelta. |
| ADR-0005 | INodeSeeder (the shipped seam) is implemented by NodeListSeeder: writes LandNet.Nodes from LandInstance.Nodes + kept diff rows before Spawn(); the NodesReady contingency clause is closed — spawn-time sync sends full list contents (V1, source-confirmed). |
| ADR-0015 | PlayerActor.Equipped{Weapon, Armor} is written only by EquipCommand/UnequipCommand.Execute through a new ICombatState.SetEquipped(key, slot, instanceId?); IGearHooks reads are unchanged. |
| ADR-0004 | carries table's reserved equipment array (CarriesSaveTable._equipmentPassThrough) becomes live: two instanceId strings (weapon, armor) — the persisted half of §5. |
design/quick-specs/inventory-hauling-2026-08-26.md | Rule 4: equip moves the tagged stack out of the bag into the slot (freeing a bag slot); unequip needs a free bag slot unless it is a swap — the "never requires bag slots" wording is narrowed to swaps (§5); Rule 7's "prediction only on own CARRY" is made concrete: transfers only, never harvest yields (§2). |
design/gdd/harvesting-system.md | EC16's ⚑ closes: swingsRemaining is a NetworkList row on LandNet, delivered with the land's spawn (§4). |
design/gdd/crafting-system.md | Rule 7's ⚑ closes: instanceId is issued host-side, rides stacks as the decimal-string instanceTag, and crosses the wire as uint (§1 TagCodec); InstanceMetadata sync is ADR-0008's snapshot + CraftCommitted row. |
architecture.md | Required-ADR row 10 ✅; §2 Harvesting/Inventory rows lose their ⚠️; QQ-13 closed (NetworkList<NodeState>). |
ADR Dependencies
| Field | Value |
|---|---|
| Depends On | ADR-0008 (representation rule, outcomes, snapshot, SessionReady), ADR-0002 (InventoryTransfer, QueueSpawn, drain phases), ADR-0003 (IInventoryTx, ItemStack, purity), ADR-0004 (tables), ADR-0005 (LandNet + seeder seam), ADR-0015 (Equipped, gear hooks) |
| Enables | ADR-0011 (a storage-category structure spawns a StorageNet through this ADR's SpawnRequest; demolish gates on StorageNet emptiness), ADR-0012 (nothing — enemies carry no items), presentation-ui-hud-screens (inventory + storage screens read CarryView/StorageView), crafting runtime epic (CraftCommitted deltas on CarryView) |
| Blocks | inventory replication stories (TR-inv-002/004/007), harvesting's two declared limbs (TR-harv-001 replication; TR-harv-007's slack is ADR-0008's), land story 009's node list, HUD inventory/storage screens |
| Ordering Note | Third of the five. ADR-0011 must use SpawnRequest.SharedStorage and StorageNet as defined here; it decides only the structure registry's own representation. |
Context
Problem Statement
The host already owns every bag and every chest: InventoryTx holds the per-PlayerKey carries and per-ContainerId storages, and InventoryTransferCommand/HarvestResolveCommand/PlaceStructureCommand mutate them inside the drain. What is undecided is how any of that reaches a client: the spec calls CARRY "owner-authoritative and client-predicted" while every mutation is a host command; SharedStorage must render host-confirmed state only; harvesting wants swingsRemaining "continuously replicated" and its EC16 wants that bounded; crafting's instanceTag and every ItemId/ContainerId are strings, and NetworkList<T> accepts only unmanaged rows. TR-inv-007 says the CARRY representation is "undefined; must be decided by the inventory replication ADR". This is that ADR.
Constraints
- ADR-0008 §5: state a joiner needs = server-written
NetworkVariable/NetworkListon the owning object; an edge =OutcomeRpc; never both for one fact (exception: append-onlyInstanceMetadata). ADR-0008 §4: outcome payloads are fixed-layout, no strings. - Inventory Rules 1, 6, 7: two components, never one system; transfers atomic on the host in arrival order; prediction only on the player's own CARRY; storage renders host-confirmed state only; the duplication/loss invariant.
- Harvesting: yield-into-bag flash and crack stages are host-confirmed broadcast only (no predicted harvest); EC1 (a disconnected player's in-flight swing still lands in its persisted CARRY).
- Crafting Rule 4/7: gear identity is a host-monotonic
instanceIdriding the stack as an opaque tag;InstanceMetadatapermanent. - BB Rule 12 /
storageEmpty: a storage placement instantiates oneSharedStorage; demolish requirescontentCount == 0. - ADR-0004:
carries(per player, equipment inside),sharedStorage(per container),harvestDiffs(droppable) are the persisted tables; only the host loads and reconciles. - Shipped shapes are kept:
ItemStack(ItemId, Count, string InstanceTag),ICarryInventory/ISharedStorage(string tags),IInventoryTx,NodeTablekeyed(SocketId, NodeId),NodeId.Value= the node's index inLandInstance.Nodes(assignednextId++byLayout.Build— deterministic on every machine).
Requirements
TR-inv-002 (CARRY owner-side + predicted; SharedStorage host-auth, N instances), TR-inv-004 (no world items; two equipment slots outside the bag), TR-inv-007 (CARRY representation), TR-inv-003 (atomic transfers — already met by InventoryTransferCommand; this ADR adds the client half), TR-harv-001 (swingsRemaining replicated), TR-harv-007's crack-stage half (host-confirmed), TR-craft-004/008 (instance ids on the wire; craft commit deltas reach the bag view), TR-bb-006 (storage placement instantiates a container).
Decision
Alternative A: the host's tables are the only truth. A player's CARRY never rides a NetworkVariable: the owner's CarryView is seeded by a CarrySync outcome when its SessionReady executes and thereafter applies the outcomes the host already broadcasts (HarvestResolved, InventoryTransferred, CraftCommitted, StructurePlaced/Demolished, EquipmentChanged), with a predicted overlay for the owner's own transfer clicks only; each SharedStorage is a StorageNet NetworkObject spawned by the placement command carrying a slot-indexed NetworkList<StackNetRow> mirrored from the host table after every drain; each docked land's nodes are a NetworkList<NodeNetRow> on LandNet, written before Spawn() and on every Decrement; string ids cross the wire through two deterministic codecs (ItemIndexTable, TagCodec); equipment is two commands and one outcome.
1. Ids on the wire — the two codecs
| Rules/Data type | Wire form | Codec | Why deterministic |
|---|---|---|---|
ItemId (string) | ushort ItemIndex | ItemIndexTable (Runtime): built once at data load as the ordinal-sorted key order of IGameData.Items; 0xFFFF = empty slot | every machine loads the same configVersion (ADR-0008 §6 disconnects on mismatch); the table's FNV-1a hash rides the snapshot header as itemIndexHash and a mismatch is a ConfigVersion disconnect — the codec can never silently mis-map |
InstanceTag (string, null = untagged) | uint InstanceId; 0 = untagged | TagCodec: a tag is the decimal rendering of crafting's host-monotonic instanceId (Rule 7 — issued from 1); a tag that does not parse as a positive uint is a data error: EditMode-fenced on fixtures; at runtime the shipped CarriesSaveTable.Reconcile posture wins — the row is kept with its tag and reported KeptOrphan (crafting Rule 9), the tag is not stripped; on the wire such a row encodes as InstanceId = 0 (untagged) — the wire form is lossy for orphans, so an orphan can never round-trip through a client view, which is fine because views never write back (TD C6) | crafting issues the ids; nothing else creates a tag |
NodeId (int) | ushort NodeIndex = NodeId.Value | none needed | Layout.Build numbers nodes 0..n-1 in a fixed order on every machine |
ContainerId (string) | uint StructureInstance | ContainerId.Value == "s" + structureInstanceId — the s prefix keeps the two decimal id spaces (containers, gear instances) distinguishable in reconcile findings and makes a cross-table mix-up a parse failure, not a silent hit (TD C6). Handed to ADR-0011 as a constraint it may reject: if it does, this row and StorageNet.StructureInstance change together and nothing else does (TD C7) | the structure instance id is host-monotonic and persisted (ADR-0004) |
PlayerKey | 16 B | ADR-0008 | — |
2. CARRY — host truth, owner view from outcomes, prediction for transfers only
- Truth:
InventoryTx._carries[PlayerKey]on the host (shipped). Every mutation is a command'sExecute(harvest yield, transfer, craft consume/produce, structure cost/refund, equip/unequip). Nothing on any client is authoritative for a bag — the spec's "owner-authoritative" reduces, on the wire, to owner-visible and owner-predicted, which is what its Rule 7 and AC "own bag updates within one frame of input" actually require. TR-inv-002 is satisfied in that reading and this ADR states the reduction rather than pretending a second authority exists. CarryView(Runtime.Inventory, one per local player; the HUD'sICarryView):Confirmed : StackBag+Pending : List<CarryDelta>;Visible=Confirmedwith pending deltas applied. Seeded byCarrySyncand updated by outcomes:
Outcome (ADR-0008 OutcomeRpc) |
Delta the view applies | Where the numbers come from |
|---|---|---|
CarrySync{Player, rows[10]{ItemIndex, Count, InstanceId}, equipment[2]} | replace Confirmed wholesale, clear Pending | host table at SessionReadyCommand.Execute (owner-addressed: OwnerClientId set, others ignore) |
HarvestResolved{Player, Socket, Node, Item, Yield} | +Yield × Item | the outcome (shipped record; Item crosses as ItemIndex) |
InventoryTransferred{Player, Container, Direction, Item, Count, InstanceTag} | ∓ on CARRY per direction; consumes the matching Pending entry (same direction/item/count/tag, FIFO) | the outcome |
CommandRejected{ClientSeq, Reason} (ADR-0008) | drop the Pending entry stamped with that ClientSeq | — |
CraftCommitted{Player, RecipeId, InstanceId, mark row} (ADR-0008) | − the recipe's inputs, + its output tagged InstanceId | IGameData.Recipes[RecipeId] — data, identical everywhere |
StructurePlaced{Instance, Structure, Origin, Placer} / StructureDemolished{…, Demolisher} (ADR-0011 fixes fields) | − StructureDef.Cost for the placer / + the refund for the demolisher | IGameData.Structures + BB's refund rule (data) |
EquipmentChanged{Player, Slot, InstanceId?, BagSlotFreed/Used} | move the tagged stack between bag and slot | the outcome |
Two shipped commands mutate without a bag-delta outcome (TD C1). (i) HarvestResolveCommand's post-Decrement BagFull path spends the node's swing and adds nothing: the node row moves (§4) and the owner receives CommandRejected(BagFull) with a ClientSeq that matches no Pending entry (harvests are never predicted). The fold treats a CommandRejected with no matching pending entry as a no-op on Confirmed, never an error — by rule, not by luck. (ii) InventoryTransferCommand's restore path (Add fails, the source takes its stack back, Fail(BagFull)) leaves both tables net-unchanged and dirty-marks neither; StorageReplicator's dirty set is therefore correct by construction. Both are asserted (Inv_FoldHandlesUnmatchedRejection; a replicator test over the restore path). A future bag-touching command must broadcast a delta-bearing outcome — the total-switch fence catches a declared kind without a fold case, but it cannot catch a mutation with no outcome at all, which is why this rule is written down and reviewed per command. The view is therefore a pure fold over an ordered stream (Rules.Inventory.CarryFold.Apply(confirmed, pending, outcome, self, data) — engine-free, EditMode-tested), and OutcomeRpc is reliable and per-object ordered (ADR-0008 §3), so Confirmed equals the host table after every outcome by construction. No NetworkVariable, no per-tick lag (ADR-0008 §2's lag is for state; outcomes leave the same tick).
- Prediction (Rule 7, AC "within one frame"): on a deposit/withdraw click the owner submits
InventoryTransferand pushesCarryDelta{ClientSeq, Direction, Item, Count, Tag}toPending;Visibleupdates that frame. The storage side is never predicted (StorageViewrendersStorageNetrows only — Rule 7's honest no-op: the loser's row vanishes on the host's delta, its bag never shows the stack). Harvest yields are not predicted (harvesting: yield flash is host-confirmed). Crafts and placements are not predicted either (their commit is the host's; the stance/ghost FX are). - Why
CarrySynconSessionReadyand not in the snapshot: a disconnected player's in-flight swing still lands in its CARRY (harvesting EC1) and a transfer from another player cannot touch it, but aHarvestResolvedthat lands between the snapshot build and the joiner'sSessionReadywould be an outcome the joiner never received (no player object yet — ADR-0008 §6). Sending the bag from insideSessionReadyCommand.Execute— the same drain that creates the row — closes the window: every later outcome for that key followsCarrySyncon the same ordered object. ADR-0008's snapshot therefore carries no CARRY at all (amended);CarrySyncis also the only resync tool (host-initiated; there is no client "request resync" path — if a view ever diverged, the nextCarrySyncis at rejoin, and divergence is a bug the EditMode fold tests exist to prevent). - The host's own
CarryViewis seeded byCarrySynctoo (TD C4):StartHostenqueues a localSessionReadyCommandfor the host'sPlayerKeyon the channel, so host and client take one code path. Size bound:CarrySyncatbagSlots = 10is ≈ 90 B; the 128 B envelope holdsbagSlots ≤ 14.inventory.json's range (8–16) is therefore validated against the wire cap (bagSlots ≤ 14until ADR-0008's envelope is amended) — a data tweak can never silently overrun the wire. transferBatchWindow(inventory.json, default 0): one command per click. If raised, the owner coalesces clicks of the same direction/item/tag inside the window into oneInventoryTransferwith the summedCountand onePendingentry — no wire change.- Other players' bags are never on any client. No party-bag inspection at MVP (HUD spec); a Tier 1 feature would be an owner-addressed outcome, not a variable.
3. SharedStorage — one StorageNet per container, slot-indexed list
- Object:
StorageNet : NetworkBehaviouron theSharedStorageprefab (inNetworkPrefabsList, not addressable):NetworkVariable<uint> StructureInstance(server-write, set beforeSpawn()),NetworkList<StackNetRow> Rows(server-write, fixed length =storageSlots, one row per slot,ItemIndex = 0xFFFFfor an empty slot). Slot-indexing means a transfer changes exactly the rows it touches via the indexer (Rows[i] = row— V2), neverAdd/RemoveAt, so clients see no reordering and the UI slot grid maps 1:1. - Spawn/despawn ride the drain's deferred effects (ADR-0002 §5 step 4):
PlaceStructureCommand(storage category) callsctx.QueueSpawn(SpawnRequest.SharedStorage(instance, slots))→ after the envelope loop the host instantiates the prefab, setsStructureInstance, fillsRowsfromInventoryTx._storages[container].Snapshot(empty at placement; non-empty on load — see below), andSpawn()s.DemolishCommandgates onChestNotEmpty(shippedReasonCode) thenctx.QueueDespawn(instance)→ after the loop,Despawn(destroy: true).StorageSpawned/Despawnedare not outcomes: the object's existence is the state (ADR-0008 rule);StructurePlaced/Demolishedare the edges presenters use (ADR-0011). StorageNet.Rowsis a mirror, not host-rail state (TD C3): no command gate reads it, no save table contains it, and it is reconstructible fromInventoryTxat any instant. ADR-0002's invariant 4 governs the tables; the mirror is written by exactly one host-side writer after the drain, in the same phase-4 window as the outcome flush, so a client can never observe a row that no command produced.- Mirror, single writer:
StorageReplicator(host) runs once after everyDrain()(ADR-0002 step 4, beforeSave.EndOfDrain) and, for everyStorageNetwhose table changed this drain (dirty set fromctx.Persist(inv.StorageTable)), diffsSnapshot.SlotsagainstRowsand writes only the changed indexes. Nothing else writesRows(fence). Cost: ≤storageSlotscomparisons per dirty container per drain. - Load: on host boot the reconciled
sharedStoragetable (ADR-0004 stage 7) is applied to tables beforeStartHost; the containers themselves are spawned when the structure registry is instantiated (ADR-0011's boot step) withRowspre-filled. A late joiner receives everyStorageNetwith its rows in the spawn message (V1's twin) — which is why the snapshot drops thesharedStoragetable. - Clients:
StorageViewperStorageNet(Rows+OnListChanged→ UI);InventoryTransferredis the feedback edge (flyout, sound) and may arrive one tick before the row delta (ADR-0008 §2) — presenters act on the outcome's payload, the grid on the row (registered rule). - Rule 7 on the wire: two withdraws of the last stack → both
InventoryTransfercommands drain in arrival order; the first succeeds (InventoryTransferred+ row delta), the second failsNotEnoughMaterials→CommandRejectedto its owner → that owner'sPendingentry is dropped and its bag view never showed the stack inConfirmed. Total items conserved by the command's own conservation check (shipped).
4. Nodes — NetworkList<NodeNetRow> on LandNet, written before Spawn()
LandNet.Nodes : NetworkList<NodeNetRow{ushort NodeIndex, byte SwingsRemaining}>(server-write), index-aligned withLandInstance.Nodes(NodeIndex == NodeId.Value, redundant but self-describing on the wire).swingsTotalis data (HarvestConfig), not replicated.- Population before spawn — the shipped
INodeSeederseam (LandSeams.cs;NoOpNodeSeedertoday) is implemented byNodeListSeeder: onLandActivated, afterNodeTable.OnLandActivated(instance, swingsTotal, keptDiffs)(which already applies keptHarvestDiffrows — EC5), copy every node'sSwingsRemainingintoNodesbeforeILandNetSpawner.Spawn(...)(ADR-0005 activation order step 2 → 3). V1 (confirmed from source) — the spawn message carries the full list;NodesReadyis not needed and ADR-0005's contingency clause is closed by this ADR. - Decrement:
LandStateAdapter.Decrement(shipped: node + diff row in one call) additionally setsNodes[NodeIndex] = new NodeNetRow(NodeIndex, after.SwingsRemaining)— the same critical section, so the list can never disagree with the table. - Clients:
NodeView(per node visual, presentation) subscribes toOnListChangedfor its index and derives the crack stage fromSwingsRemaining / swingsTotal(harvesting: derivation is Harvesting's, visualisation is UI's — the presenters epic already ships the flyout overHarvestResolved). Harvesting EC16: a mid-docking joiner gets the whole list with the land's spawn; staleness is bounded by ADR-0008's one tick. - Teardown:
LandNetdespawn (ADR-0005) takes the list with it;NodeTable.OnLandTornDownforgets the socket. Nothing persists from the list —harvestDiffsis the host's save table (ADR-0004), a client never needs it (snapshot drops it). This discharges ADR-0008 §6's "ADR-0010 owns the merge": withharvestDiffsout of the snapshot there is no second source to merge against — the list is the only node truth a client ever sees (TD C7).
5. Equipment — two slots outside the bag, two commands, one outcome
- State: the host's
CarryInventory.Equipment[2](EquipmentSlot.Weapon,.Armor; each anItemStack?— the tagged stack itself, moved out of the bag) — persisted in thecarriestable'sequipmentarray (ADR-0004, the shipped reserved field).CombatState.PlayerActor.Equipped{Weapon, Armor : InstanceId?}(ADR-0015) mirrors it throughICombatState.SetEquipped, written only by these commands (single writer holds). EquipCommand(slot, instanceTag): gates — the tagged stack is in the owner's CARRY; the item'sGearDefmatches the slot (IGameData); then: remove from bag (frees a slot), previous occupant (if any) goes into the freed slot,SetEquipped,Persist(carries),Broadcast(EquipmentChanged).UnequipCommand(slot): gates — slot occupied;CanAddof the occupant to the bag (BagFullotherwise); then the inverse. Inventory Rule 4's "never requires bag slots" is thereby narrowed to the swap case (always possible); a bare unequip into a full bag is an honest no-op — the spec is amended to say so (Documents Amended).- Capacity consequence (TD C5): moving the stack out of the bag means equipping frees a bag slot — bag capacity is equipment-dependent. This is a design-visible reading of inventory Rule 4 ("equipment is not bag space") — decided by the user 2026-09-11: equipping frees a bag slot; a bare unequip into a full bag is an honest no-op; swaps always work; the rejected fallback (the slot holds a reference to a stack that stays in the bag; capacity constant; a bare unequip never fails) is a one-line change here, not a redesign.
EquipmentChanged{Player, Slot, InstanceId?}is owner-visible (the fold applies it); other players' equipment is not visible at MVP (no gear visuals on remote bodies — art bible Tier 1 territory). Gear hooks on the host readEquipped(ADR-0015); nothing on the wire carries a stat.
6. What is deliberately not replicated
Other players' CARRY; enemy inventories (none); harvestDiffs; InstanceMetadata beyond ADR-0008's snapshot + CraftCommitted row; any count of anything for HUD purposes other than through the structures above.
7. Fences (EditMode, every CI pass)
| Fence | Asserts |
|---|---|
Inv_WireRowsAreUnmanaged | StackNetRow (8 B: ushort ItemIndex, ushort Count, uint InstanceId) and NodeNetRow (3 B) are blittable, declare IEquatable<T>, contain no reference field (reflection) |
Inv_ItemIndexIsSortedOrdinal | ItemIndexTable.Build(data) equals Items.Keys.OrderBy(ordinal); hash stable across two builds; a fixture with a changed key set changes the hash |
Inv_TagCodecRoundTrip | every fixture instanceTag round-trips through uint; a non-numeric tag is a fixture failure |
Inv_StorageRowsSingleWriter | Rows[ assignment / Rows.Add( / .Clear( appear only in StorageReplicator and StorageNet.Initialise (raw-text) |
Inv_NodesSingleWriter | Nodes[ assignment appears only in NodeListSeeder and LandStateAdapter.Decrement (raw-text) |
Inv_NoCarryOnWire | no NetworkVariable<>/NetworkList<> field whose type argument names ItemStack, StackBag, CarryRow or CarryInventory (reflection); CarryView derives from no NetworkBehaviour |
Inv_CarryFoldIsPure | CarryFold.Apply lives in Driftlands.Rules (engine-free by ADR-0003's assembly fence) and is total over every OutcomeKind the fold claims (a switch with no default fall-through — a new bag-touching outcome without a fold case fails a test) |
Inv_FoldHandlesUnmatchedRejection | a CommandRejected whose ClientSeq matches no pending entry leaves Confirmed and Pending unchanged; the harvest BagFull-after-Decrement fixture (node row moves, no HarvestResolved) reproduces the host table |
Inv_StorageRowsFixedLength | Rows.Count == storageSlots after Initialise and after every replicator pass (PlayMode, host only) |
Architecture Diagram
HOST (drain) CLIENTS
InventoryTx tables ◄── commands: HarvestResolve · InventoryTransfer · CraftCommit · PlaceStructure/Demolish · Equip/Unequip
│ phase 4 flush ── OutcomeRpc ──────────────────────────────────────► owner: CarryView.Confirmed = CarryFold(prev, outcome, data); Pending consumed/dropped
│ CarrySync (owner-addressed, from SessionReady) ─► seeds the view on join/rejoin; no CARRY in the snapshot
│ after Drain: StorageReplicator diffs Snapshot → StorageNet.Rows[i] ═══ NetworkList deltas ═══► StorageView (grid = rows; flyout = InventoryTransferred)
│ QueueSpawn(SharedStorage) / QueueDespawn ── after the loop ── spawn/despawn StorageNet (rows in the spawn message)
LandActivated: NodeTable → NodeListSeeder → LandNet.Nodes (before Spawn) ═══ spawn message ═══► NodeView crack stages
Decrement: NodeTable + diff row + Nodes[i] ═══ delta ═══► NodeView
ids: ItemId ⇄ ItemIndexTable (hash in snapshot header) · InstanceTag ⇄ TagCodec(uint) · NodeId.Value = NodeIndex · ContainerId = structureInstanceId
Key Interfaces
namespace Driftlands.Rules.Inventory // engine-free
{
public enum EquipmentSlot : byte { Weapon, Armor }
public readonly record struct CarryDelta(uint ClientSeq, TransferDirection Direction, ItemId Item, int Count, string InstanceTag);
public static class CarryFold { public static (StackBag confirmed, IReadOnlyList<CarryDelta> pending) Apply(StackBag confirmed, IReadOnlyList<CarryDelta> pending, IOutcome o, PlayerKey self, IGameData data); }
public sealed record EquipCommand(ulong OwnerClientId, uint ClientSeq, PlayerKey Player, EquipmentSlot Slot, string InstanceTag) : ICommand;
public sealed record UnequipCommand(ulong OwnerClientId, uint ClientSeq, PlayerKey Player, EquipmentSlot Slot) : ICommand;
public sealed record EquipmentChanged(PlayerKey Player, EquipmentSlot Slot, string InstanceTag /* null = emptied */) : IOutcome;
public sealed record CarrySync(PlayerKey Player, IReadOnlyList<ItemStack> Slots, ItemStack Weapon, ItemStack Armor) : IOutcome; // owner-addressed; ≤ 128 B on the wire
}
namespace Driftlands.Rules.Commands
{
public enum SpawnKind : byte { SharedStorage }
public readonly record struct SpawnRequest(SpawnKind Kind, uint StructureInstance, Cell Origin, int Slots) { public static SpawnRequest SharedStorage(uint instance, Cell origin, int slots) => new(SpawnKind.SharedStorage, instance, origin, slots); }
public readonly record struct DespawnRequest(uint StructureInstance);
// HostContext.QueueSpawn(SpawnRequest) exists (ADR-0003); QueueDespawn(DespawnRequest) is added as its sibling. ADR-0003's member list is reviewed at each new ADR (its §Risks mitigation);
// this ADR exercises that review and takes HostContext to NINETEEN members — Tests/EditMode/HostContextMemberCountTests' constant moves 18 → 19 in the same story (TD C2).
}
namespace Driftlands.Runtime.Inventory
{
public struct StackNetRow : IEquatable<StackNetRow> // unmanaged + IEquatable is the whole NetworkList constraint (src) { public ushort ItemIndex; public ushort Count; public uint InstanceId; } // 8 B; ItemIndex 0xFFFF = empty
public sealed class ItemIndexTable { public ushort Of(ItemId id); public ItemId At(ushort i); public uint Hash { get; } public static ItemIndexTable Build(IGameData data); }
public static class TagCodec { public static uint ToWire(string tag); public static string FromWire(uint id); } // 0 ⇄ null
public sealed class StorageNet : NetworkBehaviour { // one per SharedStorage; prefab in NetworkPrefabsList
public NetworkVariable<uint> StructureInstance = new(writePerm: NetworkVariableWritePermission.Server);
public NetworkList<StackNetRow> Rows = new(writePerm: NetworkVariableWritePermission.Server); // fixed length = storageSlots
internal void Initialise(uint instance, StackBag snapshot, ItemIndexTable idx); // host, before Spawn()
}
public sealed class StorageReplicator { public void AfterDrain(IReadOnlyCollection<ContainerId> dirty); } // the ONLY runtime writer of Rows
public sealed class CarryView : ICarryView { public StackBag Confirmed { get; } public StackBag Visible { get; } public void Predict(CarryDelta d); public void OnOutcome(IOutcome o); }
public sealed class StorageView { public StackBag Rows { get; } public event Action<int> SlotChanged; }
}
namespace Driftlands.Runtime.Harvest
{
public struct NodeNetRow : IEquatable<NodeNetRow> { public ushort NodeIndex; public byte SwingsRemaining; }
public sealed class NodeListSeeder : INodeSeeder { public void Seed(LandInstance instance); } // writes LandNet.Nodes before Spawn (ADR-0005 step 2→3)
// LandNet (ADR-0005/story 009) gains: public NetworkList<NodeNetRow> Nodes = new(writePerm: Server); [+ NetworkVariable<bool> NodesReady iff V1 fails]
}
namespace Driftlands.Rules.Combat { public partial interface ICombatState { void SetEquipped(PlayerKey key, EquipmentSlot slot, InstanceId? instance); } } // ADR-0015 amended; written only by Equip/Unequip
Alternatives Considered
Alternative B: an owner-read NetworkVariable<CarryNet> for the bag
- Description: server-written,
ReadPermission.Owner, ten fixed rows + equipment. - Pros: the bag is "just state"; a joiner needs no
CarrySync. - Cons: duplicates every bag-touching outcome (the rule forbids both for one fact); a fixed 10-row struct hard-codes
bagSlots; the one-tick lag would make the bag visibly trail its own flyout; owner-only read permission is one more post-cutoff surface. - Rejection Reason: user decision 2026-09-11 — the outcomes already exist and are ordered; a pure fold over them is testable in EditMode with no engine.
Alternative C: everything outcome-driven (no NetworkList for storage or nodes)
- Cons: a late joiner needs a snapshot for every chest and every land's nodes and can miss an edge between snapshot and player spawn; per-row deltas are exactly what
NetworkListprovides for free with spawn-time sync. - Rejection Reason: user decision 2026-09-11; state is state.
Alternative D: one NetworkVariable<byte> per node
- Rejection Reason: LP review (QQ-13) — 40–80 variables per land on one behaviour for no gain; user decision 2026-09-11.
Alternative E: ContainerId as an arbitrary string with a runtime id map
- Cons: a second mapping table replicated or snapshotted; two ids for one chest.
- Rejection Reason: BB already issues a host-monotonic structure instance id; the container is that structure.
Alternative F: own CARRY inside the snapshot (ADR-0008 as first written)
- Cons: the snapshot-to-
SessionReadywindow can lose aHarvestResolvedfor a rejoining player (harvesting EC1); a view with no resync path. - Rejection Reason:
CarrySyncfrom the same drain that creates the row is race-free by construction and doubles as the only resync mechanism.
Consequences
Positive
- Every bag change on every client is a pure fold over an ordered outcome stream — one EditMode-tested function, no engine, no lag, no drift path that a test cannot reach.
- Chests and nodes are plain NGO lists delivered with their objects; late join needs nothing bespoke for them and the snapshot shrinks to world tables.
- Every string id has one deterministic numeric form with a hash check; the codecs are the only place a string meets the wire.
- Equipment stops being a reserved array: two commands, one outcome, one persisted field.
Negative
- The owner's bag is only as correct as the outcome stream and the fold; a bag-touching outcome added without a fold case is a silent divergence (fenced by
Inv_CarryFoldIsPure's total-switch test, but the fence is on the author). OutcomeEnvelopegrows to 128 B for one message kind (CarrySync).- A
StorageNetper chest means NNetworkObjects at the base (tens, not hundreds — BB's "low hundreds of rows at most" is structures, chests are fewer). - A bare unequip into a full bag is an honest no-op — a spec narrowing the user must accept (Documents Amended).
Risks
- A future NGO release changes spawn-time list sync (V1 is confirmed for 2.13.2 only): the
OnNetworkSpawncount assertion fails loudly in PlayMode; theNodesReadygate is the one-line fallback, kept in ADR-0005's history. - Fold divergence in the field (a missed outcome kind): the symptom is a bag that disagrees with what the host lets you do (a transfer rejected for a stack you see). Mitigation:
CommandRejectedreasons surface in the debug HUD;CarrySyncat rejoin restores truth; the total-switch fence prevents the class at build time. ItemIndexTabledrift if a client's data files differ from the host's: caught byitemIndexHashat join (disconnect), never a silent mis-map.- Storage row churn on a busy base:
StorageReplicatorwrites only changed indexes; worst casestorageSlotsdeltas per container per drain, ≪ 1 KB.
GDD Requirements Addressed
| GDD / spec | Requirement | How This ADR Addresses It |
|---|---|---|
| inventory Rule 1, 2 (TR-inv-001/002) | two components; stack interface with opaque tag | §2/§3; TagCodec carries the tag verbatim as the id it is |
| inventory Rule 6, 7 (TR-inv-003) | atomic host transfers; race = honest no-op; prediction on own CARRY only; storage host-confirmed | shipped command + §2 Pending/CommandRejected; §3 StorageView renders rows only |
| inventory Rule 4, 5 (TR-inv-004) | equipment outside the bag; no world items | §5; nothing here creates a world item |
| inventory AC "own bag within one frame" / "storage on host confirmation" | §2 prediction; §3 rows | |
| TR-inv-007 | CARRY representation | §2 — outcomes + CarrySync, no variable |
| harvesting Rule 4, EC16, replication note (TR-harv-001) | swingsRemaining replicated; staleness bounded | §4 |
| harvesting EC1 | in-flight swing lands in a departed player's CARRY | host table (shipped) + CarrySync on rejoin |
| crafting Rules 4/7 (TR-craft-004) | instanceId monotonic, rides as a tag | §1 TagCodec |
| crafting commit deltas (TR-craft-008) | inputs consumed, output produced | §2 fold over CraftCommitted + recipe data |
base-building Rule 12, storageEmpty (TR-bb-006) | placement instantiates a container; demolish only when empty | §3 SpawnRequest.SharedStorage, QueueDespawn after ChestNotEmpty |
| save Rule 2 (TR-inv-005) | carries/sharedStorage persist | unchanged; equipment array becomes live |
Performance Implications
- CPU (host):
StorageReplicator≤storageSlotscomparisons per dirty container per drain; node row set insideDecrementO(1). CPU (client): one fold per bag-touching outcome (µs). - Memory: one
NativeListper chest and per docked land (hundreds of bytes each);ItemIndexTable≈ item count × 2 dictionaries. - Network: node row 3 B + list delta overhead per swing; storage 8 B per changed slot;
CarrySync≤ 90 B per join; spawn messages carry ≤ 320 B of rows. - Load time:
ItemIndexTable.Buildat data load, microseconds.
Migration Plan
ItemIndexTable,TagCodec, row structs +Inv_*fences (EditMode) — first inventory replication story.CarryFold+CarryViewwith EditMode tests: a scripted outcome stream reproduces the host table for every kind;Pendingconsumption and rejection;CarrySyncreplace.CarrySynccodec (ADR-0008 outcome cap raised to 128 B in the same story).StorageNet+StorageReplicator+SpawnRequest/DespawnRequestshapes +HostContext.QueueDespawn(member count pin 18 → 19);PlaceStructure(storage) andDemolishwire-ups belong to ADR-0011's stories, this ADR ships the object and the mirror.NodeListSeeder+LandNet.Nodes+Decrementrow write — inside land story 009 (NGOLandNet), with V1 decided there.Equip/Unequipcommands +EquipmentChanged+ICombatState.SetEquipped+carries.equipmentlive — the crafting runtime epic's first story or an inventory story, whichever lands first.- HUD inventory/storage screens (
presentation-ui-hud-screens) bind toICarryView/StorageView.
Validation Criteria
- EditMode: the eight §7 fences with companion negatives;
CarryFoldreproduces the host table over a 200-outcome fixture stream (mutation-checked: dropping any fold case turns the comparison red); conservation across a transfer + rejection sequence;TagCodec/ItemIndexTableround-trips; a non-numeric tag fixture fails. - PlayMode (host only):
StorageNetrows equalSnapshot.Slotsafter every drain over a scripted transfer sequence;Rows.Count == storageSlotsthroughout;Nodes[i]tracksDecrement. - Documented playtest / two standalone builds: V1 (list in spawn message — land and chest), V2 (indexer delta), two-client last-stack race (inventory INTEGRATION AC: exactly one succeeds, items conserved), rejoin mid-docking receives
CarrySyncthen later outcomes in order; a joiner's node crack stages match the host's on arrival (EC16).
Related Decisions
- ADR-0008 (outcomes, snapshot, codecs' envelope), ADR-0002 (
InventoryTransfer, deferred spawn), ADR-0003 (IInventoryTx,ItemStack), ADR-0004 (tables), ADR-0005 (LandNet, seeder seam), ADR-0015 (Equipped), ADR-0011 (structure/chest lifecycle), ADR-0006 (ItemIndexTablefrom validated data). design/quick-specs/inventory-hauling-2026-08-26.md,harvesting-system.md,crafting-system.md,base-building.md;architecture.mdrow 10, QQ-13; epicscore-inventory-carry(pure half, Done) andcore-harvesting(declared limbs re-opened here).
Amendments — 2026-09-11 (ADR-0011 structure registry replication)
- The
ContainerId = "s" + structureInstanceIdconstraint is accepted by ADR-0011;StorageNet.StructureInstanceis the join key to the registry row, bound lazily in either order (row before object or object before row).CarryFoldappliesStructurePlacedas −StructureDef.Costfor the placer andStructureDemolishedas +100 % of the current cost for the demolisher, or nothing whenRefundIsZero(relics). A client with a chest panel open closes it on the row'sRemoveevent.
Editorial amendments — 2026-09-11 (TD-MANIFEST C2)
- §2 wrote
CarryFold.Applyas a three-argument shorthand (bag, outcome, data) in 1 place(s) while Key Interfaces declares the five-argument form (confirmed, pending, outcome, self, data) — the only form that can consume aPendingentry, which §2'sInventoryTransferred/CommandRejectedrows require. Corrected in place to the five-argument form. No decision changed.
Amendments — 2026-09-13 (NetcodeSession story-006b — SpawnRequest union)
SpawnRequest(Rules.Commands, readonly struct) gets the union shape this ADR owed:{ SpawnKind Kind; ulong ClientId; PlayerKey Key; Vector2 Position; ContainerId Container; int EnemyArchetype }withenum SpawnKind : byte { Player, SharedStorage, Enemy }(append-only; fenced closed at exactly these three —Net_SpawnKindUnionIsClosed). OnlySpawnRequest.Player(ulong, PlayerKey, Vector2)is constructed today;SharedStorage/Enemyare declared fields for this ADR's ownStorageNetstory and ADR-0012's enemy simulation story respectively, andPlayerSpawner.SpawnthrowsNotSupportedExceptionnaming the owning story if either is reached before then.SessionReadyCommand.ExecuteseedsCarrySync(player, rows: Array.Empty<CarryStackRow>(), equipment: Array.Empty<CarryStackRow>())on every join —IInventoryTxexposes no row reader yet, so the sync ships genuinely empty until this ADR'sCarryFold/ItemIndexTablestory lands and can supply the real rows. Register item, not a defect: a joiner sees their bag populate on the FIRST bag-touching outcome after join, not at join time, until then.