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

17 KiB
Raw Blame History

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) 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) parses the player's full PropertyInt table into a PropertyBundle (PlayerDescriptionParserbundle.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 == UiEffectsObjectTableWiring.cs:28 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), 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). 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 valueno 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) → new event PlayerIntPropertyUpdated(uint Property, int Value) (no guid — implicitly the player).
  • ObjectTableWiring.Wire gains the Func<uint> playerGuid and subscribes PlayerIntPropertyUpdatedtable.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 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) 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<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): 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.