# D.2b-B B-Wire — inventory wire layer (design / spec) **Date:** 2026-06-21 **Phase:** D.2b inventory, sub-phase **B-Wire** (follows B-Controller, shipped `c38f098`). **Branch:** `claude/hopeful-maxwell-214a12`. **Predecessor handoff:** `docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md` §4(a). **Research oracle:** `docs/research/2026-06-16-inventory-deep-dive.md` §4 (the wire catalog, with CONFIRMED ACE `file:line` + holtburger fixtures per format). **Status:** approved design → write plan next. --- ## 1. Goal / non-goals **Goal.** Make the inventory **wire layer** retail-faithful and complete: 1. Deliver the player's **server-authoritative properties** to the player's `ClientObject`, so the burden bar reads the wire `EncumbranceVal` instead of a client-side estimate. (Retires divergence **AP-48** and **AP-49**.) 2. Fix two **latent parse bugs** in existing inbound handlers (dropped fields). 3. Add the missing inventory **builders** (C→S) and **parsers** (S→C) so the next sub-phases (B-Drag, container-open) have their wire ready and tested. **Non-goals (deferred to the consuming sub-phase).** The *behavior* that calls these builders/parsers: drag-release → DropItem/GetAndWieldItem (B-Drag), opening a side-pack / ground container → ViewContents into a second `UiItemList` (container-open), speculative-move rollback UI (B-Drag). B-Wire lands wire + parsers + tests + minimal table-apply; it does not build the UI actions. A parser with no UI consumer yet registers as **parsed-and-applied to the object table** (or parsed-and-logged where there is genuinely nothing to apply) so the wire is correct and the later phase only adds behavior. **Mandatory workflow (binding on the implementer + any subagent).** Every wire format goes through **grep-named → cross-ref (ACE + holtburger) → pseudocode → port → conformance test**. The deep-dive §4 already did the cross-ref and cites the ACE writer `file:line` + holtburger fixture for each format; re-confirm each against `docs/research/named-retail/` before porting (the named symbols to anchor on are listed per component below). Do not guess a field order. --- ## 2. Background — the root cause (why the burden bar is wrong today) The burden binding is **already correct**: `InventoryController.RefreshBurden` ([InventoryController.cs:215](../../../src/AcDream.App/UI/Layout/InventoryController.cs)) reads `EncumbranceVal` (PropertyInt 5) from `_objects.Get(playerGuid).Properties.Ints` and only falls back to `ClientObjectTable.SumCarriedBurden` when it is absent. The value **never arrives**. Three independent gaps, all confirmed in source: 1. **Login path.** `GameEventWiring`'s PlayerDescription (`0x0013`) handler ([GameEventWiring.cs:285](../../../src/AcDream.Core.Net/GameEventWiring.cs)) parses the player's full PropertyInt table into a `PropertyBundle` (`PlayerDescriptionParser` → `bundle.Ints[5]`) but **never applies that bundle to the player's `ClientObject`** — it extracts vitals / attributes / skills / spells / enchantments and records item *membership*, then drops the player's own property bundle. (The "PD is a membership manifest, not the data source" comment at line 403 is about *items* — whose weenie data comes from CreateObject — and does **not** apply to the player's own stats, which legitimately come from PD.) 2. **Live path.** Burden changes (pick up / drop) ride `PrivateUpdatePropertyInt (0x02CD)` — the player's own object, **no guid on the wire**. acdream does not parse `0x02CD` at all (the `PublicUpdatePropertyInt` 0x02CE doc-comment says exactly this). 3. **Apply gate.** Even parsed `0x02CE` updates only reach the table when `Property == UiEffects` — [ObjectTableWiring.cs:28](../../../src/AcDream.Core.Net/ObjectTableWiring.cs) hard-gates every other int out. A **consumer-side** bug compounds this: `InventoryController.Concerns(o)` returns *false* for the player's own object (its `ContainerId`/`WielderId` are not the player guid), so even once `EncumbranceVal` updates live, the burden bar would not refresh. Fixed in C1 below. --- ## 3. Components All wire code lives in `AcDream.Core.Net` (pure data, no GL). Tests in `tests/AcDream.Core.Net.Tests` (+ a burden end-to-end test in `tests/AcDream.App.Tests`). ### C1 — Player-property delivery (the core; retires AP-48/AP-49) **C1a — Login delivery.** In the PD handler ([GameEventWiring.cs:285](../../../src/AcDream.Core.Net/GameEventWiring.cs)), after the existing attribute/skill/membership extraction, apply the parsed player `PropertyBundle` to the player's `ClientObject`. Requires: - A new `Func playerGuid` threaded into the GameEvent wiring (the local player's server guid; `GameWindow._playerServerGuid`, already passed elsewhere). - A new `ClientObjectTable.UpsertProperties(uint guid, PropertyBundle bundle)` — **create-if-absent then merge** (PD may arrive before the player's own `CreateObject`; the existing `UpdateProperties` no-ops on an unknown object, [ClientObjectTable.cs:152](../../../src/AcDream.Core/Items/ClientObjectTable.cs)). The later `CreateObject` `Ingest` is already a merge-upsert, so either arrival order converges. - Apply the **whole** bundle (Ints/Floats/Bools/Int64s/Strings/DataIds/InstanceIds), not a whitelist — the bundle is dictionaries; storing the player's full property set is faithful (retail keeps them on the ACCWeenieObject) and future-proofs Str/aug/etc. (Decision §4.1.) **C1b — Live delivery.** New `PrivateUpdatePropertyInt (0x02CD)` GameMessage parser (`src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs`, mirroring `PublicUpdatePropertyInt.cs`): - Wire body: `u32 opcode(0x02CD), u8 seq, u32 property, i32 value` — **no guid** (size 13). Seq is a single byte (same `ByteSequence` as 0x02CE; not honored, latest-wins, DR-4). - `WorldSession` dispatches it (new `else if (op == PrivateUpdatePropertyInt.Opcode)` in the GameMessage switch, [WorldSession.cs:954](../../../src/AcDream.Core.Net/WorldSession.cs)) → new event `PlayerIntPropertyUpdated(uint Property, int Value)` (no guid — implicitly the player). - `ObjectTableWiring.Wire` gains the `Func playerGuid` and subscribes `PlayerIntPropertyUpdated` → `table.UpdateIntProperty(playerGuid(), property, value)`. - Anchor: named-retail `ACCWeenieObject` int-property apply (the client-side `0x02CD` consumer); ACE writer `GameMessagePrivateUpdatePropertyInt`. **C1c — Apply gate.** Loosen [ObjectTableWiring.cs:26-30](../../../src/AcDream.Core.Net/ObjectTableWiring.cs) so the `0x02CE` (`ObjectIntPropertyUpdated`) path applies **all** ints via `table.UpdateIntProperty(u.Guid, u.Property, u.Value)` (which stores in the bundle and still mirrors UiEffects→`Effects`). (Decision §4.2.) **C1d — Consumer refresh.** In `InventoryController.Concerns(o)` ([InventoryController.cs:115](../../../src/AcDream.App/UI/Layout/InventoryController.cs)) also return true when `o.ObjectId == playerGuid` so a live player-property update refreshes the burden bar. (The burden source IS the player object; today it is excluded.) ### C2 — Latent-bug fixes (existing inbound handlers) **C2a — `InventoryPutObjInContainer (0x0022)`** GameEvent: read the dropped **4th** field `containerType` (`u32 itemGuid, u32 containerGuid, u32 placement, u32 containerType`). `GameEvents.ParsePutObjInContainer` stops after 3 u32s today. Oracle: ACE `GameEventItemServerSaysContainId.cs` + holtburger `events.rs` (fixture slot=3 type=1). **C2b — `InventoryServerSaveFailed (0x00A0)`** GameEvent: read the dropped **`weenieError`** u32 (`u32 itemGuid, u32 weenieError`). `GameEvents.ParseInventoryServerSaveFailed` reads only the guid. Oracle: ACE `GameEventInventoryServerSaveFailed.cs` + holtburger `events.rs:147`. ### C3 — New C→S builders (`InventoryActions.cs`, matching the existing builder style) All ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq; u32 subOpcode; …`). Anchor each against ACE `GameAction*/…Handle` + holtburger `inventory/actions.rs` (both cited in deep-dive §4.1). | Builder | Opcode | Body | |---|---|---| | `BuildDropItem` | `0x001B` | `u32 itemGuid` | | `BuildGetAndWieldItem` | `0x001A` | `u32 itemGuid, u32 equipMask` | | `BuildNoLongerViewingContents` | `0x0195` | `u32 containerGuid` | Opcode source CONFIRMED: ACE `GameActionType.cs` (`GetAndWieldItem=0x001A, DropItem=0x001B, NoLongerViewingContents=0x0195`). Add `WorldSession.Send*` wrappers (mirroring `SendAddShortcut`/`SendRemoveShortcut`) so GameWindow can inject them via `_liveSession?.Send*`. ### C4 — New S→C parsers **C4a — `ViewContents (0x0196)` — GameEvent** (rides `0xF7B0`). New `GameEvents.ParseViewContents` + a `GameEventType.ViewContents = 0x0196` entry. Body: `u32 containerGuid, u32 count, [u32 guid, u32 containerType]×count` (a leading guid then a PackableList). Oracle: ACE `GameEventViewContents.cs` + named-retail `ClientUISystem::OnViewContents` (`?OnViewContents@ClientUISystem@@…PackableList`) + holtburger `events.rs`. No UI consumer yet → parse + apply to the table where meaningful (the listed guids are the container's contents) or parse-and-log; container-open phase wires it to a second `UiItemList`. **C4b — `SetStackSize (0x0197)` — top-level GameMessage** (UIQueue), **not** a GameEvent. New `src/AcDream.Core.Net/Messages/SetStackSize.cs` + dispatch in `WorldSession`'s GameMessage switch. Body: `u32 opcode(0x0197), u8 seq, u32 guid, u32 stackSize, u32 value` (size 17 ⇒ byte seq). Apply via the table (update the object's `StackSize` + `Value`; add a typed `ClientObjectTable.UpdateStackSize(guid, stackSize, value)` or route through the property path). Oracle: ACE `GameMessageSetStackSize.cs` (writer) + named-retail `ACCWeenieObject::ServerSaysSetStackSize` (the client consumer). **C4c — `InventoryRemoveObject (0x0024)` — top-level GameMessage** (UIQueue), **not** a GameEvent. New `src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs` + dispatch in `WorldSession`'s switch. Body: `u32 opcode(0x0024), u32 guid`. Apply: remove the object from the player's inventory **view**. Confirm evict-vs-unparent against named-retail `ClientUISystem` + ACE send sites before choosing; default to the faithful "no longer in my inventory" semantics (unparent from its container + fire the table's refresh event, so the grid drops the cell) rather than a hard global evict unless the oracle shows full destruction. Oracle: ACE `GameMessageInventoryRemoveObject.cs`. > **Namespace caution.** `SetStackSize 0x0197` / `InventoryRemoveObject 0x0024` are **GameMessage > opcodes** (top-level `WorldSession` switch, like `0x02CE`/`0xF745`). `ViewContents 0x0196` / > `InventoryPutObjInContainer 0x0022` are **GameEvent eventTypes** (inside the `0xF7B0` envelope, > dispatched by `GameEvents.Dispatch`). Adjacent numbers, different dispatchers — do not cross them. ### C5 — Registration - **GameEventWiring.WireAll** ([GameEventWiring.cs](../../../src/AcDream.Core.Net/GameEventWiring.cs)): register the GameEvent parsers that exist (or are added) but are not dispatched today: `ViewContents (0x0196)`, `InventoryPutObjectIn3D (0x019A)`, `CloseGroundContainer (0x0052)`, `InventoryServerSaveFailed (0x00A0)`. Where there is no UI consumer yet, the handler applies to the table if meaningful, else logs at debug — the registration makes the wire correct so B-Drag only adds behavior. - **WorldSession** GameMessage switch: dispatch `PrivateUpdatePropertyInt (0x02CD)`, `SetStackSize (0x0197)`, `InventoryRemoveObject (0x0024)`. - **GameWindow** wiring: pass `_playerServerGuid` into `ObjectTableWiring.Wire` and the GameEvent wiring; inject the new `WorldSession.Send*` builders where the existing shortcut sends are wired. --- ## 4. Design decisions ### 4.1 Apply the whole player `PropertyBundle` at login (vs. whitelist) **Chosen: whole bundle via upsert.** The bundle is plain dictionaries; storing the player's full property set mirrors retail (all live on the ACCWeenieObject) and means future readers (Str, aug, coin value, etc.) get their value for free without re-touching the PD handler. Whitelisting EncumbranceVal+aug would be smaller but would re-introduce the same "value never arrives" class of bug for the next property we need. No downside: the player object is the client's own authoritative store. ### 4.2 Loosen the int-apply gate to all ints (vs. extend the whitelist) **Chosen: apply all ints.** Same reasoning. `UpdateIntProperty` already stores any int in the bundle and only special-cases UiEffects for the typed mirror; the gate in `ObjectTableWiring` is the sole thing dropping non-UiEffects ints. Removing it is the faithful behavior (the server is the authority on object properties). Typed-field mirrors stay opt-in per property. ### 4.3 Consumer-less parsers register now (vs. defer) **Chosen: register now, apply-or-log.** The user chose the full wire pass to unblock B-Drag in one trip. A registered parser that applies to the object table (or logs where there's nothing to apply) keeps the wire correct and conformance-tested; the consuming phase adds only UI behavior. Any parser that ends up a pure no-op gets a one-line divergence/TODO note so it isn't mistaken for "wired". --- ## 5. Testing / conformance One conformance test per format, golden bytes from the ACE writer / holtburger fixture cited above: - `PrivateUpdatePropertyInt.TryParse` — valid 0x02CD (no guid) → (property, value); wrong opcode → null; truncation → null. - **Login delivery** — feed a PlayerDescription payload carrying PropertyInt 5 (and aug 0xE6) → assert the player `ClientObject.Properties.Ints[5]` is set after the handler runs (covers `UpsertProperties` create-if-absent both orders: PD-before-CreateObject and after). - **Gate** — a 0x02CE for a non-UiEffects int lands in the target object's bundle. - `ParsePutObjInContainer` — 4-field read (the 4th = containerType). - `ParseInventoryServerSaveFailed` — reads guid + weenieError. - `BuildDropItem` / `BuildGetAndWieldItem` / `BuildNoLongerViewingContents` — exact byte layout (envelope + body), round-tripped against the ACE handler's expected read. - `ParseViewContents` — guid + count + N×{guid,containerType}; zero-count edge. - `SetStackSize` parse — op + byte-seq + guid + stackSize + value (size 17). - `InventoryRemoveObject` parse — op + guid. - **Burden end-to-end** (App.Tests) — with the wire `EncumbranceVal` present, `RefreshBurden` uses it (not `SumCarriedBurden`); a live player-int update fires a burden refresh (C1d). All existing tests stay green (App 532 / Core 1526 baseline). `dotnet build` + `dotnet test` green before the phase is claimed. --- ## 6. Divergence register changes (`docs/architecture/retail-divergence-register.md`) - **Retire AP-48** (client-side `SumCarriedBurden` fallback) — burden now reads the wire value. Keep `SumCarriedBurden` in code as a defensive fallback for "wire value genuinely absent" and note that in the row's deletion commit; if a residual divergence remains (fallback ever used), reword the row instead of deleting. - **Retire AP-49** (carry-aug unwired) — the aug (`0xE6`) now arrives via the PD bundle. - No new deviations expected (faithful ports). If C4c's evict-vs-unparent choice, or any consumer-less no-op, ends up an approximation, add its row **in the same commit** per the register rules. --- ## 7. Out of scope (explicit) - Drag-to-drop / drag-to-wield **behavior** (B-Drag wires the C3 builders to drag-release). - Opening side-packs / ground containers into a second `UiItemList` (container-open wires C4a). - Speculative-move rollback **UI** (B-Drag; C2b lands the parser only). - Paperdoll `UiViewport` + per-slot art (Sub-phase C). - `CreateObject` field extraction — **already done** (`ObjectTableWiring.ToWeenieData` captures IconId/StackSize/Value/capacities/Burden); the deep-dive's "discards" note is stale. Verify no field is still `_`-discarded in `CreateObject.TryParse`; extend only if a real gap remains. --- ## 8. Acceptance criteria - [ ] Login PD delivers the player's PropertyInt table to the player `ClientObject` (EncumbranceVal + aug present after login), via `UpsertProperties` (both arrival orders covered by tests). - [ ] `PrivateUpdatePropertyInt (0x02CD)` parsed + dispatched + applied to the player object; the apply gate passes all ints. - [ ] Burden bar reads the wire `EncumbranceVal` and refreshes live on a player-int update (C1d). - [ ] `0x0022` reads containerType; `0x00A0` reads weenieError. - [ ] Builders `0x001B` / `0x001A` / `0x0195` added with `WorldSession.Send*` wrappers + byte tests. - [ ] Parsers `ViewContents 0x0196` / `SetStackSize 0x0197` / `InventoryRemoveObject 0x0024` added, dispatched in the correct dispatcher, applied-or-logged. - [ ] `WireAll` registers ViewContents / InventoryPutObjectIn3D / CloseGroundContainer / InventoryServerSaveFailed. - [ ] Every AC-specific format cites its named-retail symbol/address + ACE `file:line` in a comment. - [ ] AP-48 + AP-49 retired in the register; any new approximation has a row. - [ ] `dotnet build` + `dotnet test` green; existing tests unbroken.