From 2d7ea7446d205395a4d6392533797ae793f6fe27 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 21:38:24 +0200 Subject: [PATCH] docs(D.2b): paperdoll Slice 1 (equip slots) design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-phase C, Slice 1 — bind the ~25 paperdoll equip slots to live equipped-item data + make them drag-drop wield/unwield targets. No 3D doll (that's Slice 2). Brainstorm findings baked in: - The handoff's "build the wire gap" premise is STALE: BuildGetAndWieldItem + SendGetAndWieldItem already shipped in B-Wire. The whole optimistic-move machine + InventoryController:IItemListDragHandler already exist — Slice 1 mirrors them. Unwield is free (the inventory grid handler already does it). - Found a latent bug: acdream's EquipMask enum diverges from canonical AC (acclient.h:3193 INVENTORY_LOC) from bit 0x2000 up (phantom HandArmor/ FootArmor). Correct it to the verbatim retail values + a numeric-pin test. Blast radius is safe (4 round-trip test refs). - Empty equip slots are TRANSPARENT (EmptySprite=0), per the user — faithful, zero Slice-2 rework. Real scope: correct EquipMask (Core) + WieldItemOptimistic & equip-aware rollback snapshot (Core) + ConfirmMove on the WieldObject 0x0023 handler (Core.Net) + PaperdollController (App) + GameWindow wiring. Element-id→mask map verified dump ↔ deep-dive §3a ↔ acclient.h. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...d2b-paperdoll-slice1-equip-slots-design.md | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md diff --git a/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md b/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md new file mode 100644 index 00000000..01761ea6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md @@ -0,0 +1,337 @@ +# 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 = WielderId = 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); + item.WielderId = wielderGuid; // unambiguously the player's gear during the optimistic window + return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask); +} +``` + +`MoveItem` already sets `ContainerId` + `CurrentlyEquippedLocation` + reindexes + fires `ObjectMoved`. +Setting `WielderId` here matches the steady state (the item's own CreateObject carries `Wielder`). + +--- + +## 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.CurrentlyEquippedLocation != None || o.WielderId == player || o.ContainerId == player`; +`OnObjectMoved` also repaints when from/to is the player (an item being wielded/unwielded). 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 + WielderId + container; `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.