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>
17 KiB
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:
- Deliver the player's server-authoritative properties to the player's
ClientObject, so the burden bar reads the wireEncumbranceValinstead of a client-side estimate. (Retires divergence AP-48 and AP-49.) - Fix two latent parse bugs in existing inbound handlers (dropped fields).
- 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:
- Login path.
GameEventWiring's PlayerDescription (0x0013) handler (GameEventWiring.cs:285) parses the player's full PropertyInt table into aPropertyBundle(PlayerDescriptionParser→bundle.Ints[5]) but never applies that bundle to the player'sClientObject— 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.) - Live path. Burden changes (pick up / drop) ride
PrivateUpdatePropertyInt (0x02CD)— the player's own object, no guid on the wire. acdream does not parse0x02CDat all (thePublicUpdatePropertyInt0x02CE doc-comment says exactly this). - Apply gate. Even parsed
0x02CEupdates only reach the table whenProperty == UiEffects— ObjectTableWiring.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> playerGuidthreaded 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 ownCreateObject; the existingUpdatePropertiesno-ops on an unknown object, ClientObjectTable.cs:152). The laterCreateObjectIngestis 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 (sameByteSequenceas 0x02CE; not honored, latest-wins, DR-4). WorldSessiondispatches it (newelse if (op == PrivateUpdatePropertyInt.Opcode)in the GameMessage switch, WorldSession.cs:954) → new eventPlayerIntPropertyUpdated(uint Property, int Value)(no guid — implicitly the player).ObjectTableWiring.Wiregains theFunc<uint> playerGuidand subscribesPlayerIntPropertyUpdated→table.UpdateIntProperty(playerGuid(), property, value).- Anchor: named-retail
ACCWeenieObjectint-property apply (the client-side0x02CDconsumer); ACE writerGameMessagePrivateUpdatePropertyInt.
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 = 0x0196entry. Body:u32 containerGuid, u32 count, [u32 guid, u32 containerType]×count(a leading guid then a PackableList). Oracle: ACEGameEventViewContents.cs+ named-retailClientUISystem::OnViewContents(?OnViewContents@ClientUISystem@@…PackableList<ContentProfile>) + holtburgerevents.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 secondUiItemList.
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 0x0024are GameMessage opcodes (top-levelWorldSessionswitch, like0x02CE/0xF745).ViewContents 0x0196/InventoryPutObjInContainer 0x0022are GameEvent eventTypes (inside the0xF7B0envelope, dispatched byGameEvents.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
_playerServerGuidintoObjectTableWiring.Wireand the GameEvent wiring; inject the newWorldSession.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 (coversUpsertPropertiescreate-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.SetStackSizeparse — op + byte-seq + guid + stackSize + value (size 17).InventoryRemoveObjectparse — op + guid.- Burden end-to-end (App.Tests) — with the wire
EncumbranceValpresent,RefreshBurdenuses it (notSumCarriedBurden); 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
SumCarriedBurdenfallback) — burden now reads the wire value. KeepSumCarriedBurdenin 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). CreateObjectfield extraction — already done (ObjectTableWiring.ToWeenieDatacaptures IconId/StackSize/Value/capacities/Burden); the deep-dive's "discards" note is stale. Verify no field is still_-discarded inCreateObject.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), viaUpsertProperties(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
EncumbranceValand refreshes live on a player-int update (C1d). 0x0022reads containerType;0x00A0reads weenieError.- Builders
0x001B/0x001A/0x0195added withWorldSession.Send*wrappers + byte tests. - Parsers
ViewContents 0x0196/SetStackSize 0x0197/InventoryRemoveObject 0x0024added, dispatched in the correct dispatcher, applied-or-logged. WireAllregisters ViewContents / InventoryPutObjectIn3D / CloseGroundContainer / InventoryServerSaveFailed.- Every AC-specific format cites its named-retail symbol/address + ACE
file:linein a comment. - AP-48 + AP-49 retired in the register; any new approximation has a row.
dotnet build+dotnet testgreen; existing tests unbroken.