# D.2b Sub-phase C, Slice 1 — Paperdoll equip slots (design) **Date:** 2026-06-22 **Branch:** `claude/hopeful-maxwell-214a12` (tip `702058f`; `main` is a clean ff ancestor) **Status:** DESIGN — approved in brainstorm; spec under review before the implementation plan. **Supersedes the stale framing in:** `docs/research/2026-06-22-paperdoll-handoff.md` §"THE WIRE GAP" (the handoff quoted the 2026-06-16 deep-dive, which pre-dated B-Wire; the wire already exists — see §2). **Authoritative research:** `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` (§3a element-id→mask, §4 wire). **Line numbers drift — grep the symbol.** --- ## 1. Goal + scope Bind the ~25 mounted paperdoll equip slots (under `0x100001CD` / `gmPaperDollUI 0x21000024`, already imported + positioned inside the inventory frame) to live equipped-item data and make them drag-drop **wield**/**unwield** targets. After this slice: you SEE your equipped gear as icons in the correct doll slots, dragging a wieldable item onto a slot wields it (optimistic + rollback), and dragging a slot's item to the pack unwields it. **This is the lighter, functional half of Sub-phase C.** No 3D doll. ### Non-goals (Slice 2 or later — do NOT build here) - The 3D doll `UIElement_Viewport` (Type `0xD`) + the Core→App `IUiViewportRenderer` seam. - Part-selection highlight, doll rotation, auto-wield-on-doll-body (drop on the body, not a slot). - Aetheria sigil slots (`0x10000595/96/97`; `SetVisible(0)` by default in retail — left unbound). - The richer `InventoryPlacement` priority list (PlayerDescription equipped section, deep-dive §3c) — the per-item `CurrentlyEquippedLocation` is sufficient for slot icons (each item has one location). - Dual-wield-into-shield-slot special-case (deep-dive §3b line 174302). --- ## 2. What already exists (reuse, do NOT rebuild) Verified against source this session — the handoff's "build the wire gap" premise is **stale**. | Capability | Where | Status | |---|---|---| | `GetAndWieldItem 0x001A` builder | `InventoryActions.BuildGetAndWieldItem(seq,itemGuid,equipMask)` `InventoryActions.cs:153` (20-byte body) | EXISTS | | `GetAndWieldItem` sender | `WorldSession.SendGetAndWieldItem(itemGuid,equipMask)` `WorldSession.cs:1205` | EXISTS | | Unwield wire | `WorldSession.SendPutItemInContainer(item,container,placement)` `:1230` → `BuildPickUp 0x0019` | EXISTS, wired | | Optimistic move + rollback | `ClientObjectTable.MoveItemOptimistic/ConfirmMove/RollbackMove` + `_pendingMoves` (outstanding-count, I1/I2-hardened) | EXISTS | | Drag-drop spine | `IItemListDragHandler`; `UiItemSlot.OnEvent` (DragBegin→OnDragLift, DragEnter→OnDragOver, DropReleased→HandleDropRelease) | EXISTS | | Pattern to mirror | `InventoryController : IItemListDragHandler` `InventoryController.cs` | EXISTS | | Equip-state per item | `ClientObject.CurrentlyEquippedLocation` + `ValidLocations` (parsed `CreateObject.cs:752-760` → `ObjectTableWiring` → `Ingest`) | EXISTS | | Wield confirm parse | `WieldObject 0x0023` handler `GameEventWiring.cs:238` → `MoveItem(item,wielder,equip)` | EXISTS (gap: see §5) | | Slots imported + positioned | inventory frame `GameWindow.cs:2145`; paperdoll mount `PinTopLeft(0x100001CD)` `:2208`; `InventoryController.Bind` `:2231` | EXISTS | | Transparent empty slot | `UiItemSlot.OnDraw:222` — `EmptySprite != 0` gates the draw; `EmptySprite = 0` ⇒ nothing drawn | EXISTS | **Unwield is therefore free:** dragging an equipped item (a valid drag source, `IsDragSource ⇒ ItemId != 0`) onto the inventory grid already hits `InventoryController.HandleDropRelease` → `MoveItemOptimistic` (clears equip) + `SendPutItemInContainer`. The PaperdollController only implements **wield**. --- ## 3. The `EquipMask` enum is wrong — correct it first (Core) acdream's `EquipMask` (`ClientObject.cs:65`) **diverges from canonical AC** starting at bit `0x2000`. It invented two phantom bits — `HandArmor = 0x2000` and `FootArmor = 0x10000` — that do not exist in retail's `INVENTORY_LOC` enum (verbatim header `docs/research/named-retail/acclient.h:3193`), shifting every slot above `0x1000` out of alignment. It has not bitten yet only because nothing compares against a **named** mask (`InventoryController` checks `!= None`, numeric-agnostic; wire values are stored as raw `(EquipMask)uint` casts that preserve the numeric bits). The paperdoll is the first code to compare against named masks, so it would expose the bug. **Wrong vs right (sample):** acdream `0x200000 = RightRing`; retail `0x200000 = SHIELD_LOC`. acdream `0x800000 = Shield`; retail `0x800000 = MISSILE_AMMO_LOC`. ### Fix: replace the enum body with the verbatim retail `INVENTORY_LOC` values ```csharp [Flags] public enum EquipMask : uint { None = 0, HeadWear = 0x00000001, ChestWear = 0x00000002, AbdomenWear = 0x00000004, UpperArmWear = 0x00000008, LowerArmWear = 0x00000010, HandWear = 0x00000020, UpperLegWear = 0x00000040, LowerLegWear = 0x00000080, FootWear = 0x00000100, ChestArmor = 0x00000200, AbdomenArmor = 0x00000400, UpperArmArmor = 0x00000800, LowerArmArmor = 0x00001000, UpperLegArmor = 0x00002000, // was wrongly 0x4000 (phantom HandArmor at 0x2000) LowerLegArmor = 0x00004000, // was wrongly 0x8000 NeckWear = 0x00008000, // acdream had no NeckWear (called it "Necklace" at 0x20000) WristWearLeft = 0x00010000, // was wrongly "FootArmor" WristWearRight= 0x00020000, FingerWearLeft= 0x00040000, FingerWearRight=0x00080000, MeleeWeapon = 0x00100000, // was wrongly 0x400000 Shield = 0x00200000, // was wrongly 0x800000 MissileWeapon = 0x00400000, MissileAmmo = 0x00800000, Held = 0x01000000, TwoHanded = 0x02000000, // acdream lacked it (had Held here) TrinketOne = 0x04000000, // was wrongly 0x10000000 Cloak = 0x08000000, // the ONLY high bit acdream had right SigilOne = 0x10000000, SigilTwo = 0x20000000, SigilThree = 0x40000000, } ``` Removed (no longer exist): `HandArmor`, `FootArmor`, `Necklace`, `LeftBracelet`, `RightBracelet`, `LeftRing`, `RightRing`, `AetheriaRed/Yellow/Blue`. **Blast radius is safe:** the only references in the tree are 4 test files using `EquipMask.MeleeWeapon` in **round-trips** (write `(uint)…` to the wire, parse back, assert equality) — value-agnostic, so they stay green. No test pins a wrong numeric value against external truth. **Anti-regression: a numeric-pin test** (`tests/AcDream.Core.Tests/Items/EquipMaskTests.cs`) asserting the exact value of every member against `acclient.h:3193` (e.g. `Assert.Equal(0x200000u, (uint)EquipMask.Shield)`). This converts the "named mask == canonical bit" contract into a hard test so it can never silently drift. --- ## 4. Optimistic wield (Core — `ClientObjectTable`) `MoveItemOptimistic` is **unwield-shaped**: it hardcodes `CurrentlyEquippedLocation = None` (`:162`) and does a gapless container-index insert. Wield is the opposite. Add a sibling. ### 4a. Extend the pending-move snapshot to remember the pre-move equip location Today `_pendingMoves` is `Dictionary`. Extend to `(uint container, int slot, EquipMask equip, int outstanding)`. Extract a shared private helper: ```csharp private void RecordPending(uint itemId, ClientObject item) { if (_pendingMoves.TryGetValue(itemId, out var p)) _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1); else _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot, item.CurrentlyEquippedLocation, 1); } ``` `MoveItemOptimistic` calls `RecordPending` (unchanged behavior — it records `equip` which is `None` for pack items). `RollbackMove` restores all three via the existing `MoveItem` overload that already takes an `EquipMask`: ```csharp return MoveItem(itemId, pre.container, pre.slot, pre.equip); ``` This makes rollback faithful in **both** directions (today an unwield-reject loses the item's slot mask). ### 4b. `WieldItemOptimistic` ```csharp /// Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set /// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask (matching the /// server's WieldObject 0x0023 confirm), firing ObjectMoved for an immediate repaint. The caller /// sends GetAndWieldItem; ConfirmMove (on the 0x0023 echo) / RollbackMove (on 0x00A0) reconcile. public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask) { if (!_objects.TryGetValue(itemId, out var item)) return false; RecordPending(itemId, item); return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask); } ``` `MoveItem` already sets `ContainerId` + `CurrentlyEquippedLocation` + reindexes + fires `ObjectMoved`. It does **not** write `WielderId` — acdream's `WieldObject 0x0023` confirm is also ContainerId-based (it never sets `WielderId`), so the optimistic state equals the confirmed state and rollback fully restores through `MoveItem` alone. (An equipped item is detected as the player's via `ContainerId == player`; login-equipped items match via `WielderId` from their own CreateObject. Decided at the Task-3 code review.) --- ## 5. Close the wield-confirm gap (Core.Net — `GameEventWiring`) The `WieldObject 0x0023` handler (`GameEventWiring.cs:238`) does `MoveItem(item, wielder, equip)` but — unlike the `InventoryPutObjInContainer` handler at `:251` — does **not** call `ConfirmMove`. Add it so an optimistic wield's snapshot clears on the server echo (decrement the outstanding count). No-op when nothing is pending (server-initiated / login wields), so it's safe and unconditional: ```csharp items.MoveItem(p.Value.ItemGuid, newContainerId: p.Value.WielderGuid, newEquipLocation: (EquipMask)p.Value.EquipLoc); items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler) ``` Wield **rollback** rides the existing `InventoryServerSaveFailed 0x00A0` handler (`:280` → `RollbackMove`) unchanged — verify ACE emits `0x00A0` for `GetAndWieldItem` rejections at the gate (§9). --- ## 6. `PaperdollController` (App — `UI/Layout/PaperdollController.cs`) Mirrors `InventoryController`: a `gm*UI::PostInit`-style find-by-id binder that owns the equip slots, populates them from `ClientObjectTable`, and is their `IItemListDragHandler`. ### 6a. Element-id → EquipMask map (verified: dump `paperdoll-0x21000024.txt` ↔ deep-dive §3a ↔ `acclient.h:3193`) A `static readonly (uint Element, EquipMask Mask)[]` of the 21 functional slots: | element | mask | element | mask | |---|---|---|---| | `0x100005AB` HeadWear `0x1` | | `0x100005B2` LowerLegArmor `0x4000` | | | `0x100001E2` ChestWear `0x2` | | `0x100001DA` NeckWear `0x8000` | | | `0x100001E3` UpperLegWear `0x40` | | `0x100001DB` WristWearLeft `0x10000` | | | `0x100005B0` HandWear `0x20` | | `0x100001DD` WristWearRight `0x20000` | | | `0x100005B3` FootWear `0x100` | | `0x100001DC` FingerWearLeft `0x40000` | | | `0x100005AC` ChestArmor `0x200` | | `0x100001DE` FingerWearRight `0x80000` | | | `0x100005AD` AbdomenArmor `0x400` | | `0x100001E1` Shield `0x200000` | | | `0x100005AE` UpperArmArmor `0x800` | | `0x100001E0` MissileAmmo `0x800000` (LIKELY) | | | `0x100005AF` LowerArmArmor `0x1000` | | `0x100001DF` weapon composite `0x3500000` | | | `0x100005B1` UpperLegArmor `0x2000` | | `0x1000058E` TrinketOne `0x4000000` | | | | | `0x100005E9` Cloak `0x8000000` | | Weapon composite `0x3500000` = `WEAPON_READY_SLOT_LOC` (`acclient.h:3235`) = `MeleeWeapon|MissileWeapon|Held|TwoHanded`. `0x100001E0`'s mask is LIKELY (the decomp immediate was corrupted; inferred MissileAmmo from the gap + neighbors — gate-verify, §9). ### 6b. Construction / binding For each `(element, mask)`: `layout.FindElement(element) as UiItemList`; if non-null: `list.RegisterDragHandler(this)`; `list.Cell.SourceKind = ItemDragSource.Equipment`; `list.Cell.SlotIndex = `; **`list.Cell.EmptySprite = 0`** (transparent, per the brainstorm); `list.Cell.SpriteResolve = …` (the same chrome resolver the factory set). Leave the `UiItemSlot` **default** accept/reject sprites — the retail `ItemSlot_DragOver_Accept`/`_Reject` `0x060011F9`/`0x060011F8` (the discrete-slot ring/circle, NOT the inventory grid's insert-arrow `0x060011F7`, which is for insert-between-cells). Keep a `mask → UiItemList` map for populate (so `MaskFor(list)` in §6d is the reverse lookup). Subscribe `ObjectAdded/Moved/Removed/Updated`. `Bind(layout, objects, playerGuid, iconIds, sendWield)` static factory mirroring `InventoryController.Bind`. ### 6c. Populate One pass over `_objects.Objects`, building `equipped: List<(EquipMask loc, ClientObject item)>` of the **player's** gear: `o.CurrentlyEquippedLocation != None && (o.WielderId == player || o.ContainerId == player)`. Then for each slot: find the item whose `(loc & slotMask) != 0` (handles the weapon composite + paired jewelry — a single equip bit intersects exactly one slot mask). If found, `cell.SetItem(guid, iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects))`; else `cell.Clear()` (transparent — nothing drawn). `Concerns(o)`: repaint when `o.WielderId == player || o.ContainerId == player` (player-scoped — an NPC's wielded item carries `CurrentlyEquippedLocation` too and must NOT leak onto the doll; a player-equipped item always has `WielderId==player` or `ContainerId==player`). `OnObjectMoved` also repaints when from/to is the player (an item being wielded/unwielded into a side bag). Mirror `InventoryController`'s debounce. ### 6d. `IItemListDragHandler` ```csharp void OnDragLift(...) { } // no-op — item stays until the server confirms (same as inventory) bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload payload) { var item = _objects.Get(payload.ObjId); if (item is null) return false; return (item.ValidLocations & MaskFor(list)) != EquipMask.None; } void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload payload) { var item = _objects.Get(payload.ObjId); if (item is null) return; EquipMask wieldMask = item.ValidLocations & MaskFor(list); // resolves the specific weapon/finger bit if (wieldMask == EquipMask.None) return; _objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask); _sendWield?.Invoke(payload.ObjId, (uint)wieldMask); } ``` `wieldMask = item.ValidLocations & slotMask` is the uniform rule: a head item on the head slot → `0x1`; a sword on the weapon slot → `MeleeWeapon`; a ring valid in both fingers dropped on the left-finger slot → `FingerWearLeft`. (Holtburger's `resolve_and_clear_slots` intent.) --- ## 7. Wire it up (App — `GameWindow.cs`) At the inventory bind site (`:2231`, right after `InventoryController.Bind`), add: ```csharp _paperdollController = AcDream.App.UI.Layout.PaperdollController.Bind( invLayout, Objects, playerGuid: () => _playerServerGuid, iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask)); ``` (`invLayout` already contains the paperdoll subtree via the sub-window mount.) --- ## 8. Testing - **Probe (plan task 1 — de-risk):** extend `InventoryFrameImportProbe` to assert every equip-slot id (`0x100005AB`, `0x100001E1`, `0x100001DF`, …) resolves to a `UiItemList` in the imported tree. High confidence (the contents grid proves the same `0x2100003D` base path), but load-bearing — if it fails, fix the importer/factory before continuing. - **Core — `EquipMaskTests`:** numeric pin of every member vs `acclient.h:3193`. - **Core — `ClientObjectTableTests`:** `WieldItemOptimistic` sets equip + container (ContainerId-based; does NOT write WielderId); `RollbackMove` restores the pre-wield equip mask (the new snapshot field); outstanding-count across wield+move of the same item. - **Core.Net — `GameEventWiringTests`:** `WieldObject 0x0023` now calls `ConfirmMove` (an optimistic wield's snapshot clears on the echo). - **App — `PaperdollControllerTests`:** element-id→mask map matches the dump; populate shows the equipped item in the right slot (incl. weapon composite + paired finger); `OnDragOver` gates on `ValidLocations`; `HandleDropRelease` computes the correct `wieldMask`, optimistically equips, and sends `SendGetAndWieldItem`. Reuse the slot-inside-a-Draggable-frame topology from the B-Drag tests. - Full suite green at the phase boundary (not just filtered subsets — the B-Wire process lesson). --- ## 9. Divergence register rows (add in the implementing commit) - **AP-xx:** `0x100001E0` MissileAmmo `0x800000` mask is LIKELY (corrupted decomp immediate, deep-dive §7) — gate-verify. - **AP-xx:** dual-wield-into-shield-slot special (deep-dive §3b line 174302) not implemented; a melee weapon cannot be dropped on the Shield slot in acdream. - **AP-xx:** wield-reject rollback assumes ACE emits `InventoryServerSaveFailed 0x00A0` for `GetAndWieldItem` rejections; if it does not, an optimistic wield is corrected only by the next authoritative message — gate-verify via WireMCP. (The `EquipMask` correction removes a latent bug rather than adding a deviation — it's locked by the numeric-pin test, no register row.) --- ## 10. Risks / gate-verify 1. **Equip slots resolve to `UiItemList`** — mitigated by probe task 1. 2. **ACE wield-reject message** — gate-verify (§9); graceful fallback (next authoritative correction). 3. **`0x100001E0` MissileAmmo mask** — gate-verify; ammo slot is low-priority for MVP. --- ## 11. Acceptance criteria - F12 inventory: equipped gear shows as icons in the correct doll slots; empty slots are transparent. - Drag a wieldable item from the pack onto a matching slot → it wields (icon appears, instant), survives the server echo, and a wrong-slot/invalid drop is rejected (red) or rolled back. - Drag an equipped item from a slot to the pack → it unwields (via the existing inventory grid handler). - `dotnet build` + full `dotnet test` green. Visual gate by the user. Divergence rows added.