acdream/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md
Erik 3b8a39c49e fix(D.2b): PaperdollController review fixes — player-scope Concerns, loop clarity
Code-quality review on Task 5:
- I1: Concerns was unscoped (CurrentlyEquippedLocation != None) → an NPC's
  wielded item (which also carries that wire field) triggered spurious full
  repaints. Narrowed to (WielderId==p || ContainerId==p), matching
  InventoryController; OnObjectMoved's from/to-player backstop still catches
  unwield-into-a-side-bag. Populate's own scope already prevented wrong data;
  this kills the wasted repaints.
- I2: replaced the dual-`index++` (assign-vs-skip) with a for-i loop;
  SlotIndex = SlotMap position (= the drag payload's SourceSlot on unwield).
- M1/M2: comment that the cell's SpriteResolve + the discrete-slot accept/
  reject ring (0x060011F9/F8, not the grid insert-arrow) are factory-provided.
- Added two behavioral tests: a live player wield repaints the slot
  (ObjectMoved → Concerns → Populate); an NPC's wielded item never appears on
  the doll (player-scoping).
- Synced the stale spec §4b/§6c/§8 to the Task-3 Option-1 reality (the
  optimistic wield is ContainerId-based and does NOT write WielderId).

App suite 580 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:32:34 +02:00

18 KiB

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) :1230BuildPickUp 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-760ObjectTableWiringIngest) EXISTS
Wield confirm parse WieldObject 0x0023 handler GameEventWiring.cs:238MoveItem(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:222EmptySprite != 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.HandleDropReleaseMoveItemOptimistic (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

[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<uint,(uint container,int slot,int outstanding)>. Extend to (uint container, int slot, EquipMask equip, int outstanding). Extract a shared private helper:

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:

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

/// <summary>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.</summary>
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:

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 (:280RollbackMove) 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 = <index in the map>; 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

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:

_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.