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>
341 lines
18 KiB
Markdown
341 lines
18 KiB
Markdown
# 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<uint,(uint container,int slot,int outstanding)>`. 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
|
|
/// <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:
|
|
|
|
```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 = <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`
|
|
|
|
```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.
|