From 5737951a19f72a545525caadfcc271327a42ef4b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 18:25:58 +0200 Subject: [PATCH] =?UTF-8?q?docs(D.2b-B):=20B-Wire=20spec=20=E2=80=94=20inv?= =?UTF-8?q?entory=20wire=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design/spec for B-Wire (follows B-Controller c38f098). Root cause found: the burden binding already reads wire EncumbranceVal (PropertyInt 5) but the value never arrives — login PD parses the player's int table then drops it, the live 0x02CD (PrivateUpdatePropertyInt, no guid) is unparsed, and ObjectTableWiring gates all non-UiEffects ints out. Scope (user chose the full wire pass): player-property delivery (login PD bundle upsert + 0x02CD parse + loosen the apply gate, retiring AP-48/AP-49), latent-bug fixes (0x0022 4th field, 0x00A0 error), new C→S builders (DropItem 0x001B / GetAndWieldItem 0x001A / NoLongerViewingContents 0x0195), new S→C parsers (ViewContents 0x0196 GameEvent; SetStackSize 0x0197 + InventoryRemoveObject 0x0024 GameMessages), and WireAll registration. Opcodes pinned against ACE GameMessageOpcode.cs / GameActionType.cs; every format requires grep-named → cross-ref → pseudocode → port + conformance. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-21-d2b-inventory-wire-design.md | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md diff --git a/docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md b/docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md new file mode 100644 index 00000000..f1a3d5e7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md @@ -0,0 +1,272 @@ +# 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.