acdream/docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md
Erik 5737951a19 docs(D.2b-B): B-Wire spec — inventory wire layer
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) <noreply@anthropic.com>
2026-06-21 18:25:58 +02:00

272 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<uint> 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<uint> 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<ContentProfile>).
Oracle: ACE `GameEventViewContents.cs` + named-retail `ClientUISystem::OnViewContents`
(`?OnViewContents@ClientUISystem@@…PackableList<ContentProfile>`) + 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.